Skip to content

runtime: SIGSEGV at process exit in cgo test binary with LockOSThread + purego cgocall #80723

Description

@mike-ward

Summary

A cgo-enabled Go test binary SIGSEGVs at process exit (~95% of runs) on Linux when:

  1. the main goroutine is pinned to thread 0 from init (runtime.LockOSThread() in an init function), and
  2. a test goroutine also calls runtime.LockOSThread(), and
  3. the test goroutine makes a purego-style runtime.cgocall (any libc function loaded via purego), then
  4. 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 threadentrycrosscall1; 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.compiler/runtimeIssues related to the Go compiler and/or runtime.

Type

No type

Projects

Relationships

None yet

Development

No branches or pull requests

Issue actions