Skip to content

Fix GC crash from pointer-typed session handles#265

Merged
George-Payne merged 2 commits into
mainfrom
ui-1813/bindings-gc-crash
Jul 6, 2026
Merged

Fix GC crash from pointer-typed session handles#265
George-Payne merged 2 commits into
mainfrom
ui-1813/bindings-gc-crash

Conversation

@George-Payne

Copy link
Copy Markdown
Member

Fixes the fatal invalid pointer found on stack GC 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 to gaffer_session*, and s.c() reconstituted the stored uintptr as 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=100 of 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 stay uintptr/uintptr_t end-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: NewSession held the fake pointer live across consumeError, and Destroy/callback registration passed it through Go frames.
  • bindings/go/callbacks.go - registration shims (go_on_*) fold both the handle cast and the void* user_data into C; the Go side is keyed by plain uintptr (sessionKey gone, unsafe import 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.h and 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 -race to CI is a separate decision, not taken here).

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.
@George-Payne George-Payne requested a review from a team as a code owner July 6, 2026 13:35
@George-Payne George-Payne added bug Something isn't working bindings-go Go bindings to the C runtime (gafferruntime module) labels Jul 6, 2026
@George-Payne George-Payne self-assigned this Jul 6, 2026
@linear-code

linear-code Bot commented Jul 6, 2026

Copy link
Copy Markdown

UI-1813

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix Go GC crash from pointer-typed session handles

🐞 Bug fix 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Keep session handles integer-typed in Go; move all casts into C shims.
• Register/invoke callbacks using uintptr keys and C-side user_data shims.
• Document the fix (changeset) and clarify the concurrent-GC stress-test mechanism.
Diagram

