Skip to content

Add JSPI lifecycle hooks (<emscripten/jspi.h>) - #27698

Open
guybedford wants to merge 2 commits into
emscripten-core:mainfrom
guybedford:jspi-hooks
Open

Add JSPI lifecycle hooks (<emscripten/jspi.h>)#27698
guybedford wants to merge 2 commits into
emscripten-core:mainfrom
guybedford:jspi-hooks

Conversation

@guybedford

@guybedford guybedford commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

This implements <emscripten/jspi.h>, lifecycle hooks for -sJSPI fibers, backed by a Binaryen --jspi-hooks pass posted in WebAssembly/binaryen#9102.

The general idea here is that many systems need to interoperate on the JSPI context switching model and so there are two ways to do that:

  1. Have a single runtime decide all the conventions and lay down the law - fiber ids, reentrancy handling, stack switching integrations, thread local handling etc.
  2. Support a flexible ecosystem to explore this space using a general hook system, while still keeping the option to go to (1) at any time.

For example, wasm-bindgen just released support for JSPI, and we now have the very real problem that wasm-bindgen JSPI does not interoperate with Emscripten JSPI.

This first PR just integrates a runtime hooks layer that allows C bindings to add event handlers for the JSPI lifecycle hooks in a sound way - enter/exit/suspend/resume. It handles exceptions, supports passing a token through the hook lifecycle of enter - (suspend - return)* - exit, and is gated behind a new experimental -sJSPI_HOOKS.

The runtime API takes the following shape in emscripten/jspi.h:

int jspi_register(jspi_hook fn, uint32_t mask)
  • Mask consists of JSPI_ENTER, JSPI_EXIT, JSPI_SUSPEND, JSPI_RESUME
  • 0 is returned on success, -1 if no hooks API is present (no -sJSPI_HOOKS provided), and -2 if unable to register the hook (currently limited to a maximum of 64 hooks).

When built without -sJSPI_HOOKS, the register function is stubbed out and always returns -1.

The jspi_hook callback takes the following form:

typedef void* (*jspi_hook)(jspi_event event, void* token, int error);

Where

  • event is the corresponding mask component
  • error is 0 or 1 when an error is being reported across a resume or exit
  • arg allows passing a per-fiber token between same-JSPI-stack lifecycle hooks of the corresponding registration
  • the return value sets the token for the next lifecycle hook

This then lays the foundation for a follow-on -sREENTRANT_JSPI support for shadow stack switching.

Made with AI assistance under my review

Under -sJSPI every call to a promising export starts a fiber that may be
suspended while its suspending imports await, with other fibers running on
the same thread in between. Libraries with activation-affine state need to
know when that happens. This adds the experimental JSPI_HOOKS setting and
<emscripten/jspi.h>:

  jspi_register(fn, mask) registers a hook for JSPI_ENTER, JSPI_EXIT,
  JSPI_SUSPEND and JSPI_RESUME events. Each hook has its own per-fiber
  token: NULL at the first event it sees for a fiber, then whatever it
  returned at that fiber's previous event, so per-fiber state needs no
  lookup. EXIT and RESUME also report whether the export or import
  completed with an exception.

The hooks run inside the fiber's own wasm frames, immediately before/after
the boundary call, through wrappers that the new binaryen --jspi-hooks pass
places around every promising export and suspending import at link time
(a JS wrapper only observes the transition a microtask later, when another
fiber may already have run). The pass delivers the events to the
__jspi_enter/exit/suspend/resume exports provided by the runtime (libjspi),
forwarding the i64 token returned by the "before" hook of a pair to the
"after" hook through a wasm local; the runtime's token is a pointer to its
fiber record. The wrapped import set is exactly the set the JS wraps
in WebAssembly.Suspending, including __async JS library functions.

The runtime keeps one record per live fiber, holding the hooks' tokens and
whoever entered or last resumed the fiber, which becomes current again
when the fiber leaves at a suspension or exit, so user JS may call
WebAssembly.promising on a wrapped export directly. With the hooks
enabled, function pointers made promising from JS (dynCall with
promising=true, makeDynCall, embind async, the pthread entry point) go
through per-signature __jspi_dyncall_<sig> trampoline exports generated by
the pass for the signatures in the table, so no fiber runs without its
hooks. On hello-world + emscripten_sleep at -O2 the hooks cost about 800
bytes of wasm and 130 bytes of JS.

Independently of the setting, invoke_* imports are no longer treated as
suspending under JSPI, since JSPI cannot suspend across their JS frame.
…ping

The hooks pass emits its wrappers in the module's exception handling
flavor and refuses a module carrying both. With WASM_LEGACY_EXCEPTIONS=0
the module may still contain legacy instructions from prebuilt inputs, so
run --translate-to-exnref first.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant