Add REENTRANT_JSPI: a shadow stack per JSPI activation - #27699
Open
guybedford wants to merge 4 commits into
Open
Add REENTRANT_JSPI: a shadow stack per JSPI activation#27699guybedford wants to merge 4 commits into
guybedford wants to merge 4 commits into
Conversation
guybedford
force-pushed
the
reentrant-jspi
branch
from
September 11, 2026 19:48
d559426 to
17ad994
Compare
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.
guybedford
force-pushed
the
reentrant-jspi
branch
from
September 11, 2026 19:54
17ad994 to
485f6c6
Compare
…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.
C frames live on the shadow stack in linear memory, which JSPI does not
save across a suspension. When a promising activation ("fiber") resumes
while another one is suspended, its frames are pushed over the suspended
one's, so concurrent promising calls silently corrupt each other. That
rules out server-style programs handling requests concurrently under JSPI.
With -sREENTRANT_JSPI every fiber gets its own shadow stack, allocated
from the heap when the promising export is entered (JSPI_FIBER_STACK_SIZE
bytes, defaulting to STACK_SIZE) and released when it exits, with a small
per-thread pool of released stacks that is freed at thread exit. The stack
pointer is switched at the four lifecycle hooks: to the fiber's stack at
ENTER and RESUME, and back to whatever the host had at SUSPEND and EXIT,
including on the exceptional exits the hook wrappers already route
through. Since the frames never move, imports that write results through
pointers into a suspended fiber's frames (emscripten_promise_await, poll,
EM_ASYNC_JS out-parameters) keep working, and the host's own stack
allocations are never overlapped.
The four __jspi_* exports are now assembly shims around C implementations,
so that the stack pointer they install persists past their return; the
stack limits used by emscripten_stack_get_* and by STACK_OVERFLOW_CHECK=2
are switched the same way, after all C code has returned. A fiber stack
lives in the heap with no address-zero guard like the main stack, so the
setting defaults STACK_OVERFLOW_CHECK to 2 and an overflow traps at the
overflowing store; builds that explicitly opt out get a small guard region
below each stack (JSPI_FIBER_STACK_GUARD) with cookies checked whenever
the fiber suspends or exits. Fibers are
thread-affine and each thread has its own set. Dynamic linking is not
supported with the setting.
Also fixes the decorator order of test_async_ccall_promise, whose `jspi`
variant was running ASYNCIFY (mode decorators must be outermost); the new
`reentrant_jspi` test mode needs it.
Depends on the JSPI lifecycle hooks (<emscripten/jspi.h>).
A side module has no imports or exports for the hooks to wrap and no runtime to own fiber stacks, and JSPI_HOOKS is already cleared for it. Treat REENTRANT_JSPI the same rather than erroring, so a single flag set serves every link of a build. MAIN_MODULE remains an error.
guybedford
force-pushed
the
reentrant-jspi
branch
from
September 11, 2026 20:50
485f6c6 to
0be3446
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This implements
-sREENTRANT_JSPIbased to #27698 and using that primitive to support the shadow stack switching. Only the second commit is this PR.Stacks are heap allocated and switched into on ENTER and RESUME. Because heap allocated stacks don't have Wasm stack overflow protection, we add a guard regions at the end of each stack, and enforce the default
STACK_OVERFLOW_CHECK=2under this scheme. If we had page protection memory control support it could be possible to remove these in future.Configuration options are:
JSPI_FIBER_STACK_SIZEsets the per-fiber stack size and defaults toSTACK_SIZE. A fiber stack has no address-zero guard like the main stack has withSTACK_FIRST, so each one gets a guard region below it,JSPI_FIBER_STACK_GUARDbytes (default: the stack size), with cookies checked whenever the fiber suspends or exits: an overflow of up to the guard size is reported cleanly instead of corrupting the heapJSPIand impliesJSPI_HOOKS; not supported with dynamic linking. Fibers are thread-affine and each thread has its own set. Measured cost over the hooks PR on hello-world +emscripten_sleepat-O2: +1346 bytes of wasm and +347 bytes of JS, plus one fiber stack and guard per live fiber.Behaviour changes: none for programs that do not enable the setting. With it,
emscripten_stack_get_base/end/freedescribe the JSPI fiber's stack while inside a JSPI stack,jspi_registerhooks for SUSPEND/RESUME/EXIT run on the fiber's stack (ENTER on the caller's), and a trap inside a fiber leaves its stack unreleased.Tests (
test/jspi/): two fibers with filled 4 KiB frames interleaving (and the same program asserted to corrupt with the setting off); eight fibers suspending five times each in a permuted order; 200-deep recursion suspending at the leaf; nested promising entry from inside another fiber's import in all three shapes (inner outlives outer, inner synchronous, both suspended); rejected imports unwinding through fiber frames, a rejection escaping the export after a suspension, and C++ throws with and without a suspension, all with a bystander fiber suspended throughout; user hooks observing live frames at SUSPEND/RESUME,SuspendErroroutside a fiber, a ccall string argument across a suspension and a promisingdynCall; every one asserting stack-pointer balance afterwards; variants under-sASSERTIONS=2 -sSTACK_OVERFLOW_CHECK=2,-pthreadand-pthread -sPROXY_TO_PTHREAD; out-of-memory and fiber-stack-overflow aborts (default and small guard,-O2, andSTACK_OVERFLOW_CHECK=2); setting validation. The existing JSPI test suite additionally runs in areentrant_jspimode. (test_async_ccall_promisehad its mode decorator in the wrong position, so itsjspivariant was running ASYNCIFY; fixed here as the new mode depends on it.)Made with AI assistance under my review