napi_get_prototype: return null for a Proxy without running its trap, like Node - #39418
Conversation
|
Status: reproduced with the |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (8)
Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review. WalkthroughChangesPrototype exception handling
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
LGTM — small, well-scoped fix that brings napi_get_prototype in line with the Node-API pending-exception protocol.
What was reviewed:
get_prototypesignature change toJsResult<JSValue>viafrom_js_host_call; confirmed the C++ binding (JSC__JSValue__getPrototype→JSValue::getPrototype) follows the empty-iff-threw contract that wrapper requires.- Grepped all 6
get_prototypecall sites — every one is updated in this diff, and the non-napi callers already returnJsResultso?propagates cleanly. napi_get_prototype:*resultis left untouched onErr,env.pending_exception()sets last error — matches the pattern neighboring functions use and what the existingtest_pending_exception_gatetest expects.- Test fixture pre-fills
*resultwith a sentinel to distinguish "untouched" from "NULL written", covers throwing trap / non-object trap return / revoked proxy plus controls; Bun-only (notcheckSameOutput) is justified in-comment since V8 never runs the trap.
Extended reasoning...
Overview
This PR fixes napi_get_prototype to return napi_pending_exception (leaving *result unwritten) when JSC's [[GetPrototypeOf]] throws — which happens when a Proxy's getPrototypeOf trap throws, returns a non-object, or the proxy is revoked. Previously it returned napi_ok with a NULL napi_value written to *result and the exception silently pending.
The change has two parts: (1) JSValue::get_prototype in src/jsc/JSValue.rs now returns JsResult<JSValue> via host_fn::from_js_host_call (the standard wrapper for JSC calls that return empty-iff-threw), and (2) napi_get_prototype in src/runtime/napi/napi_body.rs matches on that result and returns env.pending_exception() on Err. The four other callers (ConsoleObject.rs ×3, pretty_format.rs, ScopeFunctions.rs) mechanically add ? — they already return JsResult and, per the PR description, only ever see ordinary objects (formatters unwrap proxies to their target first), so no observable behavior change there.
Security risks
None. This is exception-propagation plumbing in the N-API compatibility layer; no auth, crypto, permissions, or untrusted-input parsing involved. If anything it's a hardening: addons no longer receive a NULL napi_value handle they might dereference.
Level of scrutiny
Low-to-medium. The core change is a one-line signature change plus a match arm, both following patterns already used pervasively in the same files (unwrap_boxed_primitive uses the identical from_js_host_call wrapper immediately above; env.pending_exception() is used by dozens of neighboring napi functions). I verified via grep that all 6 get_prototype call sites in src/ are updated in this diff, and confirmed the underlying C++ binding JSC__JSValue__getPrototype is a direct pass-through to JSC's JSValue::getPrototype, which follows the empty-iff-threw contract from_js_host_call encodes.
Other factors
- The test is thorough and follows repo conventions closely: it mirrors the existing
perform_instanceofhelper structure, pre-fills*resultwith a sentinel string so it can distinguish "untouched" from "NULL handle written" (proving the*result-ordering invariant #36849 established), covers all three throwing paths plus positive controls and a trailing call showing nothing is left pending, and asserts identity on the trap's thrown error object. - The test is Bun-only (
runOn(bunExe(), ...)) rather thancheckSameOutput, which is well-justified in the test comment and PR description: V8'sObject::GetPrototypenever runs proxy traps, so Node never reaches this code path and can't be used as the reference output here. - PR description states verification under
USE_SYSTEM_BUN=1(fails),bun bd test(passes), andBUN_JSC_validateExceptionChecks=1on the affected formatter/test-runner paths. - No prior review comments on this PR; no outstanding concerns to address.
|
should we instead match node and not run js? |
|
Done in 6ebf519: |
|
Updated 2:18 AM PT - Aug 17th, 2026
✅ @robobun, your commit d515c0145cb6f1efc1b020a4ae6cc801c87c23b6 passed in 🧪 To try this PR locally: bunx bun-pr 39418That installs a local version of the PR into your bun-39418 --bun |
…tPrototypeOf]] throws JSValue::get_prototype wrapped JSC's getPrototype, which returns the empty value with an exception pending when a Proxy's getPrototypeOf trap throws (or returns a non-object, or the proxy is revoked). napi_get_prototype stored that empty value, so the addon got napi_ok, a NULL napi_value in *result, and an exception it was never told about. Make get_prototype return JsResult through from_js_host_call, like the other JSValue methods that can run JS, and have napi_get_prototype report napi_pending_exception without writing *result. The remaining callers (console formatter, test runner) propagate the error with `?`.
… like Node V8's Object::GetPrototype cannot run JS and returns null for a Proxy, so napi_get_prototype in Node never consults a getPrototypeOf trap. Do the same instead of running the trap and reporting its exception, and turn the test into a byte-for-byte comparison with Node.
afa5090 to
d515c01
Compare
There was a problem hiding this comment.
LGTM — focused Node-compat fix with byte-for-byte parity testing against Node.
What was reviewed:
napi_get_prototypeProxy special-case:js_type()is safe on non-cells (returnsCell, won't matchProxyObject), so primitives still fall through toget_prototype.JSValue::get_prototypesignature change toJsResult: all 5 Rust callers updated with?in already-JsResultcontexts;from_js_host_callfollows the same zero-is-throw pattern asunwrap_boxed_primitiveright above it, andJSValue::NULL(valid null-prototype result) is not empty so it returnsOk.- Test uses
checkSameOutputplus pinned lines, covers callable/revoked/throwing-trap proxies and a proxy in the prototype chain; sentinel distinguishes "untouched" vs NULL handle vs real null.
Extended reasoning...
Overview
This PR fixes napi_get_prototype to match Node.js/V8 behavior when called on a Proxy: return null without running the getPrototypeOf trap. Previously Bun ran the trap, and when it threw, wrote a NULL napi_value handle to *result while returning napi_ok with the exception left pending — a broken contract for addons. The fix is two-part: (1) a ProxyObject type check in napi_get_prototype that returns JSValue::NULL early, and (2) changing JSValue::get_prototype to return JsResult<JSValue> via from_js_host_call so no caller can silently receive an empty value. Five Rust callers (three in ConsoleObject.rs, one each in pretty_format.rs and ScopeFunctions.rs) are updated to propagate with ?. Test coverage adds a C++ helper perform_get_prototype (with sentinel pre-fill of *result), a JS fixture covering 11 cases, and a checkSameOutput test that also pins the expected lines.
Security risks
None. This is a Node-API compat fix affecting how prototype lookup handles Proxy objects. If anything it reduces attack surface: user JS (proxy traps) no longer runs during napi_get_prototype.
Level of scrutiny
Medium. It touches a shared JSValue helper signature, but the change is compile-time-enforced (missing a caller would fail to build) and I confirmed via grep that all callers are updated. I checked that js_type() is safe on non-cell values (returns JSType::Cell), that from_js_host_call uses the zero-is-throw contract (so a valid null prototype returns Ok(null)), and that JSC__JSValue__getPrototype in bindings.cpp is a plain forward to JSC's getPrototype (which follows empty-iff-threw). The Err arm in napi_get_prototype maps to pending_exception() — unreachable with the Proxy case handled but correct as a defensive fallback.
Other factors
The maintainer (dylan-conway) requested the current approach ("match node and not run js") and the PR was reworked to do exactly that; the comment-cop feedback about long comments was also addressed and both threads are resolved. The test quality is high: checkSameOutput gives byte-for-byte Node parity, the pinned assertions prevent a shared regression from passing, the trap-call counter proves the trap never runs, and the sentinel/"null handle" distinction proves *result receives a real null. The PR description confirms USE_SYSTEM_BUN=1 fails (7/11 lines differ) and the debug build passes the full napi suite plus formatter/test-runner tests under BUN_JSC_validateExceptionChecks=1.
Problem
napi_get_prototypeon a Proxy runs the Proxy'sgetPrototypeOftrap. Node does not: V8'sObject::GetPrototype, which Node'snapi_get_prototypewraps, cannot run JS and returnsnullfor any Proxy (verified with Node 26.3, output in the details below).napi_ok, writes a NULLnapi_valueinto*result, and leaves the exception pending without telling the addon. Current canary on the new fixture:trap throws: status=0 pending=true result=null handle exception=the trap's error.napi_get_prototypeused to call the JSC C API'sJSObjectGetPrototype, which isgetPrototypeDirect(), and JSC creates Proxy structures with a null prototype, so 1.3.14 already returnednullfor a Proxy without running anything. jsc: delete javascript_core_c_api.rs and remove all JSC C API usage from Rust #33731 replaced it withJSValue::get_prototype, JSC's full[[GetPrototypeOf]](traps included), which returned a bareJSValuethat is empty (encoded 0, i.e. a NULL handle) when it threw, so the call site insrc/runtime/napi/napi_body.rscould not tell success from failure. Affects 1.4.0 canary only; thenapi_get_prototypeline in the napi: align symbol/buffer/coercion/reference/tsfn semantics with Node.js #36801 audit ("already avoids the trap") describes the pre-jsc: delete javascript_core_c_api.rs and remove all JSC C API usage from Rust #33731 state.getPrototypeexception-handling sites found by the same reading (the inspect property walk,napi_get_all_property_names,util.isError) are separate fixes in Fix crash and dropped properties in Bun.inspect when a property lookup or getPrototypeOf throws during the property walk #29642, napi: propagate Proxy trap exceptions from napi_get_all_property_names descriptor filter #32263 and util.isError: propagate a throwing getPrototypeOf trap instead of crashing #37202; this PR merges cleanly in either order with them.Fix
napi_get_prototypereturnsnullfor aProxyObjectwithout touching its handler, the same result Node produces and the same result 1.3.14 produced. Every other object type's[[GetPrototypeOf]]in JSC is a plain read (JSGlobalProxyforwards to the global object,ImportMetaObjectreturns null, primitives get their wrapper prototype), so the function no longer runs JS for any input, like Node's.JSValue::get_prototype(src/jsc/JSValue.rs) now returnsJsResult<JSValue>throughhost_fn::from_js_host_call, the wrapperJSValue::callandunwrap_boxed_primitivealready use for JSC calls that return empty exactly when they threw. This is what let the empty value reach*result, and the remaining callers (ConsoleObject.rs,pretty_format.rs,ScopeFunctions.rs, all already returningJsResult) now propagate with?instead of holding a possibly-empty value. They only ever see ordinary objects (the formatters print a Proxy's target), so their output is unchanged. Innapi_get_prototypetheErrarm maps tonapi_pending_exceptionas the generic Node-API protocol; with the Proxy case handled above it is not reachable with today's object types.test/napi/napi.test.ts(napi_get_prototype) runs fixturetest_napi_get_prototype_proxy(napi-app/module.js) throughcheckSameOutput, so the output is compared byte for byte with Node, and then pins the lines. The helperperform_get_prototype(napi-app/js_test_helpers.cpp) pre-fills*resultwith a sentinel so the output distinguishes a realnullfrom "not written" and from a NULL handle. Cases: proxy without traps, callable proxy, a trap that counts its calls (must stay 0), a trap that throws, a trap returning a number, a revoked proxy, plus plain / null-prototype objects and an object whose prototype is a proxy (returned as-is, only the object itself is special-cased).USE_SYSTEM_BUN=1(1.4.0-canary.1: 7 of 11 lines differ from Node, see details), passes withbun bd test test/napi/napi.test.ts.test/napi/napi.test.ts, Node's owntest_generalsuite (test/napi/node-napi-tests/.../test_general/do.test.ts, which assertsnapi_get_prototypematchesObject.getPrototypeOffor ordinary objects),test/js/bun/util/inspect.test.js,test/js/bun/test/printing/diffexample.test.ts,jest-each.test.ts,describe.test.ts,pretty-format-overflow.test.ts,console-table.test.ts; plus a script formatting classes, functions, null-prototype objects and proxies, and abun testfile exercisingdescribe.each/test.each/skipIfand class-instancetoEqualdiffs, both clean underBUN_JSC_validateExceptionChecks=1.napi_remove_wrapon a Proxy or onglobalThisreports success but leaves the wrap attached, becausesrc/jsc/bindings/napi.cppremoves it with the virtualdeleteProperty(whichProxyObjectrefuses for private names andJSGlobalProxyforwards to its target) whilenapi_wrap/napi_unwrapuseputDirect/getDirect; Node removes it. Probe output is in the second details block.Background
[[GetPrototypeOf]]is the spec operation behindObject.getPrototypeOf. For ordinary objects it reads a field and cannot fail; a Proxy implements it by calling its handler'sgetPrototypeOftrap, so it can run arbitrary JS and throw. V8'sObject::GetPrototypereturns a plainLocal<Value>rather than aMaybeLocal, so it cannot run a trap, and it reportsnullfor a Proxy; that is the behavior Node-API addons are written against.getPrototypeDirect()is JSC's raw read of the prototype stored in an object's Structure, bypassing any override;getPrototype()is the full operation that dispatches toProxyObject's trap. The JSC C API'sJSObjectGetPrototypeis the former.JSValue(encoding 0) and leaving the exception on the VM.host_fn::from_js_host_callmaps that toErr(JsError::Thrown)and, in debug builds, asserts the value is empty if and only if an exception is pending.napi_valueis an encodedJSValue, so storing the empty value hands the addon a NULL handle.napi_pending_exceptionis the status a Node-API call returns when JS it ran threw; the exception stays pending fornapi_get_and_clear_last_exceptionand out-params are left unwritten.Fixture output: Node 26.3 (and this branch, identical) vs current canary
Node v26.3.0, and this branch:
Bun 1.4.0-canary.1 (
USE_SYSTEM_BUN=1):napi_remove_wrap follow-up: probe output (existing try_wrap / try_remove_wrap / try_unwrap helpers from napi-app, wrapping the number 6, then trying to re-wrap with 7)
Node v26.3.0:
Bun (this branch, unchanged in this respect):
Earlier version of this PR
The first revision kept running the trap and changed only the failure report:
napi_pending_exceptionwith*resultuntouched when the trap threw, tested on Bun alone since Node never reaches that path. Review asked to match Node instead and not run JS, which is the current shape; theJsResultchange toJSValue::get_prototypeis unchanged from that revision.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