Skip to content

fix(napi): guard JsDeferred against env teardown - #2

Open
cuva wants to merge 2 commits into
mainfrom
fix/deferred-env-teardown
Open

fix(napi): guard JsDeferred against env teardown#2
cuva wants to merge 2 commits into
mainfrom
fix/deferred-env-teardown

Conversation

@cuva

@cuva cuva commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

JsDeferred carries a raw napi_threadsafe_function with no finalize callback and no teardown awareness: resolve()/reject() call it from arbitrary threads. When a deferred settles after (or while) its environment tears down — e.g. a #[napi] async fn future resolving after its worker thread terminated — it calls straight into a freed threadsafe function and the process dies with SIGABRT (abort ← uv_mutex_lock ← v8impl::ThreadSafeFunction::Push ← napi_call_threadsafe_function ← JsDeferred::resolve).

This is upstream napi-rs#2460.

Two adjacent defects in the same path are also covered:

  • Node invokes leftover queue items with a null env while a threadsafe function closes during env teardown; napi_resolve_deferred dereferenced it.
  • The execute_tokio_future panic-handler clone could settle a second time, calling into an already-released threadsafe function.

Fix

  • The deferred (and its clones) share a DeferredHandle holding the threadsafe function as Mutex<Option<...>>. A settle take()s it, moving the release duty into the queued DeferredData; an env cleanup hook — registered after the threadsafe function's own teardown hook, so LIFO order runs ours first — abort-releases it before Node finalizes it. Whichever locks first wins; the loser observes None and drops its resolver instead of touching freed memory.
  • The lock is held across the napi_call_threadsafe_function / abort-release calls, so env teardown cannot finalize the threadsafe function while a settle on a foreign thread is inside it (an atomic flag alone cannot close that window).
  • The threadsafe function's finalize callback unregisters the cleanup hook and frees the shared weak, so per-deferred bookkeeping is reclaimed as soon as the promise settles — nothing accumulates.
  • napi_resolve_deferred returns early on the null-env teardown invocation.

Verification

Stress harness (macOS arm64, Node v22.22.2): spawn a worker that starts 32 createReferenceOnFunction calls (100 ms tokio sleep each, execute_tokio_future_with_finalize_callback path), terminate the worker while they are in flight, keep the addon alive in the main env so the shared tokio runtime survives and the futures resolve into the torn-down worker env.

build result
base (unfixed) 8/8 batches SIGABRT, faulting stack identical to the production crash
this branch 0/12 batches crashed (3,072 resolve-after-teardown events)

Memory churn (hook/box reclamation): 600k settled deferreds in one process — RSS 52 → 107 MB during warmup of the first 200k, then flat (111 → 111 MB) across the following 400k.

Also passing: cargo check (default / tokio_rt,deferred_trace,napi9,compat-mode / noop), clippy, and the @examples/napi suite (230 tests).

@cuva
cuva force-pushed the fix/deferred-env-teardown branch 3 times, most recently from 812793b to 39aa250 Compare July 17, 2026 11:42
JsDeferred carried a raw napi_threadsafe_function with no finalize
callback and no teardown awareness: resolve()/reject() called it from
arbitrary threads, so a deferred settled after (or while) its
environment tore down called straight into a freed threadsafe function
and aborted the process (uv_mutex_lock on a destroyed mutex). This is
the crash behind napi-rs#2460: a napi future resolving after its worker thread
terminated. Node also invokes leftover queue items with a null env
while a threadsafe function closes during teardown, which
napi_resolve_deferred dereferenced.

Move the threadsafe function into a shared handle holding it as
Mutex<Option<...>>, giving it linear ownership: a settle takes it
(moving the release duty into the queued DeferredData), an env cleanup
hook (registered after the threadsafe function, so it runs first in
the LIFO order) abort-releases it before Node finalizes it — whichever
locks first; the loser observes None and drops its resolver instead of
touching freed memory. The lock is held across the calls so teardown
cannot finalize the threadsafe function under an in-flight settle. The
take-once semantics also make the second settle from the
execute_tokio_future panic-handler clone a no-op instead of a second
call into a released threadsafe function. The finalize callback
unregisters the hook and frees the shared weak exactly once, and
napi_resolve_deferred now tolerates the null-env teardown invocation.

Fixes napi-rs#2460

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cuva
cuva force-pushed the fix/deferred-env-teardown branch from 39aa250 to 465773e Compare July 17, 2026 11:50
…consumed it

During an env teardown with an unsettled deferred, Node's cleanup drain
consumes the deferred's teardown hook before the threadsafe function's
finalize callback runs; unregistering the already-consumed pair from the
finalizer violates the Node-API contract (documented as aborting the
process; Bun <= 1.2.18 panics). Track the hook's registration in the
shared hook data so the finalizer only removes a hook that is still
registered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant