Skip to content

wasm: support JSPI for Emscripten target - #8285

Merged
Darksonn merged 13 commits into
tokio-rs:masterfrom
guybedford:emscripten-jspi
Sep 26, 2026
Merged

Darksonn merged 13 commits into
tokio-rs:masterfrom
guybedford:emscripten-jspi

Conversation

@guybedford

@guybedford guybedford commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Follow-on to #8281, supporting JSPI as an alternative approach to suspension.

Solution

A runtime::jspi module 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, or emscripten_set_immediate for a zero-duration park) that resolves it at the deadline, and suspends the whole Wasm stack on emscripten_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_recv and friends parks through the same primitive, so blocking calls outside a runtime suspend in the same way, and the module does not require the rt feature.

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 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_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.

Suspension requires a WebAssembly.promising activation on the stack. A wait from any other activation throws WebAssembly.SuspendError out of the import; it is a foreign exception to Rust, not catchable by catch_unwind, and aborts at the first extern "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:

  • non-JSPI: 8 passed, 0 failed
  • JSPI: 801 passed, 0 failed, 9 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 - the reason for this that Emscripten has a sync-only FS.

Real-timer tests for Emscripten are consolidated into the single rt_emscripten_jspi.rs file. sync_emscripten_blocking.rs covers blocking_recv from outside a runtime and needs only the sync feature.

  • spawn_blocking remains unsupported in non-pthread builds
  • net (epoll, over Add wasm32-unknown-emscripten target supportΒ mio#1969) remains the follow-up
  • Without -sJSPI, a would-block wait now panics rather than blocking the thread through std::thread::sleep or hanging on a condvar.

@github-actions github-actions Bot added R-loom-sync Run loom sync tests on this PR R-loom-time-driver Run loom time driver tests on this PR R-loom-current-thread Run loom current-thread tests on this PR R-loom-multi-thread Run loom multi-thread tests on this PR R-loom-blocking Run loom `runtime::spawn_blocking` tests on this PR labels Jul 15, 2026
@guybedford guybedford changed the title feat: support the wasm32-unknown-emscripten target feat: JSPI test suite for the wasm32-unknown-emscripten target Jul 15, 2026
Comment thread tokio/src/process/mod.rs Resolved
Comment thread tokio/src/blocking.rs Resolved
Comment thread tokio/tests/emscripten_timer_rearm.rs Resolved
Comment thread tokio-test/src/lib.rs Resolved
@Darksonn Darksonn added A-tokio Area: The main tokio crate T-wasm Topic: Web Assembly labels Jul 16, 2026
@github-actions github-actions Bot removed R-loom-current-thread Run loom current-thread tests on this PR R-loom-multi-thread Run loom multi-thread tests on this PR R-loom-blocking Run loom `runtime::spawn_blocking` tests on this PR labels Jul 18, 2026
@guybedford
guybedford force-pushed the emscripten-jspi branch 2 times, most recently from 6121ff9 to 3258304 Compare July 18, 2026 03:34
Comment thread tokio/src/runtime/context/jspi.rs Resolved
Comment thread tokio/src/runtime/jspi.rs Resolved
Comment thread tokio-macros/src/entry.rs Resolved
@github-actions github-actions Bot added the R-loom-blocking Run loom `runtime::spawn_blocking` tests on this PR label Aug 25, 2026
@guybedford
guybedford force-pushed the emscripten-jspi branch 5 times, most recently from c83349a to 64c8614 Compare August 26, 2026 02:03
@github-actions github-actions Bot removed R-loom-sync Run loom sync tests on this PR R-loom-time-driver Run loom time driver tests on this PR labels Sep 6, 2026
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).
Comment thread tokio/src/lib.rs Resolved
Comment thread tokio/src/lib.rs Resolved
Comment thread tokio/src/runtime/jspi.rs Resolved
Comment thread tokio/src/runtime/jspi.rs Resolved
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.
guybedford and others added 12 commits September 25, 2026 11:04
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-tokio Area: The main tokio crate R-loom-blocking Run loom `runtime::spawn_blocking` tests on this PR T-wasm Topic: Web Assembly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants