Repository navigation
napi: enforce the pending-exception gate in NAPI_PREAMBLE - #34137
Conversation
napi_throw* stores the exception in env->m_pendingException via scheduleException() without raising a JSC VM exception. NAPI_PREAMBLE only consulted the VM throw scope, so every napi call that used it proceeded to execute with a napi exception pending: napi_run_script ran the script, napi_object_freeze froze, napi_set_element mutated, napi_resolve_deferred resolved. The tail-only NAPI_RETURN_IF_EXCEPTION then reported status 10, so some calls showed a Node-matching status after already performing the side effect. Node's NAPI_PREAMBLE checks env->last_exception.IsEmpty() (the env slot, not the engine slot) up front and refuses with napi_pending_exception before doing anything. - NAPI_PREAMBLE now uses NAPI_RETURN_IF_EXCEPTION, which checks both the VM scope and env->hasPendingException(). This covers ~60 C++ entry points including object_freeze/seal, set_element, wrap, get_date_value. - napi_run_script, napi_instanceof, napi_create_dataview, napi_create_bigint_words and node_api_create_buffer_from_arraybuffer (which use NO_THROW_SCOPE) now check at the top of their own scope. - Add a NAPI_PREAMBLE_NO_PENDING_CHECK variant for pure value constructors/accessors that Node.js does not gate (create_object/double, get_value_int32/uint32/int64/double/bool/ string_*) so they keep succeeding with a pending exception, matching Node and the existing test_deferred_exceptions coverage. - Remove the two per-site hasPendingException workarounds in create_external_buffer/arraybuffer that the preamble now covers. - Rust side: add a preamble! macro that returns napi_pending_exception when NapiEnv__hasPendingException reports one, and use it for the entry points Node gates with NAPI_PREAMBLE: get_prototype, get_array_length, strict_equals, make_callback, create_promise, resolve_deferred, reject_deferred, create_date, create_buffer_copy.
|
Reproduced: PR: #34137 Review iterations: applied CI: the new
Ready for review. |
|
Updated 7:10 AM PT - Jul 14th, 2026
❌ @robobun, your commit c1b4b26 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 34137That installs a local version of the PR into your bun-34137 --bun |
WalkthroughChangesBun’s N-API bindings now detect pending VM or environment exceptions, gate selected Rust entry points, adjust C++ exception checkpoints and preambles, and add regression tests covering returned statuses and suppressed side effects. N-API pending exception behavior
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/bindings/napi.cpp`:
- Around line 2861-2863: Update the hasInstance flow near NAPI_CHECK_ARG to
store constructorObject->hasInstance(...)’s return value, check scope for
exceptions immediately afterward, and only then assign *result and return
napi_ok. Preserve the existing result mapping while ensuring thrown
Symbol.hasInstance exceptions are propagated instead of reporting success.
In `@src/runtime/napi/napi_body.rs`:
- Line 895: After the `getPrototypeOf` call in the surrounding N-API
implementation, immediately check `env.has_pending_exception()` before storing
or using the returned prototype and before returning `napi_ok`; propagate the
pending exception instead. Keep the existing `preamble!(env_)` handling
unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: c57e5273-03f7-4272-a6e8-60b8afad59ac
📒 Files selected for processing (4)
src/jsc/bindings/napi.cppsrc/runtime/napi/napi_body.rstest/napi/napi-app/standalone_tests.cpptest/napi/napi.test.ts
Node.js does not gate these with NAPI_PREAMBLE (only CHECK_ENV / CHECK_ENV_NOT_IN_GC), so they must keep returning napi_ok while a napi exception is pending. The previous commit's NAPI_PREAMBLE change would have made them return napi_pending_exception, breaking error-path cleanup (e.g. napi_reference_unref after napi_throw_error would leak the ref). Applies NAPI_PREAMBLE_NO_PENDING_CHECK to: get_cb_info, create_reference, add_finalizer, node_api_post_finalizer, reference_ref/unref, get_reference_value, is_detached_arraybuffer, detach_arraybuffer, adjust_external_memory, node_api_symbol_for, node_api_get_module_file_name, get_global, get_new_target, is_buffer, is_typedarray, get_value_bigint_int64/uint64/words, get_value_external, get/set_instance_data, create_bigint_int64/uint64, create_symbol, add/remove_env_cleanup_hook. Mid-body checks in create_bigint_int64/uint64 and create_symbol switch to NAPI_RETURN_IF_VM_EXCEPTION so a pre-existing env exception does not mask success. Extends test_pending_exception_gate to assert status=0 for nine representative non-gated functions via checkSameOutput against Node.
Four more CHECK_ENV-only entry points were still on the gating NAPI_PREAMBLE path: node_api_create_external_string_latin1/utf16 (Node's v8impl::NewString uses CHECK_ENV_NOT_IN_GC) and napi_add/remove_async_cleanup_hook (Node uses CHECK_ENV / no preamble). All four take node_api_basic_env in Node's headers, so they must be callable with a pending exception. The sync siblings napi_add/remove_env_cleanup_hook were already converted in the previous commit. Also fix the remaining mid-body NAPI_RETURN_IF_EXCEPTION in napi_get_value_bigint_uint64 (the int64 sibling has no such check), matching the treatment given to create_bigint_int64/uint64 and create_symbol. Extends test_pending_exception_gate to assert status=0 for get_value_bigint_uint64 and add/remove_async_cleanup_hook under a pending exception. Every remaining NAPI_PREAMBLE(env) caller has been cross-checked against Node.js js_native_api_v8.cc / node_api.cc.
There was a problem hiding this comment.
I didn't find further issues in this pass, but this change flips the semantics of NAPI_PREAMBLE (affecting every C++ NAPI entry point) and re-classifies ~40 functions across C++ and Rust based on a manual audit against Node's js_native_api_v8.cc/node_api.cc — worth a human sign-off on the audit's completeness given it took three rounds to converge.
What was reviewed:
- Re-checked the df04551 additions (
node_api_create_external_string_*,napi_add/remove_async_cleanup_hook,napi_get_value_bigint_uint64mid-body swap) — all now match Node'sCHECK_ENVbehavior. - Confirmed the removed per-site
hasPendingException()guards innapi_create_external_buffer/arraybufferare subsumed by the newNAPI_PREAMBLEgate (no ordering regression vs. data adoption). - Verified 38 remaining
NAPI_PREAMBLE(env)callers matches the author's stated audit count; spot-checked that the Rustpreamble!macro correctly wrapsget_env!and thatNapiEnv__hasPendingExceptionchecks both env slot and VM scope.
Extended reasoning...
Overview
This PR changes NAPI_PREAMBLE in src/jsc/bindings/napi.cpp from checking only the JSC VM exception slot to also checking env->hasPendingException() (the slot populated by napi_throw*). To avoid over-gating, it introduces NAPI_PREAMBLE_NO_PENDING_CHECK and applies it to ~35 C++ entry points that Node.js implements with CHECK_ENV/CHECK_ENV_NOT_IN_GC rather than NAPI_PREAMBLE. It also adds a Rust-side preamble! macro (backed by a new NapiEnv__hasPendingException FFI export) applied to 9 Rust-implemented entry points, removes two now-redundant per-site guards, and reorders throw-scope declarations in 5 NAPI_PREAMBLE_NO_THROW_SCOPE functions to gate before doing work. A comprehensive checkSameOutput test diffs 26 entry points against Node under a pending exception.
Security risks
None identified. This is a Node.js compatibility fix in the NAPI layer; no auth, crypto, or untrusted-input parsing is touched. The change makes Bun more restrictive (refusing side-effecting calls while an exception is pending), which if anything reduces attack surface.
Level of scrutiny
High. The correctness of this PR hinges entirely on whether every one of ~75 NAPI entry points in napi.cpp and napi_body.rs was correctly classified as gating-vs-non-gating by cross-referencing Node.js source. My earlier review passes found the classification incomplete three separate times (27 functions in ddc4b2b, then 4 more + 1 mid-body check in df04551), each of which would have been a Node-compat regression. The author now states a systematic audit of all 38 remaining NAPI_PREAMBLE(env) callers was done, and this pass found nothing further — but the audit is against an external moving target (Node's js_native_api_v8.cc) that I cannot independently verify from this repo, and a single misclassification silently breaks addons in either direction (over-gating leaks refs/buffers; under-gating performs side effects Node refuses).
Other factors
- The test is strong:
checkSameOutputbyte-diffs against real Node for 14 gated + 12 non-gated functions plus 3 side-effect probes, so the covered set is locked. But ~35 modified entry points are not exercised by the test. - Two CodeRabbit findings (missing tail exception checks after
hasInstance/getPrototypeOf) were correctly deferred to in-flight PRs #34129/#34133 as pre-existing and out of scope. - The
napi_throw_*-while-pending overwrite (my earlier 🟡) was correctly deferred to #32834. - New FFI surface (
NapiEnv__hasPendingException) is minimal and read-only.
Given the breadth (every NAPI entry point's gating contract), the iterative fix history, and the reliance on a manual external-source audit, this should get a human look from someone who can independently spot-check the Node.js classification before merge.
|
Fix conflicts |
…plicates napi_instanceof: take #34129's version (uses NAPI_PREAMBLE, which now gates on hasPendingException via this branch's change). NAPI_CHECK_TO_OBJECT: drop the per-macro hasPendingException check #34133 added (all callers use NAPI_PREAMBLE, which now covers it). Remove duplicate NapiEnv__hasPendingException export and NapiEnv::has_pending_exception method that both branches added independently; keep this branch's version which also checks the VM scope. napi_get_prototype: drop #34133's inline has_pending_exception check since preamble! already covers it.
|
Merged main (c1b4b26). Conflict resolution: took #34129's
|
There was a problem hiding this comment.
No new issues found. My earlier findings (over-gating of CHECK_ENV-only entry points, the four missed in the second pass, and the mid-body check in napi_get_value_bigint_uint64) are all addressed in df04551, and the c1b4b26 merge resolution checks out — every NAPI_CHECK_TO_OBJECT caller uses the gating NAPI_PREAMBLE, and has_pending_exception() has no other Rust callers affected by the widened FFI semantics. Given the scope (~40 entry points audited against Node's js_native_api_v8.cc/node_api.cc across two languages) this still warrants a human pass.
Checked this run: the merge's removal of the per-macro hasPendingException() guard from NAPI_CHECK_TO_OBJECT — all 14 call sites open with NAPI_PREAMBLE(env); the NapiEnv__hasPendingException dedup — only consumed by preamble!, so widening it to also consult the VM slot matches Node's NAPI_PREAMBLE and doesn't affect other callers.
Extended reasoning...
Overview
This PR changes the semantics of NAPI_PREAMBLE in src/jsc/bindings/napi.cpp to gate on env->hasPendingException() (the slot napi_throw* populates), adds a NAPI_PREAMBLE_NO_PENDING_CHECK variant applied to ~31 entry points that Node.js implements with CHECK_ENV/CHECK_ENV_NOT_IN_GC, adds explicit entry gates to 5 NAPI_PREAMBLE_NO_THROW_SCOPE users, and mirrors the gate on the Rust side via a new preamble! macro applied to 9 entry points in src/runtime/napi/napi_body.rs. It also removes two now-redundant per-site hasPendingException() workarounds and, after merging main, drops the redundant guard from NAPI_CHECK_TO_OBJECT and de-duplicates the NapiEnv__hasPendingException FFI export. Test coverage is a new test_pending_exception_gate addon function that exercises 14 gated + 12 non-gated entry points under a pending exception and asserts side-effect absence, all diffed byte-for-byte against Node via checkSameOutput.
Security risks
None. This tightens behavior to refuse operations while an exception is pending, matching Node.js. No auth, crypto, or untrusted-input parsing paths are involved.
Level of scrutiny
High. NAPI is the compatibility surface for the entire native-addon ecosystem, and the correctness of this change depends on a manual audit of every entry point against Node's js_native_api_v8.cc/node_api.cc to decide gating vs. non-gating. My earlier reviews found real regressions in that audit twice (over-gating ~27 functions, then 4 more), which the author fixed. A third audit miss would silently break addon error-handling paths, so a human should spot-check the final NAPI_PREAMBLE vs NAPI_PREAMBLE_NO_PENDING_CHECK partition against Node.
Other factors
- The PR went through three correction rounds; each found genuine issues, all now addressed and covered by the extended test.
- The c1b4b26 merge with main touched semantics (dropped a guard from
NAPI_CHECK_TO_OBJECT, widened the FFI helper to also check the VM slot, took #34129'snapi_instanceof). I verified all 14NAPI_CHECK_TO_OBJECTcallers use the gating preamble and the widenedhas_pending_exception()has no other Rust callers, so both changes are sound. - Two related concerns (
napi_throw_*overwrite,hasInstance/getPrototypeOftail checks) are intentionally deferred to #32834/#34129/#34133 to avoid conflicts; that scoping is reasonable. - Test coverage is strong:
checkSameOutputlocks both directions (status=10 for gated, status=0 for non-gated) plus side-effect assertions against Node.
What
With a napi exception pending (via
napi_throw_error), Bun executed napi calls that Node.js refuses:napi_run_scriptran the script,napi_object_freeze/napi_set_elementmutated,napi_resolve_deferredresolved, and a further 10 entry points returnednapi_ok. Node returnsnapi_pending_exceptionand performs zero side effects for every one of them.The worst face:
napi_object_freeze/napi_set_elementreturnednapi_pending_exceptionlike Node but only after performing the mutation, so a status-checking addon sees a matching code and a silently-diverged world.Repro
Cause
napi_throw*stores the exception viaenv->scheduleException()intom_pendingException(aStrong<>) without throwing to the JSC VM.NAPI_PREAMBLE(napi.cpp:88) declared a throw scope and checked only the VM exception slot, so the stashed exception never tripped the gate. The tailNAPI_RETURN_SUCCESS_UNLESS_EXCEPTIONdid consulthasPendingException(), producing the "correct status, mutation already done" rows. Node'sNAPI_PREAMBLEchecksenv->last_exception.IsEmpty()(the env slot) up front.The Rust-implemented entry points (
napi_get_prototype,napi_strict_equals,napi_create_promise, ...) had no preamble gate at all.Fix
NAPI_PREAMBLEnow callsNAPI_RETURN_IF_EXCEPTION, which checks both the VM scope andenv->hasPendingException(). Covers every C++ entry point Node.js gates with its ownNAPI_PREAMBLE(object_freeze/seal,set/get/has/delete_element, property access,wrap/unwrap,get_date_value,coerce_to_*,define_class,new_instance,create_external, ...).napi_run_script,napi_instanceof,napi_create_dataview,napi_create_bigint_words,node_api_create_buffer_from_arraybuffer(which open their own throw scope viaNAPI_PREAMBLE_NO_THROW_SCOPE) now checkhasPendingException()at the top of that scope.NAPI_PREAMBLE_NO_PENDING_CHECKfor the entry points Node.js does not gate (CHECK_ENV/CHECK_ENV_NOT_IN_GC), so they keep succeeding with a pending exception:get_cb_info,get_global,get_new_target,create_reference,reference_ref/unref,get_reference_value,add_finalizer,node_api_post_finalizer,is_buffer,is_typedarray,is/detach_detached_arraybuffer,adjust_external_memory,node_api_symbol_for,node_api_get_module_file_name,get_value_bigint_*,get_value_external,get/set_instance_data,create_bigint_int64/uint64,create_symbol,add/remove_env_cleanup_hook,create_object,create_double,get_value_int32/uint32/int64/double/bool/string_*. Before this PR these happened to match Node only because the old preamble never saw the env slot.hasPendingException()workarounds innapi_create_external_buffer/arraybufferthat the preamble now covers.napi_body.rs): addedNapiEnv::has_pending_exception()(via newNapiEnv__hasPendingExceptionFFI) and apreamble!macro, applied to every Rust entry point Node.js gates withNAPI_PREAMBLE:get_prototype,get_array_length,strict_equals,make_callback,create_promise,resolve_deferred,reject_deferred,create_date,create_buffer_copy.Verification
test_pending_exception_gatein the napi addon armsnapi_throw_error, then:get_global,create_reference,reference_unref,get_reference_value,create_bigint_int64,create_symbol,is_buffer,is_typedarray,get_instance_data);Object.isFrozen,arr[7], a global the script would touch).Run via
checkSameOutput, so Bun's output is diffed byte-for-byte against Node.Before / after
The pre-existing
test_deferred_exceptionsandtest_external_*_with_pending_exceptiontests continue to pass.no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/napi/napi.test.ts