From 6c61020fae71831f1439b07d8409be50a6e355bc Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Thu, 27 Aug 2026 17:32:32 -0400 Subject: [PATCH] runtime: block signal_recv on targets without signal delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signalstub implementation of os/signal.signal_recv returned ^uint32(0) immediately. Upstream's os/signal.loop calls signal_recv in a tight loop with no yield point, so the watcher goroutine started by signal.Notify spun forever. On the cooperative wasm scheduler that starves every other goroutine and never returns control to the host: any program calling signal.Notify — Bubble Tea does, for example — freezes the browser tab or wasm runner at 100% CPU (a V8 profile showed 99.7% of ticks in os/signal.loop). Signals can never arrive on these targets, so block forever using deadlock(), the same primitive a blocking empty select uses. This matches the real implementation's behavior while no signal is pending. Add a behavioral test that runs on the stubbed platforms (the existing signal.go test is skipped there): before this change it times out on wasm; with it the sleep completes and the program exits. Signed-off-by: Evan Wies --- main_test.go | 1 + src/runtime/signalstub.go | 10 +++++++++- testdata/signalnotify.go | 22 ++++++++++++++++++++++ testdata/signalnotify.txt | 1 + 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 testdata/signalnotify.go create mode 100644 testdata/signalnotify.txt diff --git a/main_test.go b/main_test.go index 17b2b5a572..a2408f8f36 100644 --- a/main_test.go +++ b/main_test.go @@ -81,6 +81,7 @@ func TestBuild(t *testing.T) { "print.go", "reflect.go", "signal.go", + "signalnotify.go", "slice.go", "sort.go", "stdlib.go", diff --git a/src/runtime/signalstub.go b/src/runtime/signalstub.go index fd7d2606a9..0d701b02ba 100644 --- a/src/runtime/signalstub.go +++ b/src/runtime/signalstub.go @@ -18,4 +18,12 @@ func signal_ignore(uint32) {} func signal_waitUntilIdle() {} //go:linkname signal_recv os/signal.signal_recv -func signal_recv() uint32 { return ^uint32(0) } +func signal_recv() uint32 { + // Signals can never arrive on these platforms, so block forever, like the + // real implementation does while no signal is pending. os/signal.loop + // calls this function in a tight loop; returning immediately would make + // that goroutine spin, which starves cooperative schedulers: on wasm the + // program never yields back to the host again after signal.Notify. + deadlock() + return 0 +} diff --git a/testdata/signalnotify.go b/testdata/signalnotify.go new file mode 100644 index 0000000000..aa430c6270 --- /dev/null +++ b/testdata/signalnotify.go @@ -0,0 +1,22 @@ +package main + +// Verify that signal.Notify does not wedge the scheduler on platforms where +// signals can never arrive (wasm and baremetal, see runtime/signalstub.go). +// The signal watcher goroutine that Notify starts must block forever instead +// of spinning: on a cooperative scheduler a spinning goroutine starves every +// other goroutine, so the sleep below would never return. + +import ( + "os" + "os/signal" + "time" +) + +func main() { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + // Yield so the signal watcher goroutine runs (and, before the fix, takes + // over the scheduler forever). + time.Sleep(10 * time.Millisecond) + println("done") +} diff --git a/testdata/signalnotify.txt b/testdata/signalnotify.txt new file mode 100644 index 0000000000..19f86f493a --- /dev/null +++ b/testdata/signalnotify.txt @@ -0,0 +1 @@ +done