From e4e3002f35872deda12abe020274c1054b431114 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:00:41 +0000 Subject: [PATCH 1/3] napi: return napi_pending_exception from napi_get_prototype when [[GetPrototypeOf]] 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 `?`. --- src/jsc/ConsoleObject.rs | 6 +-- src/jsc/JSValue.rs | 7 +-- src/runtime/napi/napi_body.rs | 8 ++- src/runtime/test_runner/ScopeFunctions.rs | 2 +- src/runtime/test_runner/pretty_format.rs | 2 +- test/napi/napi-app/js_test_helpers.cpp | 45 +++++++++++++++++ test/napi/napi-app/module.js | 59 +++++++++++++++++++++++ test/napi/napi.test.ts | 26 ++++++++++ 8 files changed, 146 insertions(+), 9 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index a9270cf88662..ac0ab31dd5a7 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -3239,7 +3239,7 @@ pub mod formatter { value.get_class_name(global_this, &mut name_str)?; if !name_str.eql_comptime(b"Object") { return Ok(Some(name_str)); - } else if value.get_prototype(global_this).eql_value(JSValue::NULL) { + } else if value.get_prototype(global_this)?.eql_value(JSValue::NULL) { return Ok(Some(ZigString::static_("[Object: null prototype]"))); } Ok(None) @@ -3969,7 +3969,7 @@ pub mod formatter { // (i.e. `class Foo extends Bar`). Built-in and DOM constructors // have `Function.prototype` as their prototype, which would // render as `[class X extends Function]` and is noise. - let proto = value.get_prototype(self.global_this); + let proto = value.get_prototype(self.global_this)?; let proto_is_class = !proto.is_empty_or_undefined_or_null() && proto.is_cell() && proto.is_class(self.global_this); @@ -4035,7 +4035,7 @@ pub mod formatter { } let printable = OwnedString::new(value.get_name(self.global_this)?); - let proto = value.get_prototype(self.global_this); + let proto = value.get_prototype(self.global_this)?; // "Function" | "AsyncFunction" | "GeneratorFunction" | "AsyncGeneratorFunction" let func_name = OwnedString::new(proto.get_name(self.global_this)?); diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index c4a1b5d59272..1864717e2a9d 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2334,9 +2334,10 @@ impl JSValue { pub fn unwrap_boxed_primitive(self, global: &JSGlobalObject) -> JsResult { host_fn::from_js_host_call(global, || JSC__JSValue__unwrapBoxedPrimitive(global, self)) } - /// `JSValue.getPrototype`. - pub fn get_prototype(self, global: &JSGlobalObject) -> JSValue { - JSC__JSValue__getPrototype(self, global) + /// `JSValue.getPrototype`, i.e. `[[GetPrototypeOf]]`: runs a Proxy's `getPrototypeOf` trap, + /// which may throw, and throws a TypeError for null/undefined. + pub fn get_prototype(self, global: &JSGlobalObject) -> JsResult { + host_fn::from_js_host_call(global, || JSC__JSValue__getPrototype(self, global)) } // ── Reflection / naming. ─────────────── diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 9dc7092069f7..84508644cf9a 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -976,7 +976,13 @@ extern "C" fn napi_get_prototype( return NapiEnv::set_last_error(Some(env), NapiStatus::object_expected); } - result.set(env, object.get_prototype(env.to_js())); + // A Proxy's getPrototypeOf trap runs here. If it throws, the exception stays + // pending for the addon and *result must be left untouched. + let prototype = match object.get_prototype(env.to_js()) { + Ok(prototype) => prototype, + Err(_) => return env.pending_exception(), + }; + result.set(env, prototype); env.ok() } diff --git a/src/runtime/test_runner/ScopeFunctions.rs b/src/runtime/test_runner/ScopeFunctions.rs index 14b365ba950f..340d7151a1e3 100644 --- a/src/runtime/test_runner/ScopeFunctions.rs +++ b/src/runtime/test_runner/ScopeFunctions.rs @@ -806,7 +806,7 @@ fn bind(value: JSValue, global: &JSGlobalObject, name: BunString) -> JsResult writer.print(format_args!("{} ", name_str)); } else { value - .get_prototype(global_this) + .get_prototype(global_this)? .get_name_property(global_this, &mut name_str)?; if name_str.len > 0 && !name_str.eql_comptime(b"Object") { writer.print(format_args!("{} ", name_str)); diff --git a/test/napi/napi-app/js_test_helpers.cpp b/test/napi/napi-app/js_test_helpers.cpp index 65796e7a78e8..99dc5fd39815 100644 --- a/test/napi/napi-app/js_test_helpers.cpp +++ b/test/napi/napi-app/js_test_helpers.cpp @@ -362,6 +362,50 @@ static napi_value perform_instanceof(const Napi::CallbackInfo &info) { return out; } +// perform_get_prototype(object) -> { status, result, pending, exception } +// +// `result` is the string "untouched" if napi_get_prototype did not write to +// *result, the string "null handle" if it wrote a NULL napi_value, and +// otherwise the prototype it returned. +static napi_value perform_get_prototype(const Napi::CallbackInfo &info) { + napi_env env = info.Env(); + napi_value object = info[0]; + + napi_value untouched; + NODE_API_CALL(env, napi_create_string_utf8(env, "untouched", NAPI_AUTO_LENGTH, + &untouched)); + napi_value result = untouched; + napi_status status = napi_get_prototype(env, object, &result); + + bool pending = false; + NODE_API_CALL(env, napi_is_exception_pending(env, &pending)); + + napi_value exception; + if (pending) { + NODE_API_CALL(env, napi_get_and_clear_last_exception(env, &exception)); + } else { + NODE_API_CALL(env, napi_get_undefined(env, &exception)); + } + + if (result == nullptr) { + NODE_API_CALL(env, napi_create_string_utf8(env, "null handle", + NAPI_AUTO_LENGTH, &result)); + } + + napi_value out; + NODE_API_CALL(env, napi_create_object(env, &out)); + + napi_value status_val, pending_val; + NODE_API_CALL(env, napi_create_uint32(env, status, &status_val)); + NODE_API_CALL(env, napi_get_boolean(env, pending, &pending_val)); + + NODE_API_CALL(env, napi_set_named_property(env, out, "status", status_val)); + NODE_API_CALL(env, napi_set_named_property(env, out, "result", result)); + NODE_API_CALL(env, napi_set_named_property(env, out, "pending", pending_val)); + NODE_API_CALL(env, napi_set_named_property(env, out, "exception", exception)); + return out; +} + // create_latin1_string(byte_length): returns a JS string created via // napi_create_string_latin1. Used by the leak test in napi.test.ts. static napi_value create_latin1_string(const Napi::CallbackInfo &info) { @@ -615,6 +659,7 @@ void register_js_test_helpers(Napi::Env env, Napi::Object exports) { REGISTER_FUNCTION(env, exports, perform_set); REGISTER_FUNCTION(env, exports, define_properties); REGISTER_FUNCTION(env, exports, perform_instanceof); + REGISTER_FUNCTION(env, exports, perform_get_prototype); REGISTER_FUNCTION(env, exports, throw_error); REGISTER_FUNCTION(env, exports, create_and_throw_error); REGISTER_FUNCTION(env, exports, call_fatal_exception); diff --git a/test/napi/napi-app/module.js b/test/napi/napi-app/module.js index b0aece155716..ae1a196bfe50 100644 --- a/test/napi/napi-app/module.js +++ b/test/napi/napi-app/module.js @@ -1197,6 +1197,65 @@ nativeTests.test_napi_instanceof = () => { dump("undefined ctor", nativeTests.perform_instanceof({}, undefined)); }; +// Each line reports what napi_get_prototype did with an argument whose +// [[GetPrototypeOf]] throws. result= is "untouched" when *result was left +// alone and "null handle" when a NULL napi_value was written (see +// perform_get_prototype in js_test_helpers.cpp). +nativeTests.test_napi_get_prototype_exceptions = () => { + const trapError = new RangeError("from getPrototypeOf trap"); + + function dump(label, object) { + const r = nativeTests.perform_get_prototype(object); + const result = + typeof r.result === "string" ? r.result : r.result === Object.prototype ? "Object.prototype" : String(r.result); + const exception = + r.exception === undefined + ? "none" + : r.exception === trapError + ? "the trap's error" + : r.exception instanceof TypeError + ? "TypeError" + : String(r.exception); + console.log(`${label}: status=${r.status} pending=${r.pending} result=${result} exception=${exception}`); + } + + dump("plain object", {}); + dump("null prototype", Object.create(null)); + + dump( + "trap throws", + new Proxy( + {}, + { + getPrototypeOf() { + throw trapError; + }, + }, + ), + ); + + // The engine throws a TypeError for these two: a trap result that is neither + // an object nor null, and a revoked proxy. + dump( + "trap returns a number", + new Proxy( + {}, + { + getPrototypeOf() { + return 42; + }, + }, + ), + ); + const revocable = Proxy.revocable({}, {}); + revocable.revoke(); + dump("revoked proxy", revocable.proxy); + + // Each exception above was reported to and cleared by the addon, so nothing + // is left pending. + dump("plain object again", {}); +}; + nativeTests.test_get_value_string = () => { function to16Bit(string) { if (typeof Bun != "object") return string; diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 9bede65a492e..5fd3933aa616 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -1779,6 +1779,32 @@ describe.skipIf(!canBuildNodeAddons())("cleanup hooks", () => { }); }); + describe("napi_get_prototype", () => { + it("returns napi_pending_exception and leaves *result untouched when [[GetPrototypeOf]] throws", async () => { + // Bun runs the Proxy's getPrototypeOf trap (Object.getPrototypeOf + // semantics), so a throwing trap has to be reported the way every other + // Node-API call reports JS that threw: napi_pending_exception (10), the + // exception left for napi_get_and_clear_last_exception, *result not + // written. This is not a checkSameOutput test because V8's + // Object::GetPrototype never runs proxy traps: Node prints + // "status=0 ... result=null exception=none" for all three proxies. + const output = await runOn(bunExe(), "test_napi_get_prototype_exceptions", []); + const lines = output + .trim() + .split(/\r?\n/) + // remove all debug logs + .filter(line => !/^\[\w+\]/.test(line)); + expect(lines).toEqual([ + "plain object: status=0 pending=false result=Object.prototype exception=none", + "null prototype: status=0 pending=false result=null exception=none", + "trap throws: status=10 pending=true result=untouched exception=the trap's error", + "trap returns a number: status=10 pending=true result=untouched exception=TypeError", + "revoked proxy: status=10 pending=true result=untouched exception=TypeError", + "plain object again: status=0 pending=false result=Object.prototype exception=none", + ]); + }); + }); + describe("napi_object_freeze and napi_object_seal", () => { it("should handle arrays with indexed properties", async () => { const output = await checkSameOutput("test_napi_freeze_seal_indexed", []); From 7b4efa7f9716e4f302a343a886a1bdbed22a742e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:38:34 +0000 Subject: [PATCH 2/3] napi_get_prototype: return null for a Proxy without running its trap, 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. --- src/runtime/napi/napi_body.rs | 9 +++++-- test/napi/napi-app/module.js | 47 ++++++++++++++++++++++++++--------- test/napi/napi.test.ts | 34 ++++++++++++------------- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 84508644cf9a..69b210540bc1 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -976,8 +976,13 @@ extern "C" fn napi_get_prototype( return NapiEnv::set_last_error(Some(env), NapiStatus::object_expected); } - // A Proxy's getPrototypeOf trap runs here. If it throws, the exception stays - // pending for the addon and *result must be left untouched. + // Node's v8::Object::GetPrototype cannot run JS: for a Proxy it returns null + // without consulting the getPrototypeOf trap. No other object type runs JS + // for [[GetPrototypeOf]], so this function never does either. + if object.js_type() == jsc::JSType::ProxyObject { + result.set(env, JSValue::NULL); + return env.ok(); + } let prototype = match object.get_prototype(env.to_js()) { Ok(prototype) => prototype, Err(_) => return env.pending_exception(), diff --git a/test/napi/napi-app/module.js b/test/napi/napi-app/module.js index ae1a196bfe50..31efb45493a2 100644 --- a/test/napi/napi-app/module.js +++ b/test/napi/napi-app/module.js @@ -1197,17 +1197,26 @@ nativeTests.test_napi_instanceof = () => { dump("undefined ctor", nativeTests.perform_instanceof({}, undefined)); }; -// Each line reports what napi_get_prototype did with an argument whose -// [[GetPrototypeOf]] throws. result= is "untouched" when *result was left -// alone and "null handle" when a NULL napi_value was written (see -// perform_get_prototype in js_test_helpers.cpp). -nativeTests.test_napi_get_prototype_exceptions = () => { +// V8's Object::GetPrototype (what napi_get_prototype wraps in Node) returns null +// for a Proxy without running its getPrototypeOf trap, so the proxy lines below +// all read "result=null" with nothing pending, whatever the trap would do. +// result= is "untouched" if *result was not written and "null handle" if a NULL +// napi_value was written (see perform_get_prototype in js_test_helpers.cpp). +nativeTests.test_napi_get_prototype_proxy = () => { const trapError = new RangeError("from getPrototypeOf trap"); + let trapCalls = 0; + const chainProxy = new Proxy({}, {}); + const names = [ + [Object.prototype, "Object.prototype"], + [Array.prototype, "Array.prototype"], + [Function.prototype, "Function.prototype"], + [chainProxy, "the proxy"], + ]; function dump(label, object) { const r = nativeTests.perform_get_prototype(object); - const result = - typeof r.result === "string" ? r.result : r.result === Object.prototype ? "Object.prototype" : String(r.result); + const named = names.find(([value]) => value === r.result); + const result = typeof r.result === "string" ? r.result : named ? named[1] : String(r.result); const exception = r.exception === undefined ? "none" @@ -1222,6 +1231,21 @@ nativeTests.test_napi_get_prototype_exceptions = () => { dump("plain object", {}); dump("null prototype", Object.create(null)); + dump("proxy without traps", new Proxy([], {})); + dump("callable proxy", new Proxy(function () {}, {})); + dump( + "trap returns Array.prototype", + new Proxy( + {}, + { + getPrototypeOf() { + trapCalls++; + return Array.prototype; + }, + }, + ), + ); + console.log(`getPrototypeOf trap calls: ${trapCalls}`); dump( "trap throws", new Proxy( @@ -1233,9 +1257,6 @@ nativeTests.test_napi_get_prototype_exceptions = () => { }, ), ); - - // The engine throws a TypeError for these two: a trap result that is neither - // an object nor null, and a revoked proxy. dump( "trap returns a number", new Proxy( @@ -1251,8 +1272,10 @@ nativeTests.test_napi_get_prototype_exceptions = () => { revocable.revoke(); dump("revoked proxy", revocable.proxy); - // Each exception above was reported to and cleared by the addon, so nothing - // is left pending. + // Only the object itself is special-cased; a proxy further up the chain is + // returned like any other prototype. + dump("object whose prototype is a proxy", Object.create(chainProxy)); + dump("plain object again", {}); }; diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 5fd3933aa616..4ac613252739 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -1780,26 +1780,24 @@ describe.skipIf(!canBuildNodeAddons())("cleanup hooks", () => { }); describe("napi_get_prototype", () => { - it("returns napi_pending_exception and leaves *result untouched when [[GetPrototypeOf]] throws", async () => { - // Bun runs the Proxy's getPrototypeOf trap (Object.getPrototypeOf - // semantics), so a throwing trap has to be reported the way every other - // Node-API call reports JS that threw: napi_pending_exception (10), the - // exception left for napi_get_and_clear_last_exception, *result not - // written. This is not a checkSameOutput test because V8's - // Object::GetPrototype never runs proxy traps: Node prints - // "status=0 ... result=null exception=none" for all three proxies. - const output = await runOn(bunExe(), "test_napi_get_prototype_exceptions", []); - const lines = output - .trim() - .split(/\r?\n/) - // remove all debug logs - .filter(line => !/^\[\w+\]/.test(line)); - expect(lines).toEqual([ + it("returns null for a Proxy without running its getPrototypeOf trap, like Node", async () => { + // Before this was special-cased, Bun ran the trap: a proxy without traps + // reported its target's prototype, and a throwing trap reported napi_ok + // with a NULL handle written to *result and the exception left pending. + const output = await checkSameOutput("test_napi_get_prototype_proxy", []); + // checkSameOutput already asserted parity with Node; pin the values so a + // shared failure cannot pass. + expect(output.split(/\r?\n/)).toEqual([ "plain object: status=0 pending=false result=Object.prototype exception=none", "null prototype: status=0 pending=false result=null exception=none", - "trap throws: status=10 pending=true result=untouched exception=the trap's error", - "trap returns a number: status=10 pending=true result=untouched exception=TypeError", - "revoked proxy: status=10 pending=true result=untouched exception=TypeError", + "proxy without traps: status=0 pending=false result=null exception=none", + "callable proxy: status=0 pending=false result=null exception=none", + "trap returns Array.prototype: status=0 pending=false result=null exception=none", + "getPrototypeOf trap calls: 0", + "trap throws: status=0 pending=false result=null exception=none", + "trap returns a number: status=0 pending=false result=null exception=none", + "revoked proxy: status=0 pending=false result=null exception=none", + "object whose prototype is a proxy: status=0 pending=false result=the proxy exception=none", "plain object again: status=0 pending=false result=Object.prototype exception=none", ]); }); From d515c0145cb6f1efc1b020a4ae6cc801c87c23b6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:40:47 +0000 Subject: [PATCH 3/3] Shorten the get_prototype comments --- src/jsc/JSValue.rs | 3 +-- src/runtime/napi/napi_body.rs | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index 1864717e2a9d..9256ce70d057 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2334,8 +2334,7 @@ impl JSValue { pub fn unwrap_boxed_primitive(self, global: &JSGlobalObject) -> JsResult { host_fn::from_js_host_call(global, || JSC__JSValue__unwrapBoxedPrimitive(global, self)) } - /// `JSValue.getPrototype`, i.e. `[[GetPrototypeOf]]`: runs a Proxy's `getPrototypeOf` trap, - /// which may throw, and throws a TypeError for null/undefined. + /// `JSValue.getPrototype`; runs a Proxy's `getPrototypeOf` trap, so it may throw. pub fn get_prototype(self, global: &JSGlobalObject) -> JsResult { host_fn::from_js_host_call(global, || JSC__JSValue__getPrototype(self, global)) } diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 69b210540bc1..7cd19535d041 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -976,9 +976,7 @@ extern "C" fn napi_get_prototype( return NapiEnv::set_last_error(Some(env), NapiStatus::object_expected); } - // Node's v8::Object::GetPrototype cannot run JS: for a Proxy it returns null - // without consulting the getPrototypeOf trap. No other object type runs JS - // for [[GetPrototypeOf]], so this function never does either. + // Like V8's Object::GetPrototype: a Proxy yields null and its trap never runs. if object.js_type() == jsc::JSType::ProxyObject { result.set(env, JSValue::NULL); return env.ok();