graph TD
  A["Go bindings (Session API)"] --> B["C shims (uintptr_t)"] --> C["Gaffer runtime (gaffer.h ABI)"] --> D["Go trampolines (exported)"] --> E["Callback registry (uintptr key)"] --> F["User callbacks"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Change runtime to expose integer-handle API
  • ➕ Eliminates the pointer-cast hazard across all language bindings, not just Go
  • ➕ Avoids needing per-binding shim layers
  • ➖ Requires changing and shipping the runtime/ABI surface; higher blast radius
  • ➖ Not feasible if runtime ABI must remain stable
2. Ship a small C wrapper file instead of cgo preamble shims
  • ➕ Keeps cgo preamble smaller and centralizes ABI glue in a dedicated C unit
  • ➕ Easier to test/validate with a C compiler toolchain independently
  • ➖ Adds build-system complexity (extra compiled C sources across platforms)
  • ➖ Functionally equivalent to the current approach

Recommendation: The PR’s approach (C shims + uintptr handles in Go) is the best low-risk fix that avoids changing the runtime ABI while fully removing uintptr->pointer reconstitution from Go stack frames (including callback registration paths). The only materially different alternative is changing the runtime API itself, which is a larger cross-language change and out of scope here.

Files changed (4) +166 / -85

Bug fix (2) +152 / -78
callbacks.goRegister callbacks via C shims and key registry by uintptr handle +46/-34

Register callbacks via C shims and key registry by uintptr handle

• Reworks callback registration to avoid passing fake pointer-typed session values through Go frames. Introduces C registration shims (go_on_*) that cast the integer handle to gaffer_session* and store it in user_data in C, while Go-side maps are keyed directly by the uintptr handle.

bindings/go/callbacks.go

gaffer.goRemove Session.c() and route all session/debug calls through uintptr-based C shims +106/-44

Remove Session.c() and route all session/debug calls through uintptr-based C shims

• Eliminates transient Go-side uintptr->*C.gaffer_session conversions by adding static cgo shims that accept uintptr_t and cast in C. Updates session lifecycle, feed/state/result/debug APIs, and callback registration call sites to use the integer handle end-to-end on the Go side.

bindings/go/gaffer.go

Tests (1) +9 / -7
runtime_test.goClarify concurrent-GC stress test explanation for stack-copy invalid-pointer failure +9/-7

Clarify concurrent-GC stress test explanation for stack-copy invalid-pointer failure

• Updates the stress-test comment to reflect the real failure mode: stack copies can occur mid-FFI call when callbacks re-enter Go, making pointer-typed slots holding integer handles fatal. Keeps the test intent as a probabilistic regression check alongside the deterministic type guard.

bindings/go/runtime_test.go

Documentation (1) +5 / -0
bindings-gc-crash.mdAdd patch changeset describing the Go bindings GC crash fix +5/-0

Add patch changeset describing the Go bindings GC crash fix

• Adds a release note documenting the rare but fatal Go GC crash and the mitigation. Explains that handles now remain integer-typed on the Go side with casting performed only in C shims, and notes downstream impact (e.g., CLI embedding the bindings).

.changeset/bindings-gc-crash.md

@qodo-code-review

qodo-code-review Bot commented Jul 6, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. UB callback cast ✓ Resolved 🐞 Bug ☼ Reliability
Description
The new registration shims cast Go callback symbols (declared with a uintptr_t userData parameter)
to the runtime’s callback typedefs that take void* user_data, which is an incompatible
function-pointer type. Calling a function through an incompatible function-pointer type is undefined
behavior in C and may miscompile or fail on some targets/toolchains.
Code

bindings/go/callbacks.go[R18-34]

+// Registration shims: cast the integer handle to gaffer_session* (and back
+// into the user_data slot) in C, so no fake pointer transits Go stack slots.
+static void go_on_emit(uintptr_t s) {
+	gaffer_on_emit((gaffer_session*)s, (gaffer_emit_cb)goEmitCallback, (void*)s);
+}
+static void go_on_log(uintptr_t s) {
+	gaffer_on_log((gaffer_session*)s, (gaffer_log_cb)goLogCallback, (void*)s);
+}
+static void go_on_diagnostic(uintptr_t s) {
+	gaffer_on_diagnostic((gaffer_session*)s, (gaffer_diagnostic_cb)goDiagnosticCallback, (void*)s);
+}
+static void go_on_state_changed(uintptr_t s) {
+	gaffer_on_state_changed((gaffer_session*)s, (gaffer_state_changed_cb)goStateChangedCallback, (void*)s);
+}
+static void go_on_break(uintptr_t s) {
+	gaffer_on_break((gaffer_session*)s, (gaffer_break_cb)goBreakCallback, (void*)s);
+}
Evidence
The runtime callback types in gaffer.h require void* user_data, but the new shim registers
goEmitCallback (declared with uintptr_t userData) by casting it to gaffer_emit_cb, creating an
incompatible function-pointer call site (UB). The exported Go callback signature indeed uses
C.uintptr_t, confirming the mismatch is real and introduced by the new shims.

bindings/go/callbacks.go[7-34]
runtime/include/gaffer.h[75-104]
bindings/go/callbacks_export.go[16-38]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`bindings/go/callbacks.go` registers callbacks by casting `goEmitCallback`/`goLogCallback`/etc to `gaffer_*_cb` function-pointer types, but the runtime typedefs take `void* user_data` while the exported Go functions take `uintptr_t userData`. This is undefined behavior in C.

## Issue Context
- Runtime API expects callback signatures with `void* user_data`.
- Go exports intentionally use `C.uintptr_t` to keep integer handles out of pointer-typed Go stack slots.

## Fix Focus Areas
- Implement C-level wrapper functions with the exact `gaffer_*_cb` signatures (taking `void* user_data`) that internally call the exported Go callbacks by converting `user_data` to `uintptr_t`.
- Register these wrappers without casting function pointers.

### Suggested approach (sketch)
- In the cgo preamble, add wrappers like:
 - `static void go_emit_cb(const char*..., void* ud) { goEmitCallback(..., (uintptr_t)ud); }`
 - Repeat for log/diagnostic/state_changed/break.
- Update `go_on_emit` etc to call `gaffer_on_emit((gaffer_session*)s, go_emit_cb, (void*)s);` (no cast).

## Fix Focus Areas (files/lines)
- bindings/go/callbacks.go[7-34]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread bindings/go/callbacks.go
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.
@George-Payne George-Payne merged commit 9b1f7e8 into main Jul 6, 2026
3 checks passed
@George-Payne George-Payne deleted the ui-1813/bindings-gc-crash branch July 6, 2026 13:53
George-Payne added a commit that referenced this pull request Jul 6, 2026
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.
George-Payne added a commit that referenced this pull request Jul 7, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bindings-go Go bindings to the C runtime (gafferruntime module) bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant