From 228efbfaf12c042cbf47678f46e2bbbf8bcb3458 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 1 Jul 2026 12:08:57 -0700 Subject: [PATCH 1/2] Add AUTO_INIT setting to opt into module self-initialization Instead of inferring self-initialization from an empty INCOMING_MODULE_JS_API, gate it behind an explicit AUTO_INIT setting. When set, an instance ES module (MODULARIZE=instance or WASM_ESM_INTEGRATION) initializes itself via top-level await on import and does not export the default `init` function. Since there is no init/moduleArg, module-level configuration is unavailable: INCOMING_MODULE_JS_API is disabled and passing a non-empty one is an error. AUTO_INIT requires MODULARIZE=instance or WASM_ESM_INTEGRATION. --- ChangeLog.md | 8 +++ .../tools_reference/settings_reference.rst | 31 +++++++++ src/postamble.js | 19 ++++++ src/preamble.js | 2 +- src/pthread_esm_startup.mjs | 7 +- src/runtime_pthread.js | 3 + src/settings.js | 26 ++++++++ .../test_codesize_minimal_pthreads.json | 8 +-- ...t_codesize_minimal_pthreads_memgrowth.json | 8 +-- test/test_other.py | 65 +++++++++++++++++++ tools/link.py | 27 ++++++-- 11 files changed, 188 insertions(+), 16 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index b0cb726cf66e6..6bb1e6f70bee5 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -24,6 +24,14 @@ See docs/process.md for more on how version tagging works. supported, with 256-bit variants emulated via two 128-bit operations. Pass ``-msimd128 -mfma`` to enable. With ``-mrelaxed-simd -mfma``, Wasm relaxed SIMD FMA is used. (#27183) +- New `AUTO_INIT` setting to opt an instance ES module (`MODULARIZE=instance` or + `WASM_ESM_INTEGRATION`) into self-initialization via top-level await on import, + rather than exporting a default `init` function. Since there is no + init/moduleArg, module-level configuration is unavailable: + `INCOMING_MODULE_JS_API` is disabled and passing a non-empty one is an error. +- The `GROWABLE_ARRAYBUFFERS` setting now defaults to 1, which means it will be + used when available. Note that this only affects programs that are built with + `ALLOW_MEMORY_GROWTH`, which is not enabled by default. (#27212) - The async `poll()`/`select()` implementation was refactored onto a per-inode readiness wait-queue. As part of this, the (undocumented) `stream_ops.poll` FS-backend handler signature changed from `poll(stream, timeout)` to diff --git a/site/source/docs/tools_reference/settings_reference.rst b/site/source/docs/tools_reference/settings_reference.rst index f75868f28fd64..ec12708d8ba48 100644 --- a/site/source/docs/tools_reference/settings_reference.rst +++ b/site/source/docs/tools_reference/settings_reference.rst @@ -1913,6 +1913,37 @@ used. An example of using the module is below. foo(); bar(); +The ``init`` function exists so the caller can configure the instance (via +``moduleArg``) before it starts. When there is nothing to configure, see +``AUTO_INIT`` to have the module self-initialize on import. + +Default value: false + +.. _auto_init: + +AUTO_INIT +========= + +When set, an instance ES module (``MODULARIZE=instance`` or +``WASM_ESM_INTEGRATION``) initializes itself via top-level await on import +rather than exporting an ``init`` function to be called by the consumer. The +named Wasm/runtime exports are ready to use as soon as the module is +imported:: + + import { foo, bar } from "./my_module.mjs" + foo(); + bar(); + +Since the module initializes without any caller involvement, there is no +opportunity for module-level configuration: ``moduleArg`` cannot be passed +and the entire ``INCOMING_MODULE_JS_API`` is disabled (passing a non-empty +``INCOMING_MODULE_JS_API`` is an error). + +Because no default ``init`` export is emitted, this also frees up the +``default`` export name for the program's own use. + +Requires ``MODULARIZE=instance`` or ``WASM_ESM_INTEGRATION``. + Default value: false .. _export_es6: diff --git a/src/postamble.js b/src/postamble.js index f57f24b76d773..d130ded126fbf 100644 --- a/src/postamble.js +++ b/src/postamble.js @@ -249,12 +249,19 @@ var wasmRawExports; #if ASSERTIONS var initCalled = false; #endif +#if AUTO_INIT && !WASM_ESM_INTEGRATION +// In AUTO_INIT mode `init` is not exported; we self-initialize below. +async function init() { +#else export default async function init(moduleArg = {}) { +#endif #if ASSERTIONS assert(!initCalled); initCalled = true; #endif +#if !AUTO_INIT || WASM_ESM_INTEGRATION Object.assign(Module, moduleArg); +#endif processModuleArgs(); #if WASM_ESM_INTEGRATION #if PTHREADS @@ -272,6 +279,16 @@ export default async function init(moduleArg = {}) { await run(); } +#if AUTO_INIT && !WASM_ESM_INTEGRATION +#if PTHREADS || WASM_WORKERS +// Worker threads self-init on demand from the CMD_LOAD handler (see +// runtime_pthread.js), so only the main thread inits here. +if ({{{ ENVIRONMENT_IS_MAIN_THREAD() }}}) +#endif +await init(); + +#else + #if ENVIRONMENT_MAY_BE_NODE // When run as the main script under node we run `init` immediately. if (ENVIRONMENT_IS_NODE @@ -293,6 +310,8 @@ if (ENVIRONMENT_IS_SHELL) { } #endif +#endif + #else // MODULARIZE == instance #if WASM_WORKERS || PTHREADS diff --git a/src/preamble.js b/src/preamble.js index fb5f61e74a82a..d03f77c880588 100644 --- a/src/preamble.js +++ b/src/preamble.js @@ -143,7 +143,7 @@ function initRuntime() { #endif #if PTHREADS - if (ENVIRONMENT_IS_PTHREAD) return startWorker(); + if (ENVIRONMENT_IS_PTHREAD) return; #endif #if STACK_OVERFLOW_CHECK >= 2 diff --git a/src/pthread_esm_startup.mjs b/src/pthread_esm_startup.mjs index fb4d2686dcafb..6054fb99b9237 100644 --- a/src/pthread_esm_startup.mjs +++ b/src/pthread_esm_startup.mjs @@ -48,14 +48,17 @@ self.onmessage = async (msg) => { // Now that we have the wasmMemory we can import the main program globalThis.wasmMemory = msg.data.wasmMemory; + const prog = await import('./{{{ TARGET_JS_NAME }}}'); +#if !AUTO_INIT + await prog.default() +#endif + // Now that the import is completed the main program will have installed // its own `onmessage` handler and replaced our handler. // Now we can dispatch any queued messages to this new handler. for (const msg of messageQueue) { await self.onmessage(msg); } - - await prog.default() }; diff --git a/src/runtime_pthread.js b/src/runtime_pthread.js index d95b514156b5c..db4697f991a4f 100644 --- a/src/runtime_pthread.js +++ b/src/runtime_pthread.js @@ -127,6 +127,9 @@ if (ENVIRONMENT_IS_PTHREAD) { run(); #endif #endif // MINIMAL_RUNTIME +#endif +#if !MINIMAL_RUNTIME + startWorker(); #endif } else if (cmd == {{{ CMD_RUN }}}) { #if ASSERTIONS diff --git a/src/settings.js b/src/settings.js index c7e6592b6b4d4..eca9ba43602a7 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1332,9 +1332,35 @@ var SMALL_XHR_CHUNKS = false; // foo(); // bar(); // +// The ``init`` function exists so the caller can configure the instance (via +// ``moduleArg``) before it starts. When there is nothing to configure, see +// ``AUTO_INIT`` to have the module self-initialize on import. +// // [link] var MODULARIZE = false; +// When set, an instance ES module (``MODULARIZE=instance`` or +// ``WASM_ESM_INTEGRATION``) initializes itself via top-level await on import +// rather than exporting an ``init`` function to be called by the consumer. The +// named Wasm/runtime exports are ready to use as soon as the module is +// imported:: +// +// import { foo, bar } from "./my_module.mjs" +// foo(); +// bar(); +// +// Since the module initializes without any caller involvement, there is no +// opportunity for module-level configuration: ``moduleArg`` cannot be passed +// and the entire ``INCOMING_MODULE_JS_API`` is disabled (passing a non-empty +// ``INCOMING_MODULE_JS_API`` is an error). +// +// Because no default ``init`` export is emitted, this also frees up the +// ``default`` export name for the program's own use. +// +// Requires ``MODULARIZE=instance`` or ``WASM_ESM_INTEGRATION``. +// [link] +var AUTO_INIT = false; + // Export using an ES6 Module export rather than a UMD export. MODULARIZE must // be enabled for ES6 exports and is implicitly enabled if not already set. // diff --git a/test/codesize/test_codesize_minimal_pthreads.json b/test/codesize/test_codesize_minimal_pthreads.json index 6e7f6f6739a85..6e1d93ed44b56 100644 --- a/test/codesize/test_codesize_minimal_pthreads.json +++ b/test/codesize/test_codesize_minimal_pthreads.json @@ -1,10 +1,10 @@ { - "a.out.js": 6954, - "a.out.js.gz": 3428, + "a.out.js": 6945, + "a.out.js.gz": 3424, "a.out.nodebug.wasm": 19063, "a.out.nodebug.wasm.gz": 8803, - "total": 26017, - "total_gz": 12231, + "total": 26008, + "total_gz": 12227, "sent": [ "a (memory)", "b (exit)", diff --git a/test/codesize/test_codesize_minimal_pthreads_memgrowth.json b/test/codesize/test_codesize_minimal_pthreads_memgrowth.json index 67db9029b5b74..371d9194c76c6 100644 --- a/test/codesize/test_codesize_minimal_pthreads_memgrowth.json +++ b/test/codesize/test_codesize_minimal_pthreads_memgrowth.json @@ -1,10 +1,10 @@ { - "a.out.js": 7552, - "a.out.js.gz": 3708, + "a.out.js": 7543, + "a.out.js.gz": 3705, "a.out.nodebug.wasm": 19064, "a.out.nodebug.wasm.gz": 8804, - "total": 26616, - "total_gz": 12512, + "total": 26607, + "total_gz": 12509, "sent": [ "a (memory)", "b (exit)", diff --git a/test/test_other.py b/test/test_other.py index bbf5d089ac349..85d3ac9eaf7f1 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -6216,6 +6216,71 @@ def test_modularize_instance_run_dependency(self): expected = 'add-dep\nremove-dep\nHello, world!\ngot module\n' self.assertContained(expected, self.run_js('run.mjs')) + def test_modularize_instance_auto_init(self): + create_file('library.js', '''\ + addToLibrary({ + $baz: () => console.log('baz'), + $qux: () => console.log('qux'), + });''') + # With AUTO_INIT the module self-initializes via top-level await and does not + # export `init`. + self.run_process([EMCC, test_file('modularize_instance.c'), + '-sMODULARIZE=instance', '-sAUTO_INIT', + '-Wno-experimental', + '-sEXPORTED_RUNTIME_METHODS=baz,addOnExit,HEAP32', + '-sEXPORTED_FUNCTIONS=_bar,_main,qux', + '--js-library', 'library.js', + '-o', 'modularize_instance.mjs'] + self.get_cflags()) + + # Named exports are usable directly on import; there is no `init` export. + create_file('runner.mjs', ''' + import { strict as assert } from 'assert'; + import * as mod from "./modularize_instance.mjs"; + import { _foo as foo, _bar as bar, baz, qux, addOnExit, HEAP32 } from "./modularize_instance.mjs"; + assert(mod.default === undefined); // no `init` export when self-initializing + foo(); // exported with EMSCRIPTEN_KEEPALIVE + bar(); // exported with EXPORTED_FUNCTIONS + baz(); // exported library function with EXPORTED_RUNTIME_METHODS + qux(); // exported library function with EXPORTED_FUNCTIONS + assert(typeof addOnExit === 'function'); // exported runtime function with EXPORTED_RUNTIME_METHODS + assert(typeof HEAP32 === 'object'); // exported runtime value by default + ''') + + self.assertContained('main1\nmain2\nfoo\nbar\nbaz\n', self.run_js('runner.mjs')) + + @also_with_pthreads + @requires_node_25 + def test_esm_integration_auto_init(self): + # Instance-phase wasm imports currently generate an ExperimentalWarning under node. + self.node_args += ['--no-warnings'] + create_file('library.js', '''\ + addToLibrary({ + $baz: () => console.log('baz'), + $qux: () => console.log('qux'), + });''') + self.run_process([EMCC, test_file('modularize_instance.c'), + '-Wno-experimental', + '-sWASM_ESM_INTEGRATION', '-sAUTO_INIT', + '-sEXPORTED_RUNTIME_METHODS=baz,addOnExit,HEAP32', + '-sEXPORTED_FUNCTIONS=_bar,_main,qux', + '--js-library', 'library.js', + '-o', 'modularize_instance.mjs'] + self.get_cflags()) + + create_file('runner.mjs', ''' + import { strict as assert } from 'assert'; + import * as mod from "./modularize_instance.mjs"; + import { _foo as foo, _bar as bar, baz, qux, addOnExit, HEAP32 } from "./modularize_instance.mjs"; + assert(mod.default === undefined); // no `init` export when self-initializing + foo(); + bar(); + baz(); + qux(); + assert(typeof addOnExit === 'function'); + assert(typeof HEAP32 === 'object'); + ''') + + self.assertContained('main1\nmain2\nfoo\nbar\nbaz\n', self.run_js('runner.mjs')) + def test_modularize_instantiation_error(self): self.run_process([EMCC, test_file('hello_world.c'), '-o', 'out.mjs'] + self.get_cflags()) create_file('run.mjs', ''' diff --git a/tools/link.py b/tools/link.py index dbc093d9a7bd7..110669e4dfbfa 100644 --- a/tools/link.py +++ b/tools/link.py @@ -1009,6 +1009,15 @@ def limit_incoming_module_api(): if options.use_preload_plugins or options.preload_files: exit_with_error('MODULARIZE=instance is not compatible with --embed-file/--preload-file') + if settings.AUTO_INIT: + if settings.MODULARIZE != 'instance': + exit_with_error('AUTO_INIT requires MODULARIZE=instance or WASM_ESM_INTEGRATION') + # There is no `init`/`moduleArg` to configure the instance with, so any + # incoming module API is meaningless. + if 'INCOMING_MODULE_JS_API' in user_settings: + exit_with_error('AUTO_INIT is not compatible with INCOMING_MODULE_JS_API') + settings.INCOMING_MODULE_JS_API = [] + if settings.MINIMAL_RUNTIME and options.preload_files: exit_with_error('MINIMAL_RUNTIME is not compatible with --preload-file') @@ -2158,13 +2167,21 @@ def create_esm_wrapper(wrapper_file, support_target, wasm_target): wrapper.append('// in order to avoid issues with circular dependencies.') wrapper.append(f"import * as unused from './{settings.WASM_BINARY_FILE}';") support_url = f'./{os.path.basename(support_target)}' - if js_exports: - wrapper.append(f"export {{ default, {js_exports} }} from '{support_url}';") + if settings.AUTO_INIT: + # Self-initialize via top-level await and don't re-export `init`, freeing up + # the `default` export name for the program's own use. + if js_exports: + wrapper.append(f"export {{ {js_exports} }} from '{support_url}';") + wrapper.append(f"import init from '{support_url}';") + wrapper.append('await init();') else: - wrapper.append(f"export {{ default }} from '{support_url}';") + if js_exports: + wrapper.append(f"export {{ default, {js_exports} }} from '{support_url}';") + else: + wrapper.append(f"export {{ default }} from '{support_url}';") - if settings.ENVIRONMENT_MAY_BE_NODE: - wrapper.append(f''' + if settings.ENVIRONMENT_MAY_BE_NODE: + wrapper.append(f''' // When run as the main module under node, create the module directly. This will // execute any startup code along with main (if it exists). import init from '{support_url}'; From 7a7fcae6e61895b8d216b4de5db55a63c57ad0ca Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 1 Jul 2026 13:08:37 -0700 Subject: [PATCH 2/2] remove changelog dupe --- ChangeLog.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 6bb1e6f70bee5..e97b4adad87f5 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -29,9 +29,6 @@ See docs/process.md for more on how version tagging works. rather than exporting a default `init` function. Since there is no init/moduleArg, module-level configuration is unavailable: `INCOMING_MODULE_JS_API` is disabled and passing a non-empty one is an error. -- The `GROWABLE_ARRAYBUFFERS` setting now defaults to 1, which means it will be - used when available. Note that this only affects programs that are built with - `ALLOW_MEMORY_GROWTH`, which is not enabled by default. (#27212) - The async `poll()`/`select()` implementation was refactored onto a per-inode readiness wait-queue. As part of this, the (undocumented) `stream_ops.poll` FS-backend handler signature changed from `poll(stream, timeout)` to