From ff431b249fd8c3a0e0f1a7381af99eafd149616d Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 23 Apr 2026 17:26:13 +0000 Subject: [PATCH 01/11] fix(inspect): don't crash when a Proxy in the prototype chain throws When enumerating properties via forEachProperty, a Proxy in the prototype chain eagerly invokes [[Get]] to fill the property slot. If the underlying getter throws, getPropertySlot returns false with a pending exception, and we would `continue` before clearing it. The pending exception then caused the subsequent getPrototype() on the Proxy to return an empty JSValue, and calling .getObject() on that is a null-pointer member call. Clear the exception before the early continue (matching the pattern already used in forEachPropertyOrdered), and also guard the getPrototype() result so a throwing getPrototypeOf trap stops iteration instead of crashing. --- src/jsc/bindings/bindings.cpp | 12 +++++++++--- test/js/bun/util/inspect.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 983a348f73a5..70506c88e9a9 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -5643,10 +5643,11 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: } JSC::PropertySlot slot(object, PropertySlot::InternalMethodType::Get); - if (!object->getPropertySlot(globalObject, property, slot)) - continue; + bool hasProperty = object->getPropertySlot(globalObject, property, slot); // Ignore exceptions from "Get" proxy traps. CLEAR_IF_EXCEPTION(scope); + if (!hasProperty) + continue; if ((slot.attributes() & PropertyAttribute::DontEnum) != 0) { if (property == propertyNames->underscoreProto @@ -5718,7 +5719,12 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: break; if (iterating == globalObject) break; - iterating = iterating->getPrototype(globalObject).getObject(); + JSValue iteratingProto = iterating->getPrototype(globalObject); + // Ignore exceptions from Proxy "getPrototypeOf" trap. + CLEAR_IF_EXCEPTION(scope); + if (!iteratingProto) + break; + iterating = iteratingProto.getObject(); } } diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 5c91f3dbe05f..080815b66a5d 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -100,6 +100,30 @@ it("when prototype defines the same property, don't print the same property twic expect(Bun.inspect(obj).trim()).toBe('{\n foo: "456",\n}'.trim()); }); +it("Proxy prototype with a getter that throws does not crash", () => { + const proto = { + get foo() { + throw new Error("nope"); + }, + }; + const obj = Object.create(new Proxy(proto, {})); + expect(Bun.inspect(obj)).toBe("{}"); +}); + +it("Proxy prototype with a getPrototypeOf trap that throws does not crash", () => { + const obj = Object.create( + new Proxy( + { foo: 1 }, + { + getPrototypeOf() { + throw new Error("nope"); + }, + }, + ), + ); + expect(Bun.inspect(obj)).toBe("{\n foo: 1,\n}"); +}); + it("Blob inspect", () => { expect(Bun.inspect(new Blob(["123"]))).toBe(`Blob (3 bytes)`); expect(Bun.inspect(new Blob(["123".repeat(900)]))).toBe(`Blob (2.70 KB)`); From da4cffc79b653c4fa5f29f3a48ac3792bf5da54c Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 23 Apr 2026 21:40:33 +0000 Subject: [PATCH 02/11] Keep prototype-walk object alive across Proxy trap calls; guard napi_get_all_property_names Hold the current link in the prototype chain as a JSValue under an EnsureStillAliveScope so a Proxy trap that runs JS (and may GC) during property enumeration can't free it out from under us. Also guard the equivalent getPrototype().getObject() pattern in napi_get_all_property_names, propagating Proxy trap exceptions as napi_pending_exception. Supersedes #29071 #28991 #28919 #28918 #28882 #28854 #28530 #28325. --- src/jsc/bindings/bindings.cpp | 16 +++++++++++----- src/jsc/bindings/napi.cpp | 5 ++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 70506c88e9a9..707df1bab130 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -5618,9 +5618,16 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: { - JSObject* iterating = prototypeObject.getObject(); + JSValue iteratingValue = prototypeObject; + + while (JSObject* iterating = iteratingValue.getObject()) { + if (iterating == globalObject->objectPrototype() || iterating == globalObject->functionPrototype() || (iterating->inherits() && uncheckedDowncast(iterating)->target() != globalObject)) + break; + if (prototypeCount++ >= 5) + break; + + JSC::EnsureStillAliveScope ensureIteratingStillAlive(iteratingValue); - while (iterating && !(iterating == globalObject->objectPrototype() || iterating == globalObject->functionPrototype() || (iterating->inherits() && uncheckedDowncast(iterating)->target() != globalObject)) && prototypeCount++ < 5) { if constexpr (nonIndexedOnly) { iterating->getOwnNonIndexPropertyNames(globalObject, properties, DontEnumPropertiesMode::Include); } else { @@ -5719,12 +5726,11 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: break; if (iterating == globalObject) break; - JSValue iteratingProto = iterating->getPrototype(globalObject); + iteratingValue = iterating->getPrototype(globalObject); // Ignore exceptions from Proxy "getPrototypeOf" trap. CLEAR_IF_EXCEPTION(scope); - if (!iteratingProto) + if (!iteratingValue) break; - iterating = iteratingProto.getObject(); } } diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index 6b90a8f89d58..6b796927b855 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -2077,7 +2077,10 @@ extern "C" napi_status napi_get_all_property_names( if (key_mode == napi_key_include_prototypes) { // Climb up the prototype chain to find inherited properties while (!owner->getOwnPropertyDescriptor(globalObject, propKey, desc)) { - JSObject* proto = owner->getPrototype(globalObject).getObject(); + NAPI_RETURN_IF_EXCEPTION(env); + JSValue protoValue = owner->getPrototype(globalObject); + NAPI_RETURN_IF_EXCEPTION(env); + JSObject* proto = protoValue ? protoValue.getObject() : nullptr; if (!proto) { break; } From 4ca87cf6b1f32c1838d89715e170d9b6123e312c Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 23 Apr 2026 21:55:26 +0000 Subject: [PATCH 03/11] napi_get_all_property_names: also guard own-only getOwnPropertyDescriptor --- src/jsc/bindings/napi.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index 6b796927b855..d9102bd633f4 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -2088,6 +2088,7 @@ extern "C" napi_status napi_get_all_property_names( } } else { owner->getOwnPropertyDescriptor(globalObject, propKey, desc); + NAPI_RETURN_IF_EXCEPTION(env); } // V8 never applies ONLY_WRITABLE/ONLY_CONFIGURABLE to Proxy keys From 104c572cd8ae6ae6d312bc4e8d06c0baf1514e9f Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 13 Aug 2026 22:20:48 +0000 Subject: [PATCH 04/11] test(napi): cover napi_get_all_property_names when Proxy traps throw The get_all_property_names fixture helper now clears a pending exception and returns it alongside the status, so the test can assert napi_pending_exception for both the own-only and include-prototypes descriptor walks. Output is compared against Node. --- test/napi/napi-app/js_test_helpers.cpp | 14 ++++++- test/napi/napi-app/module.js | 58 ++++++++++++++++++++++++++ test/napi/napi.test.ts | 10 +++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/test/napi/napi-app/js_test_helpers.cpp b/test/napi/napi-app/js_test_helpers.cpp index 65796e7a78e8..2121201a9809 100644 --- a/test/napi/napi-app/js_test_helpers.cpp +++ b/test/napi/napi-app/js_test_helpers.cpp @@ -376,7 +376,8 @@ static napi_value create_latin1_string(const Napi::CallbackInfo &info) { } // get_all_property_names(object, key_mode, key_filter, key_conversion) -// returns { status, keys } +// returns { status, keys, exception }; a pending exception is cleared and +// returned as `exception` so callers can observe `status` alongside it. static napi_value get_all_property_names(const Napi::CallbackInfo &info) { napi_env env = info.Env(); napi_value object = info[0]; @@ -391,6 +392,15 @@ static napi_value get_all_property_names(const Napi::CallbackInfo &info) { static_cast(key_filter), static_cast(key_conversion), &keys); + bool is_pending = false; + NODE_API_CALL(env, napi_is_exception_pending(env, &is_pending)); + napi_value exception; + if (is_pending) { + NODE_API_CALL(env, napi_get_and_clear_last_exception(env, &exception)); + } else { + NODE_API_CALL(env, napi_get_undefined(env, &exception)); + } + napi_value result; NODE_API_CALL(env, napi_create_object(env, &result)); napi_value status_val; @@ -400,6 +410,8 @@ static napi_value get_all_property_names(const Napi::CallbackInfo &info) { NODE_API_CALL(env, napi_get_undefined(env, &keys)); } NODE_API_CALL(env, napi_set_named_property(env, result, "keys", keys)); + NODE_API_CALL(env, + napi_set_named_property(env, result, "exception", exception)); return result; } diff --git a/test/napi/napi-app/module.js b/test/napi/napi-app/module.js index b0aece155716..95feb2f5ae4e 100644 --- a/test/napi/napi-app/module.js +++ b/test/napi/napi-app/module.js @@ -321,6 +321,64 @@ nativeTests.test_get_all_property_names_proxy_and_string_wrapper = () => { show("frozen writable:", apn(Object.freeze({ a: 1, b: 2 }), napi_key_writable)); }; +nativeTests.test_get_all_property_names_throwing_proxy_traps = () => { + const napi_key_include_prototypes = 0; + const napi_key_own_only = 1; + const napi_key_enumerable = 1 << 1; + const napi_key_keep_numbers = 0; + + const show = (label, { status, keys, exception }) => + console.log(label, `status=${status}`, `keys=${JSON.stringify(keys)}`, `exception=${exception?.message}`); + + // ownKeys succeeds so key collection completes; the per-key descriptor walk + // required by napi_key_enumerable is what invokes the throwing trap. + const throwingDescriptor = new Proxy( + {}, + { + ownKeys: () => ["a"], + getOwnPropertyDescriptor() { + throw new Error("gopd trap"); + }, + }, + ); + show( + "own_only gopd throws:", + nativeTests.get_all_property_names( + throwingDescriptor, + napi_key_own_only, + napi_key_enumerable, + napi_key_keep_numbers, + ), + ); + show( + "include_prototypes gopd throws on prototype:", + nativeTests.get_all_property_names( + Object.create(throwingDescriptor), + napi_key_include_prototypes, + napi_key_enumerable, + napi_key_keep_numbers, + ), + ); + + const throwingPrototype = new Proxy( + { a: 1 }, + { + getPrototypeOf() { + throw new Error("getPrototypeOf trap"); + }, + }, + ); + show( + "include_prototypes getPrototypeOf throws:", + nativeTests.get_all_property_names( + Object.create(throwingPrototype), + napi_key_include_prototypes, + napi_key_enumerable, + napi_key_keep_numbers, + ), + ); +}; + nativeTests.test_set_property = () => { const objects = [ {}, diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 9bede65a492e..1ea94d595caf 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -867,6 +867,16 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { it("handles accessor properties when filtering by napi_key_writable", async () => { await checkSameOutput("test_get_all_property_names_accessor", []); }); + it("returns napi_pending_exception when a Proxy trap throws during the descriptor walk", async () => { + const output = await checkSameOutput("test_get_all_property_names_throwing_proxy_traps", []); + expect(output).toContain("own_only gopd throws: status=10 keys=undefined exception=gopd trap"); + expect(output).toContain( + "include_prototypes gopd throws on prototype: status=10 keys=undefined exception=gopd trap", + ); + expect(output).toContain( + "include_prototypes getPrototypeOf throws: status=10 keys=undefined exception=getPrototypeOf trap", + ); + }); it("matches Node for Proxy and String wrapper with napi_key_writable/napi_key_configurable", async () => { const output = await checkSameOutput("test_get_all_property_names_proxy_and_string_wrapper", []); expect(output).toContain(`proxy own_only writable: status=0 keys=["x","y"]`); From 42df5598c66cee7108236eeddd5ec93209813e88 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 13 Aug 2026 23:08:08 +0000 Subject: [PATCH 05/11] test(napi): make the getPrototype guard in the descriptor walk load-bearing A stateless throwing getPrototypeOf trap is caught during key collection, before the descriptor walk. Use a trap that succeeds once and throws on the second call so the walk's own check is what returns napi_pending_exception. Bun-only: V8 makes a single getPrototypeOf call here, so the case has no Node counterpart. --- test/napi/napi-app/module.js | 33 ++++++++++++++++++++++----------- test/napi/napi.test.ts | 12 +++++++++--- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/test/napi/napi-app/module.js b/test/napi/napi-app/module.js index 95feb2f5ae4e..337e3e8ef506 100644 --- a/test/napi/napi-app/module.js +++ b/test/napi/napi-app/module.js @@ -360,23 +360,34 @@ nativeTests.test_get_all_property_names_throwing_proxy_traps = () => { ), ); - const throwingPrototype = new Proxy( - { a: 1 }, +}; + +nativeTests.test_get_all_property_names_get_prototype_throws_in_descriptor_walk = () => { + const napi_key_include_prototypes = 0; + const napi_key_enumerable = 1 << 1; + const napi_key_keep_numbers = 0; + + // Key collection asks the proxy for its prototype once and must succeed so + // the descriptor walk is reached; the walk asks again (the target does not + // own "a") and that second call throws. + let calls = 0; + const proxy = new Proxy( + {}, { + ownKeys: () => ["a"], getPrototypeOf() { - throw new Error("getPrototypeOf trap"); + if (calls++ > 0) throw new Error("getPrototypeOf trap"); + return null; }, }, ); - show( - "include_prototypes getPrototypeOf throws:", - nativeTests.get_all_property_names( - Object.create(throwingPrototype), - napi_key_include_prototypes, - napi_key_enumerable, - napi_key_keep_numbers, - ), + const { status, keys, exception } = nativeTests.get_all_property_names( + Object.create(proxy), + napi_key_include_prototypes, + napi_key_enumerable, + napi_key_keep_numbers, ); + console.log(`status=${status} keys=${JSON.stringify(keys)} exception=${exception?.message} calls=${calls}`); }; nativeTests.test_set_property = () => { diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 1ea94d595caf..df6bf6e46d99 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -873,9 +873,15 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { expect(output).toContain( "include_prototypes gopd throws on prototype: status=10 keys=undefined exception=gopd trap", ); - expect(output).toContain( - "include_prototypes getPrototypeOf throws: status=10 keys=undefined exception=getPrototypeOf trap", - ); + }); + it("returns napi_pending_exception when getPrototypeOf throws during the descriptor walk", async () => { + // Not checkSameOutput: V8 filters proxy keys while collecting them and + // only calls getPrototypeOf once, so a trap that throws on the second + // call never throws under Node. + const output = (await runOn(bunExe(), "test_get_all_property_names_get_prototype_throws_in_descriptor_walk", [])) + .replaceAll(/^\[\w+\].+$/gm, "") + .trim(); + expect(output).toBe("status=10 keys=undefined exception=getPrototypeOf trap calls=2"); }); it("matches Node for Proxy and String wrapper with napi_key_writable/napi_key_configurable", async () => { const output = await checkSameOutput("test_get_all_property_names_proxy_and_string_wrapper", []); From 9b5d79295b1d3bc40ebb4155285b869704d6072f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:34:53 +0000 Subject: [PATCH 06/11] Bun.sql builders: stop reporting the pending exception from inside the lazy property builder The debug-only reportUncaughtExceptionAtEventLoop call in defaultBunSQLObject and constructBunSQLObject ran with the exception still pending on the VM, so a sql module that failed to evaluate aborted debug builds instead of throwing from the Bun.sql read. The exception is propagated to the reader right below it anyway. --- src/jsc/bindings/BunObject.cpp | 6 ----- test/js/bun/util/BunObject.test.ts | 37 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 1590027c07cd..a29142d3baae 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -320,9 +320,6 @@ static JSValue defaultBunSQLObject(VM& vm, JSObject* bunObject) auto scope = DECLARE_THROW_SCOPE(vm); auto* globalObject = defaultGlobalObject(bunObject->globalObject()); JSValue sqlValue = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::BunSql); -#if BUN_DEBUG - if (scope.exception()) globalObject->reportUncaughtExceptionAtEventLoop(globalObject, scope.exception()); -#endif RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, sqlValue.getObject()->get(globalObject, vm.propertyNames->defaultKeyword)); } @@ -332,9 +329,6 @@ static JSValue constructBunSQLObject(VM& vm, JSObject* bunObject) auto scope = DECLARE_THROW_SCOPE(vm); auto* globalObject = defaultGlobalObject(bunObject->globalObject()); JSValue sqlValue = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::BunSql); -#if BUN_DEBUG - if (scope.exception()) globalObject->reportUncaughtExceptionAtEventLoop(globalObject, scope.exception()); -#endif RETURN_IF_EXCEPTION(scope, {}); auto clientData = WebCore::clientData(vm); RELEASE_AND_RETURN(scope, sqlValue.getObject()->get(globalObject, clientData->builtinNames().SQLPublicName())); diff --git a/test/js/bun/util/BunObject.test.ts b/test/js/bun/util/BunObject.test.ts index 63f2ec66c9ae..00469578ea30 100644 --- a/test/js/bun/util/BunObject.test.ts +++ b/test/js/bun/util/BunObject.test.ts @@ -1,6 +1,7 @@ import { env } from "bun"; import { hasNonReifiedStatic } from "bun:internal-for-testing"; import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; test("hasNonReifiedStatic", () => { expect(hasNonReifiedStatic(Bun), "do not eagerly initialize the Bun object. This will make Bun much slower.").toBe( true, @@ -33,3 +34,39 @@ test("await import('bun')", async () => { } expect(BunESM.default).toBe(Bun); }); + +test("a lazy property whose builtin fails to load throws from the read", async () => { + // The shell builtin ($) and the sql module body (sql, SQL, postgres) call Symbol(), so + // breaking it makes each builder throw. The read must throw that error (debug builds used to + // report the still-pending exception from inside the sql builders and abort) and the slot + // must stay unreified so a later read runs the builder again. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `globalThis.Symbol = NaN; + const results = {}; + for (const name of ["$", "sql", "SQL", "postgres"]) { + results[name] = []; + for (let i = 0; i < 2; i++) { + try { Bun[name]; results[name].push("no throw"); } catch (e) { results[name].push(e.constructor.name); } + } + } + console.log(JSON.stringify(results));`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ + $: ["TypeError", "TypeError"], + sql: ["TypeError", "TypeError"], + SQL: ["TypeError", "TypeError"], + postgres: ["TypeError", "TypeError"], + }), + stderr: "", + exitCode: 0, + }); +}); From 122760e6cccd5a1e299d216a1dda667cb67e25ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:34:53 +0000 Subject: [PATCH 07/11] inspect tests: run the throwing property lookup cases in a child and cover a throwing lazy property Each case now checks that only the property whose lookup threw is skipped, and the Bun object case covers a static lazy property whose initializer throws. --- test/js/bun/util/inspect.test.js | 94 ++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 24 deletions(-) diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 080815b66a5d..71e759db8cc4 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -100,30 +100,6 @@ it("when prototype defines the same property, don't print the same property twic expect(Bun.inspect(obj).trim()).toBe('{\n foo: "456",\n}'.trim()); }); -it("Proxy prototype with a getter that throws does not crash", () => { - const proto = { - get foo() { - throw new Error("nope"); - }, - }; - const obj = Object.create(new Proxy(proto, {})); - expect(Bun.inspect(obj)).toBe("{}"); -}); - -it("Proxy prototype with a getPrototypeOf trap that throws does not crash", () => { - const obj = Object.create( - new Proxy( - { foo: 1 }, - { - getPrototypeOf() { - throw new Error("nope"); - }, - }, - ), - ); - expect(Bun.inspect(obj)).toBe("{\n foo: 1,\n}"); -}); - it("Blob inspect", () => { expect(Bun.inspect(new Blob(["123"]))).toBe(`Blob (3 bytes)`); expect(Bun.inspect(new Blob(["123".repeat(900)]))).toBe(`Blob (2.70 KB)`); @@ -603,6 +579,76 @@ it("Bun.inspect huge sparse array summarizes holes without iterating them", asyn }); }); +// A property lookup that throws while an object is being formatted (a Proxy trap in the +// prototype chain, or a lazily initialized property whose initializer throws) used to leave +// the exception pending: the lookups of the following properties failed and were dropped from +// the output, debug builds asserted, and moving on to the next prototype dereferenced the empty +// value returned by the throwing getPrototype. Each case runs in a child so a regression fails +// the test instead of taking down the runner. +describe.concurrent("Bun.inspect when a property lookup throws", () => { + async function inspectInChild(code) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + it("skips a prototype property whose Proxy get trap throws and keeps the rest", async () => { + const result = await inspectInChild(` + const proto = new Proxy({ a: 1, b: 2, c: 3 }, { + get(target, key, receiver) { + if (key === "b") throw new Error("get trap"); + return Reflect.get(target, key, receiver); + }, + }); + const obj = Object.create(proto); + obj.own = 0; + console.log(Bun.inspect(obj)); + `); + expect(result).toEqual({ stdout: "{\n own: 0,\n a: 1,\n c: 3,\n}\n", stderr: "", exitCode: 0 }); + }); + + it("skips a prototype getter that throws behind a Proxy and keeps the rest", async () => { + const result = await inspectInChild(` + const proto = new Proxy({ a: 1, get b() { throw new Error("getter"); }, c: 3 }, {}); + console.log(Bun.inspect(Object.create(proto))); + `); + expect(result).toEqual({ stdout: "{\n a: 1,\n c: 3,\n}\n", stderr: "", exitCode: 0 }); + }); + + it("stops walking the prototype chain at a Proxy whose getPrototypeOf trap throws", async () => { + const result = await inspectInChild(` + const proto = new Proxy({ a: 1 }, { + getPrototypeOf() { + throw new Error("getPrototypeOf trap"); + }, + }); + const obj = Object.create(proto); + obj.own = 0; + console.log(Bun.inspect(obj)); + `); + expect(result).toEqual({ stdout: "{\n own: 0,\n a: 1,\n}\n", stderr: "", exitCode: 0 }); + }); + + it("skips a lazily initialized Bun property whose initializer throws and keeps the rest", async () => { + // Bun.$ is the first property of the Bun object and is built by a builtin that calls + // Symbol(), as are Bun.sql and Bun.SQL further down, so breaking Symbol makes those + // initializers throw while Bun is formatted. Custom inspect functions (Bun.env has one on + // Windows) load node:util the first time one runs, which also needs Symbol, so load it first. + const result = await inspectInChild(` + Bun.inspect({ [Bun.inspect.custom]() { return ""; } }); + globalThis.Symbol = 0; + const out = Bun.inspect(Bun); + console.log(JSON.stringify(["$", "Archive", "version"].map(key => out.includes("\\n " + key + ": ")))); + `); + expect(result).toEqual({ stdout: "[false,true,true]\n", stderr: "", exitCode: 0 }); + }); +}); + describe("console.logging function displays async and generator names", async () => { const cases = [ function () {}, From e6a0b49a0696f55890b132edea82fbaa5b6d01ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:22:09 +0000 Subject: [PATCH 08/11] forEachPropertyImpl: keep the raw prototype pointer; only guard the getPrototype result The iterating pointer is used after every call into JS in the loop body, so it is already live across them; the JSValue holder and EnsureStillAliveScope added nothing. This leaves the fix as the two hunks: clear the exception before acting on the getPropertySlot result, and stop the walk when getPrototype threw. --- src/jsc/bindings/bindings.cpp | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 707df1bab130..8a0706c9ac74 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -5618,16 +5618,9 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: { - JSValue iteratingValue = prototypeObject; - - while (JSObject* iterating = iteratingValue.getObject()) { - if (iterating == globalObject->objectPrototype() || iterating == globalObject->functionPrototype() || (iterating->inherits() && uncheckedDowncast(iterating)->target() != globalObject)) - break; - if (prototypeCount++ >= 5) - break; - - JSC::EnsureStillAliveScope ensureIteratingStillAlive(iteratingValue); + JSObject* iterating = prototypeObject.getObject(); + while (iterating && !(iterating == globalObject->objectPrototype() || iterating == globalObject->functionPrototype() || (iterating->inherits() && uncheckedDowncast(iterating)->target() != globalObject)) && prototypeCount++ < 5) { if constexpr (nonIndexedOnly) { iterating->getOwnNonIndexPropertyNames(globalObject, properties, DontEnumPropertiesMode::Include); } else { @@ -5651,7 +5644,8 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: JSC::PropertySlot slot(object, PropertySlot::InternalMethodType::Get); bool hasProperty = object->getPropertySlot(globalObject, property, slot); - // Ignore exceptions from "Get" proxy traps. + // Ignore exceptions from "Get" proxy traps and throwing lazy property + // initializers; both report the property as not found. CLEAR_IF_EXCEPTION(scope); if (!hasProperty) continue; @@ -5726,11 +5720,12 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: break; if (iterating == globalObject) break; - iteratingValue = iterating->getPrototype(globalObject); + JSValue prototype = iterating->getPrototype(globalObject); // Ignore exceptions from Proxy "getPrototypeOf" trap. CLEAR_IF_EXCEPTION(scope); - if (!iteratingValue) + if (!prototype) break; + iterating = prototype.getObject(); } } From 4e4cc164be55d76e2cc5a3f3fa79de56749dab2e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:22:09 +0000 Subject: [PATCH 09/11] inspect tests: cover a module namespace export in its TDZ; build process.env before breaking Symbol in the Bun.$ test --- test/js/bun/util/BunObject.test.ts | 7 +++++- test/js/bun/util/inspect.test.js | 38 +++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/test/js/bun/util/BunObject.test.ts b/test/js/bun/util/BunObject.test.ts index 00469578ea30..246c2d9f628e 100644 --- a/test/js/bun/util/BunObject.test.ts +++ b/test/js/bun/util/BunObject.test.ts @@ -40,11 +40,16 @@ test("a lazy property whose builtin fails to load throws from the read", async ( // breaking it makes each builder throw. The read must throw that error (debug builds used to // report the still-pending exception from inside the sql builders and abort) and the slot // must stay unreified so a later read runs the builder again. + // + // process.env is read first because the shell builtin reads it before calling Symbol(), and + // building it on Windows reifies another property of the Bun object; doing that in the middle + // of the throwing read trips a separate structure assertion in debug builds. await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", - `globalThis.Symbol = NaN; + `process.env; + globalThis.Symbol = NaN; const results = {}; for (const name of ["$", "sql", "SQL", "postgres"]) { results[name] = []; diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 71e759db8cc4..c61020fe0a37 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isASAN, isWindows, normalizeBunSnapshot, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isASAN, isWindows, normalizeBunSnapshot, tempDir, tmpdirSync } from "harness"; import { join } from "path"; import util from "util"; it("prototype", () => { @@ -580,22 +580,25 @@ it("Bun.inspect huge sparse array summarizes holes without iterating them", asyn }); // A property lookup that throws while an object is being formatted (a Proxy trap in the -// prototype chain, or a lazily initialized property whose initializer throws) used to leave -// the exception pending: the lookups of the following properties failed and were dropped from -// the output, debug builds asserted, and moving on to the next prototype dereferenced the empty -// value returned by the throwing getPrototype. Each case runs in a child so a regression fails -// the test instead of taking down the runner. +// prototype chain, a lazily initialized property whose initializer throws, a module namespace +// export that is still in its temporal dead zone) used to leave the exception pending: the +// lookups of the following properties failed and were dropped from the output or the formatter +// rethrew the exception from the next property, debug builds asserted, and moving on to the +// next prototype dereferenced the empty value returned by the throwing getPrototype. Each case +// runs in a child so a regression fails the test instead of taking down the runner. describe.concurrent("Bun.inspect when a property lookup throws", () => { - async function inspectInChild(code) { + async function runChild(args, cwd) { await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", code], + cmd: [bunExe(), ...args], env: bunEnv, + cwd, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { stdout, stderr, exitCode }; } + const inspectInChild = code => runChild(["-e", code]); it("skips a prototype property whose Proxy get trap throws and keeps the rest", async () => { const result = await inspectInChild(` @@ -647,6 +650,25 @@ describe.concurrent("Bun.inspect when a property lookup throws", () => { `); expect(result).toEqual({ stdout: "[false,true,true]\n", stderr: "", exitCode: 0 }); }); + + it("skips a module namespace export that is in its temporal dead zone and keeps the rest", async () => { + // b.mjs runs while a.mjs is still evaluating, so reading `later` off the namespace throws a + // ReferenceError. console.log used to rethrow it; util.inspect prints such an export as + // ``, this formatter leaves it out. + using dir = tempDir("inspect-tdz-namespace", { + "a.mjs": ` + import "./b.mjs"; + export const later = 1; + export function hoisted() {} + `, + "b.mjs": ` + import * as a from "./a.mjs"; + console.log(a); + `, + }); + const result = await runChild(["a.mjs"], String(dir)); + expect(result).toEqual({ stdout: "Module {\n hoisted: [Function: hoisted],\n}\n", stderr: "", exitCode: 0 }); + }); }); describe("console.logging function displays async and generator names", async () => { From fa96682113f37a35ef7ec086133cba55a5b85e7c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:00:29 +0000 Subject: [PATCH 10/11] forEachPropertyImpl: one-line comment at the getPropertySlot clear --- src/jsc/bindings/bindings.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 8a0706c9ac74..d288141faab6 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -5644,8 +5644,7 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: JSC::PropertySlot slot(object, PropertySlot::InternalMethodType::Get); bool hasProperty = object->getPropertySlot(globalObject, property, slot); - // Ignore exceptions from "Get" proxy traps and throwing lazy property - // initializers; both report the property as not found. + // Ignore exceptions from "Get" proxy traps and lazy initializers; they also report the property as not found. CLEAR_IF_EXCEPTION(scope); if (!hasProperty) continue; From 3f713ff4e80ec670527b80d479501df2920d73a1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:21:28 +0000 Subject: [PATCH 11/11] ci: rerun after GitHub rate limiting failed the git-dependency install tests