diff --git a/ChangeLog.md b/ChangeLog.md index e09084933ccd9..174b0e8e2ff1d 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -20,6 +20,17 @@ See docs/process.md for more on how version tagging works. 6.0.10 (in development) ---------------------- +- Added the experimental `-sJSPI_HOOKS` setting and ``, + lifecycle hooks for `-sJSPI` fibers: `jspi_register` receives + `JSPI_ENTER`/`JSPI_EXIT`/`JSPI_SUSPEND`/`JSPI_RESUME` events with a + per-fiber token of its own, dispatched from wrappers that the new binaryen + `--jspi-hooks` pass places around promising exports and suspending imports. + With the hooks enabled, function pointers made promising from JS (`dynCall` + with `promising`, Embind `async()`) go through a per-signature trampoline + export, which only exists for signatures present in the table at link time, + and a JSPI export fetched from the table as a function pointer is no longer + promised automatically. Independently of the setting, `invoke_*` imports are + no longer treated as suspending under JSPI. - The SDL3 port is no longer considered experimental, and the compiler diagnostic warning has been removed. (#27646) - `WASM=0` and `WASM=2` (wasm2js) were marked as deprecated. (See #27608) diff --git a/out.o b/out.o new file mode 100644 index 0000000000000..9cbe395a36a4f Binary files /dev/null and b/out.o differ diff --git a/site/source/docs/porting/asyncify.rst b/site/source/docs/porting/asyncify.rst index 0e2343259e834..a6759b705db2d 100644 --- a/site/source/docs/porting/asyncify.rst +++ b/site/source/docs/porting/asyncify.rst @@ -421,6 +421,70 @@ and exports must be explicitly set using :ref:`JSPI_IMPORTS` and using various helpers mentioned above such as: ``EM_ASYNC_JS``, Embind's Async support, ``ccall``, etc... +.. _jspi_lifecycle_hooks: + +JSPI lifecycle hooks +#################### + +With JSPI, each call to a promising export starts a *fiber*: a wasm activation +that may be suspended while its suspending imports await, during which other +fibers (or plain synchronous calls) run on the same thread. All fibers share +the same static storage and shadow stack, so libraries that keep state which +is only meaningful for one activation (for example a current-context pointer) +need to know when a fiber is entered, left and resumed. The experimental +:ref:`JSPI_HOOKS` setting and ```` provide that: + +.. code-block:: c + + #include + + void* hook(jspi_event event, void* token, int error) { + switch (event) { + case JSPI_ENTER: /* a promising export was called; token is NULL */ + return my_state_for_this_fiber(); + case JSPI_SUSPEND: /* a suspending import is about to be called */ + stash(token); + break; + case JSPI_RESUME: /* it returned (error != 0 if it threw/rejected) */ + restore(token); + break; + case JSPI_EXIT: /* the export is returning (error != 0 if throwing) */ + release(token); + break; + } + return token; + } + + jspi_register(hook, JSPI_ALL); + +``token`` is the hook's own value for the fiber the event is about: ``NULL`` +at the first event the hook sees for that fiber, then whatever the hook +returned at the fiber's previous event, so a hook needs no lookup table to +find its per-fiber state. Hooks run in registration order on the thread they +were registered on, inside the fiber's own wasm frames immediately +before/after the boundary call. ``JSPI_SUSPEND`` fires whether or not the +import actually suspends. ``error`` is set when the export or import +completed with an exception (a JS exception, a rejected promise or a wasm +exception such as a C++ ``throw``), which is rethrown unchanged after the +hooks run; hooks cannot inspect it. Hooks must not throw, suspend or call +promising exports. + +``jspi_register`` returns 0, -1 when the program was linked without +``-sJSPI_HOOKS`` (so a library can fall back at runtime rather than requiring +the setting), or -2 when the per-thread table (``JSPI_MAX_HOOKS``) is full. + +The hooks are inserted by a post-link Binaryen pass around every export in +:ref:`JSPI_EXPORTS` and every import in :ref:`JSPI_IMPORTS` (including +``EM_ASYNC_JS`` and other JS library functions marked async). Function +pointers made promising from JavaScript with ``dynCall(sig, ptr, args, true)`` +or Embind's ``async()`` policy go through the same hooks via a trampoline +export per function signature. Trampolines are generated for the signatures +of the functions in the table at link time, so a pointer added at runtime with +``addFunction`` can only be made promising if some linked function shares its +signature (an ``ASSERTIONS`` build reports the missing trampoline). Calling +``WebAssembly.promising`` on a wrapped export from your own JS is fine; only a +raw table entry made promising bypasses the hooks. + Optimizing Asyncify ################### diff --git a/site/source/docs/tools_reference/settings_reference.rst b/site/source/docs/tools_reference/settings_reference.rst index 3decfb5b6f220..3d80f687d619e 100644 --- a/site/source/docs/tools_reference/settings_reference.rst +++ b/site/source/docs/tools_reference/settings_reference.rst @@ -1373,7 +1373,9 @@ will return a ``Promise`` that will be resolved with the result. Any exports that will call an asynchronous import (listed in ``JSPI_IMPORTS``) must be included here. -By default this includes ``main``. +By default this includes ``main``. These exports are also where the +:ref:`JSPI lifecycle hooks ` see a fiber being entered and +exited (see ``JSPI_HOOKS``). Default value: [] @@ -1388,9 +1390,30 @@ asynchronous work. Note when using JS library files, the function can be marked with ``_async:: true`` in the library instead of this setting. +These imports are also where the :ref:`JSPI lifecycle hooks ` +see a fiber being suspended and resumed (see ``JSPI_HOOKS``). Default value: [] +.. _jspi_hooks: + +JSPI_HOOKS +========== + +Instrument the JSPI boundary with the fiber lifecycle hooks of +```` (see :ref:`jspi_lifecycle_hooks`): a post-link binaryen pass +wraps the promising exports and suspending imports, and a small runtime +library dispatches the events. Function pointers made promising from JS +(``dynCall(sig, ptr, args, true)``, Embind ``async()``) then go through a +per-signature trampoline export, which exists only for signatures present +in the table at link time, and a JSPI export fetched from the table as a +function pointer is no longer made promising automatically. Requires +``JSPI``; implied by ``REENTRANT_JSPI``. + +.. note:: This is an experimental setting + +Default value: false + .. _exported_runtime_methods: EXPORTED_RUNTIME_METHODS diff --git a/src/lib/libasync.js b/src/lib/libasync.js index b0d59fa7503df..c4515c789cfbf 100644 --- a/src/lib/libasync.js +++ b/src/lib/libasync.js @@ -160,16 +160,19 @@ addToLibrary({ #endif #if ASYNCIFY == 2 var exportPattern = {{{ new RegExp(`^(${ASYNCIFY_EXPORTS.join('|').replace(/\*/g, '.*')})$`) }}}; +#if !JSPI_HOOKS Asyncify.asyncExports = new Set(); +#endif #endif var ret = {}; for (let [x, original] of Object.entries(exports)) { if (typeof original == 'function') { #if ASYNCIFY == 2 // Wrap all exports with a promising WebAssembly function. - let isAsyncifyExport = exportPattern.test(x); - if (isAsyncifyExport) { + if (exportPattern.test(x)) { +#if !JSPI_HOOKS Asyncify.asyncExports.add(original); +#endif original = Asyncify.makeAsyncFunction(original); } ret[x] = original; @@ -453,13 +456,15 @@ addToLibrary({ // // JSPI implementation of Asyncify. // - - // Stores all the exported raw Wasm functions that are wrapped with async - // WebAssembly.Functions. +#if !JSPI_HOOKS + // The raw wasm exports that were wrapped with WebAssembly.promising; with + // the hooks the table holds the unwrapped functions instead, and function + // pointers are made promising through the trampolines. asyncExports: null, isAsyncExport(func) { return Asyncify.asyncExports?.has(func); }, +#endif handleAsync: async (startAsync) => { {{{ runtimeKeepalivePush(); }}} try { diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 5247fbfd06284..8cfd845dc9ee6 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -1795,14 +1795,37 @@ addToLibrary({ var f = dynCalls[sig]; return f(ptr, ...args); }, +#endif + $dynCall__deps: [ #if DYNCALLS || !WASM_BIGINT '$dynCallLegacy', #endif #if !DYNCALLS '$getWasmTableEntry', +#endif +#if JSPI_HOOKS + '$jspiDynCall', #endif ], + +#if JSPI_HOOKS + // Promising callers of the jspi-hooks trampolines, by signature. + $jspiDynCallers: {}, + $jspiDynCall__deps: ['$jspiDynCallers'], + $jspiDynCall: (sig) => { + sig = sig.replace(/p/g, {{{ MEMORY64 ? "'j'" : "'i'" }}}); + var caller = jspiDynCallers[sig]; + if (!caller) { + var trampoline = wasmExports['__jspi_dyncall_' + sig]; +#if ASSERTIONS + assert(trampoline, `no JSPI trampoline for function pointer signature '${sig}': no function with that signature was in the table at link time (see JSPI_HOOKS)`); +#endif + var promising = WebAssembly.promising(trampoline); + caller = jspiDynCallers[sig] = (ptr, ...args) => promising({{{ toIndexType('ptr') }}}, ...args); + } + return caller; + }, #endif // Used in library code to get JS function from wasm function pointer. @@ -1811,7 +1834,7 @@ addToLibrary({ $getDynCaller__deps: ['$dynCall'], $getDynCaller: (sig, ptr, promising = false) => { #if ASSERTIONS && !DYNCALLS - assert(sig.includes('j') || sig.includes('p'), 'getDynCaller should only be called with i64 sigs') + assert(promising || sig.includes('j') || sig.includes('p'), 'getDynCaller should only be called with i64 sigs') #endif return (...args) => dynCall(sig, ptr, args, promising); }, @@ -1845,13 +1868,19 @@ addToLibrary({ #if ASSERTIONS assert(getWasmTableEntry(ptr), `missing table entry in dynCall: ${ptr}`); #endif - var func = getWasmTableEntry(ptr); #if JSPI if (promising) { - func = WebAssembly.promising(func); +#if JSPI_HOOKS + // Function pointers are made promising through the per-signature + // trampoline exports the jspi-hooks pass generates, so that the fiber + // gets its lifecycle hooks like any other promising export. + return jspiDynCall(sig)(ptr, ...args).then(convert); +#else + return WebAssembly.promising(getWasmTableEntry(ptr))(...args).then(convert); +#endif } #endif - var rtn = func(...args); + var rtn = getWasmTableEntry(ptr)(...args); #endif // DYNCALLS function convert(rtn) { @@ -1864,11 +1893,6 @@ addToLibrary({ #endif } -#if JSPI - if (promising) { - return rtn.then(convert); - } -#endif return convert(rtn); }, @@ -1912,13 +1936,13 @@ addToLibrary({ if (!func) { /** @suppress {checkTypes} */ wasmTableMirror[funcPtr] = func = wasmTable.get({{{ toIndexType('funcPtr') }}}); -#if ASYNCIFY == 2 +#if ASYNCIFY == 2 && !JSPI_HOOKS if (Asyncify.isAsyncExport(func)) { wasmTableMirror[funcPtr] = func = Asyncify.makeAsyncFunction(func); } #endif } -#if ASSERTIONS && ASYNCIFY != 2 // With JSPI the function stored in the table will be a wrapper. +#if ASSERTIONS && !(ASYNCIFY == 2 && !JSPI_HOOKS) // Without the hooks the function stored in the table may be a wrapper. /** @suppress {checkTypes} */ assert(wasmTable.get({{{ toIndexType('funcPtr') }}}) == func, 'table mirror is out of date'); #endif diff --git a/src/lib/libembind.js b/src/lib/libembind.js index 3542d45f8c4e9..bb0dcfec2bb34 100644 --- a/src/lib/libembind.js +++ b/src/lib/libembind.js @@ -815,7 +815,7 @@ var LibraryEmbind = { }, $embind__requireFunction__deps: ['$AsciiToString', '$throwBindingError' -#if DYNCALLS || !WASM_BIGINT || MEMORY64 || CAN_ADDRESS_2GB +#if DYNCALLS || !WASM_BIGINT || MEMORY64 || CAN_ADDRESS_2GB || JSPI , '$getDynCaller' #endif ], @@ -840,13 +840,12 @@ var LibraryEmbind = { return getDynCaller(signature, rawFunction, isAsync); } #endif - var rtn = getWasmTableEntry(rawFunction); #if JSPI if (isAsync) { - rtn = WebAssembly.promising(rtn); + return getDynCaller(signature, rawFunction, true); } #endif - return rtn; + return getWasmTableEntry(rawFunction); #endif } diff --git a/src/parseTools.mjs b/src/parseTools.mjs index e4acfc1a4e360..a313b2c70e4fb 100644 --- a/src/parseTools.mjs +++ b/src/parseTools.mjs @@ -692,6 +692,13 @@ function makeDynCall(sig, funcPtr, promising = false) { ); assert(!(DYNCALLS && promising), 'DYNCALLS cannot be used with JSPI'); + if (promising) { + // Routed through $dynCall so that the call goes via the jspi-hooks + // trampoline for the signature; direct WebAssembly.promising of a table + // entry would run the fiber without its lifecycle hooks. + return `getDynCaller("${sig}", ${funcPtr}, true)`; + } + let args = []; for (let i = 1; i < sig.length; ++i) { args.push(`a${i}`); @@ -762,21 +769,12 @@ Please update to new syntax.`); return `(() => ${dyncall}(${funcPtr}))`; } - let getWasmTableEntry = `getWasmTableEntry(${funcPtr})`; - if (promising) { - getWasmTableEntry = `WebAssembly.promising(${getWasmTableEntry})`; - } - + const getWasmTableEntry = `getWasmTableEntry(${funcPtr})`; if (needArgConversion) { if (needRtnConversion) { - if (promising) { - return `((${args}) => ${getWasmTableEntry}.call(null, ${callArgs}).then(Number))`; - } else { - return `((${args}) => Number(${getWasmTableEntry}.call(null, ${callArgs})))`; - } - } else { - return `((${args}) => ${getWasmTableEntry}.call(null, ${callArgs}))`; + return `((${args}) => Number(${getWasmTableEntry}.call(null, ${callArgs})))`; } + return `((${args}) => ${getWasmTableEntry}.call(null, ${callArgs}))`; } return getWasmTableEntry; } diff --git a/src/settings.js b/src/settings.js index 667a51862e4fe..f15443f48b80f 100644 --- a/src/settings.js +++ b/src/settings.js @@ -931,7 +931,9 @@ var JSPI = 0; // that will call an asynchronous import (listed in ``JSPI_IMPORTS``) must be // included here. // -// By default this includes ``main``. +// By default this includes ``main``. These exports are also where the +// :ref:`JSPI lifecycle hooks ` see a fiber being entered and +// exited (see ``JSPI_HOOKS``). // [link] var JSPI_EXPORTS = []; @@ -942,9 +944,24 @@ var JSPI_EXPORTS = []; // // Note when using JS library files, the function can be marked with // ``_async:: true`` in the library instead of this setting. +// These imports are also where the :ref:`JSPI lifecycle hooks ` +// see a fiber being suspended and resumed (see ``JSPI_HOOKS``). // [link] var JSPI_IMPORTS = []; +// Instrument the JSPI boundary with the fiber lifecycle hooks of +// ```` (see :ref:`jspi_lifecycle_hooks`): a post-link binaryen pass +// wraps the promising exports and suspending imports, and a small runtime +// library dispatches the events. Function pointers made promising from JS +// (``dynCall(sig, ptr, args, true)``, Embind ``async()``) then go through a +// per-signature trampoline export, which exists only for signatures present +// in the table at link time, and a JSPI export fetched from the table as a +// function pointer is no longer made promising automatically. Requires +// ``JSPI``; implied by ``REENTRANT_JSPI``. +// [link] +// [experimental] +var JSPI_HOOKS = false; + // Runtime elements that are exported on Module by default. We used to export // quite a lot here, but have removed them all. You should use // EXPORTED_RUNTIME_METHODS for things you want to export from the runtime. diff --git a/system/include/emscripten/jspi.h b/system/include/emscripten/jspi.h new file mode 100644 index 0000000000000..2b9cc95bfda76 --- /dev/null +++ b/system/include/emscripten/jspi.h @@ -0,0 +1,62 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + */ + +#pragma once + +#include + +// Lifecycle hooks for JSPI (-sJSPI) stack-switching fibers (-sJSPI_HOOKS). +// +// A fiber is the wasm activation started by a call to a promising export. It +// is entered once, may be suspended and resumed any number of times while its +// suspending imports await, and exits once, normally or with an exception. +// A suspension is a leave of the fiber: while it is suspended other fibers +// (or the top level) run on the same thread and see the same static storage +// and shadow stack, so libraries with fiber-affine state stash it on +// JSPI_SUSPEND and restore it on JSPI_RESUME, with the lifetime bounded by +// JSPI_ENTER/JSPI_EXIT. Fibers never move between threads. + +#ifdef __cplusplus +extern "C" { +#endif + +// Events are bit flags so that they combine into the mask for jspi_register. +typedef enum { + // A promising export was called. + JSPI_ENTER = 1, + // The promising export is returning (or throwing). + JSPI_EXIT = 2, + // A suspending import is about to be called (it may or may not suspend). + JSPI_SUSPEND = 4, + // The suspending import has returned (or thrown); the fiber is running again. + JSPI_RESUME = 8, +} jspi_event; + +#define JSPI_ALL (JSPI_ENTER | JSPI_EXIT | JSPI_SUSPEND | JSPI_RESUME) + +// Called for each event of every fiber, inside the fiber's own frames +// immediately before/after the boundary call. `token` is the hook's own +// per-fiber value: NULL at the first event the hook sees for a fiber, then +// whatever the hook returned at the previous event of that fiber; the return +// value at JSPI_EXIT is ignored. `error` is nonzero when the export or import +// completed with an exception (a JS exception, a rejected promise or a wasm +// exception such as a C++ throw), which is rethrown unchanged after the hooks +// run; hooks cannot inspect it. Hooks must not throw, suspend, or call +// promising exports. +typedef void* (*jspi_hook)(jspi_event event, void* token, int error); + +// Registers `fn` for the events in `mask` on the calling thread. Hooks run in +// registration order. Returns 0, -1 if the program was linked without +// -sJSPI_HOOKS (no events are ever delivered), or -2 if the table is full +// (JSPI_MAX_HOOKS registrations per thread). +int jspi_register(jspi_hook fn, uint32_t mask); + +#define JSPI_MAX_HOOKS 64 + +#ifdef __cplusplus +} +#endif diff --git a/system/lib/jspi/jspi.c b/system/lib/jspi/jspi.c new file mode 100644 index 0000000000000..467363a1a06f1 --- /dev/null +++ b/system/lib/jspi/jspi.c @@ -0,0 +1,151 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Runtime side of the JSPI lifecycle hooks. The __jspi_enter/exit/suspend/ + * resume exports are called by the wrappers that binaryen's --jspi-hooks pass + * places around every promising export and suspending import; see + * for the model. + */ + +#include +#include +#include +#include + +typedef struct { + jspi_hook fn; + uint32_t mask; +} jspi_registration; + +static _Thread_local jspi_registration hooks[JSPI_MAX_HOOKS]; +static _Thread_local uint32_t hook_count; + +// One record per live fiber, holding each registration's token for it. The +// record's address is the token the wrappers carry between the events of a +// pair, and the current record follows the same protocol a stack pointer +// would: whoever entered or last resumed the fiber is remembered, made +// current again when the fiber leaves at SUSPEND (the import may return to +// that code synchronously), refreshed at RESUME and made current at EXIT. +typedef struct jspi_fiber { + struct jspi_fiber* host; + void* tokens[JSPI_MAX_HOOKS]; +} jspi_fiber; + +// Records for the first fibers alive at once come from a per-thread pool (a +// free record links to the next through `host`); further ones are +// heap-allocated. +#define POOL_FIBERS 64 +static _Thread_local jspi_fiber pool[POOL_FIBERS]; +static _Thread_local jspi_fiber* free_list; +static _Thread_local uint32_t pool_used; + +static jspi_fiber* alloc_fiber(void) { + jspi_fiber* f = free_list; + if (f) { + free_list = f->host; + } else if (pool_used < POOL_FIBERS) { + f = &pool[pool_used++]; + } else { + f = emscripten_builtin_malloc(sizeof(jspi_fiber)); + if (!f) { + emscripten_err("JSPI: out of memory"); + abort(); + } + } + *f = (jspi_fiber){0}; + return f; +} + +static void free_fiber(jspi_fiber* f) { + if (f >= pool && f < pool + POOL_FIBERS) { + f->host = free_list; + free_list = f; + } else { + emscripten_builtin_free(f); + } +} + +// The current fiber lives in a wasm global (per instance, hence per thread); +// NULL outside any fiber. +#ifdef __wasm64__ +#define PTR "i64" +#else +#define PTR "i32" +#endif +__asm__(".globaltype __jspi_cur_fiber, " PTR "\n" + ".globl __jspi_cur_fiber\n" + "__jspi_cur_fiber:\n"); + +static jspi_fiber* get_cur_fiber(void) { + jspi_fiber* f; + __asm__ volatile("global.get __jspi_cur_fiber\n" + "local.set %0" + : "=r"(f)); + return f; +} + +static void set_cur_fiber(jspi_fiber* f) { + __asm__ volatile("local.get %0\n" + "global.set __jspi_cur_fiber" + : + : "r"(f)); +} + +__attribute__((noinline)) static void +dispatch(jspi_event event, jspi_fiber* f, int error) { + for (uint32_t i = 0; i < hook_count; i++) { + jspi_registration* r = &hooks[i]; + if (r->mask & event) { + f->tokens[i] = r->fn(event, f->tokens[i], error); + } + } +} + +// The exports the wrappers call around promising exports (enter/exit) and +// suspending imports (suspend/resume). The token the "before" hook returns +// (the fiber) comes back to the "after" hook, along with whether the wrapped +// call threw. +uint64_t __jspi_enter(void) { + jspi_fiber* f = alloc_fiber(); + f->host = get_cur_fiber(); + set_cur_fiber(f); + dispatch(JSPI_ENTER, f, 0); + return (uintptr_t)f; +} + +void __jspi_exit(uint64_t token, int error) { + jspi_fiber* f = (jspi_fiber*)(uintptr_t)token; + dispatch(JSPI_EXIT, f, error); + set_cur_fiber(f->host); + free_fiber(f); +} + +uint64_t __jspi_suspend(void) { + jspi_fiber* f = get_cur_fiber(); + // Without a fiber the import is about to fail with a SuspendError. + if (f) { + dispatch(JSPI_SUSPEND, f, 0); + set_cur_fiber(f->host); + } + return (uintptr_t)f; +} + +void __jspi_resume(uint64_t token, int error) { + jspi_fiber* f = (jspi_fiber*)(uintptr_t)token; + if (f) { + f->host = get_cur_fiber(); + set_cur_fiber(f); + dispatch(JSPI_RESUME, f, error); + } +} + +int jspi_register(jspi_hook fn, uint32_t mask) { + if (hook_count == JSPI_MAX_HOOKS) { + return -2; + } + hooks[hook_count++] = (jspi_registration){fn, mask}; + return 0; +} diff --git a/system/lib/jspi/jspi_stub.c b/system/lib/jspi/jspi_stub.c new file mode 100644 index 0000000000000..76e0ae69e01ce --- /dev/null +++ b/system/lib/jspi/jspi_stub.c @@ -0,0 +1,12 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Linked in place of jspi.c when JSPI_HOOKS is off: no events are delivered. + */ + +#include + +int jspi_register(jspi_hook fn, uint32_t mask) { return -1; } diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index b193194f47af9..8b2055845013b 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.nodebug.wasm": 588233, - "total": 858817, + "a.out.nodebug.wasm": 588250, + "total": 858834, "sent": [ "IMG_Init", "IMG_Load", @@ -2973,6 +2973,7 @@ "jn", "jnf", "jrand48", + "jspi_register", "kill", "killpg", "l64a", diff --git a/test/core/test_jspi_hooks.c b/test/core/test_jspi_hooks.c new file mode 100644 index 0000000000000..b52826160e780 --- /dev/null +++ b/test/core/test_jspi_hooks.c @@ -0,0 +1,154 @@ +// Copyright 2026 The Emscripten Authors. All rights reserved. +// Emscripten is available under two separate licenses, the MIT license and the +// University of Illinois/NCSA Open Source License. Both these licenses can be +// found in the LICENSE file. + +#include +#include +#include +#include +#include +#include +#include + +static const char* name(jspi_event ev) { + switch (ev) { + case JSPI_ENTER: return "ENTER"; + case JSPI_EXIT: return "EXIT"; + case JSPI_SUSPEND: return "SUSPEND"; + case JSPI_RESUME: return "RESUME"; + } + return "?"; +} + +// All output goes through printf: under PROXY_TO_PTHREAD console.log from the +// worker is not ordered with the proxied stdout writes. +EMSCRIPTEN_KEEPALIVE void js_log(char* line) { + printf("%s\n", line); + free(line); +} + +// Each hook mints its own per-fiber token at ENTER (a counter) and gets it +// back at every later event of that fiber, where it may replace it. Hook A +// keeps its token; hook B increments it at every event. +static int next_a, next_b; + +void* hook_a(jspi_event ev, void* token, int error) { + if (ev == JSPI_ENTER) { + assert(token == NULL); + token = (void*)(uintptr_t)++next_a; + } + printf("A %s#%u%s\n", name(ev), (unsigned)(uintptr_t)token, error ? " err" : ""); + return token; +} + +void* hook_b(jspi_event ev, void* token, int error) { + if (ev == JSPI_ENTER) { + assert(token == NULL); + token = (void*)(uintptr_t)(++next_b * 100); + } + printf("B %s#%u%s\n", name(ev), (unsigned)(uintptr_t)token, error ? " err" : ""); + return (void*)((uintptr_t)token + 1); +} + +// Only registered for SUSPEND/RESUME: never sees ENTER, so its first token for +// a fiber is NULL; it counts that fiber's suspensions. +void* suspend_counter(jspi_event ev, void* token, int error) { + assert(ev == JSPI_SUSPEND || ev == JSPI_RESUME); + if (ev == JSPI_SUSPEND) { + token = (void*)((uintptr_t)token + 1); + } + printf("C %s suspensions=%u\n", name(ev), (unsigned)(uintptr_t)token); + return token; +} + +// A suspending import that rejects. +EM_ASYNC_JS(void, reject_after_tick, (), { + await new Promise((resolve) => setTimeout(resolve, 0)); + throw new Error('boom'); +}); + +// A suspending import that suspends and then re-enters wasm synchronously +// from JS through a promising export, before resolving. +EM_ASYNC_JS(int, nested_entry, (), { + await Promise.resolve(); + const p = Module['_nested'](7); + log(' nested returned promise:', p instanceof Promise); + return await p; +}); + +EMSCRIPTEN_KEEPALIVE int nested(int x) { + printf("nested body\n"); + emscripten_sleep(0); + printf("nested after sleep\n"); + return x * 2; +} + +// Called synchronously from a fiber or from JS: a plain export, no events of +// its own. +EMSCRIPTEN_KEEPALIVE int plain(int x) { return x + 1; } + +// The rejection of the import propagates out of the promising export. +EMSCRIPTEN_KEEPALIVE int rejects(void) { + emscripten_sleep(0); + reject_after_tick(); + return 1; +} + +EM_JS(void, run_js_tests, (), { + Module['jsTests'] = (async () => { + // Rejected promising export: RESUME and EXIT see the error, and the + // exception object reaches the caller unchanged. + const boom = await Module['_rejects']().then( + () => 'resolved', (e) => e instanceof Error ? e.message : 'not an Error'); + log('rejects ->', boom); + // Nested promising entry from inside a fiber's import. + log('nested_entry ->', await Module['_run_nested']()); + // Plain export called synchronously from outside: no events. + log('plain outside ->', Module['_plain'](100)); + // Function pointer made promising via dynCall. + log('dyncall ->', await dynCall('ii', Module['_nested_ptr'](), [21], true)); + })(); +}); + +EMSCRIPTEN_KEEPALIVE int run_nested(void) { return nested_entry(); } + +EMSCRIPTEN_KEEPALIVE int nested_ptr(void) { + return (int)(uintptr_t)&nested; +} + +void* noop(jspi_event ev, void* token, int error) { return token; } + +int main() { + // Hooks run in registration order; main's own fiber was entered before + // they existed, so they first see it at SUSPEND with a NULL token. + assert(jspi_register(hook_a, JSPI_ALL) == 0); + assert(jspi_register(hook_b, JSPI_ALL) == 0); + assert(jspi_register(suspend_counter, + JSPI_SUSPEND | JSPI_RESUME) == 0); + + emscripten_sleep(0); + printf("plain from fiber -> %d\n", plain(100)); + + // Table capacity: three registrations so far. + int ok = 0; + while (jspi_register(noop, JSPI_SUSPEND) == 0) ok++; + printf("registered %d more hooks, then -2\n", ok); + assert(ok == JSPI_MAX_HOOKS - 3); + assert(jspi_register(noop, JSPI_ALL) == -2); + + // The JS tests continue after main returns; exit from finish(). + emscripten_runtime_keepalive_push(); + EM_ASM({ + globalThis.log = (...args) => Module['_js_log'](stringToNewUTF8(args.join(' '))); + }); + run_js_tests(); + EM_ASM({ Module['jsTests'].then(() => callUserCallback(Module['_finish'])); }); + return 0; +} + +EMSCRIPTEN_KEEPALIVE void finish(void) { + printf("done\n"); + emscripten_runtime_keepalive_pop(); + exit(0); +} diff --git a/test/core/test_jspi_hooks.out b/test/core/test_jspi_hooks.out new file mode 100644 index 0000000000000..0d3da2a72f136 --- /dev/null +++ b/test/core/test_jspi_hooks.out @@ -0,0 +1,66 @@ +A SUSPEND#0 +B SUSPEND#0 +C SUSPEND suspensions=1 +A RESUME#0 +B RESUME#1 +C RESUME suspensions=1 +plain from fiber -> 101 +registered 61 more hooks, then -2 +A ENTER#1 +B ENTER#100 +A SUSPEND#1 +B SUSPEND#101 +C SUSPEND suspensions=1 +A EXIT#0 +B EXIT#2 +A RESUME#1 +B RESUME#102 +C RESUME suspensions=1 +A SUSPEND#1 +B SUSPEND#103 +C SUSPEND suspensions=2 +A RESUME#1 err +B RESUME#104 err +C RESUME suspensions=2 +A EXIT#1 err +B EXIT#105 err +rejects -> boom +A ENTER#2 +B ENTER#200 +A SUSPEND#2 +B SUSPEND#201 +C SUSPEND suspensions=1 +A ENTER#3 +B ENTER#300 +nested body +A SUSPEND#3 +B SUSPEND#301 +C SUSPEND suspensions=1 + nested returned promise: true +A RESUME#3 +B RESUME#302 +C RESUME suspensions=1 +nested after sleep +A EXIT#3 +B EXIT#303 +A RESUME#2 +B RESUME#202 +C RESUME suspensions=1 +A EXIT#2 +B EXIT#203 +nested_entry -> 14 +plain outside -> 101 +A ENTER#4 +B ENTER#400 +nested body +A SUSPEND#4 +B SUSPEND#401 +C SUSPEND suspensions=1 +A RESUME#4 +B RESUME#402 +C RESUME suspensions=1 +nested after sleep +A EXIT#4 +B EXIT#403 +dyncall -> 42 +done diff --git a/test/other/test_jspi_hooks_cpp_exception.cpp b/test/other/test_jspi_hooks_cpp_exception.cpp new file mode 100644 index 0000000000000..44985e4743314 --- /dev/null +++ b/test/other/test_jspi_hooks_cpp_exception.cpp @@ -0,0 +1,67 @@ +// Copyright 2026 The Emscripten Authors. All rights reserved. +// Emscripten is available under two separate licenses, the MIT license and the +// University of Illinois/NCSA Open Source License. Both these licenses can be +// found in the LICENSE file. + +#include +#include +#include +#include +#include + +static const char* name(jspi_event ev) { + switch (ev) { + case JSPI_ENTER: return "ENTER"; + case JSPI_EXIT: return "EXIT"; + case JSPI_SUSPEND: return "SUSPEND"; + case JSPI_RESUME: return "RESUME"; + } + return "?"; +} + +static unsigned next_id; + +void* hook(jspi_event ev, void* token, int error) { + if (ev == JSPI_ENTER) { + token = (void*)(uintptr_t)++next_id; + } + printf("%s#%u%s\n", name(ev), (unsigned)(uintptr_t)token, error ? " err" : ""); + return token; +} + +EM_ASYNC_JS(void, tick, (), { await Promise.resolve(); }); + +extern "C" { + +EMSCRIPTEN_KEEPALIVE void throws_after_suspend() { + tick(); + throw std::runtime_error("cpp"); +} + +EMSCRIPTEN_KEEPALIVE void caught_inside() { + try { + tick(); + throw std::runtime_error("cpp"); + } catch (const std::exception& e) { + printf("caught %s in wasm\n", e.what()); + } +} + +} + +EM_JS(void, run_tests, (), { + Module['done'] = (async () => { + try { + await Module['_throws_after_suspend'](); + } catch (e) { + console.log('rejected with', e instanceof WebAssembly.Exception ? 'WebAssembly.Exception' : e); + } + await Module['_caught_inside'](); + console.log('done'); + })(); +}); + +int main() { + jspi_register(hook, JSPI_ALL); + run_tests(); +} diff --git a/test/other/test_jspi_hooks_cpp_exception.out b/test/other/test_jspi_hooks_cpp_exception.out new file mode 100644 index 0000000000000..d8c0fe691e5ff --- /dev/null +++ b/test/other/test_jspi_hooks_cpp_exception.out @@ -0,0 +1,12 @@ +ENTER#1 +SUSPEND#1 +EXIT#0 +RESUME#1 +EXIT#1 err +rejected with WebAssembly.Exception +ENTER#2 +SUSPEND#2 +RESUME#2 +caught cpp in wasm +EXIT#2 +done diff --git a/test/test_core.py b/test/test_core.py index ff54c14942cec..e8838abbee750 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -8296,6 +8296,40 @@ def test_asyncify_longjmp(self): self.set_setting('STRICT') self.do_core_test('test_asyncify_longjmp.c') + @requires_jspi + @parameterized({ + '': ([],), + # The program itself has no try/catch; these check the two wrapper forms + # the jspi-hooks pass emits when libc++abi/longjmp bring in each EH kind. + 'wasm_eh_legacy': (['-fwasm-exceptions'],), + 'wasm_eh': (['-fwasm-exceptions', '-sWASM_LEGACY_EXCEPTIONS=0'],), + }) + def test_jspi_hooks(self, args): + self.set_setting('JSPI_HOOKS') + self.cflags.append('-Wno-experimental') + self.set_setting('JSPI_EXPORTS', ['rejects', 'run_nested', 'nested']) + self.set_setting('EXPORTED_RUNTIME_METHODS', ['dynCall']) + self.set_setting('DEFAULT_LIBRARY_FUNCS_TO_INCLUDE', ['$stringToNewUTF8', '$callUserCallback']) + self.set_setting('EXIT_RUNTIME') + self.do_core_test('test_jspi_hooks.c', cflags=args) + + # See test_pthread_wait_suspending for why @requires_node_25 is needed. + @requires_node_25 + @requires_pthreads + @requires_jspi + @parameterized({ + '': ([],), + 'proxy_to_pthread': (['-sPROXY_TO_PTHREAD'],), + }) + def test_jspi_hooks_pthread(self, args): + self.set_setting('JSPI_HOOKS') + self.cflags.append('-Wno-experimental') + self.set_setting('JSPI_EXPORTS', ['rejects', 'run_nested', 'nested']) + self.set_setting('EXPORTED_RUNTIME_METHODS', ['dynCall']) + self.set_setting('DEFAULT_LIBRARY_FUNCS_TO_INCLUDE', ['$stringToNewUTF8', '$callUserCallback']) + self.set_setting('EXIT_RUNTIME') + self.do_core_test('test_jspi_hooks.c', cflags=['-pthread'] + args) + # Test that a main with arguments is automatically asyncified. @with_asyncify_and_jspi def test_async_main(self): diff --git a/test/test_other.py b/test/test_other.py index 0c1adaa104f75..672dc9269c9e2 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -3688,6 +3688,59 @@ def test_jspi_add_function(self): '-sALLOW_TABLE_GROWTH=1'] self.do_runf('other/test_jspi_add_function.c', 'done\n') + @requires_jspi + @parameterized({ + 'legacy': ([],), + 'exnref': (['-sWASM_LEGACY_EXCEPTIONS=0'],), + }) + def test_jspi_hooks_cpp_exception(self, args): + # A C++ exception escaping a promising export reaches the EXIT hook as an + # error, and is caught normally inside wasm across a suspension. + self.do_other_test('test_jspi_hooks_cpp_exception.cpp', + cflags=['-sJSPI', '-sJSPI_HOOKS', '-Wno-experimental', '-fwasm-exceptions', + '-sJSPI_EXPORTS=throws_after_suspend,caught_inside'] + args) + + @requires_jspi + def test_jspi_hooks_cpp_exception_mixed_eh(self): + # An exnref link may still receive legacy EH from prebuilt objects; the + # hooks pass sees one flavor after translation. + self.run_process([EMXX, '-c', test_file('other/test_jspi_hooks_cpp_exception.cpp'), + '-fwasm-exceptions', '-sWASM_LEGACY_EXCEPTIONS', '-o', 'legacy.o']) + self.run_process([EMXX, 'legacy.o', '-sJSPI', '-sJSPI_HOOKS', '-Wno-experimental', '-fwasm-exceptions', + '-sWASM_LEGACY_EXCEPTIONS=0', '-sJSPI_EXPORTS=throws_after_suspend,caught_inside', + '-o', 'mixed.js']) + self.assertFileContents(test_file('other/test_jspi_hooks_cpp_exception.out'), self.run_js('mixed.js')) + + @requires_jspi + def test_jspi_hooks_enabled(self): + # Hooks are opt-in: without the setting nothing of them is linked in. + self.do_runf('other/test_jspi_wildcard.c', 'done\n', cflags=['-sJSPI', '-sJSPI_EXPORTS=async*']) + exports = [e.name for e in webassembly.get_exports('test_jspi_wildcard.wasm')] + self.assertFalse(any(e.startswith('__jspi_') for e in exports)) + self.do_runf('other/test_jspi_wildcard.c', 'done\n', + cflags=['-sJSPI', '-sJSPI_HOOKS', '-Wno-experimental', '-sJSPI_EXPORTS=async*']) + exports = [e.name for e in webassembly.get_exports('test_jspi_wildcard.wasm')] + self.assertIn('__jspi_enter', exports) + err = self.expect_fail([EMCC, test_file('hello_world.c'), '-sJSPI_HOOKS', '-Wno-experimental']) + self.assertContained('JSPI_HOOKS requires JSPI', err) + + def test_jspi_hooks_stub(self): + # Without the setting jspi_register links against a stub returning -1, so + # a library can degrade at runtime rather than failing to link. + create_file('main.c', r''' + #include + #include + #include + void* hook(jspi_event ev, void* token, int error) { return token; } + int main() { + assert(jspi_register(hook, JSPI_ALL) == -1); + printf("done\n"); + } + ''') + self.do_runf('main.c', 'done\n') + self.do_runf('main.c', 'done\n', cflags=['-sJSPI']) + self.assertNotIn(b'__jspi_', read_binary('main.wasm')) + @requires_jspi def test_jspi_async_function(self): # Make sure async library functions are not automatically JSPI'd. diff --git a/tools/building.py b/tools/building.py index ccd0a011084f0..3f2f8194e1404 100644 --- a/tools/building.py +++ b/tools/building.py @@ -62,6 +62,8 @@ user_requested_exports: set[str] = set() # JS library symbols exported via the `__export` decorator. extra_js_exports: set[str] = set() +# JS library symbols (mangled) that were emitted into the JS output. +js_library_symbols: set[str] = set() # Mangled wasm exports wasm-bindgen's glue reaches by name, kept off the public surface. wasm_bindgen_internal_exports: set[str] = set() # A list of feature flags to pass to each binaryen invocation (like `wasm-opt`, diff --git a/tools/emscripten.py b/tools/emscripten.py index 6aeaeea9507be..594267aed9a80 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -127,8 +127,11 @@ def update_settings_glue(wasm_file, metadata, base_metadata): # start with the MVP features, and add any detected features. building.binaryen_features = ['--mvp-features', *metadata.features] - if settings.ASYNCIFY == 2: + if settings.JSPI: building.binaryen_features += ['--enable-reference-types'] + if settings.JSPI_HOOKS: + # The jspi-hooks pass adds exception handling to the module. + building.binaryen_features += ['--enable-exception-handling'] if settings.PTHREADS: assert '--enable-threads' in building.binaryen_features @@ -456,6 +459,7 @@ def emscript(in_wasm, out_wasm, outfile_js, js_syms, finalize=True, base_metadat pre += "}\n" report_missing_exports(forwarded_json['librarySymbols']) + building.js_library_symbols.update(forwarded_json['librarySymbols']) building.extra_js_exports.update(forwarded_json['extraExports']) diff --git a/tools/link.py b/tools/link.py index c60c519f1ff0f..f37bc2ab8e34f 100644 --- a/tools/link.py +++ b/tools/link.py @@ -386,6 +386,29 @@ def check_human_readable_list(items): check_human_readable_list(settings.ASYNCIFY_ONLY) passes += [f"--pass-arg=asyncify-onlylist@{','.join(settings.ASYNCIFY_ONLY)}"] + if settings.JSPI_HOOKS: + if not settings.WASM_LEGACY_EXCEPTIONS: + # The hook wrappers use the module's exception handling flavor, so a + # module targeting exnref must not still carry legacy instructions from + # prebuilt inputs when the pass runs. + passes += ['--translate-to-exnref'] + # Wrap the promising exports and suspending imports (exactly the sets the + # JS wraps in WebAssembly.promising / WebAssembly.Suspending) with the + # fiber lifecycle hooks provided by libjspi, plus trampolines for making + # function pointers promising. Side modules are not instrumented: their + # direct imports of suspending JS functions run without hooks. + passes += ['--jspi-hooks'] + # The JS matches suspending imports by base name only (see + # instrumentWasmImports), so match any module here too. + jspi_imports = ['*.' + i.split('.', 1)[1] for i in settings.ASYNCIFY_IMPORTS] + passes += [f"--pass-arg=jspi-imports@{','.join(jspi_imports)}"] + passes += [f"--pass-arg=jspi-exports@{','.join(settings.ASYNCIFY_EXPORTS)}"] + # The trampolines keep every table entry of their signatures alive, so only + # emit them when the JS can actually make function pointers promising, + # which is only done through $dynCall. + if 'dynCall' in building.js_library_symbols: + passes += ['--pass-arg=jspi-dyncalls'] + if settings.MEMORY64 == 2: passes += ['--memory64-lowering', '--table64-lowering'] @@ -992,6 +1015,21 @@ def limit_incoming_module_api(): else: default_setting('INCOMING_MODULE_JS_API', []) + # JSPI and ASYNCIFY=2 are the same mode. + if settings.ASYNCIFY == 2: + settings.JSPI = 1 + if settings.JSPI: + settings.ASYNCIFY = 2 + if settings.JSPI_HOOKS: + diagnostics.warning('experimental', 'JSPI_HOOKS is experimental') + if not settings.JSPI: + exit_with_error('JSPI_HOOKS requires JSPI') + if not settings.WASM_BIGINT: + # The hook export takes and returns the fiber token as an i64. + exit_with_error('JSPI_HOOKS requires WASM_BIGINT') + if settings.SIDE_MODULE: + settings.JSPI_HOOKS = 0 + if settings.ASYNCIFY == 1: # ASYNCIFY=1 wraps only wasm exports so we need to enable legacy # dyncalls via dynCall_xxx exports. @@ -1707,11 +1745,15 @@ def limit_incoming_module_api(): if not settings.DISABLE_EXCEPTION_CATCHING: settings.REQUIRED_EXPORTS += ['setThrew'] + if settings.JSPI_HOOKS: + settings.REQUIRED_EXPORTS += ['__jspi_enter', '__jspi_exit', '__jspi_suspend', '__jspi_resume'] + if settings.ASYNCIFY: - if not settings.ASYNCIFY_IGNORE_INDIRECT: + if settings.ASYNCIFY == 1 and not settings.ASYNCIFY_IGNORE_INDIRECT: # if we are not ignoring indirect calls, then we must treat invoke_* as if # they are indirect calls, since that is what they do - we can't see their - # targets statically. + # targets statically. (JSPI cannot suspend across the JS frame of an + # invoke, so there they are never suspending.) settings.ASYNCIFY_IMPORTS += ['invoke_*'] # add the default imports settings.ASYNCIFY_IMPORTS += DEFAULT_ASYNCIFY_IMPORTS @@ -2327,6 +2369,13 @@ def phase_binaryen(target, wasm_target): args=passes, debug=intermediate_debug_info) building.save_intermediate(wasm_target, 'byn.wasm') + if '--pass-arg=jspi-dyncalls' in passes: + # The jspi-hooks pass adds the __jspi_dyncall_* trampoline exports, which + # the JS looks up by signature at runtime; keep them through metadce. + for e in webassembly.get_exports(wasm_target): + if e.name.startswith('__jspi_dyncall_'): + settings.WASM_EXPORTS.append(e.name) + building.user_requested_exports.add(shared.asmjs_mangle(e.name)) if settings.EVAL_CTORS: with ToolchainProfiler.profile_block('eval_ctors'): diff --git a/tools/native_sigs.py b/tools/native_sigs.py index 9878ea4ef2b9e..8954019079bbe 100644 --- a/tools/native_sigs.py +++ b/tools/native_sigs.py @@ -1124,6 +1124,7 @@ 'iswxdigit_l': '__p', 'isxdigit_l': '__p', 'jrand48': 'pp', + 'jspi_register': '_p_', 'l64a': 'pp', 'labs': 'pp', 'lchmod': '_p_', diff --git a/tools/system_libs.py b/tools/system_libs.py index f73efe18d6eaf..f4232be29c34a 100644 --- a/tools/system_libs.py +++ b/tools/system_libs.py @@ -1016,6 +1016,32 @@ class libnoexit(Library): src_files = ['atexit_dummy.c'] +class libjspi(MTLibrary): + name = 'libjspi' + src_dir = 'system/lib/jspi' + + def __init__(self, **kwargs): + self.hooks = kwargs.pop('hooks') + super().__init__(**kwargs) + + @classmethod + def vary_on(cls): + return super().vary_on() + ['hooks'] + + def get_base_name(self): + name = super().get_base_name() + if not self.hooks: + name += '-stub' + return name + + def get_files(self): + return [utils.path_from_root('system/lib/jspi', 'jspi.c' if self.hooks else 'jspi_stub.c')] + + @classmethod + def get_default_variation(cls, **kwargs): + return super().get_default_variation(hooks=settings.JSPI_HOOKS, **kwargs) + + class llvmlibc(DebugLibrary, AsanInstrumentedLibrary, MTLibrary): name = 'libllvmlibc' never_force = True @@ -2484,6 +2510,8 @@ def add_sanitizer_libs(): else: add_library('libsockets') + add_library('libjspi') + if settings.WASM_WORKERS and (not settings.SINGLE_FILE and not settings.MAIN_MODULE): # When we include libwasm_workers we use `--whole-archive` to ensure # that the static constructor (`emscripten_wasm_worker_main_thread_initialize`)