runtime: make Goexit exit host threads

The threads scheduler handled runtime.Goexit using the generic deadlock
path, which parked the current pthread forever. Add a scheduler-specific
goexit hook so it can remove the current task and exit the pthread while
ordinary blocking deadlocks still block.

Track main goroutine Goexit so the last remaining goroutine reports the
expected deadlock instead of letting the process exit successfully.
Expand the crash coverage with the Goexit cases covered by Big Go's
runtime tests.
This commit is contained in:
Jake Bailey
2026-07-03 16:48:11 -07:00
committed by Ron Evans
parent 16eefc59eb
commit 432ebe13ed
14 changed files with 225 additions and 36 deletions
+3 -3
View File
@@ -42,9 +42,9 @@ func TestBinarySize(t *testing.T) {
// This is a small number of very diverse targets that we want to test.
tests := []sizeTest{
// microcontrollers
{"hifive1b", "examples/echo", 3699, 297, 0, 2252},
{"microbit", "examples/serial", 2736, 356, 8, 2248},
{"wioterminal", "examples/pininterrupt", 7960, 1652, 132, 7480},
{"hifive1b", "examples/echo", 3765, 307, 0, 2260},
{"microbit", "examples/serial", 2824, 368, 8, 2256},
{"wioterminal", "examples/pininterrupt", 8041, 1663, 132, 7488},
// TODO: also check wasm. Right now this is difficult, because
// wasm binaries are run through wasm-opt and therefore the
+30 -14
View File
@@ -963,20 +963,36 @@ func TestGoexitCrash(t *testing.T) {
t.Fatal(err)
}
output := &bytes.Buffer{}
_, err = buildAndRun("testdata/goexit.go", config, output, nil, nil, time.Minute, func(cmd *exec.Cmd, result builder.BuildResult) error {
cmd.Stdout = nil
cmd.Stderr = nil
data, err := cmd.CombinedOutput()
output.Write(data)
return err
})
if err == nil {
t.Fatal("program unexpectedly exited successfully")
}
const want = "panic: panic after Goexit"
if !strings.Contains(output.String(), want) {
t.Fatalf("output does not contain %q:\n%s", want, output.String())
for _, tc := range []struct {
name string
want string
}{
{"main", "all goroutines are asleep - deadlock!"},
{"deadlock", "all goroutines are asleep - deadlock!"},
{"exit", "all goroutines are asleep - deadlock!"},
{"main-other", "all goroutines are asleep - deadlock!"},
{"in-panic", "all goroutines are asleep - deadlock!"},
{"panic", "panic: panic after Goexit"},
{"recovered-panic", "all goroutines are asleep - deadlock!"},
{"recover-before-panic", "all goroutines are asleep - deadlock!"},
{"recover-before-panic-loop", "all goroutines are asleep - deadlock!"},
} {
t.Run(tc.name, func(t *testing.T) {
output := &bytes.Buffer{}
_, err = buildAndRun("testdata/goexit.go", config, output, []string{tc.name}, nil, time.Minute, func(cmd *exec.Cmd, result builder.BuildResult) error {
cmd.Stdout = nil
cmd.Stderr = nil
data, err := cmd.CombinedOutput()
output.Write(data)
return err
})
if err == nil {
t.Fatal("program unexpectedly exited successfully")
}
if !strings.Contains(output.String(), tc.want) {
t.Fatalf("output does not contain %q:\n%s", tc.want, output.String())
}
})
}
}
+1
View File
@@ -51,6 +51,7 @@ type stackState struct {
// The new goroutine is immediately started.
func start(fn uintptr, args unsafe.Pointer, stackSize uintptr) {
t := &Task{}
addLiveTask(t)
t.state.initialize(fn, args, stackSize)
scheduleTask(t)
}
+42
View File
@@ -0,0 +1,42 @@
//go:build scheduler.tasks || scheduler.asyncify || scheduler.cores
package task
import "sync/atomic"
var (
mainTask *Task
liveTasks uint32
mainExitedByGoexit uint32
)
func addLiveTask(t *Task) {
if mainTask == nil {
mainTask = t
}
atomic.AddUint32(&liveTasks, 1)
}
// Exit exits the current task because runtime.Goexit was called.
func Exit() {
exit(true)
}
func exit(goexit bool) {
t := Current()
remaining := atomic.AddUint32(&liveTasks, ^uint32(0))
if t == mainTask {
if goexit {
if remaining == 0 {
runtimePanic("all goroutines are asleep - deadlock!")
}
atomic.StoreUint32(&mainExitedByGoexit, 1)
}
} else if atomic.LoadUint32(&mainExitedByGoexit) != 0 && remaining == 0 {
runtimePanic("all goroutines are asleep - deadlock!")
}
// TODO: explicitly free the stack after switching back to the scheduler.
Pause()
runtimePanic("unreachable")
}
+2 -2
View File
@@ -33,8 +33,7 @@ type state struct {
//export tinygo_task_exit
func taskExit() {
// TODO: explicitly free the stack after switching back to the scheduler.
Pause()
exit(false)
}
// initialize the state and prepare to call the specified function with the specified argument bundle.
@@ -73,6 +72,7 @@ var startTask [0]uint8
// The new goroutine is scheduled to run later.
func start(fn uintptr, args unsafe.Pointer, stackSize uintptr) {
t := &Task{}
addLiveTask(t)
t.state.initialize(fn, args, stackSize)
scheduleTask(t)
}
+5
View File
@@ -145,6 +145,11 @@ void* tinygo_task_current(void) {
return current_task;
}
// Exit the current thread.
void tinygo_task_exit(void) {
pthread_exit(NULL);
}
// Send a signal to cause the task to pause for the GC mark phase.
void tinygo_task_send_gc_signal(pthread_t thread) {
pthread_kill(thread, taskPauseSignal);
+45 -7
View File
@@ -43,7 +43,9 @@ var mainTask Task
// Queue of tasks (see QueueNext) that currently exist in the program.
var activeTasks = &mainTask
var activeTaskCount uint32 = 1
var activeTaskLock PMutex
var mainExitedByGoexit bool
func OnSystemStack() bool {
runtimePanic("todo: task.OnSystemStack")
@@ -95,9 +97,6 @@ func (t *Task) Resume() {
t.state.pauseSem.Post()
}
// otherGoroutines is the total number of live goroutines minus one.
var otherGoroutines uint32
// Start a new OS thread.
func start(fn uintptr, args unsafe.Pointer, stackSize uintptr) {
t := &Task{}
@@ -117,7 +116,7 @@ func start(fn uintptr, args unsafe.Pointer, stackSize uintptr) {
}
t.state.QueueNext = activeTasks
activeTasks = t
otherGoroutines++
activeTaskCount++
activeTaskLock.Unlock()
}
@@ -127,6 +126,12 @@ func taskExited(t *Task) {
println("*** exit:", t.state.id)
}
if exit(t) {
runtimePanic("all goroutines are asleep - deadlock!")
}
}
func exit(t *Task) bool {
// Remove from the queue.
// TODO: this can be made more efficient by using a doubly linked list.
activeTaskLock.Lock()
@@ -135,16 +140,46 @@ func taskExited(t *Task) {
if *q == t {
*q = t.state.QueueNext
found = true
activeTaskCount--
break
}
}
otherGoroutines--
deadlocked := mainExitedByGoexit && activeTaskCount == 0
activeTaskLock.Unlock()
// Sanity check.
if !found {
runtimePanic("taskExited failed")
}
return deadlocked
}
func otherTasks(current *Task) uint32 {
if activeTaskCount == 0 {
return 0
}
return activeTaskCount - 1
}
// Exit exits the current task. If this is the main task and there are no other
// goroutines, it reports a deadlock.
func Exit() {
t := Current()
if t == &mainTask {
activeTaskLock.Lock()
noOtherTasks := otherTasks(t) == 0
if !noOtherTasks {
mainExitedByGoexit = true
}
activeTaskLock.Unlock()
if noOtherTasks {
runtimePanic("all goroutines are asleep - deadlock!")
}
}
if exit(t) {
runtimePanic("all goroutines are asleep - deadlock!")
}
tinygo_task_exit()
}
// scanWaitGroup is used to wait on until all threads have finished the current state transition.
@@ -206,7 +241,7 @@ func GCStopWorldAndScan() {
gcState.Store(gcStateStopped)
// Set the number of threads to wait for.
scanWaitGroup = initWaitGroup(otherGoroutines)
scanWaitGroup = initWaitGroup(otherTasks(current))
// Pause all other threads.
for t := activeTasks; t != nil; t = t.state.QueueNext {
@@ -242,7 +277,7 @@ func GCResumeWorld() {
}
// Set the wait group to track resume progress.
scanWaitGroup = initWaitGroup(otherGoroutines)
scanWaitGroup = initWaitGroup(otherTasks(Current()))
// Set the state to resumed.
gcState.Store(gcStateResumed)
@@ -304,6 +339,9 @@ func tinygo_task_init(t *Task, thread *threadID, numCPU *int32)
//go:linkname tinygo_task_start tinygo_task_start
func tinygo_task_start(fn uintptr, args unsafe.Pointer, t *Task, thread *threadID, stackTop *uintptr, stackSize uintptr) int32
//go:linkname tinygo_task_exit tinygo_task_exit
func tinygo_task_exit()
// Pause the thread by sending it a signal.
//
//export tinygo_task_send_gc_signal
+1 -1
View File
@@ -78,7 +78,7 @@ func panicOrGoexit(message interface{}, panicking panicState) {
if panicking == panicGoexit {
// Call to Goexit() instead of a panic.
// Exit the goroutine instead of printing a panic message.
deadlock()
goexit()
}
printstring("panic: ")
printitf(message)
+5 -3
View File
@@ -2,9 +2,6 @@ package runtime
import "unsafe"
//export abort
func abort()
//export exit
func libc_exit(code int)
@@ -132,6 +129,11 @@ func putchar(c byte) {
libc_putchar(int(c))
}
func abort() {
libc_exit(1)
trap()
}
var heapSize uintptr = 128 * 1024 // small amount to start
var heapMaxSize uintptr
+4
View File
@@ -54,6 +54,10 @@ func deadlock() {
panic("unreachable")
}
func goexit() {
task.Exit()
}
// Add this task to the end of the run queue.
func scheduleTask(t *task.Task) {
runqueue.Push(t)
+4
View File
@@ -32,6 +32,10 @@ func deadlock() {
trap()
}
func goexit() {
task.Exit()
}
// Mark the given task as ready to resume.
// This is allowed even if the task isn't paused yet, but will pause soon.
func scheduleTask(t *task.Task) {
+4
View File
@@ -42,6 +42,10 @@ func deadlock() {
runtimePanic("all goroutines are asleep - deadlock!")
}
func goexit() {
deadlock()
}
func scheduleTask(t *task.Task) {
// Pause() will panic, so this should not be reachable.
}
+4 -1
View File
@@ -40,10 +40,13 @@ func sleep(duration int64) {
}
func deadlock() {
// TODO: exit the thread via pthread_exit.
task.Pause()
}
func goexit() {
task.Exit()
}
func scheduleTask(t *task.Task) {
t.Resume()
}
+75 -5
View File
@@ -1,10 +1,80 @@
package main
import "runtime"
import (
"os"
"runtime"
"time"
)
func main() {
defer func() {
panic("panic after Goexit")
}()
runtime.Goexit()
switch os.Args[1] {
case "main":
runtime.Goexit()
case "deadlock":
f := func() {
for i := 0; i < 10; i++ {
}
}
go f()
go f()
runtime.Goexit()
case "exit":
go func() {
time.Sleep(time.Millisecond)
}()
i := 0
runtime.SetFinalizer(&i, func(p *int) {})
runtime.GC()
runtime.Goexit()
case "main-other":
go func() {
println("other goroutine ran")
}()
runtime.Goexit()
case "in-panic":
go func() {
defer func() {
runtime.Goexit()
}()
panic("panic before Goexit")
}()
runtime.Goexit()
case "panic":
defer func() {
panic("panic after Goexit")
}()
runtime.Goexit()
case "recovered-panic":
defer func() {
defer func() {
recover()
}()
panic("recovered panic after Goexit")
}()
runtime.Goexit()
case "recover-before-panic":
defer func() {
if recover() == nil {
panic("bad recover")
}
}()
defer func() {
panic("panic during Goexit")
}()
runtime.Goexit()
case "recover-before-panic-loop":
for i := 0; i < 2; i++ {
defer func() {
}()
}
defer func() {
if recover() == nil {
panic("bad recover")
}
}()
defer func() {
panic("panic during Goexit")
}()
runtime.Goexit()
}
}