Skip to content

os/signal: signal.Notify permanently starves the scheduler on wasm (stubbed signal_recv returns immediately) #5619

Description

@neomantra

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions