From 8740ded9f2017aa8b44e8e9709c85ccbeda9edcf Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 17 Jul 2026 13:46:35 -0700 Subject: [PATCH 1/5] Track the last promising stack top under JSPI --- ChangeLog.md | 3 ++ src/lib/libasync.js | 18 +++++++++- src/lib/libcore.js | 2 +- src/lib/libembind.js | 2 +- test/other/test_jspi_promising_top.c | 52 ++++++++++++++++++++++++++++ test/test_other.py | 5 +++ 6 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 test/other/test_jspi_promising_top.c diff --git a/ChangeLog.md b/ChangeLog.md index 783895bf9bd41..2ea405ec4e436 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -20,6 +20,9 @@ See docs/process.md for more on how version tagging works. 6.0.4 (in development) ---------------------- +- Under JSPI, the runtime now records the stack pointer on entry to each + promising export call in `Asyncify.lastPromisingStackTop`, for use by + libraries implementing shadow stack switching on top of JSPI. (#27364) - Legacy support for ancient vendor-prefixed DOM APIs was removed (#27341, #27339, #27338, #27340, #27347) diff --git a/src/lib/libasync.js b/src/lib/libasync.js index 74bf06c588ce8..617881dc3909a 100644 --- a/src/lib/libasync.js +++ b/src/lib/libasync.js @@ -30,6 +30,9 @@ addToLibrary({ #if ASYNCIFY == 1 // Needed by allocateData and handleSleep respectively 'malloc', 'free', +#elif ASYNCIFY == 2 + // Needed by makeAsyncFunction + 'emscripten_stack_get_current', #endif ], @@ -456,6 +459,15 @@ addToLibrary({ // Stores all the exported raw Wasm functions that are wrapped with async // WebAssembly.Functions. asyncExports: null, + // The stack pointer recorded on entry to the last promising export call, + // marking the top of the stack range in use by that call. For use by + // libraries implementing shadow stack switching on top of JSPI. Note that + // this is only a marker set on promising entry: suspensions are not + // guarded, so such libraries must intercept suspending imports and + // promising call sites themselves to maintain accuracy across + // suspend/resume boundaries (on resume the stack is always empty, so the + // promising top is then the stack base). + lastPromisingStackTop: 0, isAsyncExport(func) { return Asyncify.asyncExports?.has(func); }, @@ -472,7 +484,11 @@ addToLibrary({ #if ASYNCIFY_DEBUG dbg('asyncify: makeAsyncFunction for', original); #endif - return WebAssembly.promising(original); + var promising = WebAssembly.promising(original); + return (...args) => { + Asyncify.lastPromisingStackTop = _emscripten_stack_get_current(); + return promising(...args); + }; }, #endif }, diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 3d62f559e2d67..c3bc9342e5ad7 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -1818,7 +1818,7 @@ addToLibrary({ var func = getWasmTableEntry(ptr); #if JSPI if (promising) { - func = WebAssembly.promising(func); + func = Asyncify.makeAsyncFunction(func); } #endif var rtn = func(...args); diff --git a/src/lib/libembind.js b/src/lib/libembind.js index 3542d45f8c4e9..f3bad59ebafd1 100644 --- a/src/lib/libembind.js +++ b/src/lib/libembind.js @@ -843,7 +843,7 @@ var LibraryEmbind = { var rtn = getWasmTableEntry(rawFunction); #if JSPI if (isAsync) { - rtn = WebAssembly.promising(rtn); + rtn = Asyncify.makeAsyncFunction(rtn); } #endif return rtn; diff --git a/test/other/test_jspi_promising_top.c b/test/other/test_jspi_promising_top.c new file mode 100644 index 0000000000000..9c4b8b64e23e9 --- /dev/null +++ b/test/other/test_jspi_promising_top.c @@ -0,0 +1,52 @@ +// Copyright 2026 The Emscripten Authors. All rights reserved. +// Emscripten is available under two separate licenses, the MIT license and the +// University of Illinois/NCSA Open Source License. Both these licenses can be +// found in the LICENSE file. + +#include +#include +#include +#include +#include + +uintptr_t main_top; +uintptr_t nested_top; + +// A user-defined reader of the promising stack top marker. +EM_JS(uintptr_t, get_promising_top, (), { + return Asyncify.lastPromisingStackTop; +}); + +// Called from JS while main is suspended, as a second promising export. +EMSCRIPTEN_KEEPALIVE void nested(void) { + nested_top = get_promising_top(); + assert(nested_top >= emscripten_stack_get_current()); + // main consumed stack before suspending, so this promising call was + // entered deeper into the stack. + assert(nested_top < main_top); +} + +EM_ASYNC_JS(void, call_nested, (), { + await new Promise((resolve) => setTimeout(resolve, 0)); + await _nested(); +}); + +__attribute__((noinline)) void check_deeper(void) { + // The marker is stable across call depth within a promising export. + assert(get_promising_top() == main_top); +} + +int main(void) { + volatile char pad[256]; + pad[0] = 1; + main_top = get_promising_top(); + assert(main_top != 0); + assert(main_top >= emscripten_stack_get_current()); + assert(main_top <= emscripten_stack_get_base()); + check_deeper(); + call_nested(); + // The marker tracks the last promising entry; suspensions are not guarded. + assert(get_promising_top() == nested_top); + printf("done\n"); + return 0; +} diff --git a/test/test_other.py b/test/test_other.py index 39a7d5d2efa56..cd20d55847b11 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -3620,6 +3620,11 @@ def test_jspi_add_function(self): '-sALLOW_TABLE_GROWTH=1'] self.do_runf('other/test_jspi_add_function.c', 'done\n') + @requires_jspi + def test_jspi_promising_top(self): + self.do_runf('other/test_jspi_promising_top.c', 'done\n', + cflags=['-sJSPI', '-sJSPI_EXPORTS=nested']) + @requires_jspi def test_jspi_async_function(self): # Make sure async library functions are not automatically JSPI'd. From e31a28f17dd553181583b62e49c0f322e1e60413 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 17 Jul 2026 15:20:31 -0700 Subject: [PATCH 2/5] Read the stack pointer global directly rather than calling into Wasm Under SPLIT_MODULE, exports can be lazy-loading JSPI trampolines which cannot be called outside of a promising context. --- src/lib/libasync.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/libasync.js b/src/lib/libasync.js index 617881dc3909a..8dc3c26f74419 100644 --- a/src/lib/libasync.js +++ b/src/lib/libasync.js @@ -32,7 +32,7 @@ addToLibrary({ 'malloc', 'free', #elif ASYNCIFY == 2 // Needed by makeAsyncFunction - 'emscripten_stack_get_current', + '__stack_pointer', #endif ], @@ -486,7 +486,10 @@ addToLibrary({ #endif var promising = WebAssembly.promising(original); return (...args) => { - Asyncify.lastPromisingStackTop = _emscripten_stack_get_current(); + // Read the stack pointer global directly rather than calling into + // Wasm, which is not possible outside of a promising context under + // SPLIT_MODULE where exports can be lazy-loading JSPI trampolines. + Asyncify.lastPromisingStackTop = {{{ from64Expr('___stack_pointer.value') }}}; return promising(...args); }; }, From 6dc6ce60e052c1b1ce8444afda53482dd8a60f9e Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 17 Jul 2026 17:45:31 -0700 Subject: [PATCH 3/5] Rename to lastPromisingStackBase to match Emscripten stack naming --- ChangeLog.md | 2 +- src/lib/libasync.js | 18 ++++++------ ...ising_top.c => test_jspi_promising_base.c} | 28 +++++++++---------- test/test_other.py | 4 +-- 4 files changed, 26 insertions(+), 26 deletions(-) rename test/other/{test_jspi_promising_top.c => test_jspi_promising_base.c} (64%) diff --git a/ChangeLog.md b/ChangeLog.md index 2ea405ec4e436..d4fbb56f557b9 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -21,7 +21,7 @@ See docs/process.md for more on how version tagging works. 6.0.4 (in development) ---------------------- - Under JSPI, the runtime now records the stack pointer on entry to each - promising export call in `Asyncify.lastPromisingStackTop`, for use by + promising export call in `Asyncify.lastPromisingStackBase`, for use by libraries implementing shadow stack switching on top of JSPI. (#27364) - Legacy support for ancient vendor-prefixed DOM APIs was removed (#27341, #27339, #27338, #27340, #27347) diff --git a/src/lib/libasync.js b/src/lib/libasync.js index 8dc3c26f74419..364f7bfd3ccac 100644 --- a/src/lib/libasync.js +++ b/src/lib/libasync.js @@ -460,14 +460,14 @@ addToLibrary({ // WebAssembly.Functions. asyncExports: null, // The stack pointer recorded on entry to the last promising export call, - // marking the top of the stack range in use by that call. For use by - // libraries implementing shadow stack switching on top of JSPI. Note that - // this is only a marker set on promising entry: suspensions are not - // guarded, so such libraries must intercept suspending imports and - // promising call sites themselves to maintain accuracy across - // suspend/resume boundaries (on resume the stack is always empty, so the - // promising top is then the stack base). - lastPromisingStackTop: 0, + // marking the base of the stack range in use by that call (the stack + // grows down from it). For use by libraries implementing shadow stack + // switching on top of JSPI. Note that this is only a marker set on + // promising entry: suspensions are not guarded, so such libraries must + // intercept suspending imports and promising call sites themselves to + // maintain accuracy across suspend/resume boundaries (on resume the + // stack is always empty, so the promising base is then the stack base). + lastPromisingStackBase: 0, isAsyncExport(func) { return Asyncify.asyncExports?.has(func); }, @@ -489,7 +489,7 @@ addToLibrary({ // Read the stack pointer global directly rather than calling into // Wasm, which is not possible outside of a promising context under // SPLIT_MODULE where exports can be lazy-loading JSPI trampolines. - Asyncify.lastPromisingStackTop = {{{ from64Expr('___stack_pointer.value') }}}; + Asyncify.lastPromisingStackBase = {{{ from64Expr('___stack_pointer.value') }}}; return promising(...args); }; }, diff --git a/test/other/test_jspi_promising_top.c b/test/other/test_jspi_promising_base.c similarity index 64% rename from test/other/test_jspi_promising_top.c rename to test/other/test_jspi_promising_base.c index 9c4b8b64e23e9..b6f4207587845 100644 --- a/test/other/test_jspi_promising_top.c +++ b/test/other/test_jspi_promising_base.c @@ -9,21 +9,21 @@ #include #include -uintptr_t main_top; -uintptr_t nested_top; +uintptr_t main_base; +uintptr_t nested_base; -// A user-defined reader of the promising stack top marker. -EM_JS(uintptr_t, get_promising_top, (), { - return Asyncify.lastPromisingStackTop; +// A user-defined reader of the promising stack base marker. +EM_JS(uintptr_t, get_promising_base, (), { + return Asyncify.lastPromisingStackBase; }); // Called from JS while main is suspended, as a second promising export. EMSCRIPTEN_KEEPALIVE void nested(void) { - nested_top = get_promising_top(); - assert(nested_top >= emscripten_stack_get_current()); + nested_base = get_promising_base(); + assert(nested_base >= emscripten_stack_get_current()); // main consumed stack before suspending, so this promising call was // entered deeper into the stack. - assert(nested_top < main_top); + assert(nested_base < main_base); } EM_ASYNC_JS(void, call_nested, (), { @@ -33,20 +33,20 @@ EM_ASYNC_JS(void, call_nested, (), { __attribute__((noinline)) void check_deeper(void) { // The marker is stable across call depth within a promising export. - assert(get_promising_top() == main_top); + assert(get_promising_base() == main_base); } int main(void) { volatile char pad[256]; pad[0] = 1; - main_top = get_promising_top(); - assert(main_top != 0); - assert(main_top >= emscripten_stack_get_current()); - assert(main_top <= emscripten_stack_get_base()); + main_base = get_promising_base(); + assert(main_base != 0); + assert(main_base >= emscripten_stack_get_current()); + assert(main_base <= emscripten_stack_get_base()); check_deeper(); call_nested(); // The marker tracks the last promising entry; suspensions are not guarded. - assert(get_promising_top() == nested_top); + assert(get_promising_base() == nested_base); printf("done\n"); return 0; } diff --git a/test/test_other.py b/test/test_other.py index cd20d55847b11..f2103b181599d 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -3621,8 +3621,8 @@ def test_jspi_add_function(self): self.do_runf('other/test_jspi_add_function.c', 'done\n') @requires_jspi - def test_jspi_promising_top(self): - self.do_runf('other/test_jspi_promising_top.c', 'done\n', + def test_jspi_promising_base(self): + self.do_runf('other/test_jspi_promising_base.c', 'done\n', cflags=['-sJSPI', '-sJSPI_EXPORTS=nested']) @requires_jspi From a31f387e49bd0e2e789cefc72a6da062988661c1 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 17 Jul 2026 18:09:35 -0700 Subject: [PATCH 4/5] Rename to Asyncify.stackPointerOnEntry and support ASYNCIFY=1 --- ChangeLog.md | 7 ++-- src/lib/libasync.js | 33 ++++++++++--------- ...g_base.c => test_stack_pointer_on_entry.c} | 28 +++++++++++----- test/test_other.py | 10 ++++-- 4 files changed, 48 insertions(+), 30 deletions(-) rename test/other/{test_jspi_promising_base.c => test_stack_pointer_on_entry.c} (67%) diff --git a/ChangeLog.md b/ChangeLog.md index d4fbb56f557b9..f62ae977d3d63 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -20,9 +20,10 @@ See docs/process.md for more on how version tagging works. 6.0.4 (in development) ---------------------- -- Under JSPI, the runtime now records the stack pointer on entry to each - promising export call in `Asyncify.lastPromisingStackBase`, for use by - libraries implementing shadow stack switching on top of JSPI. (#27364) +- Under ASYNCIFY and JSPI, the runtime now records the stack pointer on entry + to the outermost export call (under JSPI, to each promising export call) in + `Asyncify.stackPointerOnEntry`, for use by libraries implementing shadow + stack switching. (#27364) - Legacy support for ancient vendor-prefixed DOM APIs was removed (#27341, #27339, #27338, #27340, #27347) diff --git a/src/lib/libasync.js b/src/lib/libasync.js index 364f7bfd3ccac..f59149d7726cb 100644 --- a/src/lib/libasync.js +++ b/src/lib/libasync.js @@ -30,10 +30,9 @@ addToLibrary({ #if ASYNCIFY == 1 // Needed by allocateData and handleSleep respectively 'malloc', 'free', -#elif ASYNCIFY == 2 - // Needed by makeAsyncFunction - '__stack_pointer', #endif + // Needed to record stackPointerOnEntry + '__stack_pointer', ], $Asyncify: { @@ -43,6 +42,15 @@ addToLibrary({ #if ASYNCIFY == 1 && MEMORY64 rewindArguments: new Map(), #endif + // The stack pointer recorded on entry to the outermost export call (under + // JSPI, to each promising export call), marking the base of the stack + // range in use by that call (the stack grows down from it). For use by + // libraries implementing shadow stack switching. Note that this is only a + // marker set on entry: JSPI suspensions are not guarded, so such libraries + // must intercept suspending imports and promising call sites themselves to + // maintain accuracy across suspend/resume boundaries (on resume the stack + // is always empty, so the entry stack pointer is then the stack base). + stackPointerOnEntry: 0, instrumentWasmImports(imports) { #if EMBIND_GEN_MODE // Instrumenting is not needed when generating code. @@ -122,6 +130,9 @@ addToLibrary({ #if ASYNCIFY_DEBUG >= 2 dbg(`ASYNCIFY: ${' '.repeat(Asyncify.exportCallStack.length)} try ${original}`); #endif + if (!Asyncify.exportCallStack.length) { + Asyncify.stackPointerOnEntry = {{{ from64Expr('___stack_pointer.value') }}}; + } Asyncify.exportCallStack.push(original); try { #if MEMORY64 @@ -459,15 +470,6 @@ addToLibrary({ // Stores all the exported raw Wasm functions that are wrapped with async // WebAssembly.Functions. asyncExports: null, - // The stack pointer recorded on entry to the last promising export call, - // marking the base of the stack range in use by that call (the stack - // grows down from it). For use by libraries implementing shadow stack - // switching on top of JSPI. Note that this is only a marker set on - // promising entry: suspensions are not guarded, so such libraries must - // intercept suspending imports and promising call sites themselves to - // maintain accuracy across suspend/resume boundaries (on resume the - // stack is always empty, so the promising base is then the stack base). - lastPromisingStackBase: 0, isAsyncExport(func) { return Asyncify.asyncExports?.has(func); }, @@ -487,9 +489,10 @@ addToLibrary({ var promising = WebAssembly.promising(original); return (...args) => { // Read the stack pointer global directly rather than calling into - // Wasm, which is not possible outside of a promising context under - // SPLIT_MODULE where exports can be lazy-loading JSPI trampolines. - Asyncify.lastPromisingStackBase = {{{ from64Expr('___stack_pointer.value') }}}; + // Wasm (e.g. stackSave), which is not possible outside of a promising + // context under SPLIT_MODULE where exports can be lazy-loading JSPI + // trampolines. + Asyncify.stackPointerOnEntry = {{{ from64Expr('___stack_pointer.value') }}}; return promising(...args); }; }, diff --git a/test/other/test_jspi_promising_base.c b/test/other/test_stack_pointer_on_entry.c similarity index 67% rename from test/other/test_jspi_promising_base.c rename to test/other/test_stack_pointer_on_entry.c index b6f4207587845..886e9fed2493a 100644 --- a/test/other/test_jspi_promising_base.c +++ b/test/other/test_stack_pointer_on_entry.c @@ -10,16 +10,18 @@ #include uintptr_t main_base; -uintptr_t nested_base; -// A user-defined reader of the promising stack base marker. -EM_JS(uintptr_t, get_promising_base, (), { - return Asyncify.lastPromisingStackBase; +// A user-defined reader of the entry stack pointer marker. +EM_JS(uintptr_t, get_entry_sp, (), { + return Asyncify.stackPointerOnEntry; }); +#ifdef TEST_JSPI +uintptr_t nested_base; + // Called from JS while main is suspended, as a second promising export. EMSCRIPTEN_KEEPALIVE void nested(void) { - nested_base = get_promising_base(); + nested_base = get_entry_sp(); assert(nested_base >= emscripten_stack_get_current()); // main consumed stack before suspending, so this promising call was // entered deeper into the stack. @@ -30,23 +32,31 @@ EM_ASYNC_JS(void, call_nested, (), { await new Promise((resolve) => setTimeout(resolve, 0)); await _nested(); }); +#endif __attribute__((noinline)) void check_deeper(void) { - // The marker is stable across call depth within a promising export. - assert(get_promising_base() == main_base); + // The marker is stable across call depth within an export. + assert(get_entry_sp() == main_base); } int main(void) { volatile char pad[256]; pad[0] = 1; - main_base = get_promising_base(); + main_base = get_entry_sp(); assert(main_base != 0); assert(main_base >= emscripten_stack_get_current()); assert(main_base <= emscripten_stack_get_base()); check_deeper(); +#ifdef TEST_JSPI call_nested(); // The marker tracks the last promising entry; suspensions are not guarded. - assert(get_promising_base() == nested_base); + assert(get_entry_sp() == nested_base); +#else + emscripten_sleep(0); + // The rewind re-enters the outermost export with the stack pointer restored + // to its entry value, so the marker is stable across sleeps. + assert(get_entry_sp() == main_base); +#endif printf("done\n"); return 0; } diff --git a/test/test_other.py b/test/test_other.py index f2103b181599d..dd1d0b11aed67 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -3621,9 +3621,13 @@ def test_jspi_add_function(self): self.do_runf('other/test_jspi_add_function.c', 'done\n') @requires_jspi - def test_jspi_promising_base(self): - self.do_runf('other/test_jspi_promising_base.c', 'done\n', - cflags=['-sJSPI', '-sJSPI_EXPORTS=nested']) + def test_jspi_stack_pointer_on_entry(self): + self.do_runf('other/test_stack_pointer_on_entry.c', 'done\n', + cflags=['-sJSPI', '-sJSPI_EXPORTS=nested', '-DTEST_JSPI']) + + def test_asyncify_stack_pointer_on_entry(self): + self.do_runf('other/test_stack_pointer_on_entry.c', 'done\n', + cflags=['-sASYNCIFY']) @requires_jspi def test_jspi_async_function(self): From e4f6f813bf5cdf108fe268b6a1ab2d7c7a1aa87d Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 17 Jul 2026 18:32:37 -0700 Subject: [PATCH 5/5] Do not re-record the entry stack pointer during ASYNCIFY suspensions --- src/lib/libasync.js | 6 +++++- test/other/test_stack_pointer_on_entry.c | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/libasync.js b/src/lib/libasync.js index f59149d7726cb..680a385069458 100644 --- a/src/lib/libasync.js +++ b/src/lib/libasync.js @@ -130,7 +130,11 @@ addToLibrary({ #if ASYNCIFY_DEBUG >= 2 dbg(`ASYNCIFY: ${' '.repeat(Asyncify.exportCallStack.length)} try ${original}`); #endif - if (!Asyncify.exportCallStack.length) { + // Record the outermost entry stack pointer, except while a suspension + // is in flight (covering runtime-internal exports called during the + // sleep and the rewind itself), where the original value still + // applies. + if (!Asyncify.exportCallStack.length && !Asyncify.currData) { Asyncify.stackPointerOnEntry = {{{ from64Expr('___stack_pointer.value') }}}; } Asyncify.exportCallStack.push(original); diff --git a/test/other/test_stack_pointer_on_entry.c b/test/other/test_stack_pointer_on_entry.c index 886e9fed2493a..572ef61834419 100644 --- a/test/other/test_stack_pointer_on_entry.c +++ b/test/other/test_stack_pointer_on_entry.c @@ -53,8 +53,8 @@ int main(void) { assert(get_entry_sp() == nested_base); #else emscripten_sleep(0); - // The rewind re-enters the outermost export with the stack pointer restored - // to its entry value, so the marker is stable across sleeps. + // The rewind re-entry does not re-record, so the marker is stable across + // sleeps. assert(get_entry_sp() == main_base); #endif printf("done\n");