Skip to content

Fix exception handling in the console.log / Bun.inspect property walk, napi_get_all_property_names and util.isError - #39412

Closed
robobun wants to merge 5 commits into
mainfrom
farm/25c70bec/foreach-property-clear-exception
Closed

robobun wants to merge 5 commits into
mainfrom
farm/25c70bec/foreach-property-clear-exception

Conversation

@robobun

@robobun robobun commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes two exception handling bugs in JSC__JSValue__forEachPropertyImpl (src/jsc/bindings/bindings.cpp), the property walk behind console.log / Bun.inspect for objects that take the slow path (objects with a static property table such as Bun, objects with a Proxy in their prototype chain, ...), plus the one other place with the same prototype step (napi_get_all_property_names).

  1. When getPropertySlot returned false, the loop did continue before the CLEAR_IF_EXCEPTION that follows it. getPropertySlot returns false with an exception pending when a lazy static property initializer or a Proxy trap throws, so that exception stayed pending for the rest of the walk. Every later lookup then fails as well (setUpStaticFunctionSlot and the Proxy trap code both bail out on a pending exception), so the remaining properties were silently dropped from the output: on a release build, Bun.inspect(Bun) with a throwing Bun.$ initializer loses everything from Archive through inflateSync, and an inherited Proxy property whose get trap throws hides every property after it. On debug builds the next initializer that is implemented in Rust trips assert_exception_presence_matches, which is the crash this was found through:

    ASSERTION FAILED: Unexpected exception observed ...
    Error Exception: Symbol is not a function. (In 'Symbol("cwd")', 'Symbol' is NaN)
    ExceptionScope.h(62) : void JSC::ExceptionScope::releaseAssertNoException()
    

    (Symbol("cwd") is the first line of the Bun.$ initializer in src/js/builtins/shell.ts; the fuzzer had replaced Symbol earlier and then formatted the Bun object.)

    The fix is to clear the exception before the continue, the same order JSC__JSValue__forEachPropertyOrdered already uses.

  2. At the end of each prototype step the loop did iterating->getPrototype(globalObject).getObject(). When iterating is a Proxy and getPrototype throws (its getPrototypeOf trap throws, or an exception leaked by bug 1 is still pending), it returns the empty value, and getObject() on it dereferences null:

    console.log(Object.create(new Proxy({}, { getPrototypeOf() { throw new Error("x"); } })));
    // panic(main thread): Segmentation fault at address 0x5   (Linux release build)

    The walk now stops there; the exception is cleared on the way out like the other ignored getter exceptions in this function (and like the fast path already does for the same trap).

  3. napi_get_all_property_names (src/jsc/bindings/napi.cpp) has the same getPrototype(...).getObject() step in its descriptor filter loop (pointed out in review). With napi_key_include_prototypes and an enumerable/writable/configurable filter, a Proxy in the prototype chain whose getOwnPropertyDescriptor trap throws leaves the exception pending, the following getPrototype returns empty and getObject() dereferences null; with napi_key_own_only the pending exception instead reached NAPI_RETURN_SUCCESS (an assertion on debug builds). Both lookups are now followed by NAPI_RETURN_IF_EXCEPTION, so the call returns napi_pending_exception and the trap's exception propagates to the caller, which is what Node does.

  4. util.isError (jsFunctionIsError in src/jsc/modules/NodeUtilTypesModule.cpp, also pointed out in review) reads getPrototype() of its argument and dereferences the result without checking it. util.isError(new Proxy({}, { getPrototypeOf() { throw ... } })) segfaults on the release canary. There were two things wrong: the VMInquiry slot used for the Symbol.toStringTag lookup a few lines earlier was still alive, so the Proxy trap was not allowed to be entered at all (debug builds crash right there with "VM entry disallowed"; release builds get a TypeError instead of running the trap), and the empty value returned on that exception was then dereferenced. The slot's restriction is now released once the lookup is done (slot.disallowVMEntry.reset(), the usual JSC idiom) and getPrototype() is followed by RETURN_IF_EXCEPTION, so the trap runs and whatever it throws propagates to the caller, which is also what the old Node implementation (instanceof-based) did.

I went through the other getPrototype call sites in src/ (C++ and the Rust get_prototype callers) after that. The rest either already check for the exception (WebStreamsInspectCustom.cpp, ObjectBindings.cpp, the deepEquals prototype comparison), or can only be reached with ordinary objects whose [[GetPrototypeOf]] cannot throw (JSBuffer__isBuffer on a JSUint8Array, isAsyncFunction / isGeneratorFunction on a JSFunction, the sqlite constructor setup, the test runner's scope functions, and the console / test-runner formatters, which never format a Proxy itself, only its target). The one remaining site with a real difference is napi_get_prototype (src/runtime/napi/napi_body.rs), which returns napi_ok plus a handle to the empty value instead of napi_pending_exception when the trap throws; it does not dereference anything, so it is left out of this PR and tracked separately.

Two more pre-existing problems that these tests brushed against are tracked separately and not touched here: the Bun.sql / Bun.SQL initializers report a module load failure while the exception is still pending (debug builds only, so globalThis.Symbol = NaN; console.log(Bun) still asserts on a debug build after this change, now in Structure::storedPrototype when the walk reaches sql), and formatting a value with a custom inspect function aborts if loading node:util's inspect helpers throws (which is what happens to process.env on Windows once process.env has been made to throw; the first test below loads the helpers first to stay clear of it).

How did you verify your code works?

Three tests added to test/js/bun/util/inspect.test.js (exceptions thrown while walking properties), each running its snippet in a child bun -e:

  • a lazy property initializer that throws only hides that property: makes process.env throw (the Bun.$ initializer reads it), then checks that Bun.inspect(Bun) still lists Archive and version. Before the fix it fails with the releaseAssertNoException abort above on a debug build and prints false false true (Archive missing) on release builds, on Linux and Windows.
  • a get trap that throws for one inherited property only hides that property: before the fix, on the Linux release build, the leaked exception hides the other properties and the walk then segfaults in the prototype step.
  • a throwing getPrototypeOf trap in the prototype chain stops the walk: before the fix this segfaults on the Linux release build and is a UBSan null member call on the debug build.

One test added to test/napi/napi.test.ts (propagates an exception thrown by a Proxy trap while filtering, fixture test_get_all_property_names_throwing_trap in napi-app/module.js), which compares Bun's output with Node's for both key modes. Before the napi change the own_only case aborts in NAPI_RETURN_SUCCESS on the debug build and the include_prototypes case is the same UBSan null member call in JSValue::getObject.

One test added to the isError block of test/js/node/util/util.test.js (propagates an exception from a getPrototypeOf trap), which runs the call in a child bun -e. Before the change the child aborts on the debug build and segfaults with the release canary (exit 139), so only this test fails.

All of these pass with bun bd test test/js/bun/util/inspect.test.js, bun bd test test/napi/napi.test.ts and bun bd test test/js/node/util/util.test.js, as do the rest of those files; the three repro scripts are also clean under BUN_JSC_validateExceptionChecks=1. The fixed debug build runs the original fuzzer script cleanly.


no test proof · iteration 1 · 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

… a throwing getPrototypeOf trap

JSC__JSValue__forEachPropertyImpl skipped a property whose getPropertySlot
returned false without clearing the exception that made it return false
(a lazy static property initializer or a Proxy trap that threw). The
exception stayed pending while the remaining properties were visited:
every later static property was reported as missing and dropped from the
output, and debug builds hit releaseAssertNoException as soon as the next
initializer ran.

The prototype walk at the end of the loop also called getObject() on the
empty value returned when a Proxy getPrototypeOf trap throws, which is a
null dereference. Stop the walk instead; the exception is cleared on exit
like the other ignored getter exceptions.
@coderabbitai

coderabbitai Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 85a3100a-67b4-4947-9066-85014033d89f

📥 Commits

Reviewing files that changed from the base of the PR and between 771c7e6 and 5d909d0.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/util/inspect.test.js

Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review.


Walkthrough

JSC__JSValue__forEachPropertyImpl now clears property lookup exceptions, skips failed properties, and stops safely on failed prototype lookups. Regression tests cover lazy initializers, proxy getters, and prototype-chain failures in Bun.inspect.

Changes

Inspection exception handling

Layer / File(s) Summary
Safe property and prototype traversal
src/jsc/bindings/bindings.cpp
Property enumeration clears lookup exceptions and skips failed properties. Prototype traversal stops when getPrototype returns an empty value.
Regression coverage
test/js/bun/util/inspect.test.js
Subprocess tests verify behavior for throwing lazy initializers, proxy getters, and prototype-chain lookups.

Possibly related PRs

  • oven-sh/bun#30245: Modifies the same property enumeration logic and adds related inspection tests.
  • oven-sh/bun#39365: Updates the same traversal logic and regression tests.
  • oven-sh/bun#39382: Handles property and prototype lookup exceptions in the same implementation areas.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the exception-handling fixes for the property walk, napi_get_all_property_names, and util.isError.
Description check ✅ Passed The description includes both required sections and provides detailed implementation context and verification results.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — two targeted exception-handling fixes that match existing sibling patterns.

What was reviewed:

  • Reordering CLEAR_IF_EXCEPTION before continue matches forEachPropertyOrdered at bindings.cpp:5814-5816.
  • The getPrototype empty-check mirrors the fast path at bindings.cpp:5601-5611; the trailing tryClearException at 5732 clears the trap's exception as the comment states.
  • Tests spawn isolated subprocesses, drain pipes concurrently, and assert the combined {stdout, stderr, exitCode} object per harness conventions.
Extended reasoning...

Overview

Two small edits to JSC__JSValue__forEachPropertyImpl in src/jsc/bindings/bindings.cpp (the slow-path property walk behind console.log/Bun.inspect), plus two subprocess-based regression tests in test/js/bun/util/inspect.test.js.

Fix 1 moves CLEAR_IF_EXCEPTION(scope) to before the if (!hasProperty) continue; so a throwing lazy initializer or Proxy get trap doesn't leave an exception pending across iterations. This is the exact ordering JSC__JSValue__forEachPropertyOrdered already uses (bindings.cpp:5814-5816), so this is bringing a sibling into line rather than introducing a new pattern.

Fix 2 checks getPrototype()'s return for empty before calling .getObject() on it, breaking out of the loop when a Proxy getPrototypeOf trap throws. The fast path directly above (bindings.cpp:5601-5611) already guards this the same way, and the function's trailing scope.tryClearException() (5732-5735) clears the pending exception on the way out, so the added comment is accurate.

Security risks

None. This is inspect/formatting code; the fix removes a null-deref segfault and a pending-exception leak, both of which are strictly defensive improvements. No new user input surface, no auth/crypto/permission logic.

Level of scrutiny

Low-to-medium. The changes are ~8 lines of C++ in a well-understood function, each mirroring an existing pattern from a sibling code path in the same file. Both bugs have concrete crash reproductions (segfault at 0x5, debug releaseAssertNoException), and the PR description traces the mechanism precisely.

Other factors

The tests follow repo conventions well: subprocess isolation via bun -e, bunEnv/bunExe, concurrent pipe draining with Promise.all, and a single combined-object assertion. describe.concurrent is appropriate since both tests spawn independent subprocesses. The first test's reliance on Bun.$ reading process.env during initialization is a bit indirect, but the PR explains why (it's the actual fuzzer-found repro path) and the assertion checks specific property names rather than generic "no crash" output. The PR also explicitly scopes out the separate Bun.sql/Bun.SQL debug-assert issue rather than papering over it.

