Skip to content

Commit 485f6c6

Browse files
committed
Add REENTRANT_JSPI: a shadow stack per JSPI activation
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>).
1 parent 5e7c6bc commit 485f6c6

22 files changed

Lines changed: 1295 additions & 43 deletions

ChangeLog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ See docs/process.md for more on how version tagging works.
3131
and a JSPI export fetched from the table as a function pointer is no longer
3232
promised automatically. Independently of the setting, `invoke_*` imports are
3333
no longer treated as suspending under JSPI.
34+
- Added the experimental `-sREENTRANT_JSPI` setting, which runs every JSPI
35+
promising activation on its own shadow stack so that any number of them may
36+
be suspended at once and interleave, instead of corrupting each other's
37+
frames. `JSPI_FIBER_STACK_SIZE` sets the per-activation stack size, and
38+
`STACK_OVERFLOW_CHECK` defaults to 2 so that an overflow of one traps.
3439
- The SDL3 port is no longer considered experimental, and the compiler
3540
diagnostic warning has been removed. (#27646)
3641
- `WASM=0` and `WASM=2` (wasm2js) were marked as deprecated. (See #27608)

site/source/docs/porting/asyncify.rst

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,45 @@ signature (an ``ASSERTIONS`` build reports the missing trampoline). Calling
485485
``WebAssembly.promising`` on a wrapped export from your own JS is fine; only a
486486
raw table entry made promising bypasses the hooks.
487487
488+
.. _reentrant_jspi_stacks:
489+
490+
Concurrent JSPI activations
491+
###########################
492+
493+
Every promising export starts a fiber that may suspend; while it is suspended
494+
another promising call may start, or an earlier one resume. The frames of C
495+
code live on the shadow stack in linear memory, which JSPI does not save, so by
496+
default a fiber that resumes while another is suspended pushes its frames over
497+
the other one's and corrupts it. That is fine for the common pattern of one
498+
promising call at a time, awaited before the next, but not for a server-like
499+
program that handles requests concurrently.
500+
501+
:ref:`REENTRANT_JSPI` runs every fiber on its own shadow stack (allocated from
502+
the heap on entry, :ref:`JSPI_FIBER_STACK_SIZE` bytes each, released on exit)
503+
and switches the stack pointer at the :ref:`lifecycle hooks <jspi_hooks>`, so
504+
any number of fibers may be suspended at once and resume in any order. This
505+
also holds for a promising export entered synchronously from inside another
506+
fiber's import call, and for exceptions unwinding out of a suspended fiber.
507+
Fibers never move between threads, and each thread has its own set. Dynamic
508+
linking is not supported with it yet.
509+
510+
Sizing: every live fiber, ``main`` included, holds a :ref:`JSPI_FIBER_STACK_SIZE`
511+
stack for as long as it is suspended, and a few released stacks are kept per
512+
thread for reuse, so tune it independently of ``STACK_SIZE`` (which it defaults
513+
to): a program built with ``-sSTACK_SIZE=8MB`` that serves requests
514+
concurrently would otherwise hold 8 MB per in-flight request. A fiber stack
515+
lives in the heap and has no hardware-style guard like the main stack has with
516+
``STACK_FIRST``, so ``REENTRANT_JSPI`` defaults ``STACK_OVERFLOW_CHECK`` to 2:
517+
the stack limits are switched per fiber and an overflow traps at the
518+
overflowing store itself. Setting ``STACK_OVERFLOW_CHECK`` explicitly to 1 or
519+
0 opts out; then a :ref:`JSPI_FIBER_STACK_GUARD` region (16KB by default) below
520+
each stack keeps an overflow of up to that size inside memory the runtime
521+
owns, reported when the fiber next suspends or exits.
522+
Hooks registered with ``jspi_register`` run on the fiber's stack for
523+
``JSPI_SUSPEND``/``JSPI_RESUME``/``JSPI_EXIT`` and on the caller's stack for
524+
``JSPI_ENTER``. A trap inside a fiber leaves its stack unreleased and the
525+
stack pointer undefined, as with any trap.
526+
488527
Optimizing Asyncify
489528
###################
490529

site/source/docs/tools_reference/settings_reference.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,47 @@ function pointer is no longer made promising automatically. Requires
14141414

14151415
Default value: false
14161416

1417+
.. _reentrant_jspi:
1418+
1419+
REENTRANT_JSPI
1420+
==============
1421+
1422+
Run each promising activation on its own shadow stack, so that any number
1423+
of activations may be suspended at once on a thread and run interleaved
1424+
(see :ref:`reentrant_jspi_stacks`). Requires ``JSPI`` and implies ``JSPI_HOOKS``;
1425+
defaults ``STACK_OVERFLOW_CHECK`` to 2 so that a fiber stack overflow traps
1426+
(set it explicitly to opt out); not supported with dynamic linking.
1427+
1428+
.. note:: This is an experimental setting
1429+
1430+
Default value: false
1431+
1432+
.. _jspi_fiber_stack_size:
1433+
1434+
JSPI_FIBER_STACK_SIZE
1435+
=====================
1436+
1437+
The size of the shadow stack given to each promising activation under
1438+
``REENTRANT_JSPI``, in bytes. Every live activation holds one, so tune it
1439+
independently of ``STACK_SIZE``, which it defaults to.
1440+
1441+
Default value: 0
1442+
1443+
.. _jspi_fiber_stack_guard:
1444+
1445+
JSPI_FIBER_STACK_GUARD
1446+
======================
1447+
1448+
The size of the guard region below each ``REENTRANT_JSPI`` activation
1449+
stack, in bytes, for builds that opt out of ``STACK_OVERFLOW_CHECK=2``: a
1450+
stack overflow of up to this size stays inside memory the runtime owns and
1451+
is reported when the activation next suspends or exits, instead of silently
1452+
corrupting the heap below. Defaults to 0 with ``STACK_OVERFLOW_CHECK=2``
1453+
(the bounds check traps at the overflowing store itself) and to 16KB
1454+
otherwise; 0 disables the guard (the checks at suspension and exit remain).
1455+
1456+
Default value: -1
1457+
14171458
.. _exported_runtime_methods:
14181459

14191460
EXPORTED_RUNTIME_METHODS

src/lib/libasync.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,23 @@ addToLibrary({
483483
#endif
484484
},
485485

486+
#if REENTRANT_JSPI
487+
__jspi_fiber_stack_size__sig: 'p',
488+
__jspi_fiber_stack_size: () => {{{ JSPI_FIBER_STACK_SIZE }}},
489+
__jspi_fiber_stack_guard__sig: 'p',
490+
__jspi_fiber_stack_guard: () => {{{ JSPI_FIBER_STACK_GUARD }}},
491+
__jspi_stack_checked__sig: 'i',
492+
__jspi_stack_checked: () => {{{ STACK_OVERFLOW_CHECK >= 2 ? 1 : 0 }}},
493+
// The bounds the stack-check pass instruments against live in globals it
494+
// generates, reachable only through its export.
495+
__jspi_set_stack_limits__sig: 'vpp',
496+
__jspi_set_stack_limits: (base, end) => {
497+
#if STACK_OVERFLOW_CHECK >= 2
498+
___set_stack_limits(base, end);
499+
#endif
500+
},
501+
#endif
502+
486503
emscripten_sleep__async: 'auto',
487504
emscripten_sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
488505

src/lib/libcore.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1739,7 +1739,11 @@ addToLibrary({
17391739
var end = _emscripten_stack_get_end();
17401740
abort(`stack overflow (Attempt to set SP to ${ptrToString(requested)}` +
17411741
`, with stack limits [${ptrToString(end)} - ${ptrToString(base)}` +
1742+
#if REENTRANT_JSPI
1743+
']). If you require more stack space build with -sSTACK_SIZE=<bytes> (or, inside a JSPI activation, -sJSPI_FIBER_STACK_SIZE=<bytes>)');
1744+
#else
17421745
']). If you require more stack space build with -sSTACK_SIZE=<bytes>');
1746+
#endif
17431747
},
17441748
#endif
17451749

src/settings.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,31 @@ var JSPI_IMPORTS = [];
962962
// [experimental]
963963
var JSPI_HOOKS = false;
964964

965+
// Run each promising activation on its own shadow stack, so that any number
966+
// of activations may be suspended at once on a thread and run interleaved
967+
// (see :ref:`reentrant_jspi_stacks`). Requires ``JSPI`` and implies ``JSPI_HOOKS``;
968+
// defaults ``STACK_OVERFLOW_CHECK`` to 2 so that a fiber stack overflow traps
969+
// (set it explicitly to opt out); not supported with dynamic linking.
970+
// [link]
971+
// [experimental]
972+
var REENTRANT_JSPI = false;
973+
974+
// The size of the shadow stack given to each promising activation under
975+
// ``REENTRANT_JSPI``, in bytes. Every live activation holds one, so tune it
976+
// independently of ``STACK_SIZE``, which it defaults to.
977+
// [link]
978+
var JSPI_FIBER_STACK_SIZE = 0;
979+
980+
// The size of the guard region below each ``REENTRANT_JSPI`` activation
981+
// stack, in bytes, for builds that opt out of ``STACK_OVERFLOW_CHECK=2``: a
982+
// stack overflow of up to this size stays inside memory the runtime owns and
983+
// is reported when the activation next suspends or exits, instead of silently
984+
// corrupting the heap below. Defaults to 0 with ``STACK_OVERFLOW_CHECK=2``
985+
// (the bounds check traps at the overflowing store itself) and to 16KB
986+
// otherwise; 0 disables the guard (the checks at suspension and exit remain).
987+
// [link]
988+
var JSPI_FIBER_STACK_GUARD = -1;
989+
965990
// Runtime elements that are exported on Module by default. We used to export
966991
// quite a lot here, but have removed them all. You should use
967992
// EXPORTED_RUNTIME_METHODS for things you want to export from the runtime.

system/include/emscripten/jspi.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@
2424
extern "C" {
2525
#endif
2626

27+
// Hooks run synchronously inside the boundary call, on whatever stack the
28+
// fiber's code runs on: ENTER before the export body (the fiber is already
29+
// current), SUSPEND before the import is called with the fiber's frames still
30+
// live, RESUME after the import returned with the frames live again, and EXIT
31+
// after the export body returned, with the fiber still current. A trap inside
32+
// a fiber bypasses the hooks and leaves the JSPI state (and, with
33+
// REENTRANT_JSPI, the stack pointer) undefined.
34+
//
2735
// Events are bit flags so that they combine into the mask for jspi_register.
2836
typedef enum {
2937
// A promising export was called.

0 commit comments

Comments
 (0)