# napi-async-runtime
A shared, tokio-free async runtime for napi-rs addons: a pluggable
async/CPU/blocking scheduler plus an optional adapter that registers it as the
addon's `AsyncRuntime` backend (napi's `async-runtime` SPI).
## Layers
- **Scheduler** (always compiled, napi-free). Exposed at the crate root:
`spawn`, `try_spawn`, `spawn_detached`, `spawn_blocking`, `block_on`,
`sleep_until`, `configure`/`configure_partial`/`configured_options`,
`start`/`shutdown`, metrics (`metrics`/`reset_metrics`), and the host driver
SPIs (`CurrentThreadTaskDriver`, `TimerDriver`).
- **napi adapter** (cargo feature `napi`, on by default). `install(options)`
configures the scheduler and calls `register_async_runtime`; the
JavaScript-facing host protocol is exported under stable names
(`reserveCurrentThreadHostRegistration`, `registerCurrentThreadTaskHost`,
`registerTimerHost`, `unregisterCurrentThreadTaskHost`,
`unregisterTimerHost`, `isCurrentThreadHostRegistrationActive`,
`getCurrentThreadTaskHostContractVersion`, `configureAsyncRuntime`,
`getAsyncRuntimeConfig`, `getAsyncRuntimeMetrics`,
`resetAsyncRuntimeMetrics`).
## Usage
```rust
use napi_async_runtime::{RuntimeOptions, install};
#[napi_derive::module_init]
fn init() {
// Resolve your own configuration (env vars, defaults) FIRST; the scheduler
// never reads the environment itself.
install(RuntimeOptions::default()).expect("failed to install the shared async runtime");
}
```
This crate deliberately ships **no `module_init` of its own**: the host stays
in charge of configuration resolution order. `@napi-rs/async-runtime` provides
the matching JavaScript host installers (task host + timer host) for
CurrentThread builds.
## Implementation notes
- **Flavors.** `MultiThread` runs futures on a Rayon-backed worker pool; it is
available on native targets and on `wasm32-wasip1-threads`, and is rejected
on threadless WebAssembly. `CurrentThread` (the default on every wasm target
and the only flavor on threadless ones) never creates threads and instead
publishes _host turns_ through registered
`CurrentThreadTaskDriver`s — on Node that driver is a native threadsafe
function installed by `registerCurrentThreadTaskHost` (contract version
**4**: a registration capability is reserved and validated before host
installation performs side effects).
- **Timers.** MultiThread owns a timer heap serviced by a timekeeper thread.
CurrentThread delegates each timer to the host event loop through the JS
relay installed by `registerTimerHost`
(`(relayId, ms) => Promise<void>` paired with `(relayId) => void`
cancellation). Schedule and cancel share ONE per-relay health record (a
single strike); three consecutive live-host failures evict the host;
eviction is decided only by `Status::Closing` or the liveness probe, never
by message text; relay ids are never reused while referable.
- **Blocking admission.** The blocking cap is `worker_threads - 1`: one
execution lane always stays available for runnable futures and timer
service. There is no hidden reserve worker, and napi never creates fallback
threads when `spawn_blocking` declines.
- **Lifecycle.** `shutdown` closes admission, waits for the scheduler
generation to quiesce, JOINS native workers (TLS destructors retire inside
the barrier), and releases active resources before returning; generation
identities fail closed before u64 reuse. Detached-task semantics match
tokio: dropping a `JoinHandle` detaches, and shutdown may cancel accepted
work by dropping futures.
- **Threadless wasm** (`wasm32-wasip1`, `wasm32-unknown-unknown`): no threads,
no `Atomics.wait`. `MultiThread` is rejected at `validate()` there. A
`block_on` park that provably can never be woken fails loudly with the typed
`BlockOnDeadlock` panic instead of hanging the JS event loop.
- **Threaded wasm** (`wasm32-wasip1-threads`): `wasi.thread-spawn` gives std a
real `pthread_create`, so both flavors work exactly as on native. rustc
exposes identical cfg sets for the two WASI targets, so this crate's
build.rs discriminates them from the exact cargo `TARGET`, emitting
`napi_runtime_wasi_threads` (the threaded WASI target) and
`napi_runtime_os_threads` (native **or** the threaded WASI target — "this
build can create OS threads"). The default flavor stays `CurrentThread` on
every wasm target: selecting `MultiThread` there is always an explicit host
act.
- **Memory size on threaded wasm.** V8 updates a shared wasm memory's size
only on the thread that grew it; the other threads keep checking
`memory.fill`, `memory.copy` and atomics (and, without V8's trap handler,
every load and store) against their old size, so they trap on the new
pages. napi's allocator wrappers refresh a thread when it allocates. The
scheduler covers memory that reaches a thread without an allocation there:
on `wasm32-wasip1-threads` it calls
`napi_sys::wasi_heap_sync::refresh_if_behind` before every task poll and
every `block_on` poll, and at the start of every blocking closure, in both
flavors. The check is one atomic load and one thread-local load;
`memory.grow(0)` runs only when another thread has seen a larger memory.
There is nothing to call or configure, and no other target compiles it. The
state lives in napi-sys because napi's allocator writes it too and napi
cannot depend on this crate. Work a host runs on its own threads, outside
this scheduler, is not covered.
## Running MultiThread on `wasm32-wasip1-threads`
Everything below is the host's job. The crate adds one wasm-specific export,
and only on this target: `napi_wasm_runtime_pool_workers() -> u32`, the pool
threads the runtime will still spawn: the configured MultiThread
`worker_threads`, 0 under CurrentThread (which is also the wasm default before
any configure), and 0 once a backend has started, because its pool threads
already exist. It reads an atomic and never takes a lock. The generated
`@napi-rs/cli` threaded Node loader reads it once the module loaded (and, with
`napi.wasm.asyncRuntime`, after each successful `configureAsyncRuntime`) and
keeps that many loaded Workers idle in the emnapi pool, so the first
MultiThread call does not boot its pool threads from scratch (cli
`docs/wasi.md`, "Thread pool preload"). It comes with the scheduler, so
`default-features = false` builds export it too.
- **Pass a worker count.** `available_parallelism()` answers `Ok(1)` inside a
WASI host, so a host that flips only the flavor gets MultiThread's clamped
minimum: `worker_threads = 2`, `max_blocking_tasks = 1`. That is truthful,
not useful. Read the real number in JavaScript
(`os.availableParallelism()`, `navigator.hardwareConcurrency`) and pass it
through `configure` / `configure_partial` before the first async call. The
crate never probes the CPU count on wasm.
- **Budget `worker_threads + 1` threads.** The first `sleep_until` lazily
spawns one non-Rayon timekeeper thread — one more Node Worker than the pool
size suggests.
- **Route through the `try_*` family.** `try_spawn`, `try_spawn_blocking` and
`try_block_on_dyn` return the pool-build failure (`EAGAIN`, no SAB, a Worker
that never boots) as an `Err`. The infallible helpers panic instead, and
`panic = "abort"` makes that a dead instance.
- **Never from a browser main thread.** Under MultiThread, `block_on`,
shutdown's idle barrier and the worker join all park the calling thread on
`memory.atomic.wait32`. A browser main thread may not do that — emnapi
answers `napi_would_deadlock` — so the threaded artifact is a Node /
WebContainer target today. Inside a Worker it is fine.
- **Panics abort.** Both WASI targets are `panic = "abort"`, so this crate's
containment (`catch_unwind` around task polls, closure drops and host
callbacks) never catches anything there: a user panic takes the instance
down instead of becoming a `JoinError`. That is true on threadless wasm too;
MultiThread only widens the surface to every worker plus the timekeeper.
- **`shutdown` joins, without a bound.** It waits for every worker to exit
before returning, and on the WASI loader path it runs inside a synchronous
wasm export — so the JS thread sits in `pthread_join` for its duration, and
a blocking closure waiting on a JS turn cannot finish while it does. A
bounded join would be worse: it would leave a live thread running over
memory the loader is about to destroy. Use `RuntimeOptions::park_deadline`
if you need a park to fail loudly instead. The same applies before the join:
`shutdown` first waits for every accepted blocking closure to return, and
`park_deadline` does not bound that wait; it bounds parks, not a closure that
is running. So the rule for hosts is: a blocking closure must never wait on a
JavaScript turn (no threadsafe-function call, no napi promise, no channel fed
by JS). Route that work through `spawn` instead, where the await yields. This
is the same contract native addons already live under (`thread_cleanup` runs
`shutdown` synchronously on the JS thread); MultiThread on threaded WASI is
only the first configuration where a closure can be running on another thread
while it happens.
- **A host that can yield splits the join in two.** `begin_shutdown` publishes
the stop, closes admission, cancels and drops what is only queued, wakes what
is parked — and returns without waiting. Never, including on a runtime with
no backend at all (`Initial`, or already stopped) whose last rejected
submission is still being destroyed on another thread: that destructor is
arbitrary user `Drop` code, and on threaded WASI the thread running it may be
waiting for the very JavaScript turn the phase-1 caller can only give by
returning. So phase 1 announces the stop, parks it, and hands that wait to
phase 2 as well. It answers `true` while the announced stop still owes a
wait — backend-owned work that is still live, or such a rejected destruction.
`runtime_work_pending` re-answers that question at any time without blocking,
not even on a lock: a lock another thread holds reads as pending. On threaded
WASI a pool thread that traps while holding one unwinds nothing, so that lock
never comes back; the poll keeps answering `true` instead of parking the
host's thread on it for good.
Note that the window is open-ended by the same rule the join is:
submissions are rejected for the whole of it, and each rejection starts
another destructor, so a host that keeps submitting from a worker during its
poll turns keeps extending phase 2. `finish_shutdown` is the call that
waits, joins the workers and publishes `Stopped`, so it is the one that owes
the quiescence guarantee. `shutdown` is unchanged and is exactly the two in
sequence, so nothing that calls it behaves differently. Between the phases
the host may turn its event loop — that is the point, because that turn is
what a blocking closure is waiting for — but it may not call `start`: a
restart belongs after `finish_shutdown`. A second `begin_shutdown` before the
matching `finish_shutdown` re-reports the poll and never waits. The cli's
WASI loader drives the pair through napi's
`napi_prepare_wasm_env_cleanup_begin` / `napi_wasm_runtime_work_pending` /
`…_finish` exports, polling on one-millisecond timer turns for as long as the
poll keeps answering 1. That poll is **unbounded**, and deliberately so: the
only way to end it early is to call `finish_shutdown`, which joins on the
JavaScript thread — the very thread the work it joins may be waiting for a
turn from. A bound would not end that wait, it would only move it somewhere
the JavaScript thread can no longer be reached. So a host that breaks the rule
above keeps its disposal promise pending, with its event loop still turning,
instead of wedging the thread. The process-exit teardown is the one path with
no turns left to give: it closes the handshake with `…_finish` and blocks
there, exactly as the single call always did.
- **A crashed pool thread ends the shutdown waits.** A thread that traps
unwinds nothing: the locks it held stay held, its work never retires and it
never exits, so `begin_shutdown` and `finish_shutdown` would wait on it for
good. So on threaded WASI both phases wait in 1 ms slices on the thread that
calls them, and between slices read the crash flag in `napi-sys`: a 4-byte
word in the shared linear memory. napi's
`napi_wasm_thread_crash_flag_address` export gives the cli's loader its
address; the loader hands each pool worker an `Int32Array` over it, and the
worker raises it with `Atomics.store` when its wasm thread dies (napi's
`napi_wasm_thread_crashed` export stores into the same word). No instance is
needed, so a worker that fails while it loads, after its thread spawn
already returned, raises it too. Once it is up, the wait traps
(`unreachable`). The worker raises the loader's own crash flag first, so the
loader reports that throw as the crash. Only a crash the worker itself
reports ends these waits: a `Worker` that cannot start or is killed by its
resource limits raises nothing. The join of live threads above stays
unbounded, and pool threads and every wait outside the two phases wait as
before. See `src/sync.rs` and the cli's `docs/wasi.md`.
- **The JavaScript hosts go inert, not away.** The cli's
`napi.wasm.asyncRuntime` loaders install a CurrentThread task host and a
timer host unconditionally — they cannot know the flavor. MultiThread never
publishes a host turn and never consults a `TimerDriver`, so both
registrations simply sit unused, and the task host is an unref'd threadsafe
function that does not hold the event loop. Leave the flag on.
The scheduler and adapter were extracted from rolldown's shared async runtime
(rolldown#9977/#9978) and generalized; the wire behavior of the host protocol
is byte-compatible with the rolldown hosts at contract version 4.