Skip to content

Fix callbacks that cause goroutine stack to grow#59

Merged
kolkov merged 4 commits into
go-webgpu:mainfrom
tie:fix/syscall-arg-block-move-safety
Jul 5, 2026
Merged

Fix callbacks that cause goroutine stack to grow#59
kolkov merged 4 commits into
go-webgpu:mainfrom
tie:fix/syscall-arg-block-move-safety

Conversation

@tie

@tie tie commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

This change fixes callbacks that cause goroutine stacks to grow by using callbackArgs from a sync.Pool,
and ensures that the we do not assume non-moving GC on that path using runtime.Pinner.

See TestCallbackGrowStack for reproducer.

Also adds a minor sret test/cleanup.

tie added 2 commits June 30, 2026 21:00
CallNFloat built the syscall argument and return block on the goroutine
stack and handed its address to runtime_cgocall. syscallN runs on g0 and
writes the C return values back into that block after the call returns. If
that call runs a callback that re-enters Go and grows the goroutine stack,
the stack moves (copystack) and syscallN's write lands at the stale,
pre-move address, so the first such call silently loses its return value.

Take the block from a sync.Pool, so it lives on the heap and never sits on
a moving stack, and pin it with runtime.Pinner across the cgocall. Pooling
keeps the move-safe path allocation-free in steady state; the pin keeps it
correct should the heap ever become movable.

Add TestCallbackGrowStack, which drives a callback through CallFunction on
a fresh small-stack goroutine and forces a large copystack from inside the
callback, reproducing the lost-return-value bug deterministically instead
of relying on process-global warmup state.

Also note runtime.Pinner in CallFunctionContext's safety documentation.
Add return_struct_24 (a 24-byte struct) and TestStructReturn24B, which
exercise the sret ABI path the existing struct-return tests (all <= 16
bytes) never reach: the caller provides the rvalue buffer and the callee
writes the struct straight into it through the hidden return pointer (RDI
on amd64, X8 on arm64).

Simplify the sret setup on both architectures to point that hidden return
pointer directly at rvalue. CallFunction already documents that the return
buffer must be valid and large enough for the result, so there is no need
to allocate a scratch buffer or special-case a nil rvalue.
@tie
tie requested a review from kolkov as a code owner July 1, 2026 09:10
@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@kolkov kolkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — you found a real bug that we missed. Our v0.5.4 //go:noescape optimization was wrong: when a callback triggers copystack, syscallN on g0 writes return values to the old (freed) goroutine stack address. The runtime comment at cgocall.go:331 says it plainly: "When we call into Go, the stack is free to be moved."

We went through the runtime source in detail — exitsyscall() at line 338 of cgocallbackg clears both syscallsp and throwsplit, which removes the two guards that copystack checks (stack.go:900, stack.go:1030). After that, any stack growth in the callback moves the goroutine stack and the old pages go back to stackcache — not unmapped, just recycled. So it's silent corruption, not a segfault. Nasty.

The test is solid. growStack(512) with 4KB frames on a fresh goroutine makes it deterministic.

One concern with the current PR though: runtime.Pinner adds measurable overhead. Looking at the CI benchmarks:

main (v0.5.5):   BenchmarkGoffiIntArgs    112.3 ns/op
PR #59:          BenchmarkGoffiIntArgs    188.3 ns/op  (+68%)

The Pinner path goes through span.speciallock (pinner.go:168) on every call — that's a real mutex acquisition, not just an atomic. We measured ~60 ns per Pin+Unpin cycle sequentially.

purego uses sync.Pool without Pinner (func.go:25, go_runtime.go:13 — no //go:noescape either) and has shipped that way for years. Go's GC is explicitly "non-generational and non-compacting" (runtime/mgc.go:9). If a moving GC ever ships, adding Pinner is a one-line change — but right now it costs us 68% regression for a hypothetical future scenario.

Would you be open to dropping the Pinner and going with Pool-only, matching purego's pattern? That should bring the regression down to roughly +12% instead of +68%. We can add a comment noting where to add Pinner if moving GC ever lands.

The sret cleanup and TestStructReturn24B look good independently — happy to take those as-is.

@pekim

pekim commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Go's GC is explicitly "non-generational and non-compacting" (runtime/mgc.go:9). If a moving GC ever ships,...

It is possible to be be made rudely aware if goffi is ever used with a newer version of Go that does happen to introduce a moving GC. https://github.com/go4org/unsafe-assume-no-moving-gc/blob/main/check.go

(Of course should such a check ever be added to goffi it would be outside the scope of this PR.)

@kolkov

kolkov commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Thanks for the pointer! We looked into it — interesting approach, but probably not for us. It's a runtime panic in init() rather than a compile-time guard, and we'd rather keep go.mod dependency-free. If moving GC ever actually lands, runtime.Pinner is the real fix, not a canary. For now Go's GC is non-moving through 1.27, so we'll cross that bridge when we get to it.

Pool alone guarantees heap residence — sufficient for Go's non-compacting
GC (confirmed through Go 1.27 including Green Tea GC). Pinner added ~60ns
overhead per call due to span.speciallock mutex (measured: +68% regression).

cgocheck does not apply to goffi (uses //go:linkname, not cmd/cgo).
Also removes //go:noescape from hot-path runtime_cgocall declarations
since args is now heap-allocated via Pool.

If Go ever ships a compacting GC, add runtime.Pinner back (4 lines).
@kolkov

kolkov commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

I pushed a commit dropping the Pinner while keeping the Pool fix intact. The reasoning:

The Pool is the actual bug fix — it moves syscallArgs off the goroutine stack so copystack can't invalidate the address held by syscallN on g0. That part is solid and we want it.

The Pinner was adding ~60 ns per call through span.speciallock (a real futex-based mutex), which showed up as +68% in CI benchmarks. Since goffi uses //go:linkname directly (not cmd/cgo), the cgo pointer checker isn't even injected into our call path — so Pinner gives us no diagnostic benefit either.

Go's GC is non-compacting through 1.27 (Green Tea changed scanning locality, not object placement). If a compacting GC ever ships, re-adding Pinner is 4 lines — the comment in the code points to exactly where.

I also removed //go:noescape from the hot-path runtime_cgocall declarations since it's now counterproductive — we want args on the heap.

The dl_unix.go path keeps //go:noescape since dlopen/dlsym are cold-path calls that never trigger callbacks.

Let me know if you have concerns with this direction.

- CHANGELOG: v0.5.6 entry with bug description and credit to @tie
- README: remove incorrect '0 allocs (Unix)' claim, document Pool
- ARCHITECTURE: replace noescape description with Pool explanation
- PERFORMANCE: update allocation description
@kolkov
kolkov merged commit bbded27 into go-webgpu:main Jul 5, 2026
13 checks passed
kolkov added a commit to gogpu/wgpu that referenced this pull request Jul 5, 2026
goffi v0.5.6 fixes callback stack-move corruption: callbacks that grow
the goroutine stack could corrupt callbackArgs pointers because Go's
moving GC relocates stacks. Fix uses sync.Pool + runtime.Pinner.

PR go-webgpu/goffi#59, contributor: @tie.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants