From 432ebe13ed90f92daf8575e761a376609f2326c0 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:48:11 -0700 Subject: [PATCH] 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. --- builder/sizes_test.go | 6 +-- main_test.go | 44 ++++++++++----- src/internal/task/task_asyncify.go | 1 + src/internal/task/task_exit.go | 42 +++++++++++++++ src/internal/task/task_stack.go | 4 +- src/internal/task/task_threads.c | 5 ++ src/internal/task/task_threads.go | 52 +++++++++++++++--- src/runtime/panic.go | 2 +- src/runtime/runtime_windows.go | 8 +-- src/runtime/scheduler_cooperative.go | 4 ++ src/runtime/scheduler_cores.go | 4 ++ src/runtime/scheduler_none.go | 4 ++ src/runtime/scheduler_threads.go | 5 +- testdata/goexit.go | 80 ++++++++++++++++++++++++++-- 14 files changed, 225 insertions(+), 36 deletions(-) create mode 100644 src/internal/task/task_exit.go diff --git a/builder/sizes_test.go b/builder/sizes_test.go index 5e809760f..5f75d484b 100644 --- a/builder/sizes_test.go +++ b/builder/sizes_test.go @@ -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 diff --git a/main_test.go b/main_test.go index 53bade009..b7dfa6b0f 100644 --- a/main_test.go +++ b/main_test.go @@ -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()) + } + }) } } diff --git a/src/internal/task/task_asyncify.go b/src/internal/task/task_asyncify.go index 546b6841b..0f7437067 100644 --- a/src/internal/task/task_asyncify.go +++ b/src/internal/task/task_asyncify.go @@ -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) } diff --git a/src/internal/task/task_exit.go b/src/internal/task/task_exit.go new file mode 100644 index 000000000..0c2bb8ee8 --- /dev/null +++ b/src/internal/task/task_exit.go @@ -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") +} diff --git a/src/internal/task/task_stack.go b/src/internal/task/task_stack.go index b6c4a5df9..9db5c5890 100644 --- a/src/internal/task/task_stack.go +++ b/src/internal/task/task_stack.go @@ -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) } diff --git a/src/internal/task/task_threads.c b/src/internal/task/task_threads.c index 5ff519d91..5e94379b9 100644 --- a/src/internal/task/task_threads.c +++ b/src/internal/task/task_threads.c @@ -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); diff --git a/src/internal/task/task_threads.go b/src/internal/task/task_threads.go index 2085fa979..0d14eae34 100644 --- a/src/internal/task/task_threads.go +++ b/src/internal/task/task_threads.go @@ -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 diff --git a/src/runtime/panic.go b/src/runtime/panic.go index cec4f74fe..0afd90a11 100644 --- a/src/runtime/panic.go +++ b/src/runtime/panic.go @@ -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) diff --git a/src/runtime/runtime_windows.go b/src/runtime/runtime_windows.go index 839356510..f961a5670 100644 --- a/src/runtime/runtime_windows.go +++ b/src/runtime/runtime_windows.go @@ -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 diff --git a/src/runtime/scheduler_cooperative.go b/src/runtime/scheduler_cooperative.go index d30ec4196..5970dae38 100644 --- a/src/runtime/scheduler_cooperative.go +++ b/src/runtime/scheduler_cooperative.go @@ -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) diff --git a/src/runtime/scheduler_cores.go b/src/runtime/scheduler_cores.go index 25a2ada87..cfec67500 100644 --- a/src/runtime/scheduler_cores.go +++ b/src/runtime/scheduler_cores.go @@ -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) { diff --git a/src/runtime/scheduler_none.go b/src/runtime/scheduler_none.go index ed259b0d1..a9fc65aa8 100644 --- a/src/runtime/scheduler_none.go +++ b/src/runtime/scheduler_none.go @@ -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. } diff --git a/src/runtime/scheduler_threads.go b/src/runtime/scheduler_threads.go index acc1281f2..5b6638b98 100644 --- a/src/runtime/scheduler_threads.go +++ b/src/runtime/scheduler_threads.go @@ -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() } diff --git a/testdata/goexit.go b/testdata/goexit.go index 15c828e9d..fd6c0d2cd 100644 --- a/testdata/goexit.go +++ b/testdata/goexit.go @@ -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() + } }