Skip to content

Fix crashes when a lazy Bun property throws while being initialized - #39427

Closed
robobun wants to merge 2 commits into
mainfrom
farm/8928bc04/lazy-prop-exception-handling
Closed

robobun wants to merge 2 commits into
mainfrom
farm/8928bc04/lazy-prop-exception-handling

Conversation

@robobun

@robobun robobun commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fuzzing found a crash while formatting the Bun object close to the stack limit (expect(Bun).toBeFalse() inside a recursion that had just overflowed). The fingerprint is JSValue::getObject() being called on an empty value (JSCJSValueCell.h:92, member call on a null JSCell). Two things go wrong when one of Bun's lazy static properties throws while it is being initialized, which a stack overflow makes easy to trigger for the ones that run JavaScript (Bun.$, Bun.sql, Bun.postgres, Bun.SQL):

  1. defaultBunSQLObject / constructBunSQLObject (BunObject.cpp) had a debug-only reportUncaughtExceptionAtEventLoop call in front of their RETURN_IF_EXCEPTION. The exception is not uncaught at that point (it is about to be propagated to whoever touched Bun.sql), and the uncaught-exception machinery runs process._fatalException lookups and the default error printer with the exception still pending. Depending on the state of the process object that either trips JSC assertions, or clears the exception, in which case RETURN_IF_EXCEPTION no longer fires and sqlValue.getObject() runs on the empty value requireId returned. That is the fuzzer's crash. Either way the script's own catch was also undermined: the error got printed and the exit code set to 1. The debug block is removed; the error simply propagates like it does in release builds.

  2. JSC__JSValue__forEachPropertyImpl (bindings.cpp, used by Bun.inspect, console.log and the expect failure messages) did continue when getPropertySlot returned false, before its CLEAR_IF_EXCEPTION. When the slot was missing because the initializer threw, the exception stayed pending while the loop went on to the next property. In debug and ASAN builds the next Rust-backed lazy property then aborts in to_js_host_call (Unexpected exception observed), which is what the reproducer hits on a current debug build. In release builds setUpStaticFunctionSlot sees the stale exception and reports every following not-yet-initialized static property as missing, so Bun.inspect(Bun) silently drops everything between $ and the first property that had already been initialized. The exception is now cleared before the continue, matching what the fast path and forEachPropertyOrdered in the same file already do.

How did you verify your code works?

  • The fuzzer script aborted on the debug build before (releaseAssertNoException in BunObject_lazyPropCb_Archive); with this change it runs to completion and exits with the expected toBeFalse error, with nothing from the sanitizers or assertions.
  • Two tests in test/js/bun/util/BunObject.test.ts. Each recurses until the stack overflows and then touches Bun.sql or calls Bun.inspect(Bun) while unwinding. Without this change the first one aborts on debug builds (it passes on release builds, since the code it covers is debug-only), and the second one aborts on debug builds and fails on release builds because Archive: is missing from the output. Both pass with the change, in well under a second each on a debug build.
  • test/js/bun/util/inspect*.test.js, test/js/bun/console/, test/js/node/util/ and the node test-util-inspect-proxy.js / getters-accessing-this.js tests still pass.

no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/BunObject.test.ts

@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: 37bf8028-3fb5-440c-84fd-60c9e6b7911d

📥 Commits

Reviewing files that changed from the base of the PR and between 1dd66af and 09a4e55.

📒 Files selected for processing (3)
  • src/jsc/bindings/BunObject.cpp
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/util/BunObject.test.ts
💤 Files with no reviewable changes (1)
  • src/jsc/bindings/BunObject.cpp

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


Walkthrough

The PR removes debug exception reporting from Bun SQL lazy-property constructors and updates property enumeration to handle failed lazy initialization. New subprocess tests cover stack exhaustion and throwing property initializers.

Changes

Lazy property exception handling

Layer / File(s) Summary
SQL lazy initialization cleanup
src/jsc/bindings/BunObject.cpp
Bun SQL lazy-property constructors retain exception propagation and remove debug-only exception reporting.
Exception-safe property enumeration
src/jsc/bindings/bindings.cpp, test/js/bun/util/BunObject.test.ts
Property enumeration handles failed proxy traps and lazy initializers, while subprocess tests verify continued enumeration, successful exits, and empty stderr output.

Possibly related PRs

  • oven-sh/bun#39380: Overlaps in the Bun SQL constructors, property enumeration handling, and regression tests.
  • oven-sh/bun#39412: Overlaps in exception handling during property enumeration.
  • oven-sh/bun#39419: Overlaps in property enumeration handling and throwing-property tests.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to 09a4e

The PR fixes crashes and incorrect inspection behavior when lazy Bun properties throw, with regression coverage included; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary fix for crashes caused by throwing lazy Bun properties.
Description check ✅ Passed The description includes both required sections and provides detailed change, cause, verification, and test information.
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.

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

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing this as a duplicate of #29642. That PR already has both of these hunks (the same BunObject.cpp debug block removal and the same clear-before-continue in forEachPropertyImpl), and on top of that it stops the walk when getPrototype comes back empty, which is another way to reach the same getObject() on an empty value that this PR does not cover. Its tests also exercise the throwing Bun.$ / Bun.sql initializers without depending on a stack overflow, so nothing from here needs to land separately. #39380, #39412 and #39419 were closed in favour of it for the same reason.

Comment on lines +5646 to +5650
bool hasProperty = object->getPropertySlot(globalObject, property, slot);
// Ignore exceptions from "Get" proxy traps and lazy property initializers.
CLEAR_IF_EXCEPTION(scope);
if (!hasProperty)
continue;

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 slow-path prototype walk further down in this same function (line 5722: iterating = iterating->getPrototype(globalObject).getObject();) has no exception check — when iterating is a Proxy whose getPrototypeOf trap throws, getPrototype returns an empty JSValue and .getObject() dereferences a null cell, the exact JSCJSValueCell.h:92 fingerprint this PR is fixing. The fast-path twin at lines 5601–5611 already guards this with CLEAR_IF_EXCEPTION ("Ignore exceptions from Proxy getPrototype trap"); per REVIEW.md's "fix the whole class … fast/slow paths", it's worth guarding the slow-path sibling here too — split the call, CLEAR_IF_EXCEPTION(scope) after getPrototype, and let the existing while (iterating && …) condition terminate on null.

Extended reasoning...

What the bug is

JSC__JSValue__forEachPropertyImpl — the function this PR modifies at line 5646 — walks the prototype chain in two places. The fast path at lines 5601–5611 does:

if (JSValue proto = prototypeObject.getPrototype(globalObject)) {
    ...
}
// Ignore exceptions from Proxy "getPrototype" trap.
CLEAR_IF_EXCEPTION(scope);

The slow path at line 5722 does the same operation without the guard:

iterating = iterating->getPrototype(globalObject).getObject();

When iterating is a ProxyObject whose getPrototypeOf trap throws, getPrototype dispatches through the method table to ProxyObject::getPrototype, which invokes the trap, hits RETURN_IF_EXCEPTION, and returns JSValue(). Calling .getObject() on an empty JSValue: on JSVALUE64 the encoded value is 0, so isCell() is true (0 & NotCellMask == 0), asCell() returns nullptr, and JSCell::getObject() reads m_type from a null pointer. This is exactly the "member call on a null JSCell" at JSCJSValueCell.h:92 that the PR description names as the crash it is fixing.

Step-by-step reproduction

