napi: don't deliver terminations inside ungated functions - #38300
Conversation
NAPI_PREAMBLE_NO_PENDING_CHECK wrapped every ungated function in an unconditional JSC::SuspendExceptionScope, which restores the exception state the function was entered with. When a node:vm timeout or worker.terminate() was delivered by an exception check inside the body (napi_get_value_string_*, napi_get_value_bigint_*, napi_create_bigint_*, napi_create_symbol), the TerminationException it produced was replaced by the entry state on return. The request behind it is only delivered once, so the calling script ran forever; debug builds tripped the exception/trap-bit consistency assertion instead. Only suspend when an exception really is pending on entry, check for exceptions (servicing traps) on entry otherwise, as the macro did before, and when a suspended exception is put back, re-raise a termination the body raised on top of it. napi_get_value_bigint_int64 checks after its conversion like the uint64 variant already did, since that conversion's own exception check can deliver a termination.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 4 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
|
Status: approved by @dylan-conway at 12d4d5c. The fix is the Reproduced on a release build that includes #37075 (1.4.0-canary da3851e) with a raw addon calling Verified at 12d4d5c on a debug+ASAN build: the four |
|
Updated 6:11 PM PT - Aug 13th, 2026
@dylan-conway, your commit 5b0b5dc is building: |
The addon prints through printf(), which the Windows CRT writes as \r\n, so split on either line ending as the other printf-driven tests in the file do. The Bun-only fixture also flushes stdout before returning: the driver prints the vm error afterwards, and without BlockingStdoutScope (POSIX only) a piped stdout would otherwise be flushed at exit, after it.
…nations
Rather than checking for (and thereby delivering) a pending termination on
entry and re-raising one the body delivered, keep the ungated bodies from
servicing VM traps at all with JSC::DeferTraps. A worker.terminate() or
node:vm timeout that is requested before or during one of these calls stays
a request and is delivered by the next exception check after the call
returns, so the call itself never reports napi_pending_exception for it --
which is Node's contract for its CHECK_ENV-only functions and what
node-addon-api relies on (it aborts the process when one of them fails).
The entry exception is still stashed and restored, now with a plain
std::optional<SuspendExceptionScope> since nothing raised inside needs to
survive the restore any more.
The zero-length path of node_api_create_external_string_{latin1,utf16} ran
the addon's finalizer under this preamble; that now happens after the
preamble's scopes have closed, and the two functions share one body.
Tests: the Bun-only "termination wins over the stashed exception" case
becomes a same-output-as-Node test (no ungated call reports the timeout,
the stashed exception is still the pending one, the script still times
out); printf output is split on \r?\n and flushed for Windows.
No-Verification-Needed: comment-only change
No-Verification-Needed: comment-only change
|
Ran the branch as of 04a818e (5b0b5dc is a comment change) on a debug+ASAN build:
PR description updated to describe the |
|
Build 95257 (5b0b5dc) is down to its last few jobs with nothing red. The only |
There was a problem hiding this comment.
I reviewed this revision and found no bugs (the earlier CRLF issue is fixed). Because the change reworks NAPI_PREAMBLE_NO_PENDING_CHECK — used by 38 call sites — around JSC's DeferTraps/SuspendExceptionScope interaction with TerminationException, and makes "no JS/addon code under this macro" a load-bearing invariant for all of them, a human look would still be worthwhile.
What was reviewed:
- Macro destruction order: throw scope →
DeferTraps→ optionalSuspendExceptionScope; a stashed entry exception is restored last, and trap bits stay set for the caller's next check. createExternalStringrefactor: template deduction (std::char_traits<char>::length≡strlen),disposeNowonly set afterenv/resultare validated, finalizer runs after the preamble scopes close.napi_get_value_bigint_int64now matches theuint64variant's post-conversion check.- Tests: all four compare against Node via
checkSameOutput, printf-driven output split on/\r?\n/, worker test has a 30 s timeout for debug builds.
Extended reasoning...
Overview
The PR fixes a regression from #37075 where a node:vm timeout or worker.terminate() request that lands while an addon is inside an ungated N-API function is silently lost (release builds hang forever; debug builds assert). The fix rewrites NAPI_PREAMBLE_NO_PENDING_CHECK in src/jsc/bindings/napi.cpp to (a) only construct SuspendExceptionScope when an exception is actually pending, so its unconditional restore-on-destruct doesn't null out a termination the body raised, and (b) hold JSC::DeferTraps for the body so no exception check inside it services a VM trap. Because DeferTraps means addon/JS code must not run under the macro, the two node_api_create_external_string_* functions (whose zero-length path ran the addon's finalizer under the preamble) are refactored into a shared createExternalString<ExternalChar, Char> template that signals disposeNow back to the wrapper, which runs the finalizer after the preamble scopes have closed. napi_get_value_bigint_int64 gains the same post-conversion NAPI_RETURN_IF_VM_EXCEPTION the uint64 variant already had. Four new tests in test/napi/napi.test.ts (drivers in module.js, ungated-calls-spin-worker.js, addon side in standalone_tests.cpp) exercise vm-timeout, worker-terminate, engine-exception-pending, and timeout-during-ungated-calls, all compared against Node's output.
Security risks
None identified. This is exception/termination-delivery plumbing; no untrusted input parsing, no auth/crypto/permissions surface. The createExternalString template preserves the existing length <= INT_MAX and null-pointer checks unchanged.
Level of scrutiny
High. NAPI_PREAMBLE_NO_PENDING_CHECK is expanded at 38 sites, and the new form makes "no JS or addon code may run under it" load-bearing for every one of them (a violation would run user code with VM traps deferred). The PR audited and fixed the two known violators (external-string finalizers), but confirming no other user of the macro can reach addon code — and that the DeferTraps / conditional-SuspendExceptionScope / throw-scope destruction ordering is sound across all JSC exception-check configurations — is exactly the kind of JSC-internals judgment a maintainer should sign off on. This is C++ in src/jsc/bindings/ interacting with JSC's termination machinery, which REVIEW.md flags as the most-blocked category.
Other factors
- My earlier inline comment (Windows CRLF in the printf-driven test) was addressed in 256b0fe; both printf tests now split on
/\r?\n/. - The comment-cop feedback was addressed across several commits (04a818e, 5b0b5dc); the author explicitly justified keeping the four-line macro comment as documenting invariants the code cannot show.
- The PR description is unusually thorough, the fix was verified on debug+ASAN, and the tests are compared against Node, so the risk of a silent behavior regression is low. The remaining question is whether the DeferTraps approach is the right long-term shape for these 38 call sites, which is a design call for a human.
|
On the "no JS or addon code under the macro" invariant, for whoever merges: I went through the remaining users of
|
The value constructors/accessors implemented in napi_body.rs (napi_create_array, napi_create_string_*, napi_create_int32/uint32/int64, napi_get_undefined/null/ boolean, napi_is_*, napi_get_*_info, the handle-scope functions) had the same two problems as the C++ ones: napi_create_array delivered a pending worker.terminate() / node:vm timeout itself (constructEmptyArray checks for exceptions) and then failed, and the ones that go through JsResult helpers failed (release) or asserted (debug) whenever any exception was pending on the VM. The C++ preamble's optional SuspendExceptionScope + DeferTraps pair becomes a named NapiUngatedScope; napi_body.rs constructs it in place through two FFI calls from an `ungated!` macro that replaces `get_env!` in those 26 functions. The ungated-call fixtures now also make an array, a string, an int32 and an is_array check per round, so all four tests cover both halves.
No-Verification-Needed: comment-only change
|
Went over dbc32b6 / 12d4d5c and ran it on a debug+ASAN build:
|
Problem
node:vmtimeoutorworker.terminate()is lost when it lands while an addon is inside one of the ungated N-API functions. Release builds keep running the script forever (vm.runInNewContext(...)never returns,await worker.terminate()never resolves); debug builds abort on the next exception check withASSERTION FAILED: !!(scope).exception() == vm.traps().needHandling(JSC::VMTraps::NeedExceptionHandling), or innapi_get_value_bigint_int64withASSERTION FAILED: Unexpected exception observedfromNAPI_RETURN_SUCCESS.NAPI_PREAMBLE_NO_PENDING_CHECK(src/jsc/bindings/napi.cpp) as an unconditionalJSC::SuspendExceptionScope. That scope writes thevm.exceptionit saw on entry back when it exits. On entry nothing is pending; an exception check inside the body services the pending trap and raises theTerminationException; on exit the scope putsnullback. The trap was consumed, so nothing raises it again. Of the 38 C++ users of the macro, the bodies with such a check arenapi_get_value_string_*,napi_get_value_bigint_int64/uint64,napi_create_bigint_int64/uint64andnapi_create_symbolwith a description.napi_pending_exception; and the ungated functions implemented insrc/runtime/napi/napi_body.rs(napi_create_array*,napi_create_string_*,napi_create_int32/uint32/int64,napi_get_undefined/null/boolean,napi_is_*,napi_get_*_info, handle scopes) had no handling at all:napi_create_arraydelivered a termination itself and failed, and the ones usingJsResulthelpers failed (release) or asserted (debug) with any exception pending on the VM. Node'sCHECK_ENV-only functions never fail for either reason, and node-addon-api aborts the process (Error::ThrowAsJavaScriptException napi_throw) when one of them does; a node-addon-api callback making only ungated calls hit that under avmtimeout on Bun and not on Node.Fix
NapiUngatedScope(napi.cpp) is what every ungated function now runs under: (a) it stashes the entry exception only when one is actually pending (std::optional<JSC::SuspendExceptionScope>), so a clean VM is left alone and whatever the body raises stays pending, and (b) it holds aJSC::DeferTraps, so no exception check inside the body (its own or in the JSC helpers it calls) services VM traps. A termination requested before or during the call stays a request and is delivered by the next check after the call returns (the caller'sRETURN_IF_EXCEPTIONinNapiClass, a JS loop check, or a gated N-API call). Nothing is lost, and the ungated calls never report a termination, as in Node.NAPI_PREAMBLE_NO_PENDING_CHECKdeclares one; the 26 Rust functions construct the same C++ object in place throughNapiUngatedScope__construct/__destructfrom anungated!macro that replacesget_env!(80 bytes of 8-aligned storage, checked by astatic_assert; a guard destroys it on every return path). This also covers what napi: tolerate pending VM exception in napi_create_string_* (debug/asan abort) #36091 and napi: tolerate pending VM exception in ArrayBuffer/typed-array info accessors (debug/asan abort) #36093 were addressing separately (napi_create_string_*and the*_infoaccessors with a VM exception pending).node_api_create_external_string_{latin1,utf16}ran the addon's finalizer there; the two functions now share one body (createExternalString) and run the finalizer after its scopes have closed. The other users only read, allocate, or register callbacks.napi_get_value_bigint_int64checks for an exception after its conversion like theuint64variant already did.pending-exception gateblock oftest/napi/napi.test.ts(addon side intest/napi/napi-app/standalone_tests.cpp, drivers inmodule.jsandungated-calls-spin-worker.js), all compared against Node's output; each round of ungated calls covers both the C++ and the Rust half:node:vmtimeouton a script looping through the ungated functions: hangs without the fix (10/10 runs on a release build), asserts on a debug buildworker.terminate()of a worker doing the same: same failure modestest/napi/napi.test.tsis unchanged, and the upstreamtest_string,test_array,test_typedarray,test_dataview,test_handle_scope,test_promise,test_date,test_error,test_exception,test_number,test_conversions,2_function_arguments,test_buffer,test_worker_terminatesuites pass (a few GC-heavy files exceed the 5s harness timeout under a local debug build and pass when run directly; same without this change). A node-addon-api wrapped function looping undervmtimeouts, which aborted on every run of 20 with the entry check, survives 60/60 as on Node.Supersedes #36091 and #36093 (per-function
SuspendExceptionScopewrappers fornapi_create_string_*and the*_infoaccessors); both are covered byungated!here.Background
napi_create_object,napi_get_cb_info,napi_get_value_*, references, ...) withCHECK_ENVonly, so an addon may call them while an exception is pending, and they never fail because execution is being terminated. node-addon-api relies on both. In Bun these are split betweennapi.cpp(NAPI_PREAMBLE_NO_PENDING_CHECK) andnapi_body.rs(ungated!);NAPI_PREAMBLE/preamble!are the gated versions that refuse while an exception is pending.node:vm'stimeoutuses, or a termination request, whichworker.terminate()uses) by setting a trap bit. The next exception check that services traps (RETURN_IF_EXCEPTION, which most JSC entry points and Bun'sNAPI_RETURN_IF_VM_EXCEPTIONexpand to) consumes the bit and throws theTerminationException, which is uncatchable from JS and is what unwinds the running script. Once thrown, it is the only record of the request.JSC::DeferTrapsmakes trap servicing a no-op for its scope: checks inside it see no exception and the bits stay set, so the first check after the scope delivers the request.JSC::SuspendExceptionScopeclearsvm.exceptionon construction and writes the saved value back unconditionally on destruction.Earlier shape of this PR (3b36256 to 63653e1)
The first version kept the pre-#37075 entry check in the C++ macro (so a waiting termination was delivered by, and reported from, the ungated call) and wrapped the conditional suspend in a small scope object whose destructor re-raised a termination the body had delivered after putting the entry exception back. That fixed the hang but kept the Bun-only node-addon-api failure mode described under Problem, so it was replaced by the
DeferTrapsform (f599d79), which dbc32b6 then extended to the Rust-side functions. The Bun-only test that pinned the re-raise became the same-output "200ms of ungated calls under a 20ms timeout" test.