Skip to content

Commit d559426

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 has no address-zero guard like the main stack, so each one gets a guard region below it (JSPI_FIBER_STACK_GUARD, defaulting to the stack size) with cookies, checked whenever the fiber suspends or exits; with STACK_OVERFLOW_CHECK=2 the overflowing store itself traps. 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 68c8134 commit d559426

22 files changed

Lines changed: 1283 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` and `JSPI_FIBER_STACK_GUARD` set the
38+
per-activation stack and guard sizes.
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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,43 @@ 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 plus a :ref:`JSPI_FIBER_STACK_GUARD` region below it for as long as it
512+
is suspended, and a few released stacks are kept per thread for reuse, so tune
513+
these independently of ``STACK_SIZE`` (which both default to): a program built
514+
with ``-sSTACK_SIZE=8MB`` that serves requests concurrently would otherwise
515+
hold 16 MB per in-flight request. A fiber stack has no hardware-style guard
516+
like the main stack has with ``STACK_FIRST``; instead an overflow of up to the
517+
guard size stays inside memory the runtime owns and is reported when the fiber
518+
next suspends or exits, and ``-sSTACK_OVERFLOW_CHECK=2`` (with the limits
519+
switched per fiber) traps at the overflowing store itself.
520+
Hooks registered with ``jspi_register`` run on the fiber's stack for
521+
``JSPI_SUSPEND``/``JSPI_RESUME``/``JSPI_EXIT`` and on the caller's stack for
522+
``JSPI_ENTER``. A trap inside a fiber leaves its stack unreleased and the
523+
stack pointer undefined, as with any trap.
524+
488525
Optimizing Asyncify
489526
###################
490527

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+
not supported with dynamic linking.
1426+
1427+
.. note:: This is an experimental setting
1428+
1429+
Default value: false
1430+
1431+
.. _jspi_fiber_stack_size:
1432+
1433+
JSPI_FIBER_STACK_SIZE
1434+
=====================
1435+
1436+
The size of the shadow stack given to each promising activation under
1437+
``REENTRANT_JSPI``, in bytes. Every live activation holds one (plus its
1438+
``JSPI_FIBER_STACK_GUARD``), so tune it independently of ``STACK_SIZE``,
1439+
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. A stack overflow of up to this size stays inside memory the
1450+
runtime owns and is reported when the activation next suspends or exits,
1451+
instead of silently corrupting the heap below; to trap at the overflowing
1452+
store itself use ``STACK_OVERFLOW_CHECK=2``. Defaults to
1453+
``JSPI_FIBER_STACK_SIZE``; 0 disables the guard (the checks at suspension
1454+
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+
// not supported with dynamic linking.
969+
// [link]
970+
// [experimental]
971+
var REENTRANT_JSPI = false;
972+
973+
// The size of the shadow stack given to each promising activation under
974+
// ``REENTRANT_JSPI``, in bytes. Every live activation holds one (plus its
975+
// ``JSPI_FIBER_STACK_GUARD``), so tune it independently of ``STACK_SIZE``,
976+
// 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. A stack overflow of up to this size stays inside memory the
982+
// runtime owns and is reported when the activation next suspends or exits,
983+
// instead of silently corrupting the heap below; to trap at the overflowing
984+
// store itself use ``STACK_OVERFLOW_CHECK=2``. Defaults to
985+
// ``JSPI_FIBER_STACK_SIZE``; 0 disables the guard (the checks at suspension
986+
// 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)