Skip to content

objc/ffi: keep CIF arg types alive for the closure's lifetime - #289

Open
pmoust wants to merge 1 commit into
progrium:mainfrom
pmoust:fix-ffi-arg-types-lifetime
Open

pmoust wants to merge 1 commit into
progrium:mainfrom
pmoust:fix-ffi-arg-types-lifetime

Conversation

@pmoust

@pmoust pmoust commented Jul 29, 2026

Copy link
Copy Markdown

Fixes #286.

ffi_prep_cif stores rtype and the address of argtypes' backing
array inside the CIF for as long as the CIF is used. A closure built
from that CIF (via CreateClosure) can be invoked long after
PrepCIF returns, once an AppKit delegate callback fires, but nothing
kept either value alive past the call itself. runtime.KeepAlive only
extends liveness through the call it's in, not beyond it.

Go 1.25's smarter escape analysis started stack-allocating the
argtypes array at the call sites in call.go, block.go,
protocol.go, and type_convertion.go, instead of heap-allocating it
as earlier Go versions happened to. Once stack-allocated, that memory
gets reused before the closure fires, so cif->arg_types ends up
pointing at garbage. macOS 26's libffi.dylib added a stricter
arg_types[i]->type > 15 check that turns this into a reproducible
SIGABRT on launch, in the very first delegate callback
(applicationDidFinishLaunching:). Full disassembly-level detail is
in the issue thread.

This wraps rtype and argtypes in a cgo.Handle stored alongside
the CIF, so both stay reachable for as long as the CIF itself does,
and deletes that handle once the CIF is garbage collected via
runtime.SetFinalizer. Passing them through a cgo.Handle also forces
them onto the heap, which is what closes off the escape-analysis path
that caused the bug.

I considered copying argtypes into malloc'd C memory instead (an
option floated in the issue), but that would store Go pointers
(including ones from MakeStructType) in memory the Go GC doesn't
scan, which is itself a cgo pointer-passing rule violation. The
cgo.Handle approach avoids that.

Verification:

  • go build -gcflags='github.com/progrium/darwinkit/objc=-m' ./objc/...
    shows the flip this fix is meant to produce: before, make([]*ffi...)
    at all five affected call sites reports "does not escape"; after, all
    five report "escapes to heap".
  • go test ./objc/... passes, go vet ./objc/... is clean, gofmt -l
    reports no changes needed on the touched files.
  • Reproduced the actual crash against a real long-lived AppKit app (a
    status-bar menu app using a closure-based NSApplicationDelegate) on
    Go 1.26.1, macOS 26.5 arm64: it reliably SIGABRTs on launch without
    this fix and without the -gcflags "all=-N" workaround, and launches
    and stays running with this fix applied and that workaround removed.

ffi_prep_cif stores rtype and the address of argtypes' backing array
inside the CIF for as long as the CIF is used. A closure built from
that CIF can be invoked long after PrepCIF returns, but nothing kept
either value alive past the call itself. Go 1.25's escape analysis
started stack-allocating the argtypes array instead of heap-allocating
it, so that memory got reused before AppKit ever invoked the closure.
macOS 26's libffi added a stricter arg-type check that turns this
memory corruption into a reproducible SIGABRT on launch. Fixes progrium#286.

Wrap rtype and argtypes in a cgo.Handle so they stay reachable for as
long as the CIF does, and free that handle once the CIF is collected.
This also forces the values onto the heap, closing off the escape
analysis path that triggered the bug. Verified with
-gcflags='github.com/progrium/darwinkit/objc=-m': before the fix, the
argtypes array at the five affected call sites (call.go, block.go,
protocol.go, type_convertion.go) does not escape; after, all five
escape to heap.
@progrium

progrium commented Jul 30, 2026 •

Copy link
Copy Markdown
Owner

LGTM, can anybody verify. Will merge with a thumbs up

Also @pmoust thank you for this

Joaquimmnetto added a commit to Joaquimmnetto/macos-ghpr-bar that referenced this pull request Aug 20, 2026
darwinkit's objc/ffi.PrepCIF hands a Go-allocated arg-types array to
ffi_prep_cif, which keeps a raw pointer to it inside the ffi_cif for as
long as any closure built from it can still be invoked. Nothing kept
that array reachable past PrepCIF returning, so on Go 1.25+ (whose
smarter escape analysis stack-allocates it instead of heap-allocating
it) the memory gets reused before AppKit ever calls back into Go,
corrupting the CIF and aborting inside ffi_closure_SYSV_inner on the
very first delegate callback (applicationDidFinishLaunching:) -
reliably 100% of the time on this machine (macOS 15.7.4, Go 1.26.4).

Pins the fork/branch backing progrium/darwinkit#289 via a go.mod
replace directive until it's merged upstream. Also marks the status
bar icon as a template image so it renders correctly in both light and
dark menu bars.
@Joaquimmnetto

Copy link
Copy Markdown

I tested this in my project (macOS 15.7.4, Go 1.26.4) and works fine. Before I was having SIGABRT issues coming from libffi.
Bellow is a description of the issue from claude:

darwinkit's objc/ffi.PrepCIF hands a Go-allocated arg-types array to
ffi_prep_cif, which keeps a raw pointer to it inside the ffi_cif for as
long as any closure built from it can still be invoked. Nothing kept
that array reachable past PrepCIF returning, so on Go 1.25+ (whose
smarter escape analysis stack-allocates it instead of heap-allocating
it) the memory gets reused before AppKit ever calls back into Go,
corrupting the CIF and aborting inside ffi_closure_SYSV_inner on the
very first delegate callback (applicationDidFinishLaunching)

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.

Runtime panic on go v1.25.x running the example app.

3 participants