Skip to content

JSPI reentrancy primitive - the current promising stack base#27372

Closed
guybedford wants to merge 5 commits into
emscripten-core:mainfrom
guybedford:jspi-promising-top
Closed

JSPI reentrancy primitive - the current promising stack base#27372
guybedford wants to merge 5 commits into
emscripten-core:mainfrom
guybedford:jspi-promising-top

Conversation

@guybedford

@guybedford guybedford commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

This adds tracking of the shadow stack pointer on entry to each WebAssembly.promising export call, as described in #27364.

Libraries implementing shadow stack switching on top of JSPI (per https://blog.pyodide.org/posts/jspi-with-c-runtime/, and as landing for wasm-bindgen in wasm-bindgen/wasm-bindgen#5193) that have reentrancy need the base of the stack range in use by the current promising call, in order to save it on suspend and restore it on resume. This stack base won't necessarily be the initial stack base because promising calls may be made from reentrant synchronous Wasm stacks in JavaScript, such that the shadow stack is not empty. Stack saving needs this information it would otherwise not have access to.

  • Asyncify.lastPromisingStackBase records the stack pointer on entry to every promising call - covering wrapped exports, table-based async calls, async dynCall and embind async bindings (now all routed through Asyncify.makeAsyncFunction).
  • The marker is only valid before suspension and suspensions are not guarded, so stack switching libraries must intercept suspending imports and promising call sites themselves to use the stack base instead after suspension. But all stack switching libraries already need to track all suspensions anyway so this works out fine.

Suspension can be implemented on top of this primitive based on the following scheme:

  • Suspend functions save the stack range from the lastPromisingStackBase to the current stack position. This is because WebAssembly.promising invocations can under reentrant Wasm have existing sync execution parent stacks, and allows a minor optimization in only storing the region actually needed instead of the whole stack to the base.
  • On resume, the stack gets restored to the exact same position to ensure stack pointers remain valid (note this introduces a potential "dead zone" in the stack, since the resume stack by definition should be an empty stack, so that any leading stack up to the lastPromisingStackBase would have unwound already)
  • Most importantly, on exit of the promising function, the stack needs to then be decremented by the lastPromisingStackBase to ensure the resume stack dead zone doesn't spill.

We could expose this as a direct function, but given the suspension treatment, having the field as an implicit over an explicit valid stack pointer seems to make more sense to me with these caveats.

Test coverage in other.test_jspi_promising_base verifies the marker at entry, its stability across call depth, and that a nested promising export entered at depth (while the outer export is suspended) updates it.

Made with AI assistance under my review

@guybedford

Copy link
Copy Markdown
Collaborator Author

The heavier alternative to this would be to instrument all suspend point injections as well, with full stack save / restore applied on suspend, per (1) in the original issue. I'm open to that as well, but didn't want to assume.

@guybedford

Copy link
Copy Markdown
Collaborator Author

Of course the heavy approach isn't needed when there is an exclusive JSPI suspension point, so not assuming shared JSPI and allowing the singular stack optimization, and then shipping this little hook to allow libraries to build on top their own systems might be the right start.

Under SPLIT_MODULE, exports can be lazy-loading JSPI trampolines which
cannot be called outside of a promising context.
@sbc100
sbc100 requested review from brendandahl and kripken July 17, 2026 22:36
@sbc100

sbc100 commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Do this is the first step in allowing re-entrancy with JSPI and/or ASYNCIFY?

Can/should emscripten not implement this itself and make it "just work"? Right now IIUC emscirpten to error out if you try to re-enter a suspended function? Could we build an option that replaces that error with shadow-stack-swapping? Of course we probably still want it to be opt-in because more C programs are not prepared for re-entrancy in this way?

@sbc100

sbc100 commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

If I'm understanding correctly that this PR (and the issue it points to) are supposed to work around the current lack of rentrancy with JSPI perhaps that could be made more clear in titles/descriptions. Or maybe I'm wrong there is something else going on here?

@guybedford

guybedford commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Do this is the first step in allowing re-entrancy with JSPI and/or ASYNCIFY?

Right, this would be the required primitive to build re-entrancy, where the actual implementation is to support the stack save restore around the suspend for the stack up to this base.

If I'm understanding correctly that this PR (and the issue it points to) are supposed to work around the current lack of rentrancy with JSPI perhaps that could be made more clear in titles/descriptions. Or maybe I'm wrong there is something else going on here?

Right, I didn't think to use that framing, but that is exactly the right way to look at it. And if we did want to fully handle reentrancy that would be what I referred to as (1) in the original issue with stack save and restore per "thread" to make it a first-class thing in Emscripten.

@guybedford guybedford changed the title Track the last promising stack top under JSPI JSPI reentrancy primitive - the current promising stack base Jul 18, 2026

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see yes, I agree it seems reasonable to expose a primitive and I also think emscripten itself can/should optionally use this primitive to implement some kind -sASYNCIFY_REENTRANT setting. I think implementing that setting would be a good proof that the primitive worked. I'd be OK with landing PR standalone and the its usage as a followup (assuming you are confident this is enough?)

Comment thread src/lib/libasync.js Outdated
@sbc100

sbc100 commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Also, could we make this work with ASYNCIFY=1 too?

CC @tlively, @fgmccabe and @brendandahl who have been in discussions in the past about a reentrancy model for JSPI/ASYNCIFY.

@guybedford

guybedford commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Also, could we make this work with ASYNCIFY=1 too?

Added the asyncify support.

I see yes, I agree it seems reasonable to expose a primitive and I also think emscripten itself can/should optionally use this primitive to implement some kind -sASYNCIFY_REENTRANT setting. I think implementing that setting would be a good proof that the primitive worked. I'd be OK with landing PR standalone and the its usage as a followup (assuming you are confident this is enough?)

It is of course up to implementers to verify the ownership model differences - in particular that there are no pointers against the stack region currently shared, but otherwise yes it is sufficient.

@sbc100

sbc100 commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

It is of course up to implementers to verify the ownership model differences - in particular that there are no pointers against the stack region currently shared, but otherwise yes it is sufficient.

Could you elaborate on this danger / risk? Can you describe an example of program that would break in this way?

@guybedford

guybedford commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Interestingly, unlike #27377 this remains a sound primitive for a reentrant JSPI implementation, so I will leave it open here.

Could you elaborate on this danger / risk? Can you describe an example of program that would break in this way?

For C++ I think it is mostly common sense, but in Rust, the main issue is more handling Rust's lifetime soundness over the suspension point. In https://github.com/guybedford/jspi#example I achieved this by making the promising closure 'static + Send only. Assuming the stack pointers are not offset things should remain sound, but I'm not completely sure if that is fully sufficient though.

@guybedford

guybedford commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Can you describe an example of program that would break in this way?

A better example would be to consider a Rust program that holds a mutable borrow to something on the heap, then calls a suspending function while holding that borrow, breaking singular aliasing. I think if trying to build a proper Rust primitive around JSPI, it would need to take into account the suspending function behaving like an .await in that mutable borrow lifetimes held across it must have the lifetime of the future itself. Maybe directly using .await syntax even is the best way to reflect it.

@guybedford

Copy link
Copy Markdown
Collaborator Author

I'm going to close this one as well, since the lastPromisingStackBase is useful, but not a sufficient primitive to implement save/restore. The reason for this is that the resumed stack will still be offset by the same stack offset as lastPromisingStackBase creating a dead stack zone. On exit of the promising function this zone must be cleared as the last step of the exit path of the function wrapped in WebAssembly.promising. That instrumentation is the important one which cannot be done via a Promise.finally due to the extra microtask tick, so that the promsing-wrapped function must be wrapped regardless.

The alternative scheme is to offset the suspension restore stack, but that does lead to invalid stack pointers which simply is not going to fly. Just handling the dead zone correctly as a Binaryen instrumentation is the right and correct implementation approach IMO.

@guybedford guybedford closed this Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants