fix(napi): guard JsDeferred against env teardown - #2
Open
cuva wants to merge 2 commits into
Open
Conversation
cuva
force-pushed
the
fix/deferred-env-teardown
branch
3 times, most recently
from
July 17, 2026 11:42
812793b to
39aa250
Compare
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
force-pushed
the
fix/deferred-env-teardown
branch
from
July 17, 2026 11:50
39aa250 to
465773e
Compare
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
JsDeferredcarries a rawnapi_threadsafe_functionwith 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 fnfuture 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:
napi_resolve_deferreddereferenced it.execute_tokio_futurepanic-handler clone could settle a second time, calling into an already-released threadsafe function.Fix
DeferredHandleholding the threadsafe function asMutex<Option<...>>. A settletake()s it, moving the release duty into the queuedDeferredData; 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 observesNoneand drops its resolver instead of touching freed memory.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).napi_resolve_deferredreturns early on the null-env teardown invocation.Verification
Stress harness (macOS arm64, Node v22.22.2): spawn a worker that starts 32
createReferenceOnFunctioncalls (100 ms tokio sleep each,execute_tokio_future_with_finalize_callbackpath), 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.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/napisuite (230 tests).