From 4879c9a83232cfcbe00ad22a1b70638b67ed687c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:27:25 +0000 Subject: [PATCH 1/2] Clear exceptions from failed property lookups while formatting objects JSC__JSValue__forEachPropertyImpl left the exception pending when getPropertySlot returned false because a lazy property initializer or a Proxy trap threw. Every later lookup on the object then failed because of the stale exception, and walking to the next prototype called getObject() on the empty value returned by a throwing getPrototype, a null dereference. The same null dereference was reachable directly through a Proxy whose getPrototypeOf trap throws. Also drop the debug-only reporting of Bun.sql module load failures, which reported the exception while it was still pending on the VM and tripped a Structure assertion in debug builds. --- src/jsc/bindings/BunObject.cpp | 6 --- src/jsc/bindings/bindings.cpp | 14 ++++-- test/js/bun/util/inspect.test.js | 74 ++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 8a022228eb4c..e4d4ab407ba5 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -319,9 +319,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)); } @@ -331,9 +328,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 c084a885ee86..2fefd4b292f1 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -5596,10 +5596,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 initializers. CLEAR_IF_EXCEPTION(scope); + if (!hasProperty) + continue; if ((slot.attributes() & PropertyAttribute::DontEnum) != 0) { if (property == propertyNames->underscoreProto @@ -5671,7 +5672,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 "getPrototypeOf" proxy 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..9d35635c432d 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -467,6 +467,80 @@ describe("crash testing", () => { } }); +describe("exceptions thrown while walking properties", () => { + // Each case crashed the process before the walk cleared the exception, so + // they run in a subprocess. + async function run(fixture) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + 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.concurrent("a throwing Proxy trap in the prototype chain skips only that property", async () => { + const { stdout, stderr, exitCode } = await run(` + { + const proto = new Proxy( + { a: 1, b: 2, c: 3 }, + { + get(target, key, receiver) { + if (key === "a") throw new Error("boom"); + return Reflect.get(target, key, receiver); + }, + }, + ); + console.log(Bun.inspect(Object.create(proto))); + } + { + const proto = new Proxy( + { a: 1 }, + { + getPrototypeOf() { + throw new Error("boom"); + }, + }, + ); + console.log(Bun.inspect(Object.create(proto))); + } + `); + expect(stdout).toMatchInlineSnapshot(` + "{ + b: 2, + c: 3, + } + { + a: 1, + } + " + `); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + it.concurrent("a throwing lazy property initializer skips only that property", async () => { + // The builtin behind Bun.$ calls the global Symbol, so clobbering it makes + // that lazy property throw when it is first reified. + const { stdout, stderr, exitCode } = await run(` + globalThis.Symbol = NaN; + let threw = false; + try { + Bun.$; + } catch { + threw = true; + } + const inspected = Bun.inspect(Bun); + console.log(threw, inspected.includes("Archive:"), inspected.includes("version:")); + `); + expect(stdout).toBe("true true true\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); +}); + it("possibly formatted emojis log", () => { expect(Bun.inspect("✔")).toBe('"✔"'); }); From f548930f4e570decb90c02e5b5caab42e7d1ee3c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:19:43 +0000 Subject: [PATCH 2/2] Make the lazy property test fail only Bun.$ Replacing the whole Symbol global also broke node:util, which Windows needs to format process.env, so the subprocess died for an unrelated reason there. Intercept just the Symbol("cwd") call the shell builtin makes instead. --- test/js/bun/util/inspect.test.js | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 9d35635c432d..2ee9f83a3ca8 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -468,8 +468,9 @@ describe("crash testing", () => { }); describe("exceptions thrown while walking properties", () => { - // Each case crashed the process before the walk cleared the exception, so - // they run in a subprocess. + // Before the walk cleared these exceptions the process crashed (or, in the + // second case, a debug build asserted), and the second case replaces a + // global, so each case runs in a subprocess. async function run(fixture) { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], @@ -522,20 +523,24 @@ describe("exceptions thrown while walking properties", () => { }); it.concurrent("a throwing lazy property initializer skips only that property", async () => { - // The builtin behind Bun.$ calls the global Symbol, so clobbering it makes - // that lazy property throw when it is first reified. + // The builtin behind Bun.$ calls the global Symbol("cwd"), so making that + // one call throw makes Bun.$ (and nothing else on Bun) fail to reify while + // Bun is formatted. The properties listed after it must still be printed. const { stdout, stderr, exitCode } = await run(` - globalThis.Symbol = NaN; - let threw = false; - try { - Bun.$; - } catch { - threw = true; - } + globalThis.Symbol = new Proxy(Symbol, { + apply(target, thisArg, args) { + if (args[0] === "cwd") throw new Error("boom"); + return Reflect.apply(target, thisArg, args); + }, + }); const inspected = Bun.inspect(Bun); - console.log(threw, inspected.includes("Archive:"), inspected.includes("version:")); + console.log( + inspected.includes("\\n $: "), + inspected.includes("\\n Archive: "), + inspected.includes("\\n version: "), + ); `); - expect(stdout).toBe("true true true\n"); + expect(stdout).toBe("false true true\n"); expect(stderr).toBe(""); expect(exitCode).toBe(0); });