Skip to content

inspect: clear exceptions thrown by property lookups in forEachProperty - #38921

Open
robobun wants to merge 4 commits into
mainfrom
farm/066dda05/foreachproperty-stale-exception
Open

inspect: clear exceptions thrown by property lookups in forEachProperty#38921
robobun wants to merge 4 commits into
mainfrom
farm/066dda05/foreachproperty-stale-exception

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a fuzzer-found abort in the property walk behind Bun.inspect / console.log (JSC__JSValue__forEachPropertyImpl, the slow path used for objects with static tables and for prototype chains containing exotic objects).

The loop looked each property up with getPropertySlot and continued when it returned false. If the lookup returned false because it threw, the exception stayed pending and the walk carried on:

  • A lazy static property whose builder throws (Bun.$ calls into JS to build the shell object, which throws a stack overflow when the formatter runs right after unwinding one) leaves its exception pending; the next entry in the table (Bun.Archive) is reified through the Rust host call wrapper, whose exception scope then asserts. This is the fuzzer crash (releaseAssertNoException while formatting the Bun object for an ERR_INVALID_ARG_VALUE message).
  • A proxy in the prototype chain whose get trap throws does the same thing. Once the exception is pending, the prototype step at the end of the loop (iterating->getPrototype(globalObject).getObject()) gets the empty value back from the proxy and dereferences it: release builds segfault at address 0x5 on Bun.inspect(Object.create(new Proxy({}, { get() { throw 1 } }))) with any keys reported by ownKeys. A getPrototypeOf trap that throws hits the same dereference without needing the first bug.

The fix clears the exception regardless of what getPropertySlot returned (this is already what forEachPropertyOrdered does a few lines down), and stops walking the chain when getPrototype throws.

napi_get_all_property_names had the same getPrototype(...).getObject() chain in its filter loop (found in review). A getOwnPropertyDescriptor trap that throws, or a getPrototypeOf trap that throws on the second walk, segfaulted at the same address there; it now returns napi_pending_exception like the key collection step above it already did. That is the only other site with the pattern under src/jsc/bindings; the fast path of forEachPropertyImpl and forEachPropertyOrdered already handle both cases.

With the walk fixed, the fuzzer script got further and tripped a second debug-only assertion: the Bun.sql / Bun.SQL builders reported a failed internal module load via reportUncaughtExceptionAtEventLoop while that exception was still pending (#if BUN_DEBUG block). Running the uncaught exception handler with an exception pending makes process->get("_fatalException") reify the static entry but report it as missing, which fails a structure assertion in getPropertySlot. Every other caller of reportUncaughtExceptionAtEventLoop clears first, and the exception is propagated to whoever touched the property anyway, so the block is removed rather than reworked.

Not changed here: two lazy initializers that enter JS (process.env on Windows, and the util.inspect function used for inspect.custom) still abort the process when they fail at the stack limit. That is independent of the walk (it reproduces on main without any of this) and is tracked separately; the static table test below sets both up before recursing so it does not run into them.

How did you verify your code works?

  • The fuzzer script aborts within a second on an unfixed debug build; with this change it runs to the same result as a release build (the ERR_INVALID_ARG_VALUE error, exit code 1, no assertions). The Proxy variants segfault on the current canary release build and print { b: 2 } / {} with the fix.
  • test/js/bun/util/inspect.test.js: three tests, each run in a child process. All three fail with USE_SYSTEM_BUN=1 (truncated output for the static table case, dead child for the proxy cases) and pass with bun bd test. The static table test inspects Bun at every depth while unwinding a stack overflow and checks that the entry after the last builder that needs JS ($, sql, postgres, SQL all fail there) is still printed. Checked on Linux (debug+ASAN build, and the unfixed release canary prints false) and on Windows x64 (debug build of this branch prints true, the unfixed canary prints false); the first version of this test died on Windows because of the process.env initializer mentioned above.
  • test/napi/napi.test.ts: two new cases under napi_get_all_property_names. The throwing getOwnPropertyDescriptor case is compared against Node; the stateful getPrototypeOf case is Bun only, since Node walks the chain once and never calls the trap a second time. Both segfault with USE_SYSTEM_BUN=1 and pass with the debug build.
  • The rest of inspect.test.js, bun-inspect.test.ts, custom-inspect.test.js, BunObject.test.ts, console-table.test.ts, node-inspect-tests/ and the napi_get_all_property_names describe block pass locally.

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

JSC__JSValue__forEachPropertyImpl's slow path continued to the next
property when getPropertySlot returned false, without clearing the
exception that made it return false (a lazy static property whose builder
threw, or a throwing proxy get trap). The stale exception then leaked into
the next lookup: debug builds assert in the next lazy property's host call
wrapper, and a proxy anywhere in the prototype chain makes getPrototype
return the empty value, which was dereferenced unconditionally.

Clear the exception regardless of the result, as forEachPropertyOrdered
already does, and stop walking the prototype chain when getPrototype
throws.

Bun.sql / Bun.SQL builders reported a failed internal module load as an
uncaught exception while that exception was still pending (debug builds
only), which trips a structure assertion inside the handler once the
property walk reaches them with the stack nearly exhausted. The exception
is propagated to the caller anyway, so drop the extra report.
@coderabbitai

coderabbitai Bot commented Aug 15, 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: 9ccb4dad-1d64-476c-8e96-f6d5a4cf70d3

📥 Commits

Reviewing files that changed from the base of the PR and between 7d276b9 and 9ad6065.

📒 Files selected for processing (6)
  • src/jsc/bindings/BunObject.cpp
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/napi.cpp
  • test/js/bun/util/inspect.test.js
  • test/napi/napi-app/module.js
  • test/napi/napi.test.ts
💤 Files with no reviewable changes (1)
  • src/jsc/bindings/BunObject.cpp

Walkthrough

Changes

The PR removes debug-only SQL exception reporting and makes JavaScript and N-API property/prototype traversal handle proxy exceptions safely. Regression tests cover inspection, descriptor traps, and getPrototypeOf traps.

Exception-safe traversal

Layer / File(s) Summary
SQL constructor exception cleanup
src/jsc/bindings/BunObject.cpp
Both SQL constructors remove debug-only exception reporting while preserving exception propagation.
JavaScript inspection traversal
src/jsc/bindings/bindings.cpp, test/js/bun/util/inspect.test.js
Property and prototype traversal clears exceptions, skips invalid results, and validates safe Bun.inspect output in child processes.
N-API property-name traversal
src/jsc/bindings/napi.cpp, test/napi/napi-app/module.js, test/napi/napi.test.ts
Prototype values are checked before casting, and tests cover descriptor and prototype trap exceptions.

Possibly related PRs

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 summarizes the primary change: clearing exceptions from property lookups in the forEachProperty path used by inspect.
Description check ✅ Passed The description includes both required sections and provides detailed change context, verification results, platform coverage, and known unrelated issues.

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 15th, 2026

@robobun, your commit 9ad6065 has some failures in Build #97823 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38921

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

bun-38921 --bun

Comment thread test/js/bun/util/inspect.test.js
Comment thread src/jsc/bindings/bindings.cpp
napi_get_all_property_names walked the prototype chain with the same
getPrototype(...).getObject() chain: a getOwnPropertyDescriptor trap that
throws made the next getPrototype on the proxy return the empty value,
which was then dereferenced. Report the pending exception instead, in both
the prototype walk and the own-only branch.
Creating process.env on Windows and the util.inspect function both enter
JS from a lazy initializer, which aborts when it fails at the stack limit
independently of this change, so set both up before recursing. Check the
entry following the last builder that enters JS instead of Archive, which
some builds print even without the fix, and drop the assertion that `$` is
absent.

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

Re-reviewed after the two follow-up commits — both earlier notes are addressed and this pass found no new issues.

  • napi.cpp's prototype-walk sibling now returns on a throwing getOwnPropertyDescriptor/getPrototype trap (both branches), with tests — the getOwnPropertyDescriptor case compared against Node, the getPrototypeOf case Bun-only with the reason commented.
  • The static-table inspect test dropped the stack-layout-dependent $: half; checking only that the entry after the last JS-entering builder appears can't false-positive on a fixed build regardless of which lane first clears the soft stack limit.

Because this is JSC exception-scope handling in the formatter's property walk and in napi_get_all_property_names, and it removes the debug-only reportUncaughtExceptionAtEventLoop calls from the sql/SQL builders, a maintainer glance is still worth having.

Extended reasoning...

Overview

Fixes a fuzzer-found abort/segfault in JSC__JSValue__forEachPropertyImpl (the slow property walk behind Bun.inspect/console.log): getPropertySlot returning false because it threw left the exception pending across the rest of the walk, and getPrototype(globalObject).getObject() on a proxy dereferenced the empty value a throwing trap returns. The fix moves CLEAR_IF_EXCEPTION before the continue (matching forEachPropertyOrdered a few lines below) and splits the prototype step to break on empty. The two #if BUN_DEBUG reportUncaughtExceptionAtEventLoop blocks in defaultBunSQLObject/constructBunSQLObject are removed — running the uncaught-exception handler with an exception still pending trips a getPropertySlot structure assertion, and the exception already propagates via the following RETURN_IF_EXCEPTION.

Since the first review pass, two commits landed. 7144657a applies the same-class fix to napi_get_all_property_names's prototype walk in napi.cpp (my earlier 🟣 note): NAPI_RETURN_IF_EXCEPTION after both getOwnPropertyDescriptor and getPrototype, in both the include_prototypes loop and the own_only branch, plus two tests in module.js/napi.test.ts. b52a0b42 reworks the static-table inspect test (my earlier 🟡 note): it now pre-initializes process.env and the util.inspect custom-symbol path (whose lazy JS init would otherwise abort under stack pressure on Windows), computes afterSQL from Object.getOwnPropertyNames(Bun) at runtime, and asserts only that the entry after the last JS-entering builder appears — no longer depending on $ failing at the first unwind depth that lets Bun.inspect through.

Security risks

None identified. The change swallows exceptions from proxy traps during inspection (already the documented behaviour of the surrounding code — the pre-existing comment reads "Ignore exceptions from Get proxy traps") and propagates them from NAPI (matching Node). No new user-controlled input reaches an allocation or size computation.

Level of scrutiny

High. This is native C++ touching JSC exception scopes on paths that reach user-visible output for every console.log of an exotic object, and NAPI's property-name enumeration. A missed CLEAR_IF_EXCEPTION/RETURN_IF_EXCEPTION here is exactly the class of bug REVIEW.md flags as most-blocked. The changes are small and each is locally correct against the surrounding pattern, but the choice between swallow (inspect) and propagate (NAPI) and the removal of a debug-only diagnostic hook are judgment calls a maintainer should sign off on.

Other factors

  • Tests cover both proxy variants (release-build segfault → deterministic output) plus the debug-build assertion via a stack-overflow-then-inspect child process; the NAPI getOwnPropertyDescriptor case is checkSameOutput against Node, and the getPrototypeOf case is Bun-only with the divergence reason ("Node only walks once") commented in the fixture.
  • Object.getOwnPropertyNames(Bun) in the test enumerates static-table names without reifying values, so the builders are still lazy when Bun.inspect(Bun) runs during unwind.
  • The napi fix uses !proto.isObject() on the JSValue before asObject, so the empty-value case and a non-object prototype both break cleanly; a successful getOwnPropertyDescriptor (loop condition false) can't leave an exception pending in JSC, so no check is needed on that exit.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed two follow-ups.

CI (Windows lanes): the static table test died with exit code 9 on both Windows lanes. That is the low byte of 0xC0000409: std::abort() fast-failing, not anything in the walk. Formatting Bun at that depth reaches Bun.env, and on Windows process.env is built by calling into JS, so the lazy initializer fails at the stack limit and LazyProperty::set asserts on the null result. It reproduces with the unmodified canary (process.env touched for the first time right after a stack overflow kills the process), so it is a separate bug and is tracked on its own; same story for the util.inspect function that inspect.custom values need, which Bun.env has on Windows. The test now creates both before recursing, and instead of Archive it checks the entry after the last builder that needs JS ($, sql, postgres, SQL all fail at that depth), because the unfixed Windows build happens to print Archive anyway and only drops the entries after SQL. Verified on a Windows x64 box: this branch prints true, the canary prints false; same on Linux. The other two failures in that build (shell-pipe-read-fault, bun-patch) are unrelated and passed on retry. On the new push everything here passes; the one remaining red lane is test/bake/deinitialization.test.ts crashing at exit on Windows x64, which does not touch anything in this change and has been reported separately.

Review comments: agreed on both.

  • Dropped the assertion that $ is absent; the test only checks what the fix guarantees now.
  • napi_get_all_property_names had the same getPrototype(...).getObject() chain and did segfault at the same address with a throwing getOwnPropertyDescriptor trap (and with a getPrototypeOf trap that throws on the second walk). It now returns napi_pending_exception from the filter loop, with tests for both; the first one is compared against Node. Grepping src/jsc/bindings turned up no other site with the pattern.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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 — both earlier review points are addressed (the $: precondition assertion is dropped, and the sibling getPrototype(...).getObject() chain in napi_get_all_property_names now returns napi_pending_exception with tests for both trap variants).

What was reviewed: the CLEAR_IF_EXCEPTION reorder in forEachPropertyImpl matches what forEachPropertyOrdered already does; the getPrototype split with an empty-value guard before .getObject() removes the null-cell dereference. The napi filter loop now checks for exceptions after both getOwnPropertyDescriptor and getPrototype (and in the own_only branch), using the same NAPI_RETURN_IF_EXCEPTION macro as the key-collection step above it. The BunObject.cpp change removes a debug-only reportUncaughtExceptionAtEventLoop that ran with the exception still pending — the RETURN_IF_EXCEPTION on the next line still propagates it.

Extended reasoning...

Overview

Six files touched. Native: bindings.cpp (JSC__JSValue__forEachPropertyImpl slow path — clear the exception regardless of what getPropertySlot returned, and split getPrototype().getObject() so a throwing getPrototypeOf trap breaks the walk instead of dereferencing an empty JSValue), napi.cpp (napi_get_all_property_names filter loop — NAPI_RETURN_IF_EXCEPTION after getOwnPropertyDescriptor and getPrototype, plus the own_only branch), and BunObject.cpp (drops two debug-only reportUncaughtExceptionAtEventLoop calls that ran with the exception still pending). Tests: three subprocess-spawned inspect.test.js cases and two napi_get_all_property_names cases (one compared against Node, one Bun-only for the second-walk getPrototypeOf throw).

Security risks

None. This hardens against hostile Proxy traps that previously segfaulted the process at address 0x5; the fix clears/propagates the exception rather than introducing any new user-code entry points. No auth, crypto, or permission surface.

Level of scrutiny

Moderate-high — this is C++ in JSC bindings, memory-safety-adjacent. But each change is small (~20 native lines total) and copies the exact pattern already used one level over: forEachPropertyOrdered already clears unconditionally, and the napi key-collection step in the same function already uses NAPI_RETURN_IF_EXCEPTION. The proto.isObject() / asObject(proto) pair is safe because the exception check runs first, so proto is never the empty value. The BunObject.cpp deletion is debug-only and the exception still propagates via RETURN_IF_EXCEPTION immediately after.

Other factors

I reviewed an earlier revision and left two comments (a stack-layout-dependent test assertion, and the same-class sibling in napi.cpp); both are addressed in this revision with follow-up commits and new tests. The author verified the static-table test on Linux and Windows x64 against both this branch and the unfixed canary, and the two Proxy tests deterministically cover the release-build segfault. The napi getOwnPropertyDescriptor case is compared against Node. The bug-hunting pass on this revision found nothing.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for the record: the re-run (#97823) has no test failures (178 jobs passed, the handful of flaky entries all passed on retry). The build is marked failed only because the darwin 14 aarch64 - test-bun job expired without ever getting an agent; in the previous build that same job was still queued when it got canceled by the push, so it is the one lane that has not actually run here. Everything else, including the Linux and Windows aarch64 lanes, is green. Retrying that single job in Buildkite should be enough; I am not going to push another empty commit for it.

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