Skip to content

Fix crash when a property lookup throws while an object is being formatted - #37428

Closed
robobun wants to merge 2 commits into
mainfrom
farm/6db764f3/foreachproperty-stale-exception
Closed

robobun wants to merge 2 commits into
mainfrom
farm/6db764f3/foreachproperty-stale-exception

Conversation

@robobun

@robobun robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

What

Fixes a crash in the object property walk used by Bun.inspect, console.log and the expect() failure messages (JSC__JSValue__forEachPropertyImpl in src/jsc/bindings/bindings.cpp), found by the fuzzer. The crash needs a property lookup that throws while the object is being formatted.

Release builds segfault (Segmentation fault at address 0x5) on either of these:

const proto = new Proxy({ a: 1, b: 2, c: 3 }, {
  get(t, k, r) { if (k === "a") throw new Error("boom"); return Reflect.get(t, k, r); },
});
Bun.inspect(Object.create(proto));

const proto2 = new Proxy({ a: 1 }, { getPrototypeOf() { throw new Error("boom"); } });
Bun.inspect(Object.create(proto2));

Debug builds additionally abort with releaseAssertNoException on the fuzzer's case, which is a lazy property on the Bun object throwing while Bun is formatted (the shell builtin behind Bun.$ calls the global Symbol, which a previous REPRL script had replaced). In release builds that case silently drops every property of Bun that comes after the throwing one.

Root cause

In the slow path of the walk, a getPropertySlot that returned false was skipped with continue before the CLEAR_IF_EXCEPTION that follows it. JSC reports a throwing Proxy trap or a throwing static lazy property initializer exactly that way (returns false, exception pending), so the exception stayed on the VM:

  • every later lookup on the object fails early because of the stale exception (the silently dropped properties in release, the assertion in debug when the next lazy initializer returns a value with an exception still pending),
  • when the walk moves to the next prototype, getPrototype() bails out and returns the empty JSValue, and .getObject() on it dereferences null (an empty JSValue passes isCell(), and JSCell::getObject reads the type byte at offset 5). A Proxy whose getPrototypeOf trap throws reaches this line directly, without a stale exception.

Fix

bindings.cpp: clear the exception after getPropertySlot regardless of its result (this is what the ordered variant JSC__JSValue__forEachPropertyOrdered already does), and check the value returned by getPrototype before calling getObject() on it, ending the walk if the trap threw.

Not in this PR: napi_get_all_property_names (src/jsc/bindings/napi.cpp, the descriptor filter loop) has the same getPrototype().getObject() chain and also ignores a throwing getOwnPropertyDescriptor right before it. It is only reachable from a native addon and needs its own addon-based test, so it is being fixed separately.

BunObject.cpp: defaultBunSQLObject / constructBunSQLObject had a debug-only block that passed a load failure of the SQL module to reportUncaughtExceptionAtEventLoop while the exception was still pending on the VM. Every other caller clears the exception first. The report runs process.get("_fatalException"), which reifies a static property on process with the exception pending and trips the Structure::storedPrototype assertion, so globalThis.Symbol = NaN; Bun.sql aborted debug builds, and so did the fuzzer's case once the walk got past Bun.$. The exception is propagated to the caller right below this block anyway (Bun.sql throws it), so the block is removed rather than reordered.

Tests

Two tests added to test/js/bun/util/inspect.test.js under "exceptions thrown while walking properties", both spawning a subprocess because the unfixed binary dies:

  • the two Proxy cases above. On the unfixed build the subprocess segfaults; fixed, the throwing property is skipped and the others are printed.
  • the fuzzer's case, reduced to one property: a Proxy around Symbol makes only the Symbol("cwd") call in the shell builtin throw, so Bun.$ fails to reify while Bun.inspect(Bun) runs. Unfixed release prints false false true (Archive, the property after $, is missing), unfixed debug aborts; fixed, it prints false true true. Verified on Linux and Windows (an earlier version of this test replaced Symbol entirely, which also broke loading node:util; Windows needs that to format process.env, and a lazy initializer failing there hits an unrelated pre-existing abort, so the test failed on the Windows lanes of the first CI run).

Also ran the full inspect.test.js, BunObject.test.ts, mock-fn.test.js and sql/adapter-override.test.ts against the debug build, and the repros with BUN_JSC_validateExceptionChecks=1.


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

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.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Inspection exception safety

Layer / File(s) Summary
Exception-safe property and prototype traversal
src/jsc/bindings/bindings.cpp
Property and prototype lookup exceptions are cleared. Failing properties are skipped, and invalid prototypes stop traversal.
Inspection regression coverage
test/js/bun/util/inspect.test.js
Subprocess tests cover throwing proxy traps and lazy initializers. They verify preserved properties, clean stderr, and successful exits.
Bun SQL loader exception reporting cleanup
src/jsc/bindings/BunObject.cpp
Debug-only uncaught-exception reporting was removed from both Bun SQL loaders. Existing exception propagation remains.

Possibly related PRs

  • oven-sh/bun#37107: Modifies the same property enumeration code and adds related Bun.inspect regression tests.
  • oven-sh/bun#37234: Extends related exception handling in BunObject.cpp, bindings.cpp, and inspect.test.js.
  • oven-sh/bun#37309: Covers related exception handling to prevent inspection failures from leaking exceptions or truncating output.

Suggested reviewers: dylan-conway, jarred-sumner

🚥 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 describes the primary fix: preventing crashes during property lookup while formatting objects.
Description check ✅ Passed The description explains the cause, fix, scope, affected APIs, tests, and verification, although its headings differ slightly from the template.

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

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review above was rate limited and did not look at the diff, so nothing to address from it. CI for this branch is still running; I will follow up if anything in it fails.

Comment thread src/jsc/bindings/bindings.cpp
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

On the napi_get_all_property_names site (napi.cpp:2072): agreed that it is the same null dereference, plus the unchecked getOwnPropertyDescriptor throw right before it. It is being fixed separately, since it needs its own addon-based test under test/napi and this PR does not otherwise touch N-API. I have noted it in the description so the exclusion is on record.

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.
@robobun

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:38 PM PT - Aug 10th, 2026

✅ @robobun, your commit f548930f4e570decb90c02e5b5caab42e7d1ee3c passed in Build #91999! 🎉


🧪   To try this PR locally:

bunx bun-pr 37428

That installs a local version of the PR into your bun-37428 executable, so you can run:

bun-37428 --bun

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing to act on from the automated summaries. State of the PR: the only review finding (the napi site) is tracked separately as noted above. The second commit only changes the test fixture, which previously replaced the Symbol global outright and failed on the Windows lanes for an unrelated reason (details in the description); the fix itself is unchanged. CI is running again on that commit.

@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 BunObject.cpp debug-block removal and the consolidated regression tests.

@robobun robobun closed this Aug 17, 2026
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