diff --git a/.changeset/bindings-gc-crash.md b/.changeset/bindings-gc-crash.md new file mode 100644 index 00000000..47f12eea --- /dev/null +++ b/.changeset/bindings-gc-crash.md @@ -0,0 +1,5 @@ +--- +"@kurrent/gaffer": patch +--- + +The Go runtime bindings no longer risk a rare fatal crash (`invalid pointer found on stack`) under GC pressure. The runtime's integer session handles could appear in pointer-typed stack slots during FFI calls; if the GC moved the stack while a callback was running mid-call, the process aborted. Handles now stay integer-typed end-to-end on the Go side, with all casting done in C shims. The crash could hit anything embedding the bindings, including the CLI. diff --git a/bindings/go/callbacks.go b/bindings/go/callbacks.go index 7d5f7c50..1ee9d6dd 100644 --- a/bindings/go/callbacks.go +++ b/bindings/go/callbacks.go @@ -2,20 +2,61 @@ package gafferruntime /* #include "gaffer.h" +#include -// Forward declarations for Go callback trampolines -extern void goEmitCallback(const char* streamId, const char* eventType, const char* data, const char* metadata, int isJson, int isLink, void* userData); -extern void goLogCallback(const char* message, void* userData); -extern void goDiagnosticCallback(const char* code, const char* message, int severity, void* userData); -extern void goStateChangedCallback(const char* partition, const char* stateJson, void* userData); -extern void goBreakCallback(const char* reason, const char* source, int line, int column, void* userData); +// Forward declarations for Go callback trampolines. user_data is declared +// uintptr_t to match the //export signatures: it carries the session handle, +// a small integer that must never appear pointer-typed on the Go side (see +// the shim comment in gaffer.go's preamble). +extern void goEmitCallback(const char* streamId, const char* eventType, const char* data, const char* metadata, int isJson, int isLink, uintptr_t userData); +extern void goLogCallback(const char* message, uintptr_t userData); +extern void goDiagnosticCallback(const char* code, const char* message, int severity, uintptr_t userData); +extern void goStateChangedCallback(const char* partition, const char* stateJson, uintptr_t userData); +extern void goBreakCallback(const char* reason, const char* source, int line, int column, uintptr_t userData); + +// Thunks matching the gaffer_*_cb typedefs exactly. The void* -> uintptr_t +// conversion happens here on the user_data VALUE; casting the Go trampolines +// (uintptr_t last parameter) to the void*-taking typedefs instead would call +// through an incompatible function-pointer type, which is undefined behavior. +static void go_emit_thunk(const char* streamId, const char* eventType, const char* data, const char* metadata, int isJson, int isLink, void* userData) { + goEmitCallback(streamId, eventType, data, metadata, isJson, isLink, (uintptr_t)userData); +} +static void go_log_thunk(const char* message, void* userData) { + goLogCallback(message, (uintptr_t)userData); +} +static void go_diagnostic_thunk(const char* code, const char* message, int severity, void* userData) { + goDiagnosticCallback(code, message, severity, (uintptr_t)userData); +} +static void go_state_changed_thunk(const char* partition, const char* stateJson, void* userData) { + goStateChangedCallback(partition, stateJson, (uintptr_t)userData); +} +static void go_break_thunk(const char* reason, const char* source, int line, int column, void* userData) { + goBreakCallback(reason, source, line, column, (uintptr_t)userData); +} + +// 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, go_emit_thunk, (void*)s); +} +static void go_on_log(uintptr_t s) { + gaffer_on_log((gaffer_session*)s, go_log_thunk, (void*)s); +} +static void go_on_diagnostic(uintptr_t s) { + gaffer_on_diagnostic((gaffer_session*)s, go_diagnostic_thunk, (void*)s); +} +static void go_on_state_changed(uintptr_t s) { + gaffer_on_state_changed((gaffer_session*)s, go_state_changed_thunk, (void*)s); +} +static void go_on_break(uintptr_t s) { + gaffer_on_break((gaffer_session*)s, go_break_thunk, (void*)s); +} */ import "C" import ( "sync" "sync/atomic" - "unsafe" ) // Callback function types. @@ -27,7 +68,7 @@ type ( BreakCallback func(info BreakInfo) ) -// Global callback registry keyed by session pointer. +// Global callback registry keyed by session handle. var ( callbackMu sync.RWMutex emitCallbacks = make(map[uintptr]EmitCallback) @@ -37,52 +78,42 @@ var ( breakCallbacks = make(map[uintptr]BreakCallback) ) -func sessionKey(session *C.gaffer_session) uintptr { - return uintptr(unsafe.Pointer(session)) -} - -func sessionOnEmit(session *C.gaffer_session, cb EmitCallback) { - key := sessionKey(session) +func sessionOnEmit(handle uintptr, cb EmitCallback) { callbackMu.Lock() - emitCallbacks[key] = cb + emitCallbacks[handle] = cb callbackMu.Unlock() - C.gaffer_on_emit(session, (*[0]byte)(C.goEmitCallback), unsafe.Pointer(session)) + C.go_on_emit(C.uintptr_t(handle)) } -func sessionOnLog(session *C.gaffer_session, cb LogCallback) { - key := sessionKey(session) +func sessionOnLog(handle uintptr, cb LogCallback) { callbackMu.Lock() - logCallbacks[key] = cb + logCallbacks[handle] = cb callbackMu.Unlock() - C.gaffer_on_log(session, (*[0]byte)(C.goLogCallback), unsafe.Pointer(session)) + C.go_on_log(C.uintptr_t(handle)) } -func sessionOnDiagnostic(session *C.gaffer_session, cb DiagnosticCallback) { - key := sessionKey(session) +func sessionOnDiagnostic(handle uintptr, cb DiagnosticCallback) { callbackMu.Lock() - diagnosticCallbacks[key] = cb + diagnosticCallbacks[handle] = cb callbackMu.Unlock() - C.gaffer_on_diagnostic(session, (*[0]byte)(C.goDiagnosticCallback), unsafe.Pointer(session)) + C.go_on_diagnostic(C.uintptr_t(handle)) } -func sessionOnStateChanged(session *C.gaffer_session, cb StateChangedCallback) { - key := sessionKey(session) +func sessionOnStateChanged(handle uintptr, cb StateChangedCallback) { callbackMu.Lock() - changedCallbacks[key] = cb + changedCallbacks[handle] = cb callbackMu.Unlock() - C.gaffer_on_state_changed(session, (*[0]byte)(C.goStateChangedCallback), unsafe.Pointer(session)) + C.go_on_state_changed(C.uintptr_t(handle)) } -func sessionOnBreak(session *C.gaffer_session, cb BreakCallback) { - key := sessionKey(session) +func sessionOnBreak(handle uintptr, cb BreakCallback) { callbackMu.Lock() - breakCallbacks[key] = cb + breakCallbacks[handle] = cb callbackMu.Unlock() - C.gaffer_on_break(session, (*[0]byte)(C.goBreakCallback), unsafe.Pointer(session)) + C.go_on_break(C.uintptr_t(handle)) } -func cleanupCallbacks(session *C.gaffer_session) { - key := sessionKey(session) +func cleanupCallbacks(key uintptr) { callbackMu.Lock() delete(emitCallbacks, key) delete(logCallbacks, key) diff --git a/bindings/go/gaffer.go b/bindings/go/gaffer.go index 30a22ef6..82ba456b 100644 --- a/bindings/go/gaffer.go +++ b/bindings/go/gaffer.go @@ -25,7 +25,80 @@ package gafferruntime #cgo windows,amd64 LDFLAGS: -L${SRCDIR}/../../runtime/Gaffer.Runtime/bin/Release/net10.0/win-x64/publish -l:Gaffer.Runtime.dll #include "gaffer.h" +#include #include + +// The runtime's session handles are small integers (1, 2, 3, ...) cast to +// gaffer_session*, never dereferenced. Go must not hold them in any +// pointer-typed value, even transiently: the GC's precise stack maps treat +// such slots as pointers, and a stack copy - growth, or a shrink during the +// mark phase - aborts the process when it finds a value below the first page +// ("invalid pointer found on stack"). The stack CAN move mid-FFI-call: a +// callback re-enters Go, and while it runs the goroutine is preemptible and +// its stack movable, with the call's argument slots still live below. These +// shims keep the handle uintptr_t on the Go side and cast in C, where the GC +// can't see it. + +static uintptr_t go_session_create(const char* source, const char* options_json, const char** error_out) { + return (uintptr_t)gaffer_session_create(source, options_json, error_out); +} +static void go_session_destroy(uintptr_t s) { + gaffer_session_destroy((gaffer_session*)s); +} +static const char* go_session_feed(uintptr_t s, const char* event_json, const char** error_out) { + return gaffer_session_feed((gaffer_session*)s, event_json, error_out); +} +static const char* go_session_get_state(uintptr_t s, const char* partition, const char** error_out) { + return gaffer_session_get_state((gaffer_session*)s, partition, error_out); +} +static const char* go_session_get_shared_state(uintptr_t s, const char** error_out) { + return gaffer_session_get_shared_state((gaffer_session*)s, error_out); +} +static void go_session_set_state(uintptr_t s, const char* partition, const char* state_json, const char** error_out) { + gaffer_session_set_state((gaffer_session*)s, partition, state_json, error_out); +} +static const char* go_session_get_result(uintptr_t s, const char* partition, const char** error_out) { + return gaffer_session_get_result((gaffer_session*)s, partition, error_out); +} +static const char* go_session_get_sources(uintptr_t s, const char** error_out) { + return gaffer_session_get_sources((gaffer_session*)s, error_out); +} +static const char* go_session_get_partition_key(uintptr_t s, const char* event_json, const char** error_out) { + return gaffer_session_get_partition_key((gaffer_session*)s, event_json, error_out); +} +static const char* go_debug_set_breakpoint(uintptr_t s, int line, int column, const char* condition, const char* hit_condition, const char* log_message, const char** error_out) { + return gaffer_debug_set_breakpoint((gaffer_session*)s, line, column, condition, hit_condition, log_message, error_out); +} +static void go_debug_clear_breakpoints(uintptr_t s, const char** error_out) { + gaffer_debug_clear_breakpoints((gaffer_session*)s, error_out); +} +static void go_debug_continue(uintptr_t s, const char** error_out) { + gaffer_debug_continue((gaffer_session*)s, error_out); +} +static void go_debug_pause(uintptr_t s, const char** error_out) { + gaffer_debug_pause((gaffer_session*)s, error_out); +} +static void go_debug_step_into(uintptr_t s, const char** error_out) { + gaffer_debug_step_into((gaffer_session*)s, error_out); +} +static void go_debug_step_over(uintptr_t s, const char** error_out) { + gaffer_debug_step_over((gaffer_session*)s, error_out); +} +static void go_debug_step_out(uintptr_t s, const char** error_out) { + gaffer_debug_step_out((gaffer_session*)s, error_out); +} +static const char* go_debug_evaluate(uintptr_t s, const char* expression, const char** error_out) { + return gaffer_debug_evaluate((gaffer_session*)s, expression, error_out); +} +static const char* go_debug_get_call_stack(uintptr_t s, const char** error_out) { + return gaffer_debug_get_call_stack((gaffer_session*)s, error_out); +} +static const char* go_debug_get_scopes(uintptr_t s, int frame_index, const char** error_out) { + return gaffer_debug_get_scopes((gaffer_session*)s, frame_index, error_out); +} +static const char* go_debug_get_variables(uintptr_t s, int variables_reference, const char** error_out) { + return gaffer_debug_get_variables((gaffer_session*)s, variables_reference, error_out); +} */ import "C" @@ -38,26 +111,15 @@ import ( // Session wraps a projection runtime session. // Not thread-safe - do not use from multiple goroutines concurrently. type Session struct { - // handle is the runtime session handle. The runtime returns small - // integers (1, 2, 3, ...) cast to a pointer, not real pointers. Stored - // in a *C.gaffer_session field, the GC's stack scan treats those as - // pointers and aborts the process when one falls below the first page - // ("invalid pointer found on stack"). A uintptr is invisible to the GC, - // so the value is only reconstituted as a pointer transiently, at the - // FFI call boundary, via c(). + // handle is the runtime session handle: a small integer (1, 2, 3, ...), + // not a real pointer. It must stay integer-typed everywhere on the Go + // side - even a transient *C.gaffer_session aborts the process if the + // GC copies the stack while it's live (see the shim comment in the cgo + // preamble). The go_* shims cast it in C at the FFI boundary. handle uintptr source string } -// c reconstitutes the C session pointer for an FFI call. The handle is an -// opaque integer the runtime never dereferences, so the conversion is safe; -// keeping it in one place keeps the uintptr->pointer cast out of every call -// site (see Session.handle). -func (s *Session) c() *C.gaffer_session { - //nolint:govet // opaque integer handle, not a real pointer (see Session.handle) - return (*C.gaffer_session)(unsafe.Pointer(s.handle)) -} - // rethrowCallbackPanic re-raises, with its original value, a panic captured // from a user callback during the FFI call that just returned (see // recoverCallback). A callback panic surfaces to the caller instead of being @@ -83,14 +145,14 @@ func NewSession(source string, optionsJSON *string) (*Session, error) { defer C.free(unsafe.Pointer(opts)) } var cErr *C.char - handle := C.gaffer_session_create(cs, opts, &cErr) + handle := C.go_session_create(cs, opts, &cErr) if err := consumeError(cErr, source); err != nil { return nil, err } - if handle == nil { + if handle == 0 { return nil, &UnexpectedError{Code: "unexpected", Desc: "unknown error", Msg: "unknown error"} } - return &Session{handle: uintptr(unsafe.Pointer(handle)), source: source}, nil + return &Session{handle: uintptr(handle), source: source}, nil } // Destroy frees all resources associated with the session. @@ -102,8 +164,8 @@ func (s *Session) Destroy() { // resumes the engine, which can fire callbacks; with the maps already // cleared those are no-ops, so no user code (and no panic) runs during // teardown. cleanupCallbacks also drops any stash as a backstop. - cleanupCallbacks(s.c()) - C.gaffer_session_destroy(s.c()) + cleanupCallbacks(s.handle) + C.go_session_destroy(C.uintptr_t(s.handle)) s.handle = 0 } @@ -120,7 +182,7 @@ func (s *Session) Feed(eventJSON string) (*FeedResult, error) { cs := C.CString(eventJSON) defer C.free(unsafe.Pointer(cs)) var cErr *C.char - result := C.gaffer_session_feed(s.c(), cs, &cErr) + result := C.go_session_feed(C.uintptr_t(s.handle), cs, &cErr) defer C.gaffer_free(unsafe.Pointer(result)) if err := consumeError(cErr, s.source); err != nil { return nil, err @@ -146,7 +208,7 @@ func (s *Session) GetState(partition *string) (*string, error) { defer C.free(unsafe.Pointer(cp)) } var cErr *C.char - result := C.gaffer_session_get_state(s.c(), cp, &cErr) + result := C.go_session_get_state(C.uintptr_t(s.handle), cp, &cErr) defer C.gaffer_free(unsafe.Pointer(result)) if err := consumeError(cErr, s.source); err != nil { return nil, err @@ -163,7 +225,7 @@ func (s *Session) GetState(partition *string) (*string, error) { func (s *Session) GetSharedState() (*string, error) { s.ensureAlive() var cErr *C.char - result := C.gaffer_session_get_shared_state(s.c(), &cErr) + result := C.go_session_get_shared_state(C.uintptr_t(s.handle), &cErr) defer C.gaffer_free(unsafe.Pointer(result)) if err := consumeError(cErr, s.source); err != nil { return nil, err @@ -189,7 +251,7 @@ func (s *Session) SetState(partition *string, stateJSON string) error { cs := C.CString(stateJSON) defer C.free(unsafe.Pointer(cs)) var cErr *C.char - C.gaffer_session_set_state(s.c(), cp, cs, &cErr) + C.go_session_set_state(C.uintptr_t(s.handle), cp, cs, &cErr) return consumeError(cErr, s.source) } @@ -206,7 +268,7 @@ func (s *Session) GetResult(partition *string) (*string, error) { defer C.free(unsafe.Pointer(cp)) } var cErr *C.char - result := C.gaffer_session_get_result(s.c(), cp, &cErr) + result := C.go_session_get_result(C.uintptr_t(s.handle), cp, &cErr) defer C.gaffer_free(unsafe.Pointer(result)) if err := consumeError(cErr, s.source); err != nil { return nil, err @@ -221,7 +283,7 @@ func (s *Session) GetResult(partition *string) (*string, error) { // GetSources returns the projection's source configuration and features. func (s *Session) GetSources() ProjectionInfo { s.ensureAlive() - result := C.gaffer_session_get_sources(s.c(), nil) + result := C.go_session_get_sources(C.uintptr_t(s.handle), nil) defer C.gaffer_free(unsafe.Pointer(result)) if result == nil { return ProjectionInfo{} @@ -241,7 +303,7 @@ func (s *Session) GetPartitionKey(eventJSON string) (*string, error) { cs := C.CString(eventJSON) defer C.free(unsafe.Pointer(cs)) var cErr *C.char - result := C.gaffer_session_get_partition_key(s.c(), cs, &cErr) + result := C.go_session_get_partition_key(C.uintptr_t(s.handle), cs, &cErr) defer C.gaffer_free(unsafe.Pointer(result)) if err := consumeError(cErr, s.source); err != nil { return nil, err @@ -255,29 +317,29 @@ func (s *Session) GetPartitionKey(eventJSON string) (*string, error) { // OnEmit registers a callback for emitted events (emit and linkTo). func (s *Session) OnEmit(cb EmitCallback) { - sessionOnEmit(s.c(), cb) + sessionOnEmit(s.handle, cb) } // OnLog registers a callback for console.log output. func (s *Session) OnLog(cb LogCallback) { - sessionOnLog(s.c(), cb) + sessionOnLog(s.handle, cb) } // OnDiagnostic registers a callback for quirks that fire while processing an // event, invoked at the point each fires. The quirk is also included in the // feed result's Diagnostics. func (s *Session) OnDiagnostic(cb DiagnosticCallback) { - sessionOnDiagnostic(s.c(), cb) + sessionOnDiagnostic(s.handle, cb) } // OnStateChanged registers a callback for state changes. func (s *Session) OnStateChanged(cb StateChangedCallback) { - sessionOnStateChanged(s.c(), cb) + sessionOnStateChanged(s.handle, cb) } // OnBreak registers a callback for debug pause notifications. func (s *Session) OnBreak(cb BreakCallback) { - sessionOnBreak(s.c(), cb) + sessionOnBreak(s.handle, cb) } // SnappedBreakpoint is the actual position where a breakpoint was set after snapping. @@ -314,7 +376,7 @@ func (s *Session) SetBreakpoint(line, column int, opts *BreakpointOptions) (*Sna } } var cErr *C.char - result := C.gaffer_debug_set_breakpoint(s.c(), C.int(line), C.int(column), cond, hitCond, logMsg, &cErr) + result := C.go_debug_set_breakpoint(C.uintptr_t(s.handle), C.int(line), C.int(column), cond, hitCond, logMsg, &cErr) defer C.gaffer_free(unsafe.Pointer(result)) if err := consumeError(cErr, s.source); err != nil { return nil, err @@ -335,7 +397,7 @@ func (s *Session) SetBreakpoint(line, column int, opts *BreakpointOptions) (*Sna func (s *Session) Pause() error { s.ensureAlive() var cErr *C.char - C.gaffer_debug_pause(s.c(), &cErr) + C.go_debug_pause(C.uintptr_t(s.handle), &cErr) return consumeError(cErr, s.source) } @@ -345,7 +407,7 @@ func (s *Session) StepInto() error { s.ensureAlive() defer s.rethrowCallbackPanic() var cErr *C.char - C.gaffer_debug_step_into(s.c(), &cErr) + C.go_debug_step_into(C.uintptr_t(s.handle), &cErr) return consumeError(cErr, s.source) } @@ -355,7 +417,7 @@ func (s *Session) StepOver() error { s.ensureAlive() defer s.rethrowCallbackPanic() var cErr *C.char - C.gaffer_debug_step_over(s.c(), &cErr) + C.go_debug_step_over(C.uintptr_t(s.handle), &cErr) return consumeError(cErr, s.source) } @@ -365,7 +427,7 @@ func (s *Session) StepOut() error { s.ensureAlive() defer s.rethrowCallbackPanic() var cErr *C.char - C.gaffer_debug_step_out(s.c(), &cErr) + C.go_debug_step_out(C.uintptr_t(s.handle), &cErr) return consumeError(cErr, s.source) } @@ -376,7 +438,7 @@ func (s *Session) Evaluate(expression string) (*DebugVariable, error) { cs := C.CString(expression) defer C.free(unsafe.Pointer(cs)) var cErr *C.char - result := C.gaffer_debug_evaluate(s.c(), cs, &cErr) + result := C.go_debug_evaluate(C.uintptr_t(s.handle), cs, &cErr) defer C.gaffer_free(unsafe.Pointer(result)) if err := consumeError(cErr, s.source); err != nil { return nil, err @@ -396,7 +458,7 @@ func (s *Session) Evaluate(expression string) (*DebugVariable, error) { func (s *Session) ClearBreakpoints() error { s.ensureAlive() var cErr *C.char - C.gaffer_debug_clear_breakpoints(s.c(), &cErr) + C.go_debug_clear_breakpoints(C.uintptr_t(s.handle), &cErr) return consumeError(cErr, s.source) } @@ -406,7 +468,7 @@ func (s *Session) Continue() error { s.ensureAlive() defer s.rethrowCallbackPanic() var cErr *C.char - C.gaffer_debug_continue(s.c(), &cErr) + C.go_debug_continue(C.uintptr_t(s.handle), &cErr) return consumeError(cErr, s.source) } @@ -415,7 +477,7 @@ func (s *Session) GetCallStack() ([]DebugCallFrame, error) { s.ensureAlive() defer s.rethrowCallbackPanic() var cErr *C.char - result := C.gaffer_debug_get_call_stack(s.c(), &cErr) + result := C.go_debug_get_call_stack(C.uintptr_t(s.handle), &cErr) defer C.gaffer_free(unsafe.Pointer(result)) if err := consumeError(cErr, s.source); err != nil { return nil, err @@ -435,7 +497,7 @@ func (s *Session) GetScopes(frameIndex int) ([]DebugScopeInfo, error) { s.ensureAlive() defer s.rethrowCallbackPanic() var cErr *C.char - result := C.gaffer_debug_get_scopes(s.c(), C.int(frameIndex), &cErr) + result := C.go_debug_get_scopes(C.uintptr_t(s.handle), C.int(frameIndex), &cErr) defer C.gaffer_free(unsafe.Pointer(result)) if err := consumeError(cErr, s.source); err != nil { return nil, err @@ -455,7 +517,7 @@ func (s *Session) GetVariables(variablesReference int) ([]DebugVariable, error) s.ensureAlive() defer s.rethrowCallbackPanic() var cErr *C.char - result := C.gaffer_debug_get_variables(s.c(), C.int(variablesReference), &cErr) + result := C.go_debug_get_variables(C.uintptr_t(s.handle), C.int(variablesReference), &cErr) defer C.gaffer_free(unsafe.Pointer(result)) if err := consumeError(cErr, s.source); err != nil { return nil, err diff --git a/bindings/go/runtime_test.go b/bindings/go/runtime_test.go index b55dad2e..2caeeeff 100644 --- a/bindings/go/runtime_test.go +++ b/bindings/go/runtime_test.go @@ -105,13 +105,15 @@ func TestSessionHandleIsNotPointer(t *testing.T) { // TestSessionHandleSurvivesConcurrentGC exercises the FFI under GC pressure: // it feeds concurrently across sessions while a background goroutine churns -// runtime.GC(), so a stack scan can coincide with a live handle mid-Feed -// (consumeError -> json.Unmarshal) or mid-callback (the handle flows back -// through user_data into the emit trampoline). With the handle stored as a -// pointer this could trip the GC's invalidptr abort; uintptr storage and an -// integer-typed user_data keep it off the stack maps. This is a probabilistic -// behavioural check - TestSessionHandleIsNotPointer is the deterministic guard -// against the field type regressing. +// runtime.GC(), so a stack copy can coincide with a live handle - mid-Feed +// (consumeError -> json.Unmarshal), mid-callback (the emit trampoline +// re-enters Go while the FFI call's frames sit below it, stack movable), or +// at the call boundary itself. Any pointer-typed slot holding the integer +// handle at such a moment trips the GC's invalidptr abort (UI-1813 caught +// exactly that in this test); handles stay uintptr/uintptr_t end-to-end and +// the go_* shims cast in C, off Go's stack maps. This is a probabilistic +// behavioural check - TestSessionHandleIsNotPointer is the deterministic +// guard against the field type regressing. func TestSessionHandleSurvivesConcurrentGC(t *testing.T) { const ( sessions = 8