Skip to content

napi: tolerate pending VM exception in napi_create_string_* (debug/asan abort) - #36091

Closed
robobun wants to merge 3 commits into
mainfrom
farm/e2821ddc/napi-create-string-pending-exception
Closed

napi: tolerate pending VM exception in napi_create_string_* (debug/asan abort)#36091
robobun wants to merge 3 commits into
mainfrom
farm/e2821ddc/napi-create-string-pending-exception

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Repro

Under debug/asan builds this 3-call sequence aborts:

napi_throw(env, err);                          // 0: E pending on env
napi_call_function(env, g, fn, 0, NULL, &r);   // 10: promotes E into vm.m_exception
napi_create_string_utf8(env, "x", 1, &out);    // node/release bun: 0; debug/asan: SIGABRT
ASSERTION FAILED: Unexpected exception observed ... ExceptionScope::releaseAssertNoException

Second route needs no addon throw: napi_create_bigint_words past the engine cap throws a RangeError into the VM and returns status 10, then any napi_create_string_* hits the same abort.

Cause

The Rust napi_create_string_{utf8,latin1,utf16} bodies called bun_core::String / BunString__* helpers through the generated crate::cpp wrappers. Those wrappers open an ExceptionValidationScope and, when the C++ call returns a non-zero JSValue, call assert_exception_presence_matches(false) which asserts no VM exception is pending. When vm.m_exception is already set (the scenario above), that assertion hits TopExceptionScope__assertNoException -> releaseAssertNoException -> abort. Release builds compile the validation scope out, so only asserts builds die.

Node.js does not gate napi_create_string_* behind NAPI_PREAMBLE; they return napi_ok and leave the pending exception untouched.

Fix

Add napi_internal_create_string_{latin1,utf8,utf16} in napi.cpp that allocate the JS string under a JSC::SuspendExceptionScope, which stashes vm.m_exception for the duration of the allocation and restores it on return. The Rust entry points call these directly instead of routing through the BunString__* validation-scope wrappers. The caller's exception is preserved and the string creators return napi_ok, matching Node.

Test

test_create_string_with_vm_exception in test/napi/napi-app/standalone_tests.cpp exercises both routes (napi_throw+napi_call_function, and napi_create_bigint_words over the cap) and all three encodings plus the non-ASCII UTF-8 branch, asserting status=0, pending_after_create=true, and that the original Error: E1 survives. Output matches Node byte-for-byte via checkSameOutput.

Fail-before (src/ stashed, bun bd test): aborts with releaseAssertNoException, exit 134.
After: all three creators return 0 on both routes, exception preserved.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/napi/napi.test.ts

Scope

The three string creators are the complete set of get_env! entry points in napi_body.rs whose C++ callee returns a non-zero JSValue while a VM exception is pending (the validation scope then asserts "no exception" and aborts). napi_create_array / napi_create_array_with_length also route through a validation-scope wrapper, but constructEmptyArray has RETURN_IF_EXCEPTION(scope, nullptr) before allocating, so they return napi_pending_exception instead of aborting. That is a separate Node-compat status divergence (Node returns napi_ok), not the abort class, and is left for a follow-up.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7bc56cbd-cbf6-4121-bec2-75fe771a7c10

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6f99 and 61421a9.

📒 Files selected for processing (4)
  • src/jsc/bindings/napi.cpp
  • src/runtime/napi/napi_body.rs
  • test/napi/napi-app/standalone_tests.cpp
  • test/napi/napi.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:37 AM PT - Jul 27th, 2026

@robobun, your commit 61421a9 has 1 failures in Build #83299 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36091

That installs a local version of the PR into your bun-36091 executable, so you can run:

bun-36091 --bun

Comment thread src/jsc/bindings/napi.cpp Outdated
Comment thread src/jsc/bindings/napi.cpp
Comment thread src/runtime/napi/napi_body.rs
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is green. The new test_create_string_with_vm_exception test passed on every lane.

CI failures on build 83299 are unrelated to this change:

  • test/js/bun/http/serve.test.ts ("releases a paused request body when the handler responds without reading it", darwin 14 x64 + darwin 26 aarch64): also red on the most recent main build #83238, same lanes, same test. Reported for triage.
  • test/cli/run/no-orphans.test.ts, test/regression/issue/20144/20144.test.ts: flaky, passed on retry.

Ready for review.

