Fix crash and dropped properties in Bun.inspect when a property lookup or getPrototypeOf throws during the property walk
Problem
Bun.inspect(),console.log()andexpect()failure output crash withSegmentation fault at address 0x5(debug builds: UBSan "member call on null pointer of type 'JSC::JSCell'" inJSCJSValueCell.h) when a property lookup throws while an object is being formatted. Release repro:
and likewise with a Proxy whoseconst proto = new Proxy({ a: 1 }, { getPrototypeOf() { throw new Error("boom"); } }); console.log(Object.create(proto));gettrap (or a getter reached through a Proxy) throws for one property.- The same happens with a lazily initialized property of the
Bunobject whose initializer throws (fuzzer sample:globalThis.Symbolreplaced, thenBun.inspect(Bun); the builtin behindBun.$callsSymbol("cwd")). Release builds printBunwith most of its properties missing (72 of 115 on the shipped build); debug builds abort withASSERTION FAILED: Unexpected exception observed/Symbol is not a function. (In 'Symbol("cwd")' ...)when the next lazy property is built. - Plain-JavaScript variant of the same leak:
console.logof a module namespace during an import cycle, when one export is still in its temporal dead zone, throwsReferenceError: Cannot access 'x' before initializationout ofconsole.log(the stale exception is picked up while the next export is formatted).util.inspectprints such an export as<uninitialized>. - Cause, in the slow path of
JSC__JSValue__forEachPropertyImpl(src/jsc/bindings/bindings.cpp):object->getPropertySlot()reports a throwing Proxy trap, a throwing lazy initializer or a TDZ namespace export as "not found" with the exception still pending, and the loopcontinued before theCLEAR_IF_EXCEPTIONbelow it. The following lookups and formatting callbacks then run with that exception pending (the dropped properties, the rethrownReferenceError, the debug assertion).- When the walk moves to the next prototype,
iterating->getPrototype(globalObject).getObject()runsgetObject()on the emptyJSValuethatgetPrototypereturns when it threw (either because of the stale exception above or because thegetPrototypeOftrap itself throws). The empty value passesisCell(), so this reads the type byte of a null cell: the fault at address 5.
napi_get_all_property_names(src/jsc/bindings/napi.cpp, descriptor filter loop) has the samegetPrototype().getObject()chain after an uncheckedgetOwnPropertyDescriptor, so a Proxy trap throwing there returnednapi_okwith an exception pending in own-only mode and segfaulted in include-prototypes mode.defaultBunSQLObject/constructBunSQLObject(src/jsc/bindings/BunObject.cpp) had a debug-only block that handed a sql module load failure toreportUncaughtExceptionAtEventLoopwhile the exception was still pending on the VM, soglobalThis.Symbol = NaN; Bun.sql(and the fuzzer sample above, once the walk gets pastBun.$) aborted debug builds withASSERTION FAILED: ... object->structure() == thisinStructure::storedPrototypeinstead of throwing.
Fix
bindings.cpp: clear the exception aftergetPropertySlotregardless of its result, which is what the ordered variantJSC__JSValue__forEachPropertyOrderedalready does; read the next prototype into aJSValue, clear the exception and stop the walk when it is empty. (An earlier revision also held the prototype being walked under anEnsureStillAliveScope; dropped, since the raw pointer is used after every call into JS in the loop body and so is live across them anyway.)- Behaviour change to note: a property whose lookup throws is now left out of the output instead of the whole
console.log/Bun.inspectcall throwing or crashing. For TDZ namespace exports this differs fromutil.inspect's<uninitialized>; printing that marker would be a formatter feature on top of this fix and is not attempted here. napi.cpp: check for an exception aftergetOwnPropertyDescriptorand aftergetPrototypeand returnnapi_pending_exception, which is what Node returns for these cases.BunObject.cpp: drop the debug-only report. The exception is propagated to the reader by theRETURN_IF_EXCEPTIONright below it, so debug builds now behave like release builds (Bun.sqlthrows).- Why this is the right place: the formatter deliberately swallows errors thrown by individual properties (getters, traps) and prints the rest of the object; these two sites were the only ones in the walk that acted on a "not found" result or a prototype value before clearing the exception that produced it. Skipping just the property (or stopping at just the prototype) whose lookup threw is the existing behaviour for every other throw site in this function.
- Verification:
test/js/bun/util/inspect.test.js, "Bun.inspect when a property lookup throws" (5 spawned cases): a Proxygettrap, a getter behind a Proxy, agetPrototypeOftrap, a throwing lazyBunproperty, and a two-file import cycle with a TDZ export. Without the fix (shipped release build and an unfixed debug build) all five fail: the Proxy children segfault / fail UBSan, theBunchild prints[false,false,...]on release and aborts on debug, the cycle child exits 1 with theReferenceError; with it each prints everything except the one property whose lookup threw.test/js/bun/util/BunObject.test.ts, "a lazy property whose builtin fails to load throws from the read":Bun.$/sql/SQL/postgreswithSymbolbroken throw aTypeErroron two consecutive reads. Aborts on an unfixed debug build (theBunObject.cpphunk); passes on release either way, as the removed block is debug-only. The fixture buildsprocess.envbefore breakingSymbolbecause the$builder reads it, and building it on Windows reifies anotherBunproperty mid-lookup, which on a Windows debug build would hit the separatestoredPrototypeassertion that #37001 fixes (verified on Linux only).test/napi/napi.test.ts:getOwnPropertyDescriptortrap throwing in own-only and include-prototypes mode (compared against Node), and agetPrototypeOftrap that throws on the second call so the check aftergetPrototypeis the one that fires.- Repros above and the tests also run clean under
BUN_JSC_validateExceptionChecks=1.
Background
forEachPropertyImplis the property walk behind Bun's native formatter. It collects the property names of the object and of up to five prototypes, looks each one up through the original object withgetPropertySlot, and hands the value to a callback that formats it. Errors thrown by individual properties are swallowed on purpose so that one bad getter does not makeconsole.logthrow.- A JSC exception is "pending" state on the VM, not C++ unwinding. A function that throws returns a failure value (
false, or the emptyJSValue) and leaves the exception on the VM; until something clears or rethrows it, most JSC entry points return early as soon as they are called, and debug builds assert when a function that did not throw is observed returning with an exception pending.CLEAR_IF_EXCEPTIONdrops the pending exception. - The empty
JSValue(JSValue()) is encoded as 0.isCell()is true for it, sogetObject()on it dereferences a null cell pointer rather than returning null; callers have to test the value itself first. - Lazy properties of the
Bunobject are entries in a static property table whose value is produced by a builder the first time the property is read (PropertyCallback). Some builders evaluate built-in JavaScript modules (the shell forBun.$, the sql module forBun.sql), so they can throw when that module fails to evaluate, and JSC reports that to the reader as "property not found" plus a pending exception.
Consolidated duplicates
Found repeatedly by the fuzzer (fingerprint d678cafe50a2ad6e). Earlier round, folded in here in April: #29071 #28991 #28919 #28918 #28882 #28854 #28530 #28325. This round, closed in favour of this PR: #30099 #30245 #37160 #37175 #37213 #37256 #37428 #38700 #38921 #39363 #39365 #39380 #39412 #39413 (and #39411, closed earlier). The BunObject.cpp hunk and the BunObject.test.ts test come from #30245 / #37428; the same hunk is also part of #37001, which fixes the underlying storedPrototype assertion in JSC.
Related fixes that are not part of this bug and stay open on their own: #39382 (a custom inspect function when node:util fails to load), #37202 (util.isError with a throwing getPrototypeOf trap), #37331 (forEachPropertyOrdered when the callback throws), #37001 (stale structure in JSObject::getPropertySlot), #32263 (additional checks in the same napi loop).
no test proof · iteration 11 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/inspect.test.js test/napi/napi.test.ts