Skip to content

wasm: keep one pending scheduler wakeup instead of leaking a timer chain - #5622

Open
0pcom wants to merge 1 commit into
tinygo-org:devfrom
0magnet:wasm-sleepticks-one-timer
Open

wasm: keep one pending scheduler wakeup instead of leaking a timer chain#5622
0pcom wants to merge 1 commit into
tinygo-org:devfrom
0magnet:wasm-sleepticks-one-timer

Conversation

@0pcom

@0pcom 0pcom commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

sleepTicks armed a fresh setTimeout on every call and never cancelled the
previous one. Its callback calls go_scheduler(), which re-enters the
cooperative scheduler, which calls sleepTicks again whenever it finds nothing
runnable but something sleeping (scheduler_cooperative.go:258, and :285 on
wasmexport return). So each armed timer replaces itself indefinitely, and every
JS→Go callback that reaches the scheduler starts another such chain. Nothing
cancels one, so the pending count grows without bound.

The scheduler only ever needs one pending wakeup — the earliest deadline. This
tracks it and drops any request that is no sooner than what is already armed.
run() also clears a wakeup left over from a previous run, which would
otherwise suppress the first one the new run asks for.

Measured in Chrome with the same wasm binary both times — 50 sleeping goroutines
and a requestAnimationFrame loop — counting setTimeout calls per second:

t+3s t+20s t+40s
before 487/sec 1,498/sec 2,509/sec
after 9/sec 5/sec 5/sec

A larger application reached ~2,800/sec inside a minute. Go-side timing is
unaffected — a goroutine sleeping in a loop still advances exactly 3s over 3s of
wall clock — and frame pacing is unchanged at ~59fps with the animation
rendering correctly.

Only targets/wasm_exec.js changes, so there is no Go code to make fmt; I
verified the file parses and exercised it in a browser rather than running the
full suite.

Fixes #5621

sleepTicks armed a fresh setTimeout on every call and never cancelled the
previous one. Its callback calls go_scheduler(), which re-enters the scheduler,
which calls sleepTicks again whenever it finds nothing runnable but something
sleeping — so every armed timer replaces itself, and every JS->Go callback that
reaches the scheduler starts another such chain. The pending count grows without
bound.

The scheduler only needs one pending wakeup, the earliest. Track it, and drop
any request that is no sooner than what is already armed.

Measured in Chrome with the same wasm binary, 50 sleeping goroutines and a
requestAnimationFrame loop, counting setTimeout calls per second at three
points: 487/1498/2509 before, 9/5/5 after. Go-side timing is unchanged (a
goroutine sleeping in a loop still advances 3s over 3s of wall clock) and frame
pacing is unchanged at ~59fps.

Fixes tinygo-org#5621
0pcom added a commit to 0magnet/tinygo that referenced this pull request Aug 28, 2026
Carries the fix sent upstream as tinygo-org#5622 (issue tinygo-org#5621) onto this
branch, because the wasm-visor lane builds against this checkout's TINYGOROOT
and so ships this shim.

sleepTicks armed a fresh setTimeout on every call and never cancelled the
previous one; its callback calls go_scheduler(), which calls sleepTicks again,
so every armed timer replaces itself and every JS->Go callback that reaches the
scheduler starts another chain. Measured in Chrome on one wasm binary, counting
setTimeout calls per second at t+3s/t+20s/t+40s: 487/1498/2509 before, 9/5/5
after, with Go-side timing and frame pacing unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wasm: runtime.sleepTicks leaks an unbounded setTimeout chain per JS→Go callback

1 participant