Repository navigation
wasm: support JSPI for Emscripten target - #8285
Merged
Merged
Conversation
guybedford
force-pushed
the
emscripten-jspi
branch
from
July 15, 2026 19:19
5f0e22c to
eaab129
Compare
Darksonn
reviewed
Jul 16, 2026
guybedford
force-pushed
the
emscripten-jspi
branch
from
July 18, 2026 02:41
eaab129 to
6f1cc4d
Compare
guybedford
force-pushed
the
emscripten-jspi
branch
2 times, most recently
from
July 18, 2026 03:34
6121ff9 to
3258304
Compare
guybedford
force-pushed
the
emscripten-jspi
branch
from
July 28, 2026 01:53
25e78d4 to
54a7119
Compare
Darksonn
reviewed
Aug 5, 2026
guybedford
force-pushed
the
emscripten-jspi
branch
from
August 5, 2026 19:06
54a7119 to
ecfecd3
Compare
guybedford
force-pushed
the
emscripten-jspi
branch
from
August 25, 2026 19:09
5d67f7c to
e5bcb09
Compare
guybedford
force-pushed
the
emscripten-jspi
branch
5 times, most recently
from
August 26, 2026 02:03
c83349a to
64c8614
Compare
guybedford
force-pushed
the
emscripten-jspi
branch
from
September 6, 2026 20:45
64c8614 to
d2a8827
Compare
guybedford
added a commit
to guybedford/tokio
that referenced
this pull request
Sep 18, 2026
Follow-on to tokio-rs#8285, enabling `tokio::net` on Emscripten over mio's epoll selector and Node's raw sockets (`-sNODERAWSOCKETS`). The I/O driver is the native one. Under JSPI, Emscripten's `epoll_wait` is a blocking wait that suspends on the host event loop, resuming on readiness (or the mio waker pipe) or the deadline, so `park` and `park_timeout` need nothing target-specific beyond `jspi::io_wait`, which handles the scheduler's zero-duration maintenance park: a zero-timeout `epoll_wait` is a synchronous probe, and the host loop is the only producer of readiness, so the driver yields one host turn first, as the zero-duration `ParkThread` park already does. Without JSPI a real wait panics rather than spinning, matching the existing park semantics. Under pthreads with `-sPROXY_TO_PTHREAD` the wait blocks on the worker as on native, with no target-specific code at all. A wait with no deadline is a real `epoll_wait` that a socket could wake, so it suspends rather than panicking as the reactor-less park does; the `rt_emscripten_block_on` test of that panic is gated off `net`. TcpStream/TcpListener/UdpSocket, stream `AF_UNIX` sockets, `lookup_host` and `AsyncFd` work as on native. Gated where Node lacks the primitive: datagram `AF_UNIX` (`UnixDatagram`, `UnixSocket::new_datagram`), `socketpair(2)` (`UnixStream::pair`), and `SO_PEERCRED` (`peer_cred` reports unsupported). Name resolution goes through Emscripten's synchronous `getaddrinfo`, which maps hostnames to synthetic addresses, so the `localhost` tests are ignored on this target. CI adds `net` to both emscripten test runs on the released emsdk: JSPI (846 passed, 0 failed, 19 ignored) and pthreads, which regains `-sPROXY_TO_PTHREAD` (818 passed, 0 failed, 19 ignored). The JSPI lane links a `--pre-js` that fails a test binary whose `main` never returns: a park that suspends with no wake source leaves Node's event loop to drain and the process to exit 0, which cargo would otherwise take as success. The JSPI run uses Rust beta until 1.99 is stable, which is where `OwnedFd::try_clone` (mio's registry handle) gains Emscripten support. TEMPORARY: `mio` comes from tokio-rs/mio#1969 until released, which in turn takes `libc` from the `libc-0.2` branch for its emscripten epoll bindings (rust-lang/libc#5427).
Darksonn
reviewed
Sep 21, 2026
guybedford
force-pushed
the
emscripten-jspi
branch
from
September 23, 2026 05:56
8c8f16c to
ab3c0ed
Compare
Darksonn
reviewed
Sep 23, 2026
guybedford
force-pushed
the
emscripten-jspi
branch
from
September 23, 2026 17:58
5811c4c to
02c1614
Compare
guybedford
added a commit
to guybedford/tokio
that referenced
this pull request
Sep 24, 2026
Follow-on to tokio-rs#8285, tokio-rs#8438 and tokio-rs#8484. A Tokio-issued park leaves the scheduler context but keeps the runtime entered, so a `block_on` from a sibling promising activation during it panics as a nested runtime, and a suspension Tokio does not issue keeps everything as it was: `lookup_host` on Emscripten runs `getaddrinfo` inline in task code, it suspends on DNS, and a sibling `block_on` during it is nested. With `--cfg tokio_unstable_jspi_hooks` and a link against `-sJSPI_HOOKS` (or `-sREENTRANT_JSPI`), Tokio registers one `<emscripten/jspi.h>` lifecycle hook and the context becomes fiber-owned: at `JSPI_SUSPEND` the thread's context is moved into the fiber's token and the context of whoever entered or last resumed the fiber is put back; `JSPI_RESUME` reverses it; `JSPI_ENTER` starts a fiber from an empty context, stashing the interrupted one until `JSPI_EXIT`. Every suspension is then a leave, whoever issued it, and a fiber entered synchronously from inside a running runtime is a sibling rather than a nesting. The hook is registered on the thread's first runtime entry so such fibers are seen from their start. `nested block_on` from Rust on one fiber still panics. A plain host callback during a suspension sees the empty context too, so it spawns through a `Handle` rather than `tokio::spawn`. `Snapshot` carries the entered state, rng, current handle and depth, the scheduler context, the poll-scoped task id and budget (a task-issued suspension is mid-poll), and the `LocalSet` thread-local. `task_local!` values are not fiber-owned; one in scope across a task-issued suspension is visible to sibling fibers. Without the cfg, or when the link lacks the hooks (`jspi_register` returns -1), nothing changes. The cfg is needed because released emsdk has no `libjspi`, so the symbol cannot be referenced unconditionally. Tests, probed at runtime so one binary serves every JSPI lane: * `task_suspension_leaves_only_with_hooks` pins both contracts. * `sibling_fiber_during_task_suspension`: the `lookup_host` shape, a sibling fiber driving its runtime to completion inside a task-issued suspension; the task resumes with its own id and runtime. * `local_set_survives_sibling_fiber`: fiber B's `LocalSet` between two `spawn_local`s of fiber A. * `interleaved_suspended_runtimes` runs when `TOKIO_REENTRANT_JSPI` is set: under `-sREENTRANT_JSPI` fibers have their own shadow stacks. * `host_activation_spawns_onto_parked_runtime` spawns through the handle when the hooks are linked. CI adds a lane against the `6.0.10-cf.emscripten` frontend and the binaryen `jspi-hooks` release, in `-sJSPI_HOOKS` and `-sREENTRANT_JSPI` variants; the stock lanes are unchanged.
guybedford
added a commit
to guybedford/tokio
that referenced
this pull request
Sep 25, 2026
Follow-on to tokio-rs#8285, tokio-rs#8438 and tokio-rs#8484. A Tokio-issued park leaves the scheduler context but keeps the runtime entered, so a `block_on` from a sibling promising activation during it panics as a nested runtime, and a suspension Tokio does not issue keeps everything as it was: `lookup_host` on Emscripten runs `getaddrinfo` inline in task code, it suspends on DNS, and a sibling `block_on` during it is nested. With `--cfg tokio_unstable_jspi_hooks` and a link against `-sJSPI_HOOKS` (or `-sREENTRANT_JSPI`), Tokio registers one `<emscripten/jspi.h>` lifecycle hook and the context becomes fiber-owned: at `JSPI_SUSPEND` the thread's context is moved into the fiber's token and the context of whoever entered or last resumed the fiber is put back; `JSPI_RESUME` reverses it; `JSPI_ENTER` starts a fiber from an empty context, stashing the interrupted one until `JSPI_EXIT`. Every suspension is then a leave, whoever issued it, and a fiber entered synchronously from inside a running runtime is a sibling rather than a nesting. The hook is registered on the thread's first runtime entry so such fibers are seen from their start. `nested block_on` from Rust on one fiber still panics. A plain host callback during a suspension sees the empty context too, so it spawns through a `Handle` rather than `tokio::spawn`. `Snapshot` carries the entered state, rng, current handle and depth, the scheduler context, the poll-scoped task id and budget (a task-issued suspension is mid-poll), and the `LocalSet` thread-local. `task_local!` values are not fiber-owned; one in scope across a task-issued suspension is visible to sibling fibers. Without the cfg, or when the link lacks the hooks (`jspi_register` returns -1), nothing changes. The cfg is needed because released emsdk has no `libjspi`, so the symbol cannot be referenced unconditionally. Tests, probed at runtime so one binary serves every JSPI lane: * `task_suspension_leaves_only_with_hooks` pins both contracts. * `sibling_fiber_during_task_suspension`: the `lookup_host` shape, a sibling fiber driving its runtime to completion inside a task-issued suspension; the task resumes with its own id and runtime. * `local_set_survives_sibling_fiber`: fiber B's `LocalSet` between two `spawn_local`s of fiber A. * `interleaved_suspended_runtimes` runs when `TOKIO_REENTRANT_JSPI` is set: under `-sREENTRANT_JSPI` fibers have their own shadow stacks. * `host_activation_spawns_onto_parked_runtime` spawns through the handle when the hooks are linked. CI adds a lane against the `6.0.10-cf.emscripten` frontend and the binaryen `jspi-hooks` release, in `-sJSPI_HOOKS` and `-sREENTRANT_JSPI` variants; the stock lanes are unchanged.
Follow-on to tokio-rs#8281, supporting JSPI as an alternative approach to suspension. A `context::jspi` module provides the `__asyncjs__` suspension import timer, the only suspending import, as well as a feature check for JSPI being used. The park leaf suspends the whole Wasm stack on a host timer, so the host loop runs while Tokio waits and resumes it when the timer fires. The scheduler, task system and time driver are the canonical ones, tokio-macros is untouched, and no public API is added. The scheduler's `event_interval` maintenance park arrives as a zero-duration park and resumes through a host immediate, so a busy scheduler still gives the host loop a turn, the role the non-blocking I/O poll plays on native: the immediate it schedules next lands in the following event loop iteration, which begins by running expired timers. Without JSPI the zero-duration park is a no-op, as on native. `setTimeout(0)` also works but is clamped to a millisecond, and a microtask queue does not work at all, since those drain before the loop advances. The runtime stays entered while parked, as on native: a `block_on` from another promising activation on the same thread during the park panics as a nested runtime. Driving the runtime from several activations is a follow-on. Coverage expands from one run of the suite in which a timed wait blocked the thread, plus a single pthread test file, to three lanes: * non-JSPI: 4 passed, 0 failed * JSPI: 788 passed, 0 failed, 10 ignored * pthreads (`-pthread`): 762 passed, 0 failed, 10 ignored In the process, the pthreads test suite is ungated as well, and changed to share the blocking shim, since Emscripten has a sync-only FS. Real-timer tests for Emscripten are consolidated into the single `rt_emscripten_jspi.rs` file. * `spawn_blocking` remains unsupported in non-pthread builds * `net` (epoll, over tokio-rs/mio#1969) remains the follow-up * Without `-sJSPI`, a non-zero wait now panics rather than blocking the thread through `std::thread::sleep`. * A wait with no deadline panics in either mode. There is no reactor here, so host timers are the only mid-park wake source and nothing could ever deliver the wake. Refs: tokio-rs#8281
Moves the JSPI primitives to runtime::jspi so the thread parker behind blocking_recv and friends suspends in sync-only builds as well. The park and scheduler-context state now restore on unwind, and a stale JS park slot can no longer remove its successor's entry.
Darksonn
approved these changes
Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Follow-on to #8281, supporting JSPI as an alternative approach to suspension.
Solution
A
runtime::jspimodule implements park and unpark over Emscripten's promise and event loop C APIs (emscripten/promise.h,emscripten/eventloop.h), with a feature check for JSPI being linked. A park creates a promise, arms a host timer (emscripten_set_timeout, oremscripten_set_immediatefor a zero-duration park) that resolves it at the deadline, and suspends the whole Wasm stack onemscripten_promise_await_unchecked, Emscripten's suspending import. An unpark from a later activation (a host callback entering tokio and waking a task) resolves the promise held in the parker's slot. The host loop runs while Tokio waits and resumes it when the promise settles. No JavaScript is embedded; everything is plain C imports that link with or without-sJSPI.The thread parker behind
blocking_recvand friends parks through the same primitive, so blocking calls outside a runtime suspend in the same way, and the module does not require thertfeature.The scheduler, task system and time driver are the canonical ones,
tokio-macrosis untouched, and no public API is added.The scheduler's
event_intervalmaintenance park arrives as a zero-duration park and resumes through a host immediate, so a busy scheduler will result in a JS host event loop turn. Without JSPI the zero-duration park remains a no-op, as on native.The runtime stays entered while parked, as on native: a
block_onfrom another promising activation on the same thread during the park panics as a nested runtime. Driving the runtime from several activations is a follow-on.Suspension requires a
WebAssembly.promisingactivation on the stack. A wait from any other activation throwsWebAssembly.SuspendErrorout of the import; it is a foreign exception to Rust, not catchable bycatch_unwind, and aborts at the firstextern "C"frame. The park and scheduler-context state restore on unwind so tokio is left consistent for whatever runs afterwards.Coverage expands from one run of the suite in which a timed wait blocked the thread, plus a single pthread test file, to three lanes:
-pthread): 762 passed, 0 failed, 10 ignoredIn the process, the pthreads test suite is ungated as well, and changed to share the blocking shim - the reason for this that Emscripten has a sync-only FS.
Real-timer tests for Emscripten are consolidated into the single
rt_emscripten_jspi.rsfile.sync_emscripten_blocking.rscoversblocking_recvfrom outside a runtime and needs only thesyncfeature.spawn_blockingremains unsupported in non-pthread buildsnet(epoll, over Add wasm32-unknown-emscripten target supportΒ mio#1969) remains the follow-up-sJSPI, a would-block wait now panics rather than blocking the thread throughstd::thread::sleepor hanging on a condvar.