Skip to content

Clear the exception left by a failed lazy property in forEachProperty - #39428

Closed
robobun wants to merge 1 commit into
mainfrom
farm/f4a7ed67/foreach-property-lazy-prop-exception
Closed

Clear the exception left by a failed lazy property in forEachProperty#39428
robobun wants to merge 1 commit into
mainfrom
farm/f4a7ed67/foreach-property-lazy-prop-exception

Conversation

@robobun

@robobun robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a fuzzer-found assertion failure (ExceptionScope::releaseAssertNoException, "Unexpected exception observed") in the native property walk behind Bun.inspect / console.log.

Reduced repro: catch a stack overflow and, with the stack still almost full, inspect an object that has lazily initialized static properties:

function f() {
  try { f(); } catch {}
  Bun.inspect(Bun);
}
f();

The fuzzer got there through new CompressionStream(globalThis), whose ERR_INVALID_ARG_VALUE message inspects the argument.

Root cause: Bun.$ is a static lazy property whose initializer runs JS. With no JS stack left it throws, reifyStaticProperty stores nothing, and setUpStaticFunctionSlot returns false with the exception still pending (that is the contract in our JSC fork: the caller of getPropertySlot has to look at the exception). JSC__JSValue__forEachPropertyImpl did

if (!object->getPropertySlot(globalObject, property, slot))
    continue;
CLEAR_IF_EXCEPTION(scope);

so the continue skipped the clear. The next property in the table, Bun.Archive, was then reified with the exception still set, and its Rust-side to_js_host_call wrapper asserts on exactly that. In release builds the damage is different: setUpStaticFunctionSlot checks vm.exceptionForInspection() after reifying, so with a stale exception every following lazy property is reified but reported as missing, until the walk reaches a property that was already reified (that lookup takes the normal path and clears the exception). With an invalid REDIS_URL, Bun.inspect(Bun) on current release builds prints nothing after RedisClient: redis legitimately fails, and secrets, write and the zstd* functions go missing with it.

The fix is the hunk in bindings.cpp: call getPropertySlot, clear, then continue, which is what JSC__JSValue__forEachPropertyOrdered already does a few lines further down. I checked the other slot lookups in the bindings: JSC__JSValue__getPropertyValue returns on the exception, and JSPropertyIterator goes through from_js_host_call_generic, so the exception propagates there.

With that fixed, the same repro hit a second assertion (Structure::storedPrototype) further along the same unwind. The Bun.sql / Bun.SQL builders had a debug-only block that reported the require failure through reportUncaughtExceptionAtEventLoop while the exception was still pending. The reporter looks up process._fatalException, which is itself a lazy property, so that lookup ran into the same stale-exception situation one level down. Every other caller of reportUncaughtExceptionAtEventLoop clears first. The block is removed: RETURN_IF_EXCEPTION right below it already hands the error to whoever touched Bun.sql, which is the more useful place for it to show up anyway.

Not changed here: the lazy builders on process use a different policy (callLazyProcessBuilder clears, reports the error as uncaught and stores undefined). Running the same unwind against process leaves process.nextTick, process.stdout and a few others permanently undefined and prints six RangeErrors. That is pre-existing and a separate piece of code; tracking it separately.

How did you verify your code works?

Two tests added to test/js/bun/util/inspect.test.js:

  • only the property that failed is left out: REDIS_URL set to garbage, so Bun.redis throws, then checks that secrets, write and zstdDecompress are still printed. No stack tricks involved.
  • initializers that run out of stack: the reduced repro above. It unwinds a stack overflow one frame at a time and checks the first Bun.inspect(Bun) call that returns; Archive (declared right after $) and version have to be in the output.

