From e8b329120f025a275a8e3ea65ca178ce7032c737 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:11:37 +0000 Subject: [PATCH 1/2] Clear exceptions thrown by property lookups during inspect's property walk forEachPropertyImpl only cleared the exception from getPropertySlot when the lookup reported the property as found. A Proxy get trap or a lazy static property builder that throws makes getPropertySlot return false, so the loop moved on to the next property with that exception still pending. Every following lookup then failed as well (release builds drop the rest of the object's properties from the output), the next lazy builder tripped the exception scope assertion in debug builds, and a Proxy getPrototypeOf step returned an empty value that was dereferenced. Clear the exception before checking the lookup result, and stop the prototype walk when getPrototype itself throws instead of calling getObject() on an empty JSValue. The debug-only reportUncaughtExceptionAtEventLoop calls in the Bun.sql builders ran the uncaught exception handler while the module load error was still pending on the VM, which hits JSC assertions in the property get inside that handler. The error is propagated to the caller right after, so the extra report is not needed. --- src/jsc/bindings/BunObject.cpp | 6 --- src/jsc/bindings/bindings.cpp | 14 ++++-- test/js/bun/util/inspect.test.js | 79 ++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 04c1c4ca808b..02cee98b85fe 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/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 40e19b3e571e..357c6d13d27e 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -5605,10 +5605,11 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: } JSC::PropertySlot slot(object, PropertySlot::InternalMethodType::Get); - if (!object->getPropertySlot(globalObject, property, slot)) - continue; - // Ignore exceptions from "Get" proxy traps. + bool hasProperty = object->getPropertySlot(globalObject, property, slot); + // Ignore exceptions from "Get" proxy traps and lazy property builders. CLEAR_IF_EXCEPTION(scope); + if (!hasProperty) + continue; if ((slot.attributes() & PropertyAttribute::DontEnum) != 0) { if (property == propertyNames->underscoreProto @@ -5680,7 +5681,12 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: break; if (iterating == globalObject) break; - iterating = iterating->getPrototype(globalObject).getObject(); + JSValue prototype = iterating->getPrototype(globalObject); + // Ignore exceptions from Proxy "getPrototypeOf" traps. + CLEAR_IF_EXCEPTION(scope); + if (!prototype) [[unlikely]] + break; + iterating = prototype.getObject(); } } diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 5c91f3dbe05f..a816629a34c6 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -928,3 +928,82 @@ describe.skipIf(!isASAN)("object mutated while being formatted", () => { expect(exitCode).toBe(0); }); }); + +// Run in a child: a regression here segfaults the process instead of throwing. +describe("property lookup throws while formatting an object", () => { + it.concurrent("Proxy traps in the prototype chain", async () => { + const fixture = ` + { + // Only "a" throws, so the walk has to carry on past it without the + // exception still pending when "b" and "c" are looked up. + const proto = new Proxy( + { a: 1, b: 2, c: 3 }, + { + get(target, key, receiver) { + if (key === "a") throw new Error("get trap"); + return Reflect.get(target, key, receiver); + }, + }, + ); + console.log(Bun.inspect(Object.create(proto))); + } + { + const proto = new Proxy( + { a: 1 }, + { + getPrototypeOf() { + throw new Error("getPrototypeOf trap"); + }, + }, + ); + const obj = Object.create(proto); + obj.x = 1; + console.log(Bun.inspect(obj)); + console.log(obj); + } + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toMatchInlineSnapshot(` + "{ + b: 2, + c: 3, + } + { + x: 1, + a: 1, + } + { + x: 1, + a: 1, + } + " + `); + expect(exitCode).toBe(0); + }); + + it.concurrent("lazy property of the Bun object", async () => { + // Bun.redis builds the default client the first time it is read, and an + // invalid REDIS_URL makes that throw. Only that property may be left out; + // the properties visited after it must still be printed. + const fixture = ` + const keys = Object.keys(Bun); + const out = Bun.inspect(Bun); + console.log(JSON.stringify(keys.filter(key => !out.includes("\\n " + key + ":")))); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { ...bunEnv, REDIS_URL: "http://not-a-redis-url" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe('["redis"]\n'); + expect(exitCode).toBe(0); + }); +}); From b5b9f068d8cbc2cd3d49be2b238dd84ed5b699d1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:20:06 +0000 Subject: [PATCH 2/2] Read the child's stderr in the new inspect tests --- test/js/bun/util/inspect.test.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index a816629a34c6..9a29c9562ed2 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -968,7 +968,8 @@ describe("property lookup throws while formatting an object", () => { stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); expect(stdout).toMatchInlineSnapshot(` "{ b: 2, @@ -1002,7 +1003,8 @@ describe("property lookup throws while formatting an object", () => { stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); expect(stdout).toBe('["redis"]\n'); expect(exitCode).toBe(0); });