Summary
A cgo-enabled Go test binary SIGSEGVs at process exit (~95% of runs) on Linux when:
- the main goroutine is pinned to thread 0 from
init (runtime.LockOSThread() in an init function), and
- a test goroutine also calls
runtime.LockOSThread(), and
- the test goroutine makes a purego-style
runtime.cgocall (any libc function loaded via purego), then
- the process exits immediately.
Plain cgo calls (import "C") do not trigger it. CGO_ENABLED=0 does not trigger it. No -race needed (reproduces with and without).
Every test passes; the crash happens after the test returns, during process shutdown.
Minimal repro
go.mod:
module goexitrepro
go 1.26
require github.com/ebitengine/purego v0.10.1
lockinit_test.go:
package goexitrepro
import "runtime"
// Main goroutine pinned to the initial OS thread from init onward
// (same pattern as go-gui's gui/backend/gl/mainthread.go).
func init() {
runtime.LockOSThread()
}
call_test.go:
package goexitrepro
import (
"runtime"
"testing"
"github.com/ebitengine/purego"
)
var getpid func() int32
func init() {
libc, err := purego.Dlopen("libc.so.6", purego.RTLD_NOW)
if err != nil {
panic(err)
}
purego.RegisterLibFunc(&getpid, libc, "getpid")
}
func TestLockedPuregoCall(t *testing.T) {
runtime.LockOSThread()
getpid()
}
Reproduce:
go test -c -o repro.test . && ./repro.test -test.run '^TestLockedPuregoCall$'
19/20 runs segfault at exit (signal: segmentation fault (core dumped)). Also reproduces with plain go test and with -race.
Bisected trigger
Removing any one of these stops the crash (all verified):
- the
init LockOSThread
- the test's LockOSThread
- the purego call (plain
import "C" call with the same locks is clean)
- cgo (CGO_ENABLED=0 is clean)
Crash analysis (core dump)
si_code = SEGV_ACCERR, si_addr = a readable-but-not-executable Go heap page: the thread jumped to a freed heap object as code.
- The crashing thread is a newly created scheduler M (started via
_cgo_thread_start → pthread threadentry → crosscall1; mstartfn = runtime.mspinning, i.e. created by startm with spinning=true) that begins executing during process exit.
- Its first Go execution runs with garbage state and crashes on an indirect call. Depending on run, the "code" it executes is either stale frames or stale goroutine
sched.pc values; one core shows it running crypto/internal/fips140/aes/gcm.init.0.func1 (FIPS self-test init code) with a corrupted interface method pointer, another shows the jump target being purego's heap-escaped syscall15Args struct.
- Hypothesis (unconfirmed): at exit a goroutine is left runnable with freed g/stack, and the new M (created by
startm during shutdown) runs it — a scheduler use-after-free in the exit path. The LockOSThread pieces change the timing/state such that the race fires almost deterministically.
Environment
- Linux (X11 or headless both fine — no display needed), amd64
- Go 1.26.1
- purego v0.10.1 (matters only because it exercises
runtime.cgocall with a heap-escaped argument struct; the bug appears to be in the runtime's exit path, not purego)
Originally reported at go-gui-org/go-gui#162 (go-gui's GL backend triggers it: LockOSThread in init + LockOSThread in New + purego EGL calls + immediate exit in a go test binary).
Summary
A cgo-enabled Go test binary SIGSEGVs at process exit (~95% of runs) on Linux when:
init(runtime.LockOSThread()in aninitfunction), andruntime.LockOSThread(), andruntime.cgocall(any libc function loaded via purego), thenPlain cgo calls (
import "C") do not trigger it.CGO_ENABLED=0does not trigger it. No-raceneeded (reproduces with and without).Every test passes; the crash happens after the test returns, during process shutdown.
Minimal repro
go.mod:lockinit_test.go:call_test.go:Reproduce:
19/20 runs segfault at exit (
signal: segmentation fault (core dumped)). Also reproduces with plaingo testand with-race.Bisected trigger
Removing any one of these stops the crash (all verified):
initLockOSThreadimport "C"call with the same locks is clean)Crash analysis (core dump)
si_code = SEGV_ACCERR,si_addr= a readable-but-not-executable Go heap page: the thread jumped to a freed heap object as code._cgo_thread_start→ pthreadthreadentry→crosscall1;mstartfn = runtime.mspinning, i.e. created bystartmwith spinning=true) that begins executing during process exit.sched.pcvalues; one core shows it runningcrypto/internal/fips140/aes/gcm.init.0.func1(FIPS self-test init code) with a corrupted interface method pointer, another shows the jump target being purego's heap-escapedsyscall15Argsstruct.startmduring shutdown) runs it — a scheduler use-after-free in the exit path. The LockOSThread pieces change the timing/state such that the race fires almost deterministically.Environment
runtime.cgocallwith a heap-escaped argument struct; the bug appears to be in the runtime's exit path, not purego)Originally reported at go-gui-org/go-gui#162 (go-gui's GL backend triggers it:
LockOSThreadininit+LockOSThreadinNew+ purego EGL calls + immediate exit in ago testbinary).