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).

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

Status
Todo

Relationships

None yet

Development

No branches or pull requests

Issue actions