Both fail before the fix: on the release build they fail on the printed output (secrets: false etc., and a Bun { dump that starts at inspect:), on a debug build both child processes die on the assertions above. Both pass with bun bd test, and I ran them eight times in a row on the debug build. After the fix the first completing inspect at the bottom of the unwind is missing exactly $, sql, postgres and SQL (the four builders that run JS); before, it was missing 85 of the 115 properties.

The fuzzer script itself (bounded to the deepest 1500 frames of the unwind, the full version inspects globalThis at every one of ~50k frames and takes ages on a debug build) now runs to completion on the debug build; the unfixed debug binary still aborts on it. Also ran inspect.test.js, BunObject.test.ts, the test/js/bun/console tests and the sql adapter tests on the debug build.


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

getPropertySlot() returns false with an exception pending when a static
lazy property's initializer throws. JSC__JSValue__forEachPropertyImpl
skipped straight to the next property in that case, so the next lazy
property was reified with that exception still set: an assertion in
debug builds, and in release builds setUpStaticFunctionSlot reported
every following property as missing until an already reified one
cleared the exception. Clear it before moving on, like
forEachPropertyOrdered already does.

Also drop the debug-only reporting in the Bun.sql builders: it reported
the exception while it was still pending, which made the property
lookups inside the reporter trip over the same thing.
@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: 3046d82e-777d-4fa0-83e6-b70572899ce9

📥 Commits

Reviewing files that changed from the base of the PR and between 1dd66af and 428e6f0.

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

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


Walkthrough

Changes

Bun.inspect exception handling

Layer / File(s) Summary
Exception-safe property enumeration
src/jsc/bindings/BunObject.cpp, src/jsc/bindings/bindings.cpp
Property lookup exceptions are cleared during enumeration. Unavailable properties are skipped. Bun SQL handlers keep normal exception propagation.
Inspection regression coverage
test/js/bun/util/inspect.test.js
Subprocess tests cover throwing lazy properties and stack exhaustion during Bun.inspect. Later and required properties remain inspectable.

Possibly related PRs

  • oven-sh/bun#39427: Modifies the same bindings and adds equivalent lazy-property exception tests.
  • oven-sh/bun#39412: Updates property enumeration and Bun.inspect tests for lazy-property and proxy lookup exceptions.
  • oven-sh/bun#39382: Overlaps in exception clearing across BunObject.cpp, bindings.cpp, and inspection tests.

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 describes the primary fix: clearing exceptions after failed lazy properties during property enumeration.
Description check ✅ Passed The description includes both required sections and gives detailed root-cause, implementation, and verification information.

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 carries both hunks from here (the same clear-before-continue in forEachPropertyImpl and the same removal of the debug-only reporting in the Bun.sql builders), and on top of that it handles a throwing getPrototype in the same walk. Its tests also cover this case: they make the Bun.$ / Bun.sql initializers throw deterministically and check that Archive and version are still printed, which is the same check as the tests here, so nothing from this PR needs to land separately. The stack overflow variant the fuzzer hit (the initializer of Bun.$ running out of stack, assertion while reifying Bun.Archive) no longer reproduces with those two hunks applied, which is what this branch verified.

@robobun robobun closed this 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 — a targeted reorder so the pending exception from a failed lazy-property lookup is cleared before continue, matching what forEachPropertyOrdered already does.

What was reviewed:

  • Confirmed the bindings.cpp hunk mirrors the existing pattern at bindings.cpp:5809-5814 in forEachPropertyOrdered (call getPropertySlot, clear, then branch).
  • Checked the removed #if BUN_DEBUG blocks in BunObject.cpp — they called reportUncaughtExceptionAtEventLoop with the exception still pending; RETURN_IF_EXCEPTION immediately below already propagates it, and no other caller uses that report-while-pending pattern.
  • Tests spawn subprocesses, drain pipes concurrently, assert stdout before exit code, and cover both the release-build symptom (properties after redis dropped) and the debug-build assertion (stack-overflow unwind).
Extended reasoning...

Overview

Three files touched: a 4-line reorder in src/jsc/bindings/bindings.cpp inside JSC__JSValue__forEachPropertyImpl, removal of two debug-only reportUncaughtExceptionAtEventLoop blocks in src/jsc/bindings/BunObject.cpp (defaultBunSQLObject / constructBunSQLObject), and two new subprocess tests in test/js/bun/util/inspect.test.js.

The root cause is well diagnosed in the PR description: when getPropertySlot returns false because a lazy-property initializer threw, the old code hit continue before CLEAR_IF_EXCEPTION, leaving the exception pending for the next iteration. The fix stores the return value, clears unconditionally, then branches — exactly what the sibling forEachPropertyOrdered at bindings.cpp:5809-5814 already does.

Security risks

None. This is exception-handling ordering inside an inspection/formatting path; no untrusted input parsing, allocation sizing, or auth surface is involved.

Level of scrutiny

Low-to-medium. The core change is a mechanical reorder that brings one loop body in line with its ordered twin a few lines below. The BunObject.cpp deletions remove debug-only code that violated the "clear before calling reportUncaughtExceptionAtEventLoop" convention the PR description says every other caller follows; the RETURN_IF_EXCEPTION immediately after already surfaces the error to the caller, so no diagnostic is lost — it just moves from an event-loop uncaught report to the actual throw site.

Other factors

The two new tests are well-constructed per the repo's review guidelines: it.concurrent subprocess spawns with bunEnv spread, concurrent pipe drains, stdout asserted before exit code, and both cover distinct observable symptoms (the REDIS_URL test needs no stack tricks and asserts specific properties by name; the stack-overflow test unwinds frame-by-frame and checks the first completing inspect plus a fully-unwound one). The PR description documents that both fail on release and debug builds pre-fix and pass post-fix across eight debug runs, and that the sibling slot-lookup sites (JSC__JSValue__getPropertyValue, JSPropertyIterator) were audited and already handle the exception. The known related issue on process lazy builders is explicitly scoped out with a rationale. No prior review comments on the PR.

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