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..9256ce70d057 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2334,9 +2334,9 @@ 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`; 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)) } // ── Reflection / naming. ─────────────── diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 9dc7092069f7..7cd19535d041 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -976,7 +976,16 @@ 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())); + // 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(); + } + 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..31efb45493a2 100644 --- a/test/napi/napi-app/module.js +++ b/test/napi/napi-app/module.js @@ -1197,6 +1197,88 @@ nativeTests.test_napi_instanceof = () => { dump("undefined ctor", nativeTests.perform_instanceof({}, undefined)); }; +// 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 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" + : 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("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( + {}, + { + getPrototypeOf() { + throw trapError; + }, + }, + ), + ); + dump( + "trap returns a number", + new Proxy( + {}, + { + getPrototypeOf() { + return 42; + }, + }, + ), + ); + const revocable = Proxy.revocable({}, {}); + revocable.revoke(); + dump("revoked proxy", revocable.proxy); + + // 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", {}); +}; + 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..4ac613252739 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -1779,6 +1779,30 @@ describe.skipIf(!canBuildNodeAddons())("cleanup hooks", () => { }); }); + describe("napi_get_prototype", () => { + 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", + "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", + ]); + }); + }); + 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", []);