napi-async-runtime 0.2.7

Shared pluggable async runtime for napi-rs addons: a tokio-free async/CPU/blocking scheduler plus an optional napi AsyncRuntime SPI adapter
Documentation
# 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.