Stop the ordered property walk when the inspect callback throws - #37331
Stop the ordered property walk when the inspect callback throws#37331robobun wants to merge 5 commits into
Conversation
JSC__JSValue__forEachPropertyOrdered kept calling getPropertySlot for the
remaining keys after the iteration callback threw, with that exception
still pending. The next key's getter or lazy initializer then ran with a
pending exception (an assertion failure in debug/ASAN builds), and the
exception was cleared by the walk's own getter exception handling, so
Bun.inspect(x, { sorted: true }) returned a string with an empty slot for
the property and bun:test snapshots recorded the object without it.
Return after the callback throws, as the unordered walk already does; the
Rust callers (ConsoleObject print_object, pretty_format) already check for
the exception after the call.
|
Status Reproduced on main (827475e): Fix: one |
WalkthroughThe ordered property iterator now propagates callback exceptions. Snapshot formatting and ChangesInspection exception propagation
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/bun/test/snapshot-tests/bun-snapshots.test.ts`:
- Around line 83-92: Update the inline snapshot assertion around the throwing
formatter in the test to assert the propagated error message is exactly "boom",
rather than only checking that an error is thrown. Preserve the existing
snapshot setup and ensure the assertion verifies the getter exception itself is
retained.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6eaf2cd2-2cd4-4cee-b208-237f4482d2ad
📒 Files selected for processing (3)
src/jsc/bindings/bindings.cpptest/js/bun/test/snapshot-tests/bun-snapshots.test.tstest/js/bun/util/inspect.test.js
There was a problem hiding this comment.
LGTM — one-line RETURN_IF_EXCEPTION after the iter(...) callback in forEachPropertyOrdered, mirroring what the unordered walk already does at bindings.cpp:5662.
What was reviewed:
- Confirmed the early return skipping
properties.releaseData()is safe: the same function already returns early at line 5741 without it, and the unordered sibling does the same at lines 5583/5662 —PropertyNameArrayBuilderis RAII-managed. - Verified both Rust callers (
ConsoleObject.rs:5463,pretty_format.rs:2349) propagate theJsResultvia?, and the wrapper (JSValue.rs:2513) checks the scope after the call. - Tests cover both callers, sorted vs unsorted parity, the subprocess abort repro, and the snapshot matcher path; the CodeRabbit feedback on
.toThrow()was addressed in 7a3aae5.
Extended reasoning...
Overview
The PR adds a single RETURN_IF_EXCEPTION(scope, void()) after the iter(...) callback in JSC__JSValue__forEachPropertyOrdered (src/jsc/bindings/bindings.cpp:5804), replacing a // TODO: properly propagate exception upwards comment. Without it, an exception thrown by the per-property callback (e.g. a nested value's inspect.custom) was left pending while the loop continued to the next key: in release builds the following iteration's tryClearException() swallowed it; in debug/ASAN builds a lazily-initialized native property's exception check aborted. Two test files add coverage: inspect.test.js for Bun.inspect/Bun.inspect.table with {sorted: true} plus a subprocess repro against the Bun object, and bun-snapshots.test.ts for the snapshot matcher path.
Security risks
None. This is an exception-propagation fix in a formatting/inspect code path. No auth, crypto, or untrusted-size arithmetic is touched.
Level of scrutiny
Low-to-medium. The native change is one line and is byte-for-byte the same pattern as the unordered sibling JSC__JSValue__forEachPropertyImpl (lines 5548, 5583, 5662), which has done this since #15985. I checked the one non-obvious concern — whether the early return leaks by skipping properties.releaseData() — and confirmed it does not: the same function already returns early without it after getOwnPropertyNames throws (line 5741), and the unordered variant has three such early returns. PropertyNameArrayBuilder is stack-allocated with a RefPtr-backed destructor. I also verified the Rust side: for_each_property_ordered in JSValue.rs wraps the FFI call in a TopExceptionScope and returns JsResult, and both call sites (ConsoleObject.rs print_object and pretty_format.rs) propagate with ?.
Other factors
The tests are strong: they assert specific error messages ("boom" for Bun.inspect, "Failed to pretty format value" for the snapshot matcher), assert exact visited/reads arrays to prove the walk stopped, cover both sorted and unsorted for parity, cover Bun.inspect.table as the second caller, and run the debug-abort repro in a subprocess with pipes drained concurrently. The inline snapshot argument is deliberately the unfixed build's output so the test never writes anything on either build. The one CodeRabbit inline comment was addressed and resolved. The PR description explicitly scopes out two adjacent issues (DiffFormatter's pending-exception assert and the wrapper message dropping the original error) as separate work, which is the right call — they are pre-existing and not part of this bug class.
There was a problem hiding this comment.
LGTM — one-line RETURN_IF_EXCEPTION after the callback in JSC__JSValue__forEachPropertyOrdered, mirroring the unordered sibling exactly.
What was reviewed
- Confirmed the added check matches
forEachPropertyImpl(bindings.cpp:5659-5662) byte-for-byte, including the comment; the function is alreadycheck_slowandfor_each_property_orderedin JSValue.rs already checks the scope. - Early return skips
properties.releaseData()— same as the unordered path since #15985;PropertyNameArrayDatais refcounted so the destructor releases it. - Both Rust callers (
ConsoleObject.rs:5459via?,pretty_format.rs:2337via captured result) propagate the error. - Tests cover both callers, nested walks, and the debug-build abort in a subprocess; the CodeRabbit thread on the snapshot assertion is resolved.
Extended reasoning...
Overview
The PR adds a single RETURN_IF_EXCEPTION(scope, void()) after the iter(...) callback in JSC__JSValue__forEachPropertyOrdered (bindings.cpp:5802-5804), replacing a TODO: properly propagate exception upwards comment. Two test files gain regression coverage: four cases in inspect.test.js for Bun.inspect/Bun.inspect.table with sorted: true, and one case in bun-snapshots.test.ts for the snapshot matcher path.
Security risks
None. This is exception-propagation plumbing in the console/inspect formatter — no auth, crypto, parsing of untrusted input, or resource limits involved.
Level of scrutiny
Low-to-medium. The native change is one line that copies the exact pattern (same macro, same comment text) already present at bindings.cpp:5659-5662 in the unordered forEachPropertyImpl. The function is already exported as [[ZIG_EXPORT(check_slow)]] and its Rust wrapper (JSValue.rs:2510-2516) already declares a scope and calls return_if_exception() after the FFI call — so the calling contract was already in place; only the C++ side wasn't honoring it. I verified both Rust callers (ConsoleObject.rs print_object via ?, pretty_format.rs via a captured result) propagate. The early return that skips properties.releaseData() mirrors the sibling exactly; PropertyNameArrayData is a RefPtr-held type, so scope exit releases it.
Other factors
- The PR description gives a full mechanism trace (why release builds swallowed the exception via the next iteration's
tryClearException(), why debug builds abort on the lazy-property native callback's exception check) and confirmsUSE_SYSTEM_BUN=1fails /bun bdpasses for every new test. - Tests are non-vacuous: the
visited/readsarrays assert the walk actually stopped at the throwing property (not just that something threw), and the subprocess test assertsstdout === "caught: boom\n"so it fails on both the unfixed release output (returned) and an unfixed debug abort. - The one CodeRabbit inline comment was addressed in 7a3aae5 (tightened bare
toThrow()totoThrow("Failed to pretty format value")) and the thread is resolved. - No prior claude[bot] reviews on this PR.
|
Updated 6:59 PM PT - Aug 10th, 2026
✅ @robobun, your commit b060a5ca723160824a3236525fa84e7720be61d9 passed in 🧪 To try this PR locally: bunx bun-pr 37331That installs a local version of the PR into your bun-37331 --bun |
There was a problem hiding this comment.
LGTM — one-line RETURN_IF_EXCEPTION after the callback in JSC__JSValue__forEachPropertyOrdered, mirroring what the unordered walk already does.
What was reviewed
- Confirmed the sibling unordered path (
forEachPropertyImpl, bindings.cpp:5662) has the identical early-return pattern, so skippingproperties.releaseData()on the exception path is established (also matches the pre-existing early return at :5741 and WebCore'sJSDOMConvertRecord.h). - Both Rust callers (
ConsoleObject.rs,pretty_format.rs) already propagate via?onfor_each_property_ordered'sJsResult; the function is exportedcheck_slow, so no Rust-side changes needed. - Tests cover both callers (
Bun.inspectandBun.inspect.table), nested propagation across two walk levels, the debug-build abort case in a subprocess, and the snapshot matcher path — withvisited/readsarrays proving the walk actually stops. - CodeRabbit's
toThrow()note was addressed with the correct wrapper message; thread resolved.
Extended reasoning...
Overview
The native change is a single line in src/jsc/bindings/bindings.cpp: adding RETURN_IF_EXCEPTION(scope, void()) after the iter(...) callback in JSC__JSValue__forEachPropertyOrdered, replacing a // TODO: properly propagate exception upwards comment. This makes the ordered property walk stop when the per-property callback throws, instead of continuing to the next property with a pending exception (which either got swallowed by the next iteration's tryClearException() in release builds, or tripped a debug-build releaseAssertNoException when the next property was a lazy native getter). Two test files gain regression coverage.
Security risks
None. This is exception-handling correctness in the object formatter/inspector; no auth, crypto, network, or filesystem surface.
Level of scrutiny
Low-to-medium. The native change is mechanical: it copies the exact pattern already present in the unordered sibling JSC__JSValue__forEachPropertyImpl (bindings.cpp:5662 and :5583), which was added in #15985 for the same reason. I checked that the early return skipping properties.releaseData() is not a leak: the same skip already occurs on the pre-existing early return at line 5741 (after getOwnPropertyNames fails), on both early returns in the unordered variant, and in WebCore's JSDOMConvertRecord.h — PropertyNameArrayBuilder is a stack RAII type. On the Rust side, the function is exported as check_slow and JSValue::for_each_property_ordered returns JsResult<()>; both callers (ConsoleObject::print_object at ConsoleObject.rs:5459 and pretty_format.rs:2337) already ?-propagate, so no changes were needed there — the PR description's claim checks out.
Other factors
The tests are unusually thorough for a one-line fix: they assert exact visitation order via a visited array (so the test can only pass if the walk actually stopped, not just if some error was thrown), cover both entry points that use the ordered walk (Bun.inspect({sorted:true}) and Bun.inspect.table), cover nested propagation across two walk levels, and run the debug-abort repro in a subprocess with concurrent pipe drains and a combined stdout/stderr/exitCode assertion. The snapshot test's inline snapshot argument is deliberately what the unfixed build recorded, so it never writes on either build. The one CodeRabbit review comment (bare toThrow()) was correctly addressed with toThrow("Failed to pretty format value") — the author's explanation that snapshot matchers wrap the error (unlike Bun.inspect, which the other tests cover with toThrow("boom")) is accurate and the thread is resolved. The PR description also flags the known interaction with #37334 for whichever lands second.
There was a problem hiding this comment.
LGTM — one-line RETURN_IF_EXCEPTION after the callback in JSC__JSValue__forEachPropertyOrdered, bringing it to parity with the unordered walk.
What was reviewed
- Confirmed the identical pattern already exists at
bindings.cpp:5662inforEachPropertyImpl; the early return beforeproperties.releaseData()matches the pre-existing exit at :5741 and the unordered path (RefPtr-backed, destructor releases). - Verified the Rust side already honors the
check_slowcontract:JSValue::for_each_property_orderedchecks the scope, and both callers (ConsoleObjectprint_object,pretty_format) propagate via?. - Tests cover both callers (
Bun.inspect/.tableand snapshot matcher), nested propagation, and the debug-build assertion via subprocess; assertions onvisited/readsprove the walk stops rather than merely throwing.
Extended reasoning...
Overview
The PR adds a single RETURN_IF_EXCEPTION(scope, void()) after the iter(...) callback in JSC__JSValue__forEachPropertyOrdered (src/jsc/bindings/bindings.cpp), replacing a // TODO: properly propagate exception upwards comment. Without it, an exception raised by the per-property callback (e.g. a nested value's nodejs.util.inspect.custom throwing) was swallowed by the next iteration's tryClearException() in release builds — producing a partial formatted string — and tripped releaseAssertNoException in debug/ASAN builds when the next property was a lazily-initialized native. The unordered JSC__JSValue__forEachPropertyImpl has had this exact check since #15985 (line 5662), so this brings the ordered variant to parity. Two test files add regression coverage.
Security risks
None. This is exception-propagation plumbing in the object formatter; no untrusted input parsing, auth, crypto, or resource handling is touched.
Level of scrutiny
Low-to-moderate. The native change is one line and mechanically mirrors the sibling function. I checked the one thing that could differ from a pure copy: the early return skips properties.releaseData(). That is not a leak — the same early return already exists in this function at line 5741 (after getOwnPropertyNames throws) and in the unordered variant at line 5662; PropertyNameArrayBuilder holds its data via RefPtr and releases in its destructor. On the Rust side, the function is exported check_slow, for_each_property_ordered wraps it in a TopExceptionScope and returns JsResult, and both callers (ConsoleObject.rs:5459, pretty_format.rs:2349) propagate with ?, so nothing else needed to change.
Other factors
Tests are thorough for a one-line fix: they exercise both Rust callers (Bun.inspect with sorted: true, Bun.inspect.table, and the snapshot matcher's pretty_format), assert the walk stops via a visited/reads array (not just that something threw), cover the nested case where the exception must cross two levels of the loop, and reproduce the debug-build abort in a subprocess. The CodeRabbit suggestion to assert "boom" in the snapshot test was correctly declined (the matcher wraps the error) and the assertion tightened to "Failed to pretty format value" instead; that thread is resolved. The PR description explicitly names the related-but-unchanged DiffFormatter issue and the interaction with #37334, so scope is clear.
Repro
The same walk is used for every object
bun testformats for a snapshot, so on the unfixed build this records{ "b": ... }as the snapshot of an object whoseathrew while being formatted (andtoMatchInlineSnapshot()writes that into the test file), while the same getter on the property that happens to sort last fails the matcher.Cause
JSC__JSValue__forEachPropertyOrdered(src/jsc/bindings/bindings.cpp) callediter(...)for each key and just continued to the next one (there was a// TODO: properly propagate exception upwardsabove the call). With the callback's exception still pending, the loop calledgetPropertySlotfor the next key. On theBunobject that key is a lazily initialized property backed by a native callback, whose exception check fires in debug/ASAN builds. In release builds thetryClearException()that guards the walk's own getter calls swallowed the callback's exception, so the result depended on the sort position of the throwing property: only an exception from the last key reached the caller.Fix
RETURN_IF_EXCEPTION(scope, void())afteriter(...), which is what the unorderedJSC__JSValue__forEachPropertyImplhas done since #15985. Propagating is the contract the rest of the code already assumes: the function is exported ascheck_slow,JSValue::for_each_property_orderedchecks the scope after the call, and both callers (ConsoleObjectprint_objectandpretty_format) propagate the error. The ordered variant was the one place that did not honor it. Nothing else needed to change on the Rust side: both property callbacks already return with the exception pending, exactly like the unordered path.Tests
test/js/bun/util/inspect.test.jssorted: truethrows and visits no further properties, same as the unsorted walkBun.inspect.table(..., { sorted: true }), the other caller of the ordered walkBunobject case above, in a subprocess (aborts on an unfixed debug build, printsreturnedon an unfixed release build)test/js/bun/test/snapshot-tests/bun-snapshots.test.ts$$typeofgetter throws fails the snapshot matcher, and neither its siblings nor the outer object's remaining properties are formatted (this exercises the callback'sformat()error return as well as itsTag::getone); the inline snapshot argument is what the unfixed build used to record and match (amissing), so the test never writes anything on either buildAll of these fail with
USE_SYSTEM_BUN=1and pass withbun bd test. Also ranbun-inspect-table.test.ts,console-table.test.tsandsnapshot-tests/against the debug build; the two failures there (snapshots/snapshot.test.ts"error snapshots" without a color TTY, andpretty-format-overflow.test.tsexiting 139 under ASAN) reproduce identically on a build without this change.Related, not changed here
toEqualthrows,DiffFormattergoes on to format the expected value with the exception still pending and hits the same kind of assertion in debug builds. That is reachable today whenever the throwing property is the last one and is the subject of Clear pending JSC exception in DiffFormatter catch blocks #28538 / test: clear pending JS exception when diff formatting throws #29784, so the new tests use snapshot matchers rather thantoEqual.Failed to pretty format value:(the original error is dropped). This change makes that message consistent across properties; bun:test: throw the formatter's own error from snapshot matchers instead of "Failed to pretty format value: " #37334 makes the matchers rethrow the original error instead. The two touch the samedescribeblock inbun-snapshots.test.ts, and once bun:test: throw the formatter's own error from snapshot matchers instead of "Failed to pretty format value: " #37334 is in, the new test here should expect"boom"instead of the wrapper message (whichever lands second picks that up).no test proof · iteration 3 · 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