const p = new Proxy({}, { getPrototypeOf() { throw new Error(); } });
const obj = Object.create(p);
obj.x = 1;
Bun.inspect(obj);   // or console.log(obj)
  1. obj is a plain JSFinalObject with own property x. It passes canPerformFastPropertyEnumerationForIterationBun (no OverridesGetOwnPropertySlot, no static props, no getters), so fast = true.
  2. The fast path processes x, sets anyHits = true.
  3. Line 5601: obj.getPrototype(globalObject) is the ordinary internal-slot read (obj is not itself a Proxy) and returns p. p is not objectPrototype/functionPrototype, not a JSGlobalProxy (ProxyObject is a different class), and its structure has overridesGetOwnPropertySlot() set, so canPerformFastPropertyEnumerationForIterationBun(p) returns false at line 633 → fast = false, prototypeObject = p, goto restart.
  4. Slow path: iterating = p. The while condition at line 5623 passes (p is not a JSGlobalProxy, not objectPrototype, prototypeCount 1→2 < 5).
  5. Line 5627: getOwnPropertyNames on p — no ownKeys trap defined, so it forwards to the target {} and returns an empty list without throwing. The property loop is a no-op.
  6. nonIndexedOnly is false (Bun.inspect calls the non-indexed variant), so the break at 5712 doesn't fire. p is not callable (its target isn't callable). p != globalObject.
  7. Line 5722: p->getPrototype(globalObject) invokes the throwing getPrototypeOf trap → returns JSValue() → .getObject() → null-cell dereference → segfault.

There is no RETURN_IF_EXCEPTION or CLEAR_IF_EXCEPTION between line 5710 (inside the property loop, not reached with an empty property list) and line 5722.

Why existing code doesn't prevent it

The last exception check before line 5722 is the RETURN_IF_EXCEPTION at line 5710, which is inside the for (auto& property : properties) loop and never runs when the property list is empty. The while condition at line 5623 does check iterating &&, but that only helps on the next iteration — the crash happens inside .getObject() before control returns to the loop head. The trailing if (scope.exception()) at line 5728 is after the loop and never reached.

Impact

User-reachable segfault via Bun.inspect / console.log on any object whose prototype chain contains a Proxy with a throwing getPrototypeOf trap. This is the same crash signature (JSCJSValueCell.h:92) the PR is fixing 76 lines above.

Why this belongs in this PR

REVIEW.md, "Correctness: the bug class, not the bug": "Fix the whole class in the same PR (same-class sites are ONE concern, not scope creep). Grep for every sibling site sharing the pattern: … fast/slow paths." Line 5722 is the literal slow-path twin of the fast-path call at 5601–5611, which already has the guard and the comment "Ignore exceptions from Proxy getPrototype trap." It is in the same function, the same while loop's neighborhood, and produces the identical crash fingerprint the PR description names. This is pre-existing (the PR did not introduce it), but it falls squarely under the same-class rule.

Fix

Split the call and clear the exception, matching the fast path:

JSValue proto = iterating->getPrototype(globalObject);
// Ignore exceptions from Proxy "getPrototype" trap.
CLEAR_IF_EXCEPTION(scope);
iterating = proto ? proto.getObject() : nullptr;

The existing while (iterating && …) condition then terminates the loop cleanly on the next check.

Comment on lines +106 to +109
expect(rest).toEqual({ archive: true, $: "function" });
// On Windows the formatter's own stack headroom is larger than JSC's, so the
// initializer never gets to fail there.
if (!isWindows) expect(shell).toBe(false);

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 if (!isWindows) expect(shell).toBe(false) assertion depends on the ratio between the native stack consumed by getPropertySlot → setUpStaticPropertySlot → constructBunShell → JSC::call and one JS recurse() frame — the !isWindows gate already concedes this ratio flips on one platform, and it can plausibly flip on release/aarch64/JIT lanes too (the PR body notes only debug was verified locally). Since the load-bearing regression check is archive: true + stderr === "" + exitCode === 0, consider dropping the shell assertion or gating it on isDebug so a lane where the initializer happens to succeed at the same frame does not turn the test red on correct code.