@robobun

robobun commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:33 AM PT - Aug 17th, 2026

@robobun, your commit 68e8fdf is building: #99909

…roxy get trap case

On Windows process.env is a Proxy whose target has a custom inspect
function, and formatting it loads node:util's inspect helpers, which
read process.env. Load them before the test makes process.env throw so
that only the Bun.$ initializer throws during the walk.

Also cover the same leak through a Proxy get trap that throws for one
inherited property.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The two fixes are small and mechanically correct — the reordered clear-before-continue now matches forEachPropertyOrdered at bindings.cpp:5813-5818, and the split getPrototype guard mirrors the fast path's existing empty-check at :5601. The inline nit about the identical chain in napi.cpp is pre-existing and in a separate subsystem; worth a follow-up or a one-line exclusion note, but not blocking.

Extended reasoning...

Overview

Two exception-handling fixes in JSC__JSValue__forEachPropertyImpl (src/jsc/bindings/bindings.cpp), the slow-path property walker behind console.log / Bun.inspect, plus three subprocess regression tests in test/js/bun/util/inspect.test.js.

  • Fix 1 reorders CLEAR_IF_EXCEPTION(scope) to run before the if (!hasProperty) continue, so an exception from a lazy static initializer or Proxy trap that made getPropertySlot return false doesn't leak into subsequent iterations. This is exactly the ordering forEachPropertyOrdered already uses (lines 5813-5818).
  • Fix 2 splits iterating->getPrototype(globalObject).getObject() so an empty JSValue (returned when a Proxy getPrototypeOf trap throws) breaks the loop instead of null-derefing inside JSCell::getObject(). The pending exception is then cleared by the existing block at :5732-5735. The fast path at :5601 already guards getPrototype the same way.

