Once I finally had BubbleTea Lists compiling with stacks small enough to run (#5618), I encountered hung browsers. Upon investigation, it is related to how signal_recv is implemented for WASM, ironically it's immediate return cause infinite loops at startup.
The text below is written by LLM but I have read and reviewed it; here's some handy links for it:
What happened
On GOOS=js GOARCH=wasm, any program that calls signal.Notify stops
yielding control back to the host right after the call: the browser tab (or
node) pins a core at 100% and no JavaScript — timers, promises, event
handlers — ever runs again. The program appears to hang while actually
busy-looping.
This makes every Bubble Tea program unusable on wasm (tea.Program.Run
installs signal handlers unconditionally), and more generally breaks any
library that touches os/signal, which is common in code being ported from
the terminal to the browser.
Diagnosis
A V8 sampling profile (node --prof) of a hung Bubble Tea list app shows
where all the time goes:
ticks parent name
9330 99.7% JS: os/signal.loop
9330 100.0% JS: os/signal.Notify$1$1.gowrapper
9330 100.0% JS: tinygo_launch
9330 100.0% JS: runtime.scheduler
9330 100.0% JS: _start
The mechanism is a two-line interaction:
-
Upstream Go's os/signal package (which TinyGo uses unmodified) starts a
watcher goroutine on the first Notify:
func loop() {
for {
process(syscall.Signal(signal_recv()))
}
}
This loop has no yield point of its own — it relies on signal_recv
blocking until a signal arrives, which is what the runtime implementation
does on POSIX systems (runtime_unix.go).
-
On targets that can never receive signals, TinyGo stubs it out
(src/runtime/signalstub.go, build tags tinygo.wasm || baremetal):
//go:linkname signal_recv os/signal.signal_recv
func signal_recv() uint32 { return ^uint32(0) }
It returns immediately, forever. The watcher goroutine therefore spins,
and on a cooperative scheduler a spinning goroutine is always runnable:
the scheduler never goes idle, never calls sleepTicks, and on wasm
_start never returns to the event loop. Every other goroutine — and the
entire host — starves. The same stub also serves baremetal targets, where
a Notify call similarly wedges the (cooperative) scheduler.
Minimal reproduction
package main
import (
"os"
"os/signal"
"time"
)
func main() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
time.Sleep(10 * time.Millisecond)
println("done")
}
tinygo run on the host prints done. Built for -target=wasm, it never
prints and never returns to the host. Verified on dev @ 86d58db.
Why no test caught it
testdata/signal.go is explicitly skipped on wasm, baremetal, and windows
("Signals only work on POSIX-like systems"), so the stubbed platforms have
no os/signal coverage at all — the stub's behavior was simply never
observed.
Fix
Signals can never arrive on these targets, so signal_recv should block
forever — which is exactly what the real implementation does while no
signal is pending. TinyGo already has the right primitive: deadlock(),
used by a blocking empty select. With that change the watcher goroutine
parks, the scheduler idles normally, and Notify becomes a harmless no-op
observationally (the channel just never receives anything), matching what
gc's js/wasm port does.
I have this implemented with a behavioral test (signal.Notify, then
time.Sleep, then print) that runs on the stubbed platforms: it times out
on current dev and passes with the fix. PR incoming.
Found while verifying the parameter-spilling work (#5615) in a real
browser; it is independent of that change and reproduces on stock dev.
Environment
- TinyGo
dev @ 86d58db, LLVM 22.1.8, go1.26.7, darwin/arm64 host
- Observed on
GOOS=js GOARCH=wasm in Chrome and node (V8); mechanism
applies to all tinygo.wasm || baremetal targets
Once I finally had BubbleTea Lists compiling with stacks small enough to run (#5618), I encountered hung browsers. Upon investigation, it is related to how
signal_recvis implemented for WASM, ironically it's immediate return cause infinite loops at startup.The text below is written by LLM but I have read and reviewed it; here's some handy links for it:
signal_recvin Golangsignal_unix.gosignal_recvin TinyGoWhat happened
On
GOOS=js GOARCH=wasm, any program that callssignal.Notifystopsyielding control back to the host right after the call: the browser tab (or
node) pins a core at 100% and no JavaScript — timers, promises, event
handlers — ever runs again. The program appears to hang while actually
busy-looping.
This makes every Bubble Tea program unusable on wasm (
tea.Program.Runinstalls signal handlers unconditionally), and more generally breaks any
library that touches
os/signal, which is common in code being ported fromthe terminal to the browser.
Diagnosis
A V8 sampling profile (
node --prof) of a hung Bubble Tea list app showswhere all the time goes:
The mechanism is a two-line interaction:
Upstream Go's
os/signalpackage (which TinyGo uses unmodified) starts awatcher goroutine on the first
Notify:This loop has no yield point of its own — it relies on
signal_recvblocking until a signal arrives, which is what the runtime implementation
does on POSIX systems (
runtime_unix.go).On targets that can never receive signals, TinyGo stubs it out
(
src/runtime/signalstub.go, build tagstinygo.wasm || baremetal):It returns immediately, forever. The watcher goroutine therefore spins,
and on a cooperative scheduler a spinning goroutine is always runnable:
the scheduler never goes idle, never calls
sleepTicks, and on wasm_startnever returns to the event loop. Every other goroutine — and theentire host — starves. The same stub also serves baremetal targets, where
a
Notifycall similarly wedges the (cooperative) scheduler.Minimal reproduction
tinygo runon the host printsdone. Built for-target=wasm, it neverprints and never returns to the host. Verified on
dev@ 86d58db.Why no test caught it
testdata/signal.gois explicitly skipped on wasm, baremetal, and windows("Signals only work on POSIX-like systems"), so the stubbed platforms have
no
os/signalcoverage at all — the stub's behavior was simply neverobserved.
Fix
Signals can never arrive on these targets, so
signal_recvshould blockforever — which is exactly what the real implementation does while no
signal is pending. TinyGo already has the right primitive:
deadlock(),used by a blocking empty
select. With that change the watcher goroutineparks, the scheduler idles normally, and
Notifybecomes a harmless no-opobservationally (the channel just never receives anything), matching what
gc's js/wasm port does.
I have this implemented with a behavioral test (
signal.Notify, thentime.Sleep, then print) that runs on the stubbed platforms: it times outon current dev and passes with the fix. PR incoming.
Found while verifying the parameter-spilling work (#5615) in a real
browser; it is independent of that change and reproduces on stock dev.
Environment
dev@ 86d58db, LLVM 22.1.8, go1.26.7, darwin/arm64 hostGOOS=js GOARCH=wasmin Chrome and node (V8); mechanismapplies to all
tinygo.wasm || baremetaltargets