Extended reasoning...

What the assertion encodes

The spawned script recurses until the stack overflows, then on each unwind frame retries Bun.inspect(Bun, {depth: 0}) until it first succeeds. At that first-success frame, the available stack headroom H lies in the interval [T_inspect, T_inspect + one_JS_frame) — where T_inspect is the minimum headroom for forEachPropertyImpl to pass vm.isSafeToRecurse() (bindings.cpp:5490) and finish enumerating, and one_JS_frame is the size of one recurse() activation.

For shell === false to hold, constructBunShell's JSC::call(globalObject, createShellFn, ...) re-entry must still overflow at that same frame, i.e. H < T_shell where T_shell is the headroom needed for the interpreter re-entry to pass its own soft-stack check. That requires T_shell − T_inspect ≥ one_JS_frame. In words: the native stack between the top of forEachPropertyImpl and the interpreter re-entry inside constructBunShell must be larger than one JS frame.

Why that ratio is not stable

T_shell − T_inspect is the stack consumed by getPropertySlot → setUpStaticPropertySlot → constructBunShell (three JSFunction::create + a MarkedArgumentBuffer) → JSC::call → Interpreter::executeCall → vmEntryToJavaScript. That native span is set by the compiler and the target: release inlines and shrinks the chain relative to debug, aarch64 and x64 lay frames out differently, and ASAN inflates every frame. one_JS_frame is set by the execution tier — an LLInt recurse() frame is smaller than a baseline-JIT one, so once recurse tiers up, unwinding one frame reclaims more headroom and the window may be jumped in a single step.

The !isWindows gate on line 109 is direct evidence this is not hypothetical: the comment says "On Windows the formatter's own stack headroom is larger than JSC's, so the initializer never gets to fail there" — i.e. on one supported platform T_shell ≤ T_inspect + one_JS_frame and shell comes back true. The PR body's evidence footer says "Platform-specific test(s) that do not run on this machine. Deferring to CI", so the remaining non-Windows lanes (release × x64/aarch64 × ASAN) were not verified locally.

Step-by-step failure

  1. On some non-Windows lane (say release aarch64), the inlined constructBunShell chain consumes ~1.5 KB of native stack, and a tiered-up recurse() frame is ~2 KB.
  2. The script unwinds to the first frame where Bun.inspect(Bun) succeeds; headroom H ≈ T_inspect + 1.8 KB.
  3. forEachPropertyImpl enumerates $; constructBunShell needs only ~1.5 KB more to reach vmEntryToJavaScript, which passes its soft-stack check → $ initializes successfully → out.includes("$:") is true.
  4. archive is also true (nothing threw, so nothing to leak), stderr is empty, exitCode is 0 — the fix is exercised correctly and the runtime is behaving.
  5. expect(shell).toBe(false) fails; the test goes red on correct code.

What is actually load-bearing

The regression this PR fixes is: when $'s initializer throws inside getPropertySlot, the pending exception used to leak into the next iteration and either abort (releaseAssertNoException in the next Rust prop callback) or make setUpStaticFunctionSlot skip every following static property. The assertion that distinguishes fixed from unfixed on all builds is archive: true (proves properties after $ are still enumerated) plus stderr === "" / exitCode === 0 (proves no abort). shell: false is the setup-precondition check — it proves the initializer did throw so archive: true isn't vacuous — but as written it encodes a frame-size assumption that already needed one platform carve-out.

Suggested fix

Either drop the shell assertion entirely (the PR description already states archive: true distinguishes fixed from unfixed on release builds), or gate it more tightly — e.g. if (isDebug && !isWindows) — where the un-inlined native chain reliably exceeds one JS frame. Per REVIEW.md's "branch per-platform rather than dropping precision" and the RSS-threshold guidance to branch on isASAN/isDebug, narrowing the gate is the smallest change that keeps the precondition check without risking a red lane on correct code.

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