Security risks

None. This is output-only formatting code; the change replaces a segfault and a silent property-drop with graceful exception clearing. No new user input parsing, no allocation sizing, no trust boundary.

Level of scrutiny

Medium — native JSC bindings code with exception scopes, but the change is ~10 lines, purely local, and each half copies an existing pattern from a sibling site in the same file. There is no new control flow beyond an early break/continue and no lifetime or GC change.

Other factors

  • Tests follow harness conventions: bunEnv/bunExe, concurrent pipe drain, combined {stdout, stderr, exitCode} assertion, describe.concurrent for independent subprocesses. They cover both fixes (throwing lazy initializer, throwing get trap on an inherited property, throwing getPrototypeOf trap) and exercise both Bun.inspect and console.log.
  • The first test depends on Bun.$ reading process.env during initialization, which is an internal detail, but the comment explains it and the second test covers the same exception-leak path via a plain Proxy without that dependency.
  • The bug hunter flagged one sibling getPrototype(globalObject).getObject() chain in napi.cpp:2080. It's the only other instance in src/, is pre-existing, lives in a separate subsystem (N-API property enumeration), and this PR doesn't make it worse. Per REVIEW.md the author should either apply the same 3-line guard there or note the exclusion, but it's a nit rather than a blocker for these two crash fixes.

Comment thread src/jsc/bindings/bindings.cpp Resolved
…criptor or prototype lookup throws

The descriptor filter loop had the same problem as forEachProperty: a
Proxy getOwnPropertyDescriptor trap that throws left the exception
pending, the following getPrototype returned the empty value and
getObject() dereferenced null. In own_only mode the pending exception
reached NAPI_RETURN_SUCCESS instead. Check after both calls, which is
also what Node returns in this situation.
@robobun robobun changed the title Fix exceptions leaking between properties in the console.log / Bun.inspect property walk Fix exceptions leaking between properties in the console.log / Bun.inspect property walk and napi_get_all_property_names Aug 17, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the napi.cpp sibling from the earlier review is now fixed with a matching checkSameOutput test, and the core forEachPropertyImpl change is a minimal, well-tested crash fix. The remaining inline note about util.isError in NodeUtilTypesModule.cpp is a pre-existing same-class site in a file this PR doesn't touch; folding it in or adding it to the "tracked separately" list would close out the class, but it doesn't block.

