From 01f18bf007ec88c3838bce391e850a4107bc5630 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:29:39 +0000 Subject: [PATCH 1/7] Clear pending exception when a property slot lookup throws during inspect JSC__JSValue__forEachPropertyImpl skipped its CLEAR_IF_EXCEPTION when getPropertySlot returned false, but a lazy static property whose builder throws (and a throwing proxy trap) reports the slot as not found while leaving the exception pending. The walk then continued with a stale VM exception, which trips releaseAssertNoException in debug builds. Reproduced by clobbering a global the Bun.$ builtin reads at setup time and then printing the Bun object: globalThis.process = undefined; console.log(Bun); Clear the exception before the continue, matching JSC__JSValue__forEachPropertyOrdered. --- src/jsc/bindings/bindings.cpp | 7 ++++--- test/js/bun/util/inspect.test.js | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index c084a885ee86..256f14cde08b 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 builders. 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..5014ae70887d 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -928,3 +928,24 @@ describe.skipIf(!isASAN)("object mutated while being formatted", () => { expect(exitCode).toBe(0); }); }); + +// The Bun object's lazy properties (like Bun.$) are built on first access. With +// `process` clobbered, building Bun.$ throws, and the property walk used by +// console.log must clear that pending exception instead of leaving it set, +// which aborted debug builds at the next exception-scope assertion. +it("console.log survives a lazy property builder throwing mid-walk", async () => { + const code = ` + globalThis.process = undefined; + console.log(Bun); + console.log("SURVIVED"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toContain("SURVIVED"); + expect(exitCode).toBe(0); +}); From 0d0389001f1b49d91a41950d94de763afefae3ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:34:07 +0000 Subject: [PATCH 2/7] Drain child stderr in the inspect regression test --- test/js/bun/util/inspect.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 5014ae70887d..d7f95f66d8ec 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -945,7 +945,7 @@ it("console.log survives a lazy property builder throwing mid-walk", async () => stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stdout).toContain("SURVIVED"); expect(exitCode).toBe(0); }); From 6ae6ac0b7f822237940ce9704184e7634b666585 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:03:11 +0000 Subject: [PATCH 3/7] Install a fallback when the util.inspect lazy property initializer throws Requiring node:util runs the module body, which throws when user code has clobbered globals it reads (globalThis.process = undefined). The lazy property initializers for m_utilInspectFunction and m_utilInspectStylizeColorFunction returned early on exception without calling init.set(), violating the LazyProperty contract: debug builds hit the lazyTag assertion and release builds fail fast. Reachable from plain JS on every platform by formatting any object with a nodejs.util.inspect.custom function after clobbering process. On Windows console.log(Bun) reaches it through Bun.env, whose custom inspect loads util.inspect. Clear the non-termination exception and install a stub inspect function (or the no-color stylizer) so formatting degrades instead of aborting. --- src/jsc/bindings/ZigGlobalObject.cpp | 59 +++++++++++++++++++--------- test/js/bun/util/inspect.test.js | 22 +++++++++++ 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 3022c09cafe3..8334b2b39d03 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -1929,6 +1929,13 @@ extern "C" napi_env ZigGlobalObject__makeNapiEnvForFFI(Zig::GlobalObject* global return globalObject->makeNapiEnvForFFI(); } +// Fallback for when requiring node:util throws during lazy initialization of +// m_utilInspectFunction (e.g. globalThis.process was clobbered by user code). +JSC_DEFINE_HOST_FUNCTION(jsFunctionUtilInspectFallback, (JSGlobalObject * globalObject, CallFrame*)) +{ + return JSValue::encode(jsEmptyString(globalObject->vm())); +} + JSC_DEFINE_HOST_FUNCTION(jsFunctionPerformMicrotaskVariadic, (JSGlobalObject * globalObject, CallFrame* callframe)) { auto& vm = JSC::getVM(globalObject); @@ -2307,12 +2314,21 @@ void GlobalObject::finishCreation(VM& vm) [](const Initializer& init) { auto scope = DECLARE_THROW_SCOPE(init.vm); JSValue nodeUtilValue = uncheckedDowncast(init.owner)->internalModuleRegistry()->requireId(init.owner, init.vm, Bun::InternalModuleRegistry::Field::NodeUtil); - RETURN_IF_EXCEPTION(scope, ); - RELEASE_ASSERT(nodeUtilValue.isObject()); - auto prop = nodeUtilValue.getObject()->getIfPropertyExists(init.owner, Identifier::fromString(init.vm, "inspect"_s)); - RETURN_IF_EXCEPTION(scope, ); - ASSERT(prop); - init.set(uncheckedDowncast(prop)); + if (!scope.exception()) [[likely]] { + RELEASE_ASSERT(nodeUtilValue.isObject()); + auto prop = nodeUtilValue.getObject()->getIfPropertyExists(init.owner, Identifier::fromString(init.vm, "inspect"_s)); + if (!scope.exception()) [[likely]] { + if (auto* inspect = dynamicDowncast(prop)) [[likely]] { + init.set(inspect); + return; + } + } + } + // Requiring node:util runs JS that can throw (e.g. user code clobbered + // globalThis.process). The initializer must still set the property, so + // degrade to a stub instead of crashing. + (void)scope.tryClearException(); + init.set(JSFunction::create(init.vm, init.owner, 2, "inspect"_s, jsFunctionUtilInspectFallback, ImplementationVisibility::Public)); }); m_utilInspectOptionsStructure.initLater( @@ -2334,21 +2350,26 @@ void GlobalObject::finishCreation(VM& vm) auto scope = DECLARE_THROW_SCOPE(init.vm); JSC::MarkedArgumentBuffer args; args.append(uncheckedDowncast(init.owner)->utilInspectFunction()); - RETURN_IF_EXCEPTION(scope, ); - JSC::JSFunction* getStylize = JSC::JSFunction::create(init.vm, init.owner, utilInspectGetStylizeWithColorCodeGenerator(init.vm), init.owner); - RETURN_IF_EXCEPTION(scope, ); - - JSC::CallData callData = JSC::getCallData(getStylize); - NakedPtr returnedException = nullptr; - auto result = JSC::profiledCall(init.owner, ProfilingReason::API, getStylize, callData, jsNull(), args, returnedException); - RETURN_IF_EXCEPTION(scope, ); - - if (returnedException) { - throwException(init.owner, scope, returnedException.get()); + if (!scope.exception()) [[likely]] { + JSC::JSFunction* getStylize = JSC::JSFunction::create(init.vm, init.owner, utilInspectGetStylizeWithColorCodeGenerator(init.vm), init.owner); + + JSC::CallData callData = JSC::getCallData(getStylize); + NakedPtr returnedException = nullptr; + auto result = JSC::profiledCall(init.owner, ProfilingReason::API, getStylize, callData, jsNull(), args, returnedException); + if (returnedException) [[unlikely]] + throwException(init.owner, scope, returnedException.get()); + if (!scope.exception()) [[likely]] { + if (auto* stylize = dynamicDowncast(result)) [[likely]] { + init.set(stylize); + return; + } + } } - RETURN_IF_EXCEPTION(scope, ); - init.set(uncheckedDowncast(result)); + // The initializer must still set the property even when util.inspect is + // unavailable; fall back to the stylizer that applies no colors. + (void)scope.tryClearException(); + init.set(JSC::JSFunction::create(init.vm, init.owner, utilInspectStylizeWithNoColorCodeGenerator(init.vm), init.owner)); }); m_utilInspectStylizeNoColorFunction.initLater( diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index d7f95f66d8ec..6d1b5c6b0cc1 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -949,3 +949,25 @@ it("console.log survives a lazy property builder throwing mid-walk", async () => expect(stdout).toContain("SURVIVED"); expect(exitCode).toBe(0); }); + +// util.inspect is loaded lazily the first time a custom inspect function is +// formatted. With `process` clobbered, requiring node:util throws; the lazy +// property initializer must still install a fallback instead of aborting. +// On Windows, console.log(Bun) also reaches this through Bun.env. +it("console.log survives util.inspect failing to load for custom inspect", async () => { + const code = ` + globalThis.process = undefined; + console.log({ [Symbol.for("nodejs.util.inspect.custom")]() { return "hi" } }); + console.log("SURVIVED"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("hi"); + expect(stdout).toContain("SURVIVED"); + expect(exitCode).toBe(0); +}); From 4349414770d501eb310ffa4b68fe6da4e92d64cb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:05:12 +0000 Subject: [PATCH 4/7] Shorten fallback comments --- src/jsc/bindings/ZigGlobalObject.cpp | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 8334b2b39d03..a02957cf7822 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -1929,8 +1929,7 @@ extern "C" napi_env ZigGlobalObject__makeNapiEnvForFFI(Zig::GlobalObject* global return globalObject->makeNapiEnvForFFI(); } -// Fallback for when requiring node:util throws during lazy initialization of -// m_utilInspectFunction (e.g. globalThis.process was clobbered by user code). +// Stub installed by m_utilInspectFunction's initializer when node:util fails to load. JSC_DEFINE_HOST_FUNCTION(jsFunctionUtilInspectFallback, (JSGlobalObject * globalObject, CallFrame*)) { return JSValue::encode(jsEmptyString(globalObject->vm())); @@ -2324,9 +2323,7 @@ void GlobalObject::finishCreation(VM& vm) } } } - // Requiring node:util runs JS that can throw (e.g. user code clobbered - // globalThis.process). The initializer must still set the property, so - // degrade to a stub instead of crashing. + // Requiring node:util can throw; a LazyProperty initializer must always init.set(). (void)scope.tryClearException(); init.set(JSFunction::create(init.vm, init.owner, 2, "inspect"_s, jsFunctionUtilInspectFallback, ImplementationVisibility::Public)); }); @@ -2366,8 +2363,7 @@ void GlobalObject::finishCreation(VM& vm) } } } - // The initializer must still set the property even when util.inspect is - // unavailable; fall back to the stylizer that applies no colors. + // The initializer must still init.set() when util.inspect is unavailable. (void)scope.tryClearException(); init.set(JSC::JSFunction::create(init.vm, init.owner, utilInspectStylizeWithNoColorCodeGenerator(init.vm), init.owner)); }); From 5c287446cd319bd1534953d36f9934aca4c70f38 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:22:55 +0000 Subject: [PATCH 5/7] Use the colorless stylizer when util.inspect has no styles table getStylizeWithColor returns a closure without reading its argument, so the color stylizer initializer happily wrapped the fallback inspect stub and the closure then threw on first stylize call (the stub has no styles or colors). Check for inspect.styles before building the color stylizer so the broken environment gets stylizeWithNoColor instead. --- src/jsc/bindings/ZigGlobalObject.cpp | 32 +++++++++++++++++----------- test/js/bun/util/inspect.test.js | 20 +++++++++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index a02957cf7822..a7678b216c74 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2345,21 +2345,27 @@ void GlobalObject::finishCreation(VM& vm) m_utilInspectStylizeColorFunction.initLater( [](const Initializer& init) { auto scope = DECLARE_THROW_SCOPE(init.vm); - JSC::MarkedArgumentBuffer args; - args.append(uncheckedDowncast(init.owner)->utilInspectFunction()); + JSC::JSFunction* inspect = uncheckedDowncast(init.owner)->utilInspectFunction(); if (!scope.exception()) [[likely]] { - JSC::JSFunction* getStylize = JSC::JSFunction::create(init.vm, init.owner, utilInspectGetStylizeWithColorCodeGenerator(init.vm), init.owner); - - JSC::CallData callData = JSC::getCallData(getStylize); - NakedPtr returnedException = nullptr; - auto result = JSC::profiledCall(init.owner, ProfilingReason::API, getStylize, callData, jsNull(), args, returnedException); - if (returnedException) [[unlikely]] - throwException(init.owner, scope, returnedException.get()); - if (!scope.exception()) [[likely]] { - if (auto* stylize = dynamicDowncast(result)) [[likely]] { - init.set(stylize); - return; + // stylizeWithColor reads inspect.styles at call time; the fallback stub + // installed when node:util fails to load has none, so stay colorless then. + JSValue styles = inspect->getIfPropertyExists(init.owner, Identifier::fromString(init.vm, "styles"_s)); + if (!scope.exception() && styles && styles.isObject()) [[likely]] { + JSC::MarkedArgumentBuffer args; + args.append(inspect); + JSC::JSFunction* getStylize = JSC::JSFunction::create(init.vm, init.owner, utilInspectGetStylizeWithColorCodeGenerator(init.vm), init.owner); + + JSC::CallData callData = JSC::getCallData(getStylize); + NakedPtr returnedException = nullptr; + auto result = JSC::profiledCall(init.owner, ProfilingReason::API, getStylize, callData, jsNull(), args, returnedException); + if (returnedException) [[unlikely]] + throwException(init.owner, scope, returnedException.get()); + if (!scope.exception()) [[likely]] { + if (auto* stylize = dynamicDowncast(result)) [[likely]] { + init.set(stylize); + return; + } } } } diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 6d1b5c6b0cc1..11bcfd265ead 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -971,3 +971,23 @@ it("console.log survives util.inspect failing to load for custom inspect", async expect(stdout).toContain("SURVIVED"); expect(exitCode).toBe(0); }); + +// With colors on, options.stylize comes from a closure built around util.inspect. +// When util.inspect failed to load, the colorless stylizer must be used instead. +it("stylize degrades to no color when util.inspect failed to load", async () => { + const code = ` + globalThis.process = undefined; + console.log({ [Symbol.for("nodejs.util.inspect.custom")](depth, options) { return options.stylize("hi", "string"); } }); + console.log("SURVIVED"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: { ...bunEnv, FORCE_COLOR: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("hi"); + expect(stdout).toContain("SURVIVED"); + expect(exitCode).toBe(0); +}); From a81eb32b649b9b2ba7ee71c85e3ebf0959a3e2d0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:30:35 +0000 Subject: [PATCH 6/7] Shorten stylize fallback comment --- src/jsc/bindings/ZigGlobalObject.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index a7678b216c74..fbe87687bf0c 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2348,8 +2348,7 @@ void GlobalObject::finishCreation(VM& vm) JSC::JSFunction* inspect = uncheckedDowncast(init.owner)->utilInspectFunction(); if (!scope.exception()) [[likely]] { - // stylizeWithColor reads inspect.styles at call time; the fallback stub - // installed when node:util fails to load has none, so stay colorless then. + // stylizeWithColor reads inspect.styles at call time; the fallback stub has none. JSValue styles = inspect->getIfPropertyExists(init.owner, Identifier::fromString(init.vm, "styles"_s)); if (!scope.exception() && styles && styles.isObject()) [[likely]] { JSC::MarkedArgumentBuffer args; From c4ad31e9e475d2a1739e5df5fbe0095277cb463f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:34:18 +0000 Subject: [PATCH 7/7] Assert exact stdout in the util.inspect fallback tests --- test/js/bun/util/inspect.test.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 11bcfd265ead..08faac2cd7b9 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -967,8 +967,7 @@ it("console.log survives util.inspect failing to load for custom inspect", async stderr: "pipe", }); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toContain("hi"); - expect(stdout).toContain("SURVIVED"); + expect(stdout).toBe("hi\nSURVIVED\n"); expect(exitCode).toBe(0); }); @@ -987,7 +986,6 @@ it("stylize degrades to no color when util.inspect failed to load", async () => stderr: "pipe", }); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toContain("hi"); - expect(stdout).toContain("SURVIVED"); + expect(stdout).toBe("hi\nSURVIVED\n"); expect(exitCode).toBe(0); });