Skip to content

Fix stale exception when a lazy property initializer throws during property enumeration - #37158

Closed
robobun wants to merge 1 commit into
mainfrom
farm/740464f1/fix-foreachproperty-stale-exception
Closed

Fix stale exception when a lazy property initializer throws during property enumeration#37158
robobun wants to merge 1 commit into
mainfrom
farm/740464f1/fix-foreachproperty-stale-exception

Conversation

@robobun

@robobun robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes a fuzzer-found abort (fingerprint 45150bc578c49ad4).

Repro

globalThis.Symbol = 0;
Bun.inspect(Bun); // debug build: ASSERTION FAILED: Unexpected exception observed

The fuzzer hit this through Bun.jest(...).expect(Bun).toBeNil(), whose failure message formats the Bun object. The global Symbol had been clobbered by an earlier iteration in the same fuzzer process, so reifying the lazy Bun.$ property threw Symbol is not a function. (In 'Symbol("cwd")', 'Symbol' is 0) from the shell builtin.

Bug 1: JSC__JSValue__forEachPropertyImpl leaks the exception

A static hash-table property with a PropertyCallback that throws during reification makes getPropertySlot return false with the exception still pending. The enumeration loop did:

if (!object->getPropertySlot(globalObject, property, slot))
    continue;                 // skips the clear below
CLEAR_IF_EXCEPTION(scope);

so the stale exception leaked into the next property's lazy callback, whose host-call wrapper release-asserts no pending exception, aborting the process in debug/ASAN builds. In release builds the stale exception made every subsequent static-table lookup report not-found, so Bun.inspect(Bun) silently dropped most properties (output shrinks from ~13 KB to ~1.4 KB). The fix clears before deciding to skip, matching what forEachPropertyOrdered already does.

Bug 2: Bun.sql initializer runs the uncaughtException machinery mid-reification

With bug 1 fixed, the same repro then died in the debug-only block in defaultBunSQLObject:

#if BUN_DEBUG
    if (scope.exception()) globalObject->reportUncaughtExceptionAtEventLoop(...);
#endif

This runs Bun__handleUncaughtException while the module-load error is still pending on the VM. Its process->get(globalObject, "_fatalException") then reifies _fatalException (a structure transition) but reports it missing because setUpStaticFunctionSlot sees the pending exception, and JSObject::getPropertySlot hits the storedPrototype stale-structure assertion:

ASSERTION FAILED: isCompilationThread() || Thread::mayBeGCThread() || object->structure() == this

Reproducible on its own with globalThis.Symbol = 0; Bun.sql in a debug build. The report call also misclassifies a propagated error as uncaught: the very next line rethrows it to the property access, where user code can catch it. Removed both blocks; the error now propagates like every other lazy property callback.

Test

test/js/bun/util/inspect.test.js: tampers Symbol in a subprocess, checks Bun.inspect(Bun) still contains later properties, and that Bun.$ / Bun.sql throw catchable errors. Fails on an unfixed release build (missing properties) and an unfixed debug build (abort); passes with this change.


[stamp-90s] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/inspect.test.js
bun test v1.4.0 (3565abebd)

test/js/bun/util/inspect.test.js:
(pass) prototype [305.33ms]
(pass) getters [4.89ms]
(pass) setters [3.86ms]
(pass) getter/setters [1.86ms]
(pass) Timeout [5.03ms]
(pass) when prototype defines the same property, don't print the same property twice [2.11ms]
(pass) Blob inspect [8.56ms]
(pass) utf16 property name [59.94ms]
(pass) latin1 [4.01ms]
(pass) Request object [2.80ms]
(pass) MessageEvent [1.83ms]
(pass) MessageEvent with no data set [1.91ms]
(pass) MessageEvent with deleted data [2.61ms]
(pass) TypedArray prints [93.22ms]
(pass) BigIntArray [30.67ms]
(pass) Float32Array 42.68000030517578 [4.04ms]
(pass) Float32Array 42.68 [3.97ms]
(pass) Float64Array 42.68000030517578 [1.36ms]
(pass) Float64Array 42.68 [1.34ms]
(pass) jsx with two elements [28.02ms]
(pass) jsx with anon component [2.63ms]
(pass) jsx with fragment [4.47ms]
(pass) inspect [67.97ms]
(pass) latin1 supplemental > latin1 (input) "äbc" [ "äbc" ] [1.85ms]
(pass) latin1 supplemental > latin1 (input) "cbä" 
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (0ffabf64d)

test/js/bun/util/inspect.test.js:
(pass) prototype [4.09ms]
(pass) getters [0.10ms]
(pass) setters [0.04ms]
(pass) getter/setters [0.03ms]
(pass) Timeout [0.10ms]
(pass) when prototype defines the same property, don't print the same property twice [0.04ms]
(pass) Blob inspect [0.23ms]
(pass) utf16 property name [4.99ms]
(pass) latin1 [0.05ms]
(pass) Request object [0.05ms]
(pass) MessageEvent [0.03ms]
(pass) MessageEvent with no data set [0.02ms]
(pass) MessageEvent with deleted data [0.03ms]
(pass) TypedArray prints [0.94ms]
(pass) BigIntArray [0.30ms]
(pass) Float32Array 42.68000030517578 [0.07ms]
(pass) Float32Array 42.68 [0.05ms]
(pass) Float64Array 42.68000030517578
(pass) Float64Array 42.68
(pass) jsx with two elements [0.53ms]
(pass) jsx with anon component [0.03ms]
(pass) jsx with fragment [0.06ms]
(pass) inspect [0.69ms]
(pass) latin1 supplemental > latin1 (input) "äbc" [ "äbc" ] [0.03ms]
(pass) latin1 supplemental > latin1 (input) "cbä" [ "cbä" ]
(pass) latin1 supplemental > latin1 (input) "cäb" [ "cäb" ]
(pass) latin1 supplemental > latin1 (input) "äbc äbc" [ "äbc äbc" ]
(pass) latin1 supplemental > latin1 (
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/inspect.test.js
bun test v1.4.0 (3565abebd)

test/js/bun/util/inspect.test.js:
(pass) prototype [301.25ms]
(pass) getters [4.89ms]
(pass) setters [3.96ms]
(pass) getter/setters [1.93ms]
(pass) Timeout [5.66ms]
(pass) when prototype defines the same property, don't print the same property twice [2.19ms]
(pass) Blob inspect [8.40ms]
(pass) utf16 property name [61.34ms]
(pass) latin1 [4.12ms]
(pass) Request object [2.65ms]
(pass) MessageEvent [1.85ms]
(pass) MessageEvent with no data set [1.90ms]
(pass) MessageEvent with deleted data [2.54ms]
(pass) TypedArray prints [90.64ms]
(pass) BigIntArray [30.50ms]
(pass) Float32Array 42.68000030517578 [3.99ms]
(pass) Float32Array 42.68 [3.93ms]
(pass) Float64Array 42.68000030517578 [1.31ms]
(pass) Float64Array 42.68 [1.61ms]
(pass) jsx with two elements [27.04ms]
(pass) jsx with anon component [2.61ms]
(pass) jsx with fragment [4.84ms]
(pass) inspect [66.58ms]
(pass) latin1 supplemental > latin1 (input) "äbc" [ "äbc" ] [1.72ms]
(pass) latin1 supplemental > latin1 (input) "cbä" 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     3565abebd8
  features     baseline

22 deps, 105 codegen, 1175 objects in 717ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1237] install /workspace/bun
bun install v1.4.0-canary.1 (0ffabf64d)

Checked 107 installs across 153 packages (no changes) [4.00ms]
[2/1237] gen ErrorCode+*.h
[3/1237] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (0ffabf64d)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1237] gen bindgenv2
[5/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (0ffabf64d)

Checked 129 installs across 147 packages (no changes) [15.00ms]
[6/1237] fetch picohttpparser
[picohttpparser] up to date
[7/1237] fetch tinycc
[tinycc] up to date
[8/1236] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[9/1236] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.
... (truncated)
diff hotspot
src/jsc/bindings/BunObject.cpp   |  6 ------
 src/jsc/bindings/bindings.cpp    |  7 ++++---
 test/js/bun/util/inspect.test.js | 27 +++++++++++++++++++++++++++
 3 files changed, 31 insertions(+), 9 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                              reads  edits  tests
src/jsc/bindings/BunObject.cpp        4      2      0
src/jsc/bindings/bindings.cpp         2      1      0
test/js/bun/util/inspect.test.js      0      0      0

When a static hash-table property with a PropertyCallback throws during
reification (for example Bun.$ evaluating Symbol("cwd") after the
global Symbol was overwritten), getPropertySlot returns false with the
exception still pending. JSC__JSValue__forEachPropertyImpl skipped its
CLEAR_IF_EXCEPTION in that case, so the stale exception leaked into the
next property's host call and tripped releaseAssertNoException, aborting
the process. In release builds the stale exception made every later
static property lookup report not-found, so Bun.inspect(Bun) silently
dropped most of the Bun object.

Also remove the debug-only reportUncaughtExceptionAtEventLoop calls in
the Bun.sql lazy initializers. They ran the uncaughtException machinery
while the load error was still pending on the VM, and the
process._fatalException lookup inside it reified a property (structure
transition) and then reported it as missing because of the pending
exception, hitting the storedPrototype stale-structure assertion. The
error already propagates to the property access via RETURN_IF_EXCEPTION,
matching every other lazy property callback.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 45dc6e52-7b19-48f6-9538-c5682c476643

📥 Commits

Reviewing files that changed from the base of the PR and between 45ee955 and 3565abe.

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

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

@github-actions github-actions Bot added the claude label Aug 7, 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 reorder in forEachPropertyImpl matches what forEachPropertyOrdered already does at bindings.cpp:5545-5549, and the removed #if BUN_DEBUG blocks were debug-only and demonstrably wrong (they treated a propagating error as uncaught). One test-hygiene nit inline.

Extended reasoning...

Overview

Three files touched: a 4-line reorder in src/jsc/bindings/bindings.cpp (JSC__JSValue__forEachPropertyImpl), removal of two 3-line #if BUN_DEBUG blocks in src/jsc/bindings/BunObject.cpp (defaultBunSQLObject / constructBunSQLObject), and a new subprocess regression test in test/js/bun/util/inspect.test.js.

Correctness

The bindings.cpp change moves CLEAR_IF_EXCEPTION(scope) from after the continue guard to before it. This is the exact pattern the sibling forEachPropertyOrdered already uses (bindings.cpp:5545-5549: bool hasProperty = ...; scope.tryClearException(); if (!hasProperty) continue;). The old ordering meant a lazy PropertyCallback that throws (returning false from getPropertySlot) leaked its exception into the next iteration, where the next lazy callback's host wrapper release-asserts no pending exception. The reorder is a no-op on the hasProperty == true path and a strict fix on the false path.

The BunObject.cpp change deletes debug-only blocks that called reportUncaughtExceptionAtEventLoop while the module-load exception was still pending — misclassifying a propagating error (the very next line is RETURN_IF_EXCEPTION) as uncaught, and re-entering JS with a pending exception, which the PR shows trips the storedPrototype stale-structure assertion. Removing them makes these two callbacks behave like every other lazy property callback in the file (e.g. constructBunShell), and has zero effect on release builds.

Security risks

None. This is exception-handling hygiene in the inspect/enumeration path; no untrusted-input parsing, auth, or crypto is touched.

Level of scrutiny

Medium-low. bindings.cpp is a sensitive file, but the change is a mechanical reorder that mirrors an existing sibling implementation, and the BunObject.cpp change removes debug-only code. The PR description traces both failure modes precisely (debug abort + release-build silently dropping properties), and the test covers both the enumeration behavior and direct Bun.$ / Bun.sql access throwing catchably.

Other factors

One nit was flagged inline: the new test pipes stderr but never drains it, diverging from the neighboring "huge sparse array" test's Promise.all([stdout, stderr, exited]) + combined-object assertion. This is test hygiene (better failure diagnostics on regression), not a correctness issue, and doesn't block approval.

Comment on lines +828 to +830
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout.trim()).toBe("OK 2");

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.

🟡 The test sets stderr: "pipe" but never drains it — only proc.stdout.text() and proc.exited are awaited. Match the neighboring "huge sparse array" test and REVIEW.md's subprocess rule: const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]), then assert the combined { stdout: stdout.trim(), stderr, exitCode } object. Otherwise a regression that reintroduces the debug abort will show only received "" with the stderr backtrace silently discarded.

Extended reasoning...

What the bug is

The new test at test/js/bun/util/inspect.test.js:823-830 spawns a subprocess with stderr: "pipe" but only awaits proc.stdout.text() and proc.exited:

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.trim()).toBe("OK 2");
expect(exitCode).toBe(0);

The stderr pipe is opened but never read. REVIEW.md rejects this pattern explicitly under Tests reviewers reject → Subprocess tests: drain pipes concurrently: "Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) — an unread pipe fills the ~64KB OS buffer and deadlocks the child. assert a combined { stdout, stderr, exitCode } object."

Why local convention also demands it

The immediately neighboring subprocess test in the same file — "Bun.inspect huge sparse array summarizes holes without iterating them" — follows the correct pattern exactly:

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "...", stderr: "", exitCode: 0 });

REVIEW.md separately requires "Match the exact file's local conventions", so this test diverges from both the repo-wide rule and the pattern established a few lines above it.

Step-by-step: what goes wrong on a regression

The whole point of this test is to catch a regression of the debug-build abort described in the PR (ASSERTION FAILED: Unexpected exception observed). Walk through what happens if that abort comes back:

  1. Child runs globalThis.Symbol = 0; Bun.inspect(Bun); — the lazy Bun.$ callback throws, the stale exception leaks, and the debug build aborts with a JSC assertion.
  2. The assertion message and backtrace are written to stderr.
  3. The child exits before printing "OK 2" to stdout.
  4. The parent reads stdout"", and exitCode → non-zero (or a signal).
  5. The test failure prints only: expect(stdout.trim()).toBe("OK 2") — Expected "OK 2", Received "".

The actual diagnostic — the assertion text and backtrace on stderr — is silently discarded because nothing ever read the pipe. Whoever debugs the CI failure has no idea why the child died. With the combined-object assertion, the failure output would instead show stderr: "ASSERTION FAILED: Unexpected exception observed... <backtrace>" and exitCode: <signal>, which is exactly the signal you want.

Deadlock direction

Bun.inspect(Bun) reifies every lazy property on the Bun object; with Symbol clobbered several of these ($, sql, postgres, SQL) throw during reification. In the current passing path stderr stays empty, so the ~64KB pipe-buffer deadlock does not occur today. But the pattern is what REVIEW.md rejects regardless — if any of those reification paths (or a future warning) becomes noisy on stderr, the child could block writing to a full pipe while the parent only drains stdout.

Fix

One-line change to match the neighboring test:

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "OK 2", stderr: "", exitCode: 0 });

Severity

Nit. The test passes as written and nothing concretely breaks today — the deadlock risk is theoretical for this particular script, and the diagnostic loss only bites on a future regression. But it violates an explicit repo review rule, diverges from the immediate neighboring convention, and the fix is trivial.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Don't leak a pending exception when a property getter throws during inspect enumeration #37107 - Applies the byte-identical forEachPropertyImpl fix (hoist hasProperty so CLEAR_IF_EXCEPTION runs before continue), with its test in the same inspect.test.js.
  2. Fix Bun.inspect null deref with Proxy prototypes; stop reporting from inside the sql lazy-property builders #30245 - Contains both halves: the same forEachPropertyImpl reorder and removal of the same #if BUN_DEBUG blocks in defaultBunSQLObject/constructBunSQLObject.
  3. Fix stale-structure abort when a lazy Bun property builder throws mid-lookup #37001 - Deletes the exact same two #if BUN_DEBUG ... reportUncaughtExceptionAtEventLoop blocks for the same stale-structure assertion root cause.
  4. fix(inspect): don't crash when a Proxy in the prototype chain throws #29642 - Same hasProperty/CLEAR_IF_EXCEPTION hunk in forEachPropertyImpl, framed around Proxy traps.
  5. fix(inspect): handle Proxy trap exceptions when walking prototype chain #30099 - Same forEachPropertyImpl hunk plus extra exception clears on the prototype-chain walk.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Confirmed overlap: #37107 already contains the same forEachPropertyImpl change (clear the pending exception before skipping a not-found property) with a test that covers the enumeration behavior, and #37001 removes the same debug-only reportUncaughtExceptionAtEventLoop blocks in the Bun.sql initializers, plus a WebKit bump that fixes the storedPrototype stale-structure assertion at its actual source in JSObject::getPropertySlot. Between the two of them every line of this PR is covered, so closing this one in their favor.

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