Skip to content

inspect: resolve property values live after the mutation-safe key snapshot - #37178

Open
robobun wants to merge 6 commits into
mainfrom
farm/37027076/inspect-live-values
Open

inspect: resolve property values live after the mutation-safe key snapshot#37178
robobun wants to merge 6 commits into
mainfrom
farm/37027076/inspect-live-values

Conversation

@robobun

@robobun robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #37169.

Problem

#37169 fixed the use-after-free in the Bun.inspect / console.log fast property walk by snapshotting entries before invoking the formatter callback. It captured both keys and values, which changed observable behavior: when a hook (for example inspect.custom on a nested value) mutates a sibling that has not been formatted yet, the frozen value was printed instead of the current one, and a deleted property was printed anyway.

const p = {};
for (let i = 0; i < 8; i++) p['k'+i] = i;
let f = 0;
p.a = { [Symbol.for('nodejs.util.inspect.custom')]() { if (!f++) p.z = 777; return 'a'; } };
p.z = 1;
console.log(Bun.inspect(p));
hook mutates p.z Bun 1.4.0 (pre #37169) after #37169 Node v26 this PR
p.z = 777 z: 777 z: 1 (stale) z: 777 z: 777
delete p.z omitted z: 1 (resurrected) throws (Node bug) omitted
delete p.z; p.z = 999 z: 999 z: 1 (stale) z: 999 z: 999
redefine as getter z: [Getter] z: 1 (stale) z: [Getter] z: [Getter]
delete p.z shadowing inherited z z: "inherited" z: 1 (stale) throws (Node bug) z: "inherited"

The slow path in the same function (taken when the object has getters or is a Proxy) reads values live, so the two paths also disagreed with each other.

Fix

The use-after-free fix only requires that no user code runs while Structure::forEachProperty iterates the PropertyTable. So snapshot just the key list during the walk, and resolve each value by name in the post-walk loop: getDirect(vm, propertyName, attributes) against the live structure, falling back to a PropertySlot lookup with the slow path's accessor handling when the direct lookup misses. A by-name lookup holds no iteration state, so mutation between callbacks is fine: overwritten keys report the current value, a property redefined as an accessor shows [Getter] because the attributes are read live too, and a deleted key re-resolves through the prototype chain, printing the inherited value it was shadowing (an inherited accessor stays [Getter], not invoked) or omitted when gone, exactly like the slow path.

This restores the pre-#37169 value semantics (which matched Node) while keeping its key-list snapshot semantics (which also match Node: properties added mid-format are not shown). The parallel MarkedArgumentBuffer value buffer is no longer needed and is removed; each value is now fetched right before its callback and protected by the existing EnsureStillAliveScope.

Verification

  • Extended the ASAN fixture in test/js/bun/util/inspect.test.js with overwrite, delete, delete+re-add, redefine-as-getter, delete-of-a-shadowing-key, and delete-of-a-key-shadowing-an-inherited-getter of a later-ordered sibling. The new assertions fail on current main (stale z: 1) and pass with this change; the use-after-free cases from Fix use-after-free in Bun.inspect when user code mutates the object mid-format #37169 still pass under ASAN with Malloc=1.
  • Added a behavior matrix test (runs on every build, no ASAN needed) asserting the six mutation cases produce identical output on the fast path, the slow path (forced by a getter on the parent), and the sorted walk. The inherited-getter case throws if the formatter invokes it. Verified the combinations also match Bun 1.4.0.
  • test/js/bun/util/inspect.test.js: 80 pass. test/js/bun/console/: 85 pass, 1 skip. test/js/node/util/custom-inspect.test.js + bun-inspect.test.ts: 56 pass.

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

…pshot

Follow-up to #37169, which fixed the use-after-free by snapshotting both keys
and values before running formatter callbacks. Freezing the values diverged
from Node, from previous Bun releases, and from the slow path in the same
function: a hook that overwrote a later-ordered sibling printed the stale
value, and a deleted property was printed anyway.

Keep the key-list snapshot (that is what the use-after-free fix requires) but
resolve each value by name on the live structure in the post-walk loop, the
same way the prototype path already did. Deleted properties are omitted,
overwritten ones report the current value, and accessor redefinitions show
[Getter], matching Node and the slow path. This also removes the
MarkedArgumentBuffer value buffer, which is no longer needed.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The inspection enumerator now snapshots property identifiers and resolves values and attributes during the second pass. Tests cover mutations involving overwrites, deletions, re-additions, accessors, inherited properties, and multiple formatter paths.

Inspection enumeration

Layer / File(s) Summary
Snapshot identifiers and resolve live properties
src/jsc/bindings/bindings.cpp
JSC__JSValue__forEachPropertyImpl snapshots identifiers, resolves values and attributes by name, and updates private-symbol detection.
Validate mutation behavior across inspection paths
test/js/bun/util/inspect.test.js
Tests cover mutation scenarios and verify consistent output across fast, slow, and sorted inspection walks.

Possibly related PRs

  • oven-sh/bun#37107: Both PRs modify JSC__JSValue__forEachPropertyImpl and inspection tests, but address different enumeration issues.
  • oven-sh/bun#37160: Both PRs modify property-enumeration logic and address dynamic property changes.

Suggested reviewers: jarred-sumner, cirospaciari, sosukesuzuki

🚥 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 main change: live value resolution after a mutation-safe key snapshot.
Description check ✅ Passed The description explains the problem, fix, behavior changes, and verification results, covering the template requirements.

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

@github-actions github-actions Bot added the claude label Aug 8, 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.

I reviewed this PR and didn't find any bugs. Because it touches the JSC property-walk path that #37169 hardened against use-after-free (removing the MarkedArgumentBuffer and switching to live getDirect by-name lookups), a maintainer look would still be worthwhile.

What was reviewed:

  • The forEachProperty lambda now only appends Identifiers — no user code can run during iteration, so the UAF invariant from #37169 is preserved.
  • Removing MarkedArgumentBuffer is safe: values are no longer held across iterations; each is fetched immediately before its callback and covered by the existing EnsureStillAliveScope.
  • The objectToUse != object prototype path still falls through to getIfPropertyExists (attributes stays 0, !propertyValue short-circuits).
  • New test cases (overwrite / delete / delete+re-add / redefine-as-getter) exercise the live-value semantics; the redefine case correctly hits the Accessor attribute check because getDirect(vm, name, attrs) reads attributes live.
Extended reasoning...

Overview

This PR is a follow-up to #37169, which fixed a use-after-free in Bun.inspect's fast property walk by snapshotting entries before invoking formatter callbacks. That fix captured both keys and values, which regressed observable behavior: mutations to not-yet-formatted siblings (via inspect.custom hooks) were ignored because the frozen value was printed. This PR changes the snapshot to capture only the key list, then resolves each value by name via getDirect(vm, propertyName, attributes) in the post-walk loop. It touches ~20 lines in src/jsc/bindings/bindings.cpp and extends the ASAN-gated mutation test in test/js/bun/util/inspect.test.js with four new scenarios.

Security risks

None identified. The change is confined to the inspect/console.log formatter's property enumeration and does not touch auth, crypto, network, or filesystem paths. It does sit adjacent to a memory-safety fix, so the primary risk would be reintroducing the UAF — but the forEachProperty lambda now does strictly less than before (only appends Identifiers, no getDirect(offset) call), so the invariant that no user code runs during PropertyTable iteration is trivially preserved. The removed MarkedArgumentBuffer was rooting values held across the walk; since values are no longer held across the walk, there is nothing left to root, and each fetched value is covered by EnsureStillAliveScope for the callback duration.

Level of scrutiny

High. This is C++ JSC bindings code in the exact function that #37169 hardened against a use-after-free, and it removes a GC-rooting buffer. Per the repo's review guidelines, memory safety is the most-blocked category and changes here warrant maintainer eyes. The reasoning is sound and the diff simplifies the code, but I don't think this qualifies as "simple, mechanical, or obvious" enough for auto-approval.

Other factors

  • The by-name getDirect(vm, PropertyName, unsigned&) lookup is a pure structure lookup that cannot run user code, and it queries the object's current structure (not the pre-walk cached structure pointer), so mutations between callbacks are handled correctly.
  • The prototype-walk branch (objectToUse != object) still works: propertyValue stays empty, attributes stays 0, and the !propertyValue disjunct routes to the existing getIfPropertyExists fallback.
  • The new test assertions are placed in the existing subprocess fixture and assert exact stdout, which will fail on current main (stale z: 1) per the PR description. The existing ASAN UAF cases from #37169 remain in place.
  • No prior human reviews or outstanding comments on the PR.

A getDirect miss for a key deleted mid-format skipped the property entirely,
but the slow path resolves it through the prototype chain and prints the
inherited value it was shadowing. Fall through to getIfPropertyExists instead:
truly-gone keys are still omitted, shadowed ones match the slow path and
previous releases. Also pin the overwrite/delete/re-add/redefine/shadow
behavior across the fast, slow, and sorted formatter paths with a test that
runs on every build.
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp

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

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 `@src/jsc/bindings/bindings.cpp`:
- Around line 5493-5495: Update the fallback in the surrounding property lookup
logic to use a PropertySlot-based lookup instead of
objectToUse->getIfPropertyExists(), preserving inherited GetterSetter values as
accessors rather than invoking them. Add a test covering deletion of own z when
the prototype defines get z(), and verify the live result retains the accessor
representation (such as [Getter]).
🪄 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: 3f882403-564a-47a1-83ac-ae872efc731a

📥 Commits

Reviewing files that changed from the base of the PR and between 37c4428 and ec6e951.

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

Comment thread src/jsc/bindings/bindings.cpp Outdated
The deleted-key fallback used getIfPropertyExists, which invokes an inherited
getter and prints its result; the slow path keeps the accessor and prints
[Getter]. Resolve through a PropertySlot with the slow path's accessor
handling instead, so the getter is not called and all paths agree.
Comment thread src/jsc/bindings/bindings.cpp
Comment thread src/jsc/bindings/bindings.cpp

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

My earlier inline concern (inherited accessor invocation via getIfPropertyExists) is addressed in bdd3758 — the fallback now goes through getPropertySlot with the slow path's isAccessor() / isCacheableGetter() handling, and the throwing-getter row in the new matrix test pins it. I found no further issues on this revision. Given this is the memory-safety-sensitive fast property walk in the JSC bindings, a human look is still worthwhile.

What was reviewed:

  • GC safety after dropping MarkedArgumentBufferIdentifier is refcounted (not GC-managed) and each value is fetched fresh under EnsureStillAliveScope, so nothing needs marking.
  • Exception handling around getPropertySlot / slot.getValue — both covered by the existing CLEAR_IF_EXCEPTION, mirroring the slow path.
  • objectToUse != object prototype-walk case now routes through the same accessor-preserving fallback instead of getIfPropertyExists — strictly safer than before.
  • Test matrix covers fast/slow/sorted parity for all six mutation shapes; the inherited-getter row throws if invoked.
Extended reasoning...

Overview

Follow-up to #37169 in src/jsc/bindings/bindings.cpp (JSC__JSValue__forEachPropertyImpl, the fast structure walk backing Bun.inspect / console.log). #37169 fixed a use-after-free by snapshotting property entries before running the formatter callback, but froze both keys and values, so a hook mutating a not-yet-formatted sibling printed stale data. This PR keeps the key snapshot (still needed for the UAF fix) but re-resolves each value live via getDirect(vm, name, attributes), falling back to a PropertySlot lookup with the slow path's accessor handling when the direct lookup misses. The parallel MarkedArgumentBuffer is removed. Tests in test/js/bun/util/inspect.test.js add six mutation cases to the existing ASAN fixture plus a new non-ASAN cross-path matrix test asserting fast/slow/sorted agree.

Security risks

None. This is output formatting for Bun.inspect; no auth, crypto, or trust boundaries. The relevant safety property is that inspect must not invoke user getters — which this PR now enforces on the newly-reachable deleted-shadowing-key fallback (my prior review flagged the earlier revision for invoking inherited getters via getIfPropertyExists; bdd3758 fixed it and added a throwing-getter test row).

Level of scrutiny

High. This is C++ in the JSC bindings on a code path that just had a use-after-free (#37169), and it changes how values are held across callbacks that run arbitrary user JS. I checked: (1) dropping MarkedArgumentBuffer is safe because the snapshot now holds only Identifiers (refcounted UniquedStringImpl, not GC-managed) and each JSValue is fetched immediately before its callback under EnsureStillAliveScope; (2) getDirect(vm, PropertyName, attributes&) is a non-throwing structure lookup, and the fallback's getPropertySlot + slot.getValue are covered by CLEAR_IF_EXCEPTION matching the slow path at lines 5577–5627; (3) the isAccessor()isCacheableGetter() ? getPureResult() : getterSetter() branch is byte-identical to the slow path's handling.

Other factors

  • I previously reviewed at 0b6d0ed and flagged the inherited-accessor invocation; that was fixed in bdd3758 with test coverage (the getter throws if called), and CodeRabbit independently confirmed the fix. This is my first look at the fixed revision.
  • The comment-cop bot fired repeatedly on comment length; the author trimmed to a one-sentence invariant note plus a two-line fallback comment, which seems appropriate for a UAF-adjacent invariant.
  • Test coverage is thorough: ASAN fixture extended with 6 new cases, plus a build-agnostic 6×3 matrix (fast/slow/sorted) that would catch any future divergence between paths.
  • Not approving because this is non-trivial JSC binding logic on a memory-safety-critical path; a maintainer familiar with forEachProperty / PropertySlot semantics should confirm the accessor-resolution shape.

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:06 PM PT - Aug 7th, 2026

@robobun, your commit bdd3758de51a831c2eb0b8e1e898c12462e0e6e5 passed in Build #90481! 🎉


🧪   To try this PR locally:

bunx bun-pr 37178

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

bun-37178 --bun

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