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 callsregister_async_runtime; the JavaScript-facing host protocol is exported under stable names (reserveCurrentThreadHostRegistration,registerCurrentThreadTaskHost,registerTimerHost,unregisterCurrentThreadTaskHost,unregisterTimerHost,isCurrentThreadHostRegistrationActive,getCurrentThreadTaskHostContractVersion,configureAsyncRuntime,getAsyncRuntimeConfig,getAsyncRuntimeMetrics,resetAsyncRuntimeMetrics).
Usage
use ;
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.
MultiThreadruns futures on a Rayon-backed worker pool; it is available on native targets and onwasm32-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 registeredCurrentThreadTaskDrivers — on Node that driver is a native threadsafe function installed byregisterCurrentThreadTaskHost(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) => voidcancellation). Schedule and cancel share ONE per-relay health record (a single strike); three consecutive live-host failures evict the host; eviction is decided only byStatus::Closingor 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 whenspawn_blockingdeclines. - Lifecycle.
shutdowncloses 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 aJoinHandledetaches, and shutdown may cancel accepted work by dropping futures. - Threadless wasm (
wasm32-wasip1,wasm32-unknown-unknown): no threads, noAtomics.wait.MultiThreadis rejected atvalidate()there. Ablock_onpark that provably can never be woken fails loudly with the typedBlockOnDeadlockpanic instead of hanging the JS event loop. - Threaded wasm (
wasm32-wasip1-threads):wasi.thread-spawngives std a realpthread_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 cargoTARGET, emittingnapi_runtime_wasi_threads(the threaded WASI target) andnapi_runtime_os_threads(native or the threaded WASI target — "this build can create OS threads"). The default flavor staysCurrentThreadon every wasm target: selectingMultiThreadthere 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.copyand 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: onwasm32-wasip1-threadsit callsnapi_sys::wasi_heap_sync::refresh_if_behindbefore every task poll and everyblock_onpoll, 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()answersOk(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 throughconfigure/configure_partialbefore the first async call. The crate never probes the CPU count on wasm. - Budget
worker_threads + 1threads. The firstsleep_untillazily spawns one non-Rayon timekeeper thread — one more Node Worker than the pool size suggests. - Route through the
try_*family.try_spawn,try_spawn_blockingandtry_block_on_dynreturn the pool-build failure (EAGAIN, no SAB, a Worker that never boots) as anErr. The infallible helpers panic instead, andpanic = "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 onmemory.atomic.wait32. A browser main thread may not do that — emnapi answersnapi_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_unwindaround task polls, closure drops and host callbacks) never catches anything there: a user panic takes the instance down instead of becoming aJoinError. That is true on threadless wasm too; MultiThread only widens the surface to every worker plus the timekeeper. shutdownjoins, 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 inpthread_joinfor 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. UseRuntimeOptions::park_deadlineif you need a park to fail loudly instead. The same applies before the join:shutdownfirst waits for every accepted blocking closure to return, andpark_deadlinedoes 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 throughspawninstead, where the await yields. This is the same contract native addons already live under (thread_cleanuprunsshutdownsynchronously 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_shutdownpublishes 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 userDropcode, 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 answerstruewhile the announced stop still owes a wait — backend-owned work that is still live, or such a rejected destruction.runtime_work_pendingre-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 answeringtrueinstead 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_shutdownis the call that waits, joins the workers and publishesStopped, so it is the one that owes the quiescence guarantee.shutdownis 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 callstart: a restart belongs afterfinish_shutdown. A secondbegin_shutdownbefore the matchingfinish_shutdownre-reports the poll and never waits. The cli's WASI loader drives the pair through napi'snapi_prepare_wasm_env_cleanup_begin/napi_wasm_runtime_work_pending/…_finishexports, 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 callfinish_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…_finishand 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_shutdownandfinish_shutdownwould 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 innapi-sys: a 4-byte word in the shared linear memory. napi'snapi_wasm_thread_crash_flag_addressexport gives the cli's loader its address; the loader hands each pool worker anInt32Arrayover it, and the worker raises it withAtomics.storewhen its wasm thread dies (napi'snapi_wasm_thread_crashedexport 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: aWorkerthat 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. Seesrc/sync.rsand the cli'sdocs/wasi.md. - The JavaScript hosts go inert, not away. The cli's
napi.wasm.asyncRuntimeloaders install a CurrentThread task host and a timer host unconditionally — they cannot know the flavor. MultiThread never publishes a host turn and never consults aTimerDriver, 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.