From d49b9865a003d7efb09d6a3d0b51788f858af897 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:05:48 -0500 Subject: [PATCH] wasm: keep one pending scheduler wakeup instead of leaking a timer chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #5621 --- targets/wasm_exec.js | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/targets/wasm_exec.js b/targets/wasm_exec.js index 2689692dd4..fd4c8a04d4 100644 --- a/targets/wasm_exec.js +++ b/targets/wasm_exec.js @@ -270,15 +270,24 @@ // func sleepTicks(timeout int64) "runtime.sleepTicks": (timeout) => { - // Do not sleep, only reactivate scheduler after the given timeout. - setTimeout(() => { + // Do not sleep, only reactivate the scheduler after the given + // timeout, keeping exactly one pending wakeup. + const ms = Number(timeout) / 1e6; + const due = Date.now() + ms; + if (this._scheduledWakeup !== undefined) { + if (this._scheduledWakeupDue <= due) return; + clearTimeout(this._scheduledWakeup); + } + this._scheduledWakeupDue = due; + this._scheduledWakeup = setTimeout(() => { + this._scheduledWakeup = undefined; if (this.exited) return; try { this._inst.exports.go_scheduler(); } catch (e) { if (e !== wasmExit) throw e; } - }, Number(timeout) / 1e6); + }, ms); }, // func finalizeRef(v ref) @@ -489,6 +498,12 @@ this._ids = new Map(); // mapping from JS values to reference ids this._idPool = []; // unused ids that have been garbage collected this.exited = false; // whether the Go program has exited + // A wakeup left pending by a previous run would otherwise suppress the + // first one this run asks for, and the scheduler would never start. + if (this._scheduledWakeup !== undefined) { + clearTimeout(this._scheduledWakeup); + this._scheduledWakeup = undefined; + } this.exitCode = 0; // syscall/js.handleEvent reads _pendingEvent and returns early only when // it IsNull(). Leaving it `undefined` is not null, so handleEvent falls