diff --git a/ChangeLog.md b/ChangeLog.md index 783895bf9bd41..f62ae977d3d63 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -20,6 +20,10 @@ See docs/process.md for more on how version tagging works. 6.0.4 (in development) ---------------------- +- 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 74bf06c588ce8..680a385069458 100644 --- a/src/lib/libasync.js +++ b/src/lib/libasync.js @@ -31,6 +31,8 @@ addToLibrary({ // Needed by allocateData and handleSleep respectively 'malloc', 'free', #endif + // Needed to record stackPointerOnEntry + '__stack_pointer', ], $Asyncify: { @@ -40,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. @@ -119,6 +130,13 @@ addToLibrary({ #if ASYNCIFY_DEBUG >= 2 dbg(`ASYNCIFY: ${' '.repeat(Asyncify.exportCallStack.length)} try ${original}`); #endif + // 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); try { #if MEMORY64 @@ -472,7 +490,15 @@ addToLibrary({ #if ASYNCIFY_DEBUG dbg('asyncify: makeAsyncFunction for', original); #endif - return WebAssembly.promising(original); + var promising = WebAssembly.promising(original); + return (...args) => { + // Read the stack pointer global directly rather than calling into + // 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); + }; }, #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_stack_pointer_on_entry.c b/test/other/test_stack_pointer_on_entry.c new file mode 100644 index 0000000000000..572ef61834419 --- /dev/null +++ b/test/other/test_stack_pointer_on_entry.c @@ -0,0 +1,62 @@ +// 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_base; + +// 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_entry_sp(); + assert(nested_base >= emscripten_stack_get_current()); + // main consumed stack before suspending, so this promising call was + // entered deeper into the stack. + assert(nested_base < main_base); +} + +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 an export. + assert(get_entry_sp() == main_base); +} + +int main(void) { + volatile char pad[256]; + pad[0] = 1; + 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_entry_sp() == nested_base); +#else + emscripten_sleep(0); + // 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"); + return 0; +} diff --git a/test/test_other.py b/test/test_other.py index 39a7d5d2efa56..dd1d0b11aed67 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -3620,6 +3620,15 @@ 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_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): # Make sure async library functions are not automatically JSPI'd.