Skip to content
Open
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
11 changes: 11 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<emscripten/jspi.h>`,
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)
Expand Down
Binary file added out.o
Binary file not shown.
64 changes: 64 additions & 0 deletions site/source/docs/porting/asyncify.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<emscripten/jspi.h>`` provide that:

.. code-block:: c

#include <emscripten/jspi.h>

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
###################

Expand Down
25 changes: 24 additions & 1 deletion site/source/docs/tools_reference/settings_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jspi_hooks>` see a fiber being entered and
exited (see ``JSPI_HOOKS``).

Default value: []

Expand All @@ -1388,9 +1390,30 @@ asynchronous work.

Note when using JS library files, the function can be marked with
``<function_name>_async:: true`` in the library instead of this setting.
These imports are also where the :ref:`JSPI lifecycle hooks <jspi_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
``<emscripten/jspi.h>`` (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
Expand Down
15 changes: 10 additions & 5 deletions src/lib/libasync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 35 additions & 11 deletions src/lib/libcore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
},
Expand Down Expand Up @@ -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) {
Expand All @@ -1864,11 +1893,6 @@ addToLibrary({
#endif
}

#if JSPI
if (promising) {
return rtn.then(convert);
}
#endif
return convert(rtn);
},

Expand Down Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions src/lib/libembind.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
],
Expand All @@ -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
}

Expand Down
22 changes: 10 additions & 12 deletions src/parseTools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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;
}
Expand Down
19 changes: 18 additions & 1 deletion src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jspi_hooks>` see a fiber being entered and
// exited (see ``JSPI_HOOKS``).
// [link]
var JSPI_EXPORTS = [];

Expand All @@ -942,9 +944,24 @@ var JSPI_EXPORTS = [];
//
// Note when using JS library files, the function can be marked with
// ``<function_name>_async:: true`` in the library instead of this setting.
// These imports are also where the :ref:`JSPI lifecycle hooks <jspi_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
// ``<emscripten/jspi.h>`` (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.
Expand Down
Loading
Loading