From 46a4bda2f153bc1684fa49b0b06bfe43def62fe0 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 15 Sep 2026 23:45:34 -0700 Subject: [PATCH 1/6] Release runtime keepalive in emscripten_clear_timeout and make clear_timeout/clear_immediate idempotent --- ChangeLog.md | 3 ++ src/lib/libeventloop.js | 44 +++++++++++++-------- test/test_emscripten_clear_timeout.c | 59 ++++++++++++++++++++++++++++ test/test_other.py | 14 +++++++ 4 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 test/test_emscripten_clear_timeout.c diff --git a/ChangeLog.md b/ChangeLog.md index 68b62107b58e0..ef5a219ee6c83 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -36,6 +36,9 @@ See docs/process.md for more on how version tagging works. the per-call garbage on such calls. The registration ABI gained size and triviality parameters, so object files built against an older `bind.h` need to be rebuilt. (#27610) +- `emscripten_clear_timeout` now releases the runtime keepalive held by the + pending timeout, and both `emscripten_clear_timeout` and + `emscripten_clear_immediate` are no-ops for ids that already fired. 6.0.9 - 09/01/26 ---------------- diff --git a/src/lib/libeventloop.js b/src/lib/libeventloop.js index 042dcad37b652..b6a4cdb1c3905 100644 --- a/src/lib/libeventloop.js +++ b/src/lib/libeventloop.js @@ -11,14 +11,21 @@ LibraryJSEventLoop = { throw 'unwind'; }, - $safeSetTimeout__deps: ['$callUserCallback'], + // Pending timeout ids that hold a runtime keepalive. + $liveTimeouts: 'new Set()', + + $safeSetTimeout__deps: ['$callUserCallback', '$liveTimeouts'], $safeSetTimeout__docs: '/** @param {number=} timeout */', $safeSetTimeout: (func, timeout) => { {{{ runtimeKeepalivePush() }}} - return setTimeout(() => { + // Coerce to a number since node returns a Timeout object. + var id = +setTimeout(() => { + liveTimeouts.delete(id); {{{ runtimeKeepalivePop() }}} callUserCallback(func); }, timeout); + liveTimeouts.add(id); + return id; }, // Just like setImmediate but returns an i32 that can be passed back @@ -43,13 +50,13 @@ LibraryJSEventLoop = { }, // Just like clearImmediate but takes an i32 rather than an object. + // Returns true if the immediate was still pending. $clearImmediateWrapped: (id) => { -#if ASSERTIONS - assert(id); - assert(setImmediateWrapped.mapping[id]); -#endif - clearImmediate(setImmediateWrapped.mapping[id]); + var handle = setImmediateWrapped.mapping[id]; + if (!handle) return false; + clearImmediate(handle); setImmediateWrapped.mapping[id] = undefined; + return true; }, $emSetImmediate__deps: ['$setImmediateWrapped', '$clearImmediateWrapped', '$emClearImmediate'], @@ -81,8 +88,10 @@ LibraryJSEventLoop = { } emClearImmediate = /**@type{function(number=)}*/((id) => { var index = id - __setImmediate_id_counter; + if (index < 0 || !__setImmediate_queue[index]) return false; // must preserve the order and count of elements in the queue, so replace the pending callback with an empty function - if (index >= 0 && index < __setImmediate_queue.length) __setImmediate_queue[index] = null; + __setImmediate_queue[index] = null; + return true; }) }`, $emSetImmediate: undefined, @@ -101,8 +110,9 @@ LibraryJSEventLoop = { emscripten_clear_immediate__deps: ['$emClearImmediate'], emscripten_clear_immediate: (id) => { - {{{ runtimeKeepalivePop(); }}} - emClearImmediate(id); + if (emClearImmediate(id)) { + {{{ runtimeKeepalivePop(); }}} + } }, emscripten_set_immediate_loop__deps: ['$emSetImmediate', '$callUserCallback'], @@ -124,13 +134,13 @@ LibraryJSEventLoop = { emscripten_set_timeout: (cb, msecs, userData) => safeSetTimeout(() => {{{ makeDynCall('vp', 'cb') }}}(userData), msecs), -#if AUDIO_WORKLET - // Use a wrapper function here since simply aliasing `clearTimeout` would - // cause the module to fail to load in the audio worklet context. - emscripten_clear_timeout: (id) => clearTimeout(id), -#else - emscripten_clear_timeout: 'clearTimeout', -#endif + emscripten_clear_timeout__deps: ['$liveTimeouts'], + emscripten_clear_timeout: (id) => { + if (liveTimeouts.delete(id)) { + {{{ runtimeKeepalivePop() }}} + clearTimeout(id); + } + }, emscripten_set_timeout_loop__deps: ['$callUserCallback', 'emscripten_get_now'], emscripten_set_timeout_loop: (cb, msecs, userData) => { diff --git a/test/test_emscripten_clear_timeout.c b/test/test_emscripten_clear_timeout.c new file mode 100644 index 0000000000000..86354b78e8221 --- /dev/null +++ b/test/test_emscripten_clear_timeout.c @@ -0,0 +1,59 @@ +#include +#include +#include +#include + +int fired_id; + +void never(void *arg) { + printf("cleared callback ran\n"); + abort(); +} + +void fired(void *arg) { + printf("fired\n"); + // Clearing an id that already fired must be a no-op. + emscripten_clear_timeout(fired_id); + emscripten_clear_timeout(fired_id); +} + +void immediate_fired(void *arg) { + emscripten_clear_immediate(fired_id); + emscripten_clear_immediate(fired_id); +} + +// Only runs if the runtime actually exits. +void at_exit() { + printf("done\n"); +} + +int main() { + atexit(at_exit); +#if MODE_CLEARED + // Clearing a pending timeout releases its keepalive so the runtime exits + // from main without waiting for it. + int id = emscripten_set_timeout(never, 10000, NULL); + assert(emscripten_runtime_keepalive_check()); + emscripten_clear_timeout(id); + assert(!emscripten_runtime_keepalive_check()); + return 42; +#elif MODE_IDEMPOTENT + int id = emscripten_set_timeout(never, 1000, NULL); + emscripten_clear_timeout(id); + emscripten_clear_timeout(id); + fired_id = emscripten_set_timeout(fired, 0, NULL); + // Still-pending timer must keep the runtime alive until it fires. + emscripten_set_timeout(fired, 50, NULL); + return 0; +#elif MODE_IMMEDIATE + int id = emscripten_set_immediate(never, NULL); + emscripten_clear_immediate(id); + emscripten_clear_immediate(id); + fired_id = emscripten_set_immediate(immediate_fired, NULL); + emscripten_set_timeout(fired, 50, NULL); + return 0; +#else + emscripten_set_timeout(fired, 50, NULL); + return 0; +#endif +} diff --git a/test/test_other.py b/test/test_other.py index d2c96fa81755b..299428f232ce9 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13724,6 +13724,20 @@ def test_emscripten_set_timeout(self): def test_emscripten_set_timeout_loop(self): self.do_runf('emscripten_set_timeout_loop.c', args=['-pthread', '-sPROXY_TO_PTHREAD']) + @parameterized({ + 'fires': ([], 0, 'fired\ndone\n'), + 'cleared': (['-DMODE_CLEARED'], 42, 'done\n'), + 'idempotent': (['-DMODE_IDEMPOTENT'], 0, 'fired\nfired\ndone\n'), + 'immediate': (['-DMODE_IMMEDIATE'], 0, 'fired\ndone\n'), + }) + def test_emscripten_clear_timeout(self, cflags, returncode, expected): + js_file = self.build('test_emscripten_clear_timeout.c', cflags=['-sEXIT_RUNTIME'] + cflags) + start = time.time() + output = self.run_js(js_file, assert_returncode=returncode) + # A cleared 10s timeout must not delay exit. + self.assertLess(time.time() - start, 5) + self.assertEqual(output, expected) + @parameterized({ '': ([],), 'asyncify': (['-sASYNCIFY'],), From 82f0cadf3bf74c630f684e6f19561e8ce75e8886 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 00:25:51 -0700 Subject: [PATCH 2/6] rebaseline --- test/codesize/test_codesize_hello_O0.json | 8 ++++---- test/codesize/test_codesize_minimal_O0.expected.js | 1 + test/codesize/test_codesize_minimal_O0.json | 8 ++++---- test/codesize/test_unoptimized_code_size.json | 12 ++++++------ 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/test/codesize/test_codesize_hello_O0.json b/test/codesize/test_codesize_hello_O0.json index 017298ab01b7c..72701ef3f398a 100644 --- a/test/codesize/test_codesize_hello_O0.json +++ b/test/codesize/test_codesize_hello_O0.json @@ -1,10 +1,10 @@ { - "a.out.js": 23579, - "a.out.js.gz": 8576, + "a.out.js": 23592, + "a.out.js.gz": 8582, "a.out.nodebug.wasm": 14279, "a.out.nodebug.wasm.gz": 7101, - "total": 37858, - "total_gz": 15677, + "total": 37871, + "total_gz": 15683, "sent": [ "fd_write" ], diff --git a/test/codesize/test_codesize_minimal_O0.expected.js b/test/codesize/test_codesize_minimal_O0.expected.js index a8d77958ced86..44387773ca2c9 100644 --- a/test/codesize/test_codesize_minimal_O0.expected.js +++ b/test/codesize/test_codesize_minimal_O0.expected.js @@ -1023,6 +1023,7 @@ missingLibrarySymbols.forEach(missingLibrarySymbol) 'restoreOldWindowedStyle', 'UNWIND_CACHE', 'ExitStatus', + 'liveTimeouts', 'emSetImmediate', 'emClearImmediate_deps', 'emClearImmediate', diff --git a/test/codesize/test_codesize_minimal_O0.json b/test/codesize/test_codesize_minimal_O0.json index 00a34fec5ebd7..3d0614f4fc780 100644 --- a/test/codesize/test_codesize_minimal_O0.json +++ b/test/codesize/test_codesize_minimal_O0.json @@ -1,10 +1,10 @@ { - "a.out.js": 18784, - "a.out.js.gz": 6752, + "a.out.js": 18797, + "a.out.js.gz": 6758, "a.out.nodebug.wasm": 1071, "a.out.nodebug.wasm.gz": 636, - "total": 19855, - "total_gz": 7388, + "total": 19868, + "total_gz": 7394, "sent": [], "imports": [], "exports": [ diff --git a/test/codesize/test_unoptimized_code_size.json b/test/codesize/test_unoptimized_code_size.json index 920e5a7844084..f78303822b47f 100644 --- a/test/codesize/test_unoptimized_code_size.json +++ b/test/codesize/test_unoptimized_code_size.json @@ -1,16 +1,16 @@ { - "hello_world.js": 54683, - "hello_world.js.gz": 17343, + "hello_world.js": 54701, + "hello_world.js.gz": 17349, "hello_world.wasm": 14279, "hello_world.wasm.gz": 7101, "no_asserts.js": 23596, "no_asserts.js.gz": 8277, "no_asserts.wasm": 11393, "no_asserts.wasm.gz": 5646, - "strict.js": 51834, - "strict.js.gz": 16342, + "strict.js": 51852, + "strict.js.gz": 16349, "strict.wasm": 14279, "strict.wasm.gz": 7098, - "total": 170064, - "total_gz": 61807 + "total": 170100, + "total_gz": 61820 } From 3f31356379e25e095f35b72842ca3572189ab1b1 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 11:44:43 -0700 Subject: [PATCH 3/6] Use safeSetTimeout.mapping for timeout handles, add safeClearTimeout --- src/lib/libeventloop.js | 35 +++++++++++-------- src/lib/libsdl.js | 4 +-- test/codesize/test_codesize_hello_O0.json | 4 +-- .../test_codesize_minimal_O0.expected.js | 2 +- test/codesize/test_codesize_minimal_O0.json | 4 +-- test/codesize/test_unoptimized_code_size.json | 12 +++---- 6 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/lib/libeventloop.js b/src/lib/libeventloop.js index b6a4cdb1c3905..2f4fc25a63d81 100644 --- a/src/lib/libeventloop.js +++ b/src/lib/libeventloop.js @@ -11,23 +11,34 @@ LibraryJSEventLoop = { throw 'unwind'; }, - // Pending timeout ids that hold a runtime keepalive. - $liveTimeouts: 'new Set()', - - $safeSetTimeout__deps: ['$callUserCallback', '$liveTimeouts'], + // Just like setTimeout but returns an i32 that can be passed back to wasm + // rather than a JS object, and holds a runtime keepalive while pending. + $safeSetTimeout__deps: ['$callUserCallback'], $safeSetTimeout__docs: '/** @param {number=} timeout */', $safeSetTimeout: (func, timeout) => { {{{ runtimeKeepalivePush() }}} - // Coerce to a number since node returns a Timeout object. - var id = +setTimeout(() => { - liveTimeouts.delete(id); + // Slot 0 is reserved so that, like setTimeout, ids are always non-zero. + safeSetTimeout.mapping ||= [0]; + var id = safeSetTimeout.mapping.length; + safeSetTimeout.mapping[id] = setTimeout(() => { + safeSetTimeout.mapping[id] = undefined; {{{ runtimeKeepalivePop() }}} callUserCallback(func); }, timeout); - liveTimeouts.add(id); return id; }, + // Clears a pending safeSetTimeout and releases its keepalive. No-op if the + // timeout has already fired or been cleared. + $safeClearTimeout__deps: ['$safeSetTimeout'], + $safeClearTimeout: (id) => { + var handle = safeSetTimeout.mapping?.[id]; + if (!handle) return; + clearTimeout(handle); + safeSetTimeout.mapping[id] = undefined; + {{{ runtimeKeepalivePop() }}} + }, + // Just like setImmediate but returns an i32 that can be passed back // to wasm rather than a JS object. $setImmediateWrapped: (func) => { @@ -134,13 +145,7 @@ LibraryJSEventLoop = { emscripten_set_timeout: (cb, msecs, userData) => safeSetTimeout(() => {{{ makeDynCall('vp', 'cb') }}}(userData), msecs), - emscripten_clear_timeout__deps: ['$liveTimeouts'], - emscripten_clear_timeout: (id) => { - if (liveTimeouts.delete(id)) { - {{{ runtimeKeepalivePop() }}} - clearTimeout(id); - } - }, + emscripten_clear_timeout: '$safeClearTimeout', emscripten_set_timeout_loop__deps: ['$callUserCallback', 'emscripten_get_now'], emscripten_set_timeout_loop: (cb, msecs, userData) => { diff --git a/src/lib/libsdl.js b/src/lib/libsdl.js index a0e5e63f33e92..d439077adc70a 100644 --- a/src/lib/libsdl.js +++ b/src/lib/libsdl.js @@ -2562,14 +2562,14 @@ var LibrarySDL = { }, SDL_PauseAudio__proxy: 'sync', - SDL_PauseAudio__deps: ['$safeSetTimeout'], + SDL_PauseAudio__deps: ['$safeSetTimeout', '$safeClearTimeout'], SDL_PauseAudio: (pauseOn) => { if (!SDL.audio) { return; } if (pauseOn) { if (SDL.audio.timer !== undefined) { - clearTimeout(SDL.audio.timer); + safeClearTimeout(SDL.audio.timer); SDL.audio.numAudioTimersPending = 0; SDL.audio.timer = undefined; } diff --git a/test/codesize/test_codesize_hello_O0.json b/test/codesize/test_codesize_hello_O0.json index 72701ef3f398a..0a131354a605f 100644 --- a/test/codesize/test_codesize_hello_O0.json +++ b/test/codesize/test_codesize_hello_O0.json @@ -1,9 +1,9 @@ { - "a.out.js": 23592, + "a.out.js": 23596, "a.out.js.gz": 8582, "a.out.nodebug.wasm": 14279, "a.out.nodebug.wasm.gz": 7101, - "total": 37871, + "total": 37875, "total_gz": 15683, "sent": [ "fd_write" diff --git a/test/codesize/test_codesize_minimal_O0.expected.js b/test/codesize/test_codesize_minimal_O0.expected.js index 44387773ca2c9..61b5952de1fd0 100644 --- a/test/codesize/test_codesize_minimal_O0.expected.js +++ b/test/codesize/test_codesize_minimal_O0.expected.js @@ -948,6 +948,7 @@ Module['FS_createPreloadedFile'] = FS.createPreloadedFile; 'initRandomFill', 'randomFill', 'safeSetTimeout', + 'safeClearTimeout', 'setImmediateWrapped', 'safeRequestAnimationFrame', 'clearImmediateWrapped', @@ -1023,7 +1024,6 @@ missingLibrarySymbols.forEach(missingLibrarySymbol) 'restoreOldWindowedStyle', 'UNWIND_CACHE', 'ExitStatus', - 'liveTimeouts', 'emSetImmediate', 'emClearImmediate_deps', 'emClearImmediate', diff --git a/test/codesize/test_codesize_minimal_O0.json b/test/codesize/test_codesize_minimal_O0.json index 3d0614f4fc780..f960557acfdd1 100644 --- a/test/codesize/test_codesize_minimal_O0.json +++ b/test/codesize/test_codesize_minimal_O0.json @@ -1,9 +1,9 @@ { - "a.out.js": 18797, + "a.out.js": 18801, "a.out.js.gz": 6758, "a.out.nodebug.wasm": 1071, "a.out.nodebug.wasm.gz": 636, - "total": 19868, + "total": 19872, "total_gz": 7394, "sent": [], "imports": [], diff --git a/test/codesize/test_unoptimized_code_size.json b/test/codesize/test_unoptimized_code_size.json index f78303822b47f..a6bc588a5bc49 100644 --- a/test/codesize/test_unoptimized_code_size.json +++ b/test/codesize/test_unoptimized_code_size.json @@ -1,16 +1,16 @@ { - "hello_world.js": 54701, - "hello_world.js.gz": 17349, + "hello_world.js": 54705, + "hello_world.js.gz": 17355, "hello_world.wasm": 14279, "hello_world.wasm.gz": 7101, "no_asserts.js": 23596, "no_asserts.js.gz": 8277, "no_asserts.wasm": 11393, "no_asserts.wasm.gz": 5646, - "strict.js": 51852, - "strict.js.gz": 16349, + "strict.js": 51856, + "strict.js.gz": 16348, "strict.wasm": 14279, "strict.wasm.gz": 7098, - "total": 170100, - "total_gz": 61820 + "total": 170108, + "total_gz": 61825 } From 7bf18d9c8f891982b0a5da9d64051ee4d3fcdd5b Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 12:50:00 -0700 Subject: [PATCH 4/6] SDL_RemoveTimer: use safeClearTimeout for safeSetTimeout ids --- src/lib/libsdl.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/libsdl.js b/src/lib/libsdl.js index d439077adc70a..ccd5394ae16a9 100644 --- a/src/lib/libsdl.js +++ b/src/lib/libsdl.js @@ -3579,8 +3579,9 @@ var LibrarySDL = { interval), SDL_RemoveTimer__proxy: 'sync', + SDL_RemoveTimer__deps: ['$safeClearTimeout'], SDL_RemoveTimer: (id) => { - clearTimeout(id); + safeClearTimeout(id); return true; }, From d7506cdb8455f686b161e9b87674eb90285104fd Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 12:59:35 -0700 Subject: [PATCH 5/6] rebaseline --- test/codesize/test_codesize_hello_dylink_all.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 19e642f329f77..2599da95e6573 100644 --- a/test/codesize/test_codesize_hello_dylink_all.json +++ b/test/codesize/test_codesize_hello_dylink_all.json @@ -1,7 +1,7 @@ { - "a.out.js": 270520, + "a.out.js": 270695, "a.out.nodebug.wasm": 588289, - "total": 858809, + "total": 858984, "sent": [ "IMG_Init", "IMG_Load", From a8ce99588e059f3f49c0ff3a6581fb33c5a8c4bc Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 13:10:26 -0700 Subject: [PATCH 6/6] Add PR number to changelog, drop wall-clock check from clear_timeout test --- ChangeLog.md | 2 +- test/test_emscripten_clear_timeout.c | 5 +++-- test/test_other.py | 7 +------ 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index ef5a219ee6c83..a3fe632c528d1 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -38,7 +38,7 @@ See docs/process.md for more on how version tagging works. to be rebuilt. (#27610) - `emscripten_clear_timeout` now releases the runtime keepalive held by the pending timeout, and both `emscripten_clear_timeout` and - `emscripten_clear_immediate` are no-ops for ids that already fired. + `emscripten_clear_immediate` are no-ops for ids that already fired. (#27720) 6.0.9 - 09/01/26 ---------------- diff --git a/test/test_emscripten_clear_timeout.c b/test/test_emscripten_clear_timeout.c index 86354b78e8221..ea2073dcbf622 100644 --- a/test/test_emscripten_clear_timeout.c +++ b/test/test_emscripten_clear_timeout.c @@ -31,8 +31,9 @@ int main() { atexit(at_exit); #if MODE_CLEARED // Clearing a pending timeout releases its keepalive so the runtime exits - // from main without waiting for it. - int id = emscripten_set_timeout(never, 10000, NULL); + // from main without waiting for it (a leak fails the check below; a timer + // that was not actually cleared aborts when it fires). + int id = emscripten_set_timeout(never, 1000, NULL); assert(emscripten_runtime_keepalive_check()); emscripten_clear_timeout(id); assert(!emscripten_runtime_keepalive_check()); diff --git a/test/test_other.py b/test/test_other.py index 299428f232ce9..bd63374a7c13b 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13731,12 +13731,7 @@ def test_emscripten_set_timeout_loop(self): 'immediate': (['-DMODE_IMMEDIATE'], 0, 'fired\ndone\n'), }) def test_emscripten_clear_timeout(self, cflags, returncode, expected): - js_file = self.build('test_emscripten_clear_timeout.c', cflags=['-sEXIT_RUNTIME'] + cflags) - start = time.time() - output = self.run_js(js_file, assert_returncode=returncode) - # A cleared 10s timeout must not delay exit. - self.assertLess(time.time() - start, 5) - self.assertEqual(output, expected) + self.do_runf('test_emscripten_clear_timeout.c', expected, cflags=['-sEXIT_RUNTIME'] + cflags, assert_returncode=returncode) @parameterized({ '': ([],),