dylan-conway added a commit that referenced this pull request Aug 14, 2026
### Problem
- Regression from #37075: a `node:vm` `timeout` or `worker.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 with
`ASSERTION FAILED: !!(scope).exception() ==
vm.traps().needHandling(JSC::VMTraps::NeedExceptionHandling)`, or in
`napi_get_value_bigint_int64` with `ASSERTION FAILED: Unexpected
exception observed` from `NAPI_RETURN_SUCCESS`.
- Cause: #37075 rewrote `NAPI_PREAMBLE_NO_PENDING_CHECK`
(`src/jsc/bindings/napi.cpp`) as an unconditional
`JSC::SuspendExceptionScope`. That scope writes the `vm.exception` it
saw on entry back when it exits. On entry nothing is pending; an
exception check inside the body services the pending trap and raises the
`TerminationException`; on exit the scope puts `null` back. The trap was
consumed, so nothing raises it again. Of the 38 C++ users of the macro,
the bodies with such a check are `napi_get_value_string_*`,
`napi_get_value_bigint_int64/uint64`, `napi_create_bigint_int64/uint64`
and `napi_create_symbol` with a description.
- Two related gaps in the same set of functions, fixed along the way:
before #37075 (and in the first version of this PR) the C++ macro
checked for exceptions on entry, so a waiting termination made the
ungated call itself fail with `napi_pending_exception`; and the ungated
functions implemented in `src/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_array` delivered a termination itself and failed, and the
ones using `JsResult` helpers failed (release) or asserted (debug) with
any exception pending on the VM. Node's `CHECK_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 a
`vm` timeout 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
a `JSC::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's `RETURN_IF_EXCEPTION` in
`NapiClass`, 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_CHECK` declares one; the 26 Rust functions
construct the same C++ object in place through
`NapiUngatedScope__construct/__destruct` from an `ungated!` macro that
replaces `get_env!` (80 bytes of 8-aligned storage, checked by a
`static_assert`; a guard destroys it on every return path). This also
covers what #36091 and #36093 were addressing separately
(`napi_create_string_*` and the `*_info` accessors with a VM exception
pending).
- Consequence of (b): no JS or addon code may run under the scope. The
zero-length path of `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_int64` checks for an exception after its
conversion like the `uint64` variant already did.
- Tests, in the `pending-exception gate` block of
`test/napi/napi.test.ts` (addon side in
`test/napi/napi-app/standalone_tests.cpp`, drivers in `module.js` and
`ungated-calls-spin-worker.js`), all compared against Node's output;
each round of ungated calls covers both the C++ and the Rust half:
- a `node:vm` `timeout` on a script looping through the ungated
functions: hangs without the fix (10/10 runs on a release build),
asserts on a debug build
  - `worker.terminate()` of a worker doing the same: same failure modes
- the ungated functions succeed with an exception pending on the VM and
leave it pending
- 200ms of ungated calls under a 20ms timeout with an exception pending:
no call reports the timeout, the pending exception is still the original
one afterwards, and the script still times out on return
- Verified on a debug+ASAN build at 12d4d5c: the four tests pass, the
rest of `test/napi/napi.test.ts` is unchanged, and the upstream
`test_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_terminate` suites
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 under `vm` timeouts, which
aborted on every run of 20 with the entry check, survives 60/60 as on
Node.


Supersedes #36091 and #36093 (per-function `SuspendExceptionScope`
wrappers for `napi_create_string_*` and the `*_info` accessors); both
are covered by `ungated!` here.

### Background
- Ungated functions: Node implements pure value constructors/accessors
(`napi_create_object`, `napi_get_cb_info`, `napi_get_value_*`,
references, ...) with `CHECK_ENV` only, 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
between `napi.cpp` (`NAPI_PREAMBLE_NO_PENDING_CHECK`) and `napi_body.rs`
(`ungated!`); `NAPI_PREAMBLE` / `preamble!` are the gated versions that
refuse while an exception is pending.
- VM traps: JSC delivers asynchronous requests (a watchdog timeout,
which `node:vm`'s `timeout` uses, or a termination request, which
`worker.terminate()` uses) by setting a trap bit. The next exception
check that services traps (`RETURN_IF_EXCEPTION`, which most JSC entry
points and Bun's `NAPI_RETURN_IF_VM_EXCEPTION` expand to) consumes the
bit and throws the `TerminationException`, which is uncatchable from JS
and is what unwinds the running script. Once thrown, it is the only
record of the request.
- `JSC::DeferTraps` makes 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::SuspendExceptionScope`
clears `vm.exception` on construction and writes the saved value back
unconditionally on destruction.

<details>
<summary>Earlier shape of this PR (3b36256 to 63653e1)</summary>

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 `DeferTraps` form (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.

</details>

---------

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
@dylan-conway

Copy link
Copy Markdown
Member

Superseded by #38300 (merged as 54f0271): every ungated N-API function, including the Rust-side napi_create_string_*, now runs under NapiUngatedScope, which stashes a pending VM exception for the call (only when there is one) and defers trap delivery, so the per-function SuspendExceptionScope wrapper here is no longer needed. The pending-exception gate tests there call napi_create_string_utf8 with an engine exception pending.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Confirmed: on main at 54f0271 the Rust napi_create_string_{utf8,latin1,utf16} entry points run under ungated! / NapiUngatedScope, which handles the pending VM exception case generically. This per-function fix is no longer needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants