Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 30 additions & 23 deletions src/lib/libeventloop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
6 changes: 6 additions & 0 deletions src/preamble.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions test/browser/test_main_loop_scheduler_lifetime.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2026 The Emscripten Authors
* SPDX-License-Identifier: MIT
*/

#include <assert.h>
#include <stdlib.h>
#include <emscripten.h>
#include <emscripten/eventloop.h>

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);
}
115 changes: 115 additions & 0 deletions test/browser/test_main_loop_scheduler_lifetime.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<!doctype html>
<script>
if (FORCE_FALLBACK) {
Object.defineProperty(globalThis, 'scheduler', { value: undefined });
globalThis.setImmediate = undefined;
}

// Observe resource ownership without retaining module callbacks or channels.
const listeners = [];
const nativeAdd = globalThis.addEventListener;
const nativeRemove = globalThis.removeEventListener;
globalThis.addEventListener = function(type, callback, options) {
if (type === 'message') listeners.push({ callback: new WeakRef(callback), removed: false });
return nativeAdd.call(this, type, callback, options);
};
globalThis.removeEventListener = function(type, callback, options) {
for (const listener of listeners) {
if (type === 'message' && listener.callback.deref() === callback) listener.removed = true;
}
return nativeRemove.call(this, type, callback, options);
};
let openPorts = 0;
const NativeChannel = globalThis.MessageChannel;
globalThis.MessageChannel = class extends NativeChannel {
constructor() {
super();
for (const port of [this.port1, this.port2]) {
++openPorts;
let closed = false;
const close = port.close.bind(port);
port.close = () => {
if (!closed) --openPorts;
closed = true;
close();
};
}
}
};
const errors = [];
globalThis.addEventListener('error', event => {
if (event.message.includes('expected scheduler abort')) {
event.preventDefault();
event.stopImmediatePropagation();
} else {
errors.push(event.message);
}
}, true);
globalThis.addEventListener('unhandledrejection', event => {
// scheduler.postTask reports a thrown callback through its promise.
if (String(event.reason).includes('expected scheduler abort')) {
event.preventDefault();
event.stopImmediatePropagation();
} else {
errors.push(String(event.reason));
}
}, true);
// Also usable by a local CDP/GC diagnostic after the test completes. The
// portable regression below asserts deterministic listener/port cleanup.
globalThis.schedulerWeakRefs = [];
</script>
<script src="browser_reporting.js"></script>
<script src="a.out.js"></script>
<script>const firstFactory = createModule;</script>
<script src="a.out.js"></script>
<script>
const secondFactory = createModule;
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

async function run(factory, mode) {
let resolve;
let reject;
const done = new Promise((yes, no) => { resolve = yes; reject = no; });
// The debug runtime adds getters that close over the module to its options.
// Avoid V8 retaining the first instance via an object-literal allocation site.
const options = Object.assign(Object.create(null), {
testMode: mode,
onExit(status) {
if (mode < 2 && status === 0) resolve();
else reject(new Error('unexpected exit: ' + status));
},
onAbort(reason) {
if (mode >= 2 && reason === 'expected scheduler abort') resolve();
else reject(new Error('unexpected abort: ' + reason));
// Cleanup must also happen if the application's callback throws.
if (mode === 3) throw new Error('expected scheduler abort callback');
},
});
const module = await factory(options);
schedulerWeakRefs.push({ module: new WeakRef(module), buffer: new WeakRef(module.HEAPU8.buffer) });
await done;
}

async function checkReleased() {
// Allow queued native tasks/messages to run after shutdown.
await delay(20);
const liveListeners = listeners.filter(entry => !entry.removed && entry.callback.deref());
if (liveListeners.length || openPorts || errors.length) {
throw new Error(`scheduler resources after shutdown: ${liveListeners.length} listeners, ${openPorts} ports, errors: ${errors}`);
}
}

(async () => {
for (const mode of (EXIT_RUNTIME_ENABLED ? [0, 1, 2, 3] : [2, 3])) {
await run(firstFactory, mode);
await checkReleased();
await run(secondFactory, mode);
await checkReleased();
}
// One instance may exit while another is still receiving ticks. Separately
// evaluated factories must not share queues or intercept each other's work.
await Promise.all([run(firstFactory, EXIT_RUNTIME_ENABLED ? 0 : 2), run(secondFactory, EXIT_RUNTIME_ENABLED ? 1 : 3)]);
await checkReleased();
reportResultToServer(0);
})().catch(error => reportResultToServer(String(error)));
</script>
4 changes: 2 additions & 2 deletions test/codesize/test_codesize_hello_dylink_all.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
26 changes: 25 additions & 1 deletion test/test_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'],),
Expand Down