Extended reasoning...

Overview

Two exception-handling fixes in JSC__JSValue__forEachPropertyImpl (bindings.cpp): reorder CLEAR_IF_EXCEPTION before the continue when getPropertySlot returns false, and guard getPrototype() returning empty before .getObject(). After the previous review round, the same guard was applied to napi_get_all_property_names (napi.cpp) with NAPI_RETURN_IF_EXCEPTION on both getOwnPropertyDescriptor paths and the prototype step. Three subprocess tests in inspect.test.js and a checkSameOutput napi test cover both.

Security risks

None. Fixes a null-deref segfault reachable via hostile Proxy traps in console.log/Bun.inspect and napi_get_all_property_names — strictly a robustness improvement.

Level of scrutiny

Medium. C++ JSC exception handling is sensitive, but both changes are the standard local pattern: the CLEAR_IF_EXCEPTION reorder mirrors what forEachPropertyOrdered already does a few hundred lines below, and the split-then-check-empty on getPrototype matches the safe idiom already used at bindings.cpp:5506/5601. The napi.cpp early returns are through NAPI_RETURN_IF_EXCEPTION (returns napi_pending_exception), and everything acquired above them (exportKeys, filteredKeys) is GC-managed, so no cleanup is skipped.

Other factors

The author addressed the prior review feedback in full — napi.cpp fix plus a Node-parity test via checkSameOutput. The new inspect tests follow harness conventions (subprocess spawn, bunEnv, drain all pipes, assert combined {stdout, stderr, exitCode}), and the PR description already documents two adjacent pre-existing issues as tracked separately, which is the same treatment the remaining NodeUtilTypesModule.cpp nit warrants if not folded in here.

Comment thread src/jsc/bindings/bindings.cpp Resolved
…it throws

The VMInquiry slot used for the Symbol.toStringTag lookup was still
alive when getPrototype() ran, so a Proxy trap could not be entered:
debug builds crashed on the disallowed VM entry, release builds got a
TypeError and then dereferenced the empty value that getPrototype
returned. Release the slot's restriction after the lookup and check
for an exception after getPrototype().
@robobun robobun changed the title Fix exceptions leaking between properties in the console.log / Bun.inspect property walk and napi_get_all_property_names Fix exception handling in the console.log / Bun.inspect property walk, napi_get_all_property_names and util.isError Aug 17, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier same-class sites (napi.cpp and jsFunctionIsError) are now fixed. Beyond the inline nit, I checked that slot.disallowVMEntry.reset() in jsFunctionIsError is placed correctly — the following slot.getValue() is gated on slot.isValue() so it doesn't enter the VM, and the reset is needed before getPrototype can run a trap. Also checked that the new NAPI_RETURN_IF_EXCEPTION calls in napi.cpp don't introduce a new path that reads desc uninitialized (they return early, and the pre-existing if (!proto) break shape is unchanged).

Extended reasoning...

Both prior review comments were addressed by commits a414f0f (napi.cpp) and 71aa532 (NodeUtilTypesModule.cpp). The remaining finding this round is a test-isolation nit. Given this is native JSC exception-handling / crash-fix code across three files, deferring the final sign-off to a human; the note above records what was additionally verified so it isn't re-explored.

Comment thread test/js/node/util/util.test.js Resolved
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #29642, which fixes the same stale-exception / getPrototype null dereference in forEachPropertyImpl (the same two hunks in bindings.cpp) and now also carries the consolidated regression tests.

@robobun robobun closed this Aug 17, 2026
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

To complete the picture for the version of this PR that was closed (it had grown since it was first compared): the napi_get_all_property_names checks here are the same ones already in #29642 (with napi tests), and the util.isError fix (NodeUtilTypesModule.cpp) is a separate bug that #37202 is being kept open for. Nothing from this PR is left without an open owner.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all three prior review comments have been addressed (napi.cpp sibling, jsFunctionIsError, and spawning the util.isError crash test in a child process).

What was reviewed:

  • The four exception-handling fixes each follow the pattern already used at sibling sites (CLEAR_IF_EXCEPTION reordering matches forEachPropertyOrdered; NAPI_RETURN_IF_EXCEPTION matches the check at :2065; RETURN_IF_EXCEPTION after getPrototype matches the other guarded sites in bindings.cpp).
  • slot.disallowVMEntry.reset() is placed after the VMInquiry lookup and before getPrototype; slot.getValue inside the if (slot.isValue()) branch reads the stored value without re-entering the VM, so resetting first is safe.
  • All five new crash tests run the crashing input in a spawned child and assert on {stdout, stderr, exitCode}; the napi test compares against Node via checkSameOutput.
Extended reasoning...

Overview

This PR fixes four instances of the same bug class — getPrototype() on a Proxy returning the empty JSValue when a trap throws, followed by a null dereference — across three C++ files: forEachPropertyImpl in bindings.cpp (two fixes: reorder CLEAR_IF_EXCEPTION before continue, and check for empty prototype before .getObject()), napi_get_all_property_names in napi.cpp (add NAPI_RETURN_IF_EXCEPTION after both descriptor lookups and the prototype step), and jsFunctionIsError in NodeUtilTypesModule.cpp (release the VMInquiry slot's disallowVMEntry and add RETURN_IF_EXCEPTION after getPrototype). Five new tests cover each fix as a spawned crash fixture.

Security risks

None. These are defensive exception checks that prevent null dereferences on adversarial Proxy inputs. No auth, crypto, or permissions code touched.

Level of scrutiny

Medium — native C++ in JSC exception-handling paths. However, every added line follows the exact pattern already used at neighboring sites in the same function or file, and the changes are purely additive (no removed guards, no changed control flow beyond the reordered CLEAR_IF_EXCEPTION). The disallowVMEntry.reset() addition is the standard JSC idiom for releasing a VMInquiry slot before code that may enter the VM; the author's response to my second review comment demonstrated they investigated the actual failure mechanism (the VMInquiry slot was blocking the trap from running at all) rather than applying my suggested fix verbatim.

Other factors

This PR went through three rounds of my review feedback, each addressed correctly with a confirming reproduction and a targeted fix commit. The PR description enumerates the remaining getPrototype call sites in src/ and explains why each is either already guarded or unreachable with a Proxy, and explicitly tracks the two related pre-existing issues (napi_get_prototype returning napi_ok with an empty handle, and the Bun.sql initializer assertion) as separate work. Test coverage is thorough: each crash test spawns a child process, drains all pipes concurrently, asserts on the combined {stdout, stderr, exitCode} object, and the napi test compares byte-for-byte against Node.

dylan-conway pushed a commit that referenced this pull request Aug 18, 2026
…p or getPrototypeOf throws during the property walk (#29642)

### Problem

- `Bun.inspect()`, `console.log()` and `expect()` failure output crash
with `Segmentation fault at address 0x5` (debug builds: UBSan "member
call on null pointer of type 'JSC::JSCell'" in `JSCJSValueCell.h`) when
a property lookup throws while an object is being formatted. Release
repro:
  ```js
const proto = new Proxy({ a: 1 }, { getPrototypeOf() { throw new
Error("boom"); } });
  console.log(Object.create(proto));
  ```
and likewise with a Proxy whose `get` trap (or a getter reached through
a Proxy) throws for one property.
- The same happens with a lazily initialized property of the `Bun`
object whose initializer throws (fuzzer sample: `globalThis.Symbol`
replaced, then `Bun.inspect(Bun)`; the builtin behind `Bun.$` calls
`Symbol("cwd")`). Release builds print `Bun` with most of its properties
missing (72 of 115 on the shipped build); debug builds abort with
`ASSERTION 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.log` of a module
namespace during an import cycle, when one export is still in its
temporal dead zone, throws `ReferenceError: Cannot access 'x' before
initialization` out of `console.log` (the stale exception is picked up
while the next export is formatted). `util.inspect` prints 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 loop `continue`d before the
`CLEAR_IF_EXCEPTION` below it. The following lookups and formatting
callbacks then run with that exception pending (the dropped properties,
the rethrown `ReferenceError`, the debug assertion).
- When the walk moves to the next prototype,
`iterating->getPrototype(globalObject).getObject()` runs `getObject()`
on the empty `JSValue` that `getPrototype` returns when it threw (either
because of the stale exception above or because the `getPrototypeOf`
trap itself throws). The empty value passes `isCell()`, 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 same `getPrototype().getObject()` chain after an
unchecked `getOwnPropertyDescriptor`, so a Proxy trap throwing there
returned `napi_ok` with 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 to `reportUncaughtExceptionAtEventLoop` while
the exception was still pending on the VM, so `globalThis.Symbol = NaN;
Bun.sql` (and the fuzzer sample above, once the walk gets past `Bun.$`)
aborted debug builds with `ASSERTION FAILED: ... object->structure() ==
this` in `Structure::storedPrototype` instead of throwing.

### Fix

- `bindings.cpp`: clear the exception after `getPropertySlot` regardless
of its result, which is what the ordered variant
`JSC__JSValue__forEachPropertyOrdered` already does; read the next
prototype into a `JSValue`, clear the exception and stop the walk when
it is empty. (An earlier revision also held the prototype being walked
under an `EnsureStillAliveScope`; 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.inspect`
call throwing or crashing. For TDZ namespace exports this differs from
`util.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 after `getOwnPropertyDescriptor`
and after `getPrototype` and return `napi_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 the `RETURN_IF_EXCEPTION` right below it, so
debug builds now behave like release builds (`Bun.sql` throws).
- 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 Proxy `get` trap, a getter behind a
Proxy, a `getPrototypeOf` trap, a throwing lazy `Bun` property, 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, the `Bun` child prints
`[false,false,...]` on release and aborts on debug, the cycle child
exits 1 with the `ReferenceError`; 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` /
`postgres` with `Symbol` broken throw a `TypeError` on two consecutive
reads. Aborts on an unfixed debug build (the `BunObject.cpp` hunk);
passes on release either way, as the removed block is debug-only. The
fixture builds `process.env` before breaking `Symbol` because the `$`
builder reads it, and building it on Windows reifies another `Bun`
property mid-lookup, which on a Windows debug build would hit the
separate `storedPrototype` assertion that #37001 fixes (verified on
Linux only).
- `test/napi/napi.test.ts`: `getOwnPropertyDescriptor` trap throwing in
own-only and include-prototypes mode (compared against Node), and a
`getPrototypeOf` trap that throws on the second call so the check after
`getPrototype` is the one that fires.
- Repros above and the tests also run clean under
`BUN_JSC_validateExceptionChecks=1`.

### Background

- `forEachPropertyImpl` is 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 with
`getPropertySlot`, 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 make `console.log` throw.
- A JSC exception is "pending" state on the VM, not C++ unwinding. A
function that throws returns a failure value (`false`, or the empty
`JSValue`) 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_EXCEPTION` drops
the pending exception.
- The empty `JSValue` (`JSValue()`) is encoded as 0. `isCell()` is true
for it, so `getObject()` on it dereferences a null cell pointer rather
than returning null; callers have to test the value itself first.
- Lazy properties of the `Bun` object 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 for `Bun.$`, the sql module for `Bun.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).

<!-- robobun:evidence:begin -->

---

**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

<!-- robobun:evidence:end -->

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant