Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
28 changes: 27 additions & 1 deletion src/lib/libasync.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ addToLibrary({
// Needed by allocateData and handleSleep respectively
'malloc', 'free',
#endif
// Needed to record stackPointerOnEntry
'__stack_pointer',
],

$Asyncify: {
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
},
Expand Down
2 changes: 1 addition & 1 deletion src/lib/libcore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/libembind.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
62 changes: 62 additions & 0 deletions test/other/test_stack_pointer_on_entry.c
Original file line number Diff line number Diff line change
@@ -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 <assert.h>
#include <stdio.h>
#include <emscripten/em_js.h>
#include <emscripten/emscripten.h>
#include <emscripten/stack.h>

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;
}
9 changes: 9 additions & 0 deletions test/test_other.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down