diff --git a/src/lib/libeventloop.js b/src/lib/libeventloop.js index 042dcad37b652..fb6742fdf4c97 100644 --- a/src/lib/libeventloop.js +++ b/src/lib/libeventloop.js @@ -198,11 +198,14 @@ LibraryJSEventLoop = { $MainLoop__internal: true, $MainLoop__deps: ['$setMainLoop', '$callUserCallback', 'emscripten_set_main_loop_timing'], - $MainLoop__postset: ` - Module['requestAnimationFrame'] = MainLoop.requestAnimationFrame; - Module['pauseMainLoop'] = MainLoop.pause; - Module['resumeMainLoop'] = MainLoop.resume; - MainLoop.init();`, + $MainLoop__postset: () => { + addAtExit('MainLoop.disposeImmediate();'); + return ` + Module['requestAnimationFrame'] = MainLoop.requestAnimationFrame; + Module['pauseMainLoop'] = MainLoop.pause; + Module['resumeMainLoop'] = MainLoop.resume; + MainLoop.init();`; + }, $MainLoop: { // The main loop tick function that will be called at each iteration. // This will be non-null whenever a loop function is registered. @@ -225,6 +228,11 @@ LibraryJSEventLoop = { preMainLoop: [], postMainLoop: [], + /** @type {?function(function(): ?, ...?): ?} */ + setImmediate: null, + // Replaced when the immediate scheduler allocates browser resources. + disposeImmediate() {}, + pause() { if (MainLoop.scheduler) { MainLoop.scheduler = null; @@ -368,27 +376,26 @@ LibraryJSEventLoop = { #if RUNTIME_DEBUG dbg('setImmediate: using polyfill'); #endif - // Emulate setImmediate. (note: not a complete polyfill, we don't emulate clearImmediate() to keep code size to minimum, since not needed) + // A private channel keeps ticks local to this module, including in + // a Worker. A global message listener would retain the module and + // receive ticks posted by other instances. var setImmediates = []; - var emscriptenMainLoopMessageId = 'setimmediate'; - /** @param {Event} event */ - var MainLoop_setImmediate_messageHandler = (event) => { - if (event.data === emscriptenMainLoopMessageId) { - event.stopPropagation(); - setImmediates.shift()(); - } + var channel = new MessageChannel(); + channel.port1.onmessage = () => setImmediates.shift()?.(); + MainLoop.disposeImmediate = () => { + setImmediates.length = 0; + channel.port1.onmessage = null; + channel.port1.close(); + channel.port2.close(); + // A runner unwinding through exit must not enqueue more work or + // recreate the channel. Cleanup may also be called more than once. + MainLoop.setImmediate = (func) => {}; + MainLoop.disposeImmediate = () => {}; }; - addEventListener('message', MainLoop_setImmediate_messageHandler, true); - MainLoop.setImmediate = /** @type{function(function(): ?, ...?): number} */((func) => { + MainLoop.setImmediate = (func) => { setImmediates.push(func); - if (ENVIRONMENT_IS_WORKER) { - // The postMessge API in a Worker, sends message to the main - // thread and does not support the `targetOrigin` (*) argument. - postMessage(emscriptenMainLoopMessageId); - } else { - postMessage(emscriptenMainLoopMessageId, '*'); - } - }); + channel.port2.postMessage(0); + }; } } MainLoop.scheduler = function MainLoop_scheduler_setImmediate() { diff --git a/src/preamble.js b/src/preamble.js index 54b537db5e8cc..b5d27614b759e 100644 --- a/src/preamble.js +++ b/src/preamble.js @@ -245,6 +245,12 @@ function postRun() { * @param {string|number=} what */ function abort(what) { +#if librarySymbols.includes('MainLoop') + // abort skips atexit, but must release the scheduler's browser resources. + // Do this before onAbort, which may itself throw. Early startup can abort + // before the MainLoop object has been initialized. + MainLoop?.disposeImmediate(); +#endif #if expectToReceiveOnModule('onAbort') Module['onAbort']?.(what); #endif diff --git a/test/browser/test_main_loop_scheduler_lifetime.c b/test/browser/test_main_loop_scheduler_lifetime.c new file mode 100644 index 0000000000000..0d6c39e7981bb --- /dev/null +++ b/test/browser/test_main_loop_scheduler_lifetime.c @@ -0,0 +1,47 @@ +/* + * Copyright 2026 The Emscripten Authors + * SPDX-License-Identifier: MIT + */ + +#include +#include +#include +#include + +static int frames; + +static void resume(void* unused) { + emscripten_resume_main_loop(); +} + +static void tick(void) { + ++frames; + if (frames == 2) { + emscripten_pause_main_loop(); + emscripten_set_timeout(resume, 10, NULL); + } else if (frames == 4) { + assert(emscripten_set_main_loop_timing(EM_TIMING_SETTIMEOUT, 1) == 0); + } else if (frames == 6) { + assert(emscripten_set_main_loop_timing(EM_TIMING_SETIMMEDIATE, 0) == 0); + } else if (frames == 8) { + emscripten_cancel_main_loop(); + emscripten_set_main_loop(tick, 0, 0); + assert(emscripten_set_main_loop_timing(EM_TIMING_SETIMMEDIATE, 0) == 0); + } else if (frames == 16) { + int mode = EM_ASM_INT({ return Module['testMode']; }); + if (mode >= 2) { + EM_ASM({ abort('expected scheduler abort'); }); + } else if (mode == 1) { + // Exit with a registered loop, without first cancelling it. + emscripten_force_exit(0); + } else { + emscripten_cancel_main_loop(); + exit(0); + } + } +} + +int main(void) { + emscripten_set_main_loop(tick, 0, 0); + assert(emscripten_set_main_loop_timing(EM_TIMING_SETIMMEDIATE, 0) == 0); +} diff --git a/test/browser/test_main_loop_scheduler_lifetime.html b/test/browser/test_main_loop_scheduler_lifetime.html new file mode 100644 index 0000000000000..a0803ac172666 --- /dev/null +++ b/test/browser/test_main_loop_scheduler_lifetime.html @@ -0,0 +1,115 @@ + + + + + + + diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 823f5fce3301c..2ca8eba6be094 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": 270584, + "a.out.js": 271051, "a.out.nodebug.wasm": 588266, - "total": 858850, + "total": 859317, "sent": [ "IMG_Init", "IMG_Load", diff --git a/test/test_browser.py b/test/test_browser.py index ce35e41905e63..2970f182f8b37 100644 --- a/test/test_browser.py +++ b/test/test_browser.py @@ -1830,10 +1830,34 @@ def test_emscripten_main_loop_and_blocker_exit(self): def test_emscripten_main_loop_setimmediate(self, args): self.btest_exit('test_emscripten_main_loop_setimmediate.c', cflags=args) + @also_with_proxy_to_pthread def test_emscripten_main_loop_setimmediate_polyfill(self): - create_file('remove_setimmediate.js', 'globalThis.setImmediate = undefined;') + create_file('remove_setimmediate.js', ''' + globalThis.setImmediate = undefined; + Object.defineProperty(globalThis, 'scheduler', { value: undefined }); + ''') self.btest_exit('test_emscripten_main_loop_setimmediate.c', cflags=['-sRUNTIME_DEBUG', '--pre-js=remove_setimmediate.js']) + @parameterized({ + 'default': (False, True, []), + 'fallback': (True, True, []), + 'default_closure': (False, True, ['-O2', '--closure=1']), + 'fallback_closure': (True, True, ['-O2', '--closure=1']), + 'default_no_exit_runtime': (False, False, []), + 'fallback_no_exit_runtime': (True, False, []), + }) + def test_main_loop_scheduler_lifetime(self, fallback, exit_runtime, args): + self.compile_btest('browser/test_main_loop_scheduler_lifetime.c', [ + '-sMODULARIZE', '-sEXPORT_NAME=createModule', f'-sEXIT_RUNTIME={int(exit_runtime)}', + '-sEXPORTED_RUNTIME_METHODS=HEAPU8', '-sENVIRONMENT=web', + ] + args, reporting=Reporting.NONE) + self.add_browser_reporting() + html = read_file(test_file('browser/test_main_loop_scheduler_lifetime.html')) + html = html.replace('FORCE_FALLBACK', str(fallback).lower()) + html = html.replace('EXIT_RUNTIME_ENABLED', str(exit_runtime).lower()) + create_file('test.html', html) + self.run_browser('test.html', '/report_result?0') + @parameterized({ '': ([],), 'O1': (['-O1'],),