From 428e6f086acc0a30d2c8a59cc78db76bd9b19f7c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:54:32 +0000 Subject: [PATCH] Clear the exception left by a failed lazy property in forEachProperty getPropertySlot() returns false with an exception pending when a static lazy property's initializer throws. JSC__JSValue__forEachPropertyImpl skipped straight to the next property in that case, so the next lazy property was reified with that exception still set: an assertion in debug builds, and in release builds setUpStaticFunctionSlot reported every following property as missing until an already reified one cleared the exception. Clear it before moving on, like forEachPropertyOrdered already does. Also drop the debug-only reporting in the Bun.sql builders: it reported the exception while it was still pending, which made the property lookups inside the reporter trip over the same thing. --- src/jsc/bindings/BunObject.cpp | 6 --- src/jsc/bindings/bindings.cpp | 7 +-- test/js/bun/util/inspect.test.js | 78 ++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 9 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/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 983a348f73a5..47e68a10ef7c 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; - // 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 diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 5c91f3dbe05f..840dd014bf30 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -579,6 +579,84 @@ it("Bun.inspect huge sparse array summarizes holes without iterating them", asyn }); }); +// When a lazily initialized property throws while being looked up, the property walk used to +// leave that exception pending. The next lazy property was then initialized with an exception +// already set (an assertion in debug builds), and in release builds every property after the +// failing one was dropped from the output until an already-initialized property was reached. +describe("Bun.inspect and lazy properties whose initializer throws", () => { + it.concurrent("only the property that failed is left out", async () => { + // An unparseable REDIS_URL makes the Bun.redis initializer throw. The properties declared + // after it (secrets, write, zstd*) must still be printed. + const code = ` + try { + Bun.redis; + } catch (e) { + console.log("Bun.redis threw:", e.message); + } + const out = Bun.inspect(Bun, { depth: 0 }); + for (const name of ["redis", "secrets", "write", "zstdDecompress"]) { + console.log(name + ":", out.includes("\\n " + name + ": ")); + } + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: { ...bunEnv, REDIS_URL: "not a url" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe( + [ + "Bun.redis threw: Invalid URL format", + "redis: false", + "secrets: true", + "write: true", + "zstdDecompress: true", + "", + ].join("\n"), + ); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + it.concurrent("initializers that run out of stack", async () => { + // Bun.$, Bun.sql and friends run JS to build their value, so with (almost) no JS stack left + // they throw and are left out. Unwind a stack overflow one frame at a time and check the + // first Bun.inspect(Bun) call that completes: Archive (declared right after $) and version + // have initializers that cannot fail, so they must be in the output. + const code = ` + let result; + function recurse() { + try { + recurse(); + } catch {} + if (result !== undefined) return; + let out; + try { + out = Bun.inspect(Bun, { depth: 0 }); + } catch { + return; + } + result = out.includes("\\n Archive: ") && out.includes("\\n version: ") ? "ok" : out; + } + recurse(); + console.log(result); + const out = Bun.inspect(Bun, { depth: 0 }); + console.log(out.includes("\\n $: ") && out.includes("\\n Archive: ") && out.includes("\\n version: ") ? "ok" : out); + `; + 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]); + expect(stdout).toBe("ok\nok\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); +}); + describe("console.logging function displays async and generator names", async () => { const cases = [ function () {},