Fix GC crash from pointer-typed session handles#265
Conversation
The runtime's session handles are small integers cast to gaffer_session*. s.c() reconstituted the uintptr handle as *C.gaffer_session for each FFI call, putting a fake pointer in pointer-mapped stack slots. While the C call runs the stack is pinned, but callbacks re-enter Go mid-call - the goroutine becomes preemptible and its stack movable with the fake pointer still live below. A stack copy (growth, or a shrink during GC mark) then aborts the process with "invalid pointer found on stack" (UI-1813, hit twice in CI - the "garbage" values 0x61 and 0x1 are handles 97 and 1). - Handles stay uintptr/uintptr_t end-to-end on the Go side; static C shims in the cgo preambles do the (gaffer_session*) casts in C, where the GC can't see them. - Also closes the sibling windows: NewSession held the fake pointer live across consumeError, and callback registration passed it around as Go function parameters and unsafe.Pointer user_data. - Reproduced near-deterministically pre-fix (the concurrent-GC stress test crashed on its first iteration in two consecutive -count=100 runs, same signature as CI); post-fix 100 iterations pass, and the full suite is clean with and without -race - checkptr no longer trips because no uintptr-to-pointer conversion remains.
PR Summary by QodoFix Go GC crash from pointer-typed session handles
AI Description
Diagram
High-Level Assessment
Files changed (4)
|
Code Review by Qodo
1.
|
The registration shims cast the Go trampolines (uintptr_t last parameter) to the void*-taking gaffer_*_cb typedefs - calling through an incompatible function-pointer type, undefined behavior per C11 6.3.2.3p8. ABI-identical on every supported target, but it would trip CFI or -fsanitize=function. Per-callback thunks match the typedefs exactly and convert the user_data VALUE to uintptr_t instead, which is well-defined.
Follow-up to #265: run the Go binding tests under `-race`, now that the checkptr-illegal handle conversion is gone. - **`bindings/go/justfile`** - `-race` added to the `test` recipe. CI inherits it via `just _test` -> `bindings::test` -> `go::test`, no workflow changes. Beyond data races, `-race` enables checkptr, so a future integer-handle-in-pointer-slot regression fatals deterministically at the conversion site instead of crashing the GC once in a blue moon (UI-1813). Measured cost: the suite goes from ~0.5s to ~1.6s, with race-instrumented artifacts cached by setup-go, so steady-state CI pays a couple of seconds.
Turns `-race` on for the cli suite (possible since #265 removed the checkptr blocker) and fixes everything the detector and the race-loaded CI runs surfaced: five data races, two real product bugs, and two brittle test budgets. Merge-gated on three consecutive green CI runs on the head SHA. - **`engine/runner.go` + `runner_debug.go`** - every session-crossing Runner method registers an in-flight op; `Destroy` refuses new ops, drains, waits the in-flight ones out, then frees the session. Fixes all five flagged races (three via the MCP stop path, two via the DAP harness): the goroutine that unparks a blocked Feed is by construction still alive when teardowns run, so `Destroy` was freeing the native session under a live FFI call. `ProcessOne` registers too, so a straggling feed can't race teardown. After `Destroy`, control verbs and inspection refuse with `ErrSessionDestroyed` instead of panicking or reporting a step that never happened. `TestRunner_Destroy_WaitsForInflightStep` pins the guarantee. - **`cmd/dev.go` + `mcpserver/server.go` + `dap/adapter_test.go`** - all teardown paths route through `Runner.Destroy` (the vscode dev loop had the same exposure on untested paths). History-store ownership moves to creators: the dev loop shares one store across restart iterations, so the Runner closing it would have silently dropped history after the first restart. - **`deploy/apply.go` + `remote/errors.go`** - real bug caught by a race-loaded run: KurrentDB settles projection deletes asynchronously, so `gaffer recreate` / `deploy_recreate` could bounce the rebuild's create off the still-registered name (envelope `Conflict`) and strand the projection deleted-but-not-recreated. The create now retries over a 10s settle window via `RetryCreate`/`remote.IsCreateConflict`. - **`lsp/lifecycle.go` + `handlers.go` + `server.go`** - real bug caught by rerun sampling: `jsonrpc2.NewConn` starts dispatching handlers before `Run` stored the run state, so a fast client's `initialized` could find `runCtxFn` nil and silently lose the workspace walk (and watcher registration) for the whole session. The run context is now stored before the conn exists and handler dispatch gates on a `ready` channel closed once the conn is stored. - **Test robustness** - the status config-drift assertion retries instead of trusting the advisory check's single 3s window; the lsp `waitForTimeout` goes 5s -> 30s (free for passing tests). - **`cli/justfile`** - `-race` on `test` and `test-integration`; CI inherits via `just _test`. Unit suite ~5s -> ~50s wall locally; race artifacts land in the Go build cache so CI steady state pays less. Integration validated under race against a nightly KurrentDB. - **Changesets** - `runner-teardown-race` and `recreate-settle-retry` (both patch).
Fixes the fatal
invalid pointer found on stackGC crash in the Go bindings (UI-1813, hit twice in CI). The "garbage" values in both crashes were session handles (0x61= 97,0x1= 1): the runtime's handles are small integers cast togaffer_session*, ands.c()reconstituted the storeduintptras a fake pointer for every FFI call. Callbacks re-enter Go mid-call, making the goroutine's stack movable while the fake pointer is still live in pointer-mapped slots below - a stack copy then aborts the process. Reproduced near-deterministically pre-fix (-count=100of the concurrent-GC stress test crashed on its first iteration, twice in a row, with the exact CI signature).bindings/go/gaffer.go-Session.c()removed; handles stayuintptr/uintptr_tend-to-end and static C shims (go_session_*,go_debug_*) in the cgo preamble do the(gaffer_session*)casts in C, where the GC can't see them. Also closes the sibling windows:NewSessionheld the fake pointer live acrossconsumeError, andDestroy/callback registration passed it through Go frames.bindings/go/callbacks.go- registration shims (go_on_*) fold both the handle cast and thevoid*user_data into C; the Go side is keyed by plainuintptr(sessionKeygone,unsafeimport gone).bindings/go/runtime_test.go- stress-test comment updated to the actual mechanism..changeset/bindings-gc-crash.md- patch: the crash is reachable through the CLI, which links the bindings.gaffer.hand the runtime are untouched. Post-fix: stress test clean at-count=100, full bindings suite clean with and without-race- the race detector was previously unusable on FFI tests because checkptr fataled on the same illegal conversion; with the conversion gone it runs clean (adding-raceto CI is a separate decision, not taken here).