napi-async-runtime 0.2.3

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.

## Running MultiThread on `wasm32-wasip1-threads`

Everything below is the host's job; the crate adds no wasm-specific API.

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