Conversation
The structure walk in JSC__JSValue__forEachPropertyImpl emitted properties in property-table (insertion) order, so console.log and Bun.inspect printed symbol keys wherever they were inserted, while objects formatted through getOwnPropertyNames (anything with an accessor) printed them after the string keys. Walk the table twice, string keys then symbols, the same way Structure::getPropertyNamesFromStructure does, so both walks and node agree.
|
Status: reproduced on bun 1.4.0 with |
|
Warning Review limit reached
Next review available in: 28 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
There was a problem hiding this comment.
LGTM — small, well-scoped ordering fix that makes the fast property walk match the slow path and Node's util.inspect.
What was reviewed:
- The two-pass
structure->forEachPropertywalk: both passes are pure reads over the property table with no JS execution between them, so the snapshot invariant (no user code mid-collect) still holds;hasSymbolKeys/snapshot/snapshotValuesare block-scoped so thegoto restartprototype loop re-initializes them correctly. - Confirmed
JSC__JSValue__forEachPropertyis only reached from theBun.inspect/console.logformatter (ConsoleObject.rs), so this only affects display ordering. - Tests cover both walks per case (plain object + same object with a getter), symbol first/middle/multiple/only, class fields, nested, and both prototype-walk shapes; assertions are exact
toBeon the formatted string.
Extended reasoning...
Overview
The PR changes the fast property walk in JSC__JSValue__forEachPropertyImpl (src/jsc/bindings/bindings.cpp) so that string keys are snapshotted before symbol keys, matching OrdinaryOwnPropertyKeys order. Previously the fast walk emitted keys in raw insertion order (symbols interleaved with strings) while the slow walk (accessors, indexed properties) already produced strings-then-symbols via getOwnPropertyNames, so Bun.inspect output depended on which path was taken. The fix extracts the existing per-entry filter/snapshot body into a lambda and calls structure->forEachProperty twice — first collecting non-symbols, then (only if any were seen) symbols. A new describe("symbol keys print after string keys") block in test/js/bun/util/inspect.test.js covers 9 shapes, each asserted through both the fast and slow walks.
Security risks
None. This is display-ordering only in the inspect formatter; no parsing of untrusted input, no auth/crypto/permissions surface, and no change to which properties are printed — only their relative order.
Level of scrutiny
Low-to-medium. The mechanical change is ~20 lines: hoist the loop body into a lambda, add a symbol-skip first pass and a symbol-only second pass. The surrounding code is memory-safety-sensitive (the snapshot exists precisely to avoid running user JS while iterating the property table), so I checked that the new arrangement preserves that invariant: both forEachProperty calls only read entry.key()/attributes()/offset() and append to local containers; no JS executes until the emit loop, which is unchanged. The two calls iterate the same immutable property table (JSC's own Structure::getPropertyNamesFromStructure uses the identical two-pass scheme). All new locals live inside the if (fast) block, so the existing goto restart prototype-chain loop re-initializes them cleanly on each level.
Other factors
- Sole caller of this FFI entry point is the console/inspect formatter (
src/jsc/ConsoleObject.rs:5455), verified by grep —forEachPropertyOrdered(sorted output, snapshot printer) andforEachPropertyNonIndexedare separate exports and unaffected. - Test coverage is strong: exact-string
toBeassertions,it.eachmatrix, and each case is exercised through both the fast walk and the slow walk (by defining a getter) so future divergence between the two paths would fail immediately. Tests live in the existinginspect.test.jsnext to related symbol/getter coverage. - Cost is one
isSymbol()bit check per property when no symbols are present, and one extra table walk when they are — negligible for a formatter path. - No prior human reviews or outstanding comments to address.
Problem
console.log/Bun.inspectprint symbol-keyed properties in a position that depends on which property walk the formatter happens to take:{ a: 2, Symbol(s): 1 }for both: string keys in insertion order, then symbols.JSC__JSValue__forEachPropertyImpl(src/jsc/bindings/bindings.cpp) has two walks. The fast walk iteratesstructure->forEachProperty(...), which yields the property table in insertion order with symbols interleaved. The slow walk (taken when the object has an accessor, indexed properties, etc.) usesgetOwnPropertyNames, which lists string keys first and symbols afterwards. The same inconsistency shows up on every level of the fast walk's prototype chain visit.Fix
OrdinaryOwnPropertyKeys, the order the slow walk already produces, and the order node'sutil.inspectprints; JSC's ownStructure::getPropertyNamesFromStructureuses the identical two-pass scheme to get there. Symbols keep their insertion order among themselves; the set of printed properties does not change.isSymbol()flag check per property for objects without symbol keys; a second read-only table walk only when the object has one.test/js/bun/util/inspect.test.js("symbol keys print after string keys"): each case is formatted through both walks (the second time with an added getter) and must match the expected string. Covers symbol first / in the middle / several symbols / only symbols, class fields, nested objects, and both prototype-walk shapes of the fast path (own + prototype properties, and an object with no own properties). 8 of the 9 cases fail on the current release; all pass with this change.test/js/web/console,test/js/bun/console,test/js/node/util/bun-inspect.test.ts,test/js/node/util/custom-inspect.test.js: no changes in behaviour. No existing expectation depended on the old interleaved order.Background
OrdinaryOwnPropertyKeysis the spec order used byReflect.ownKeys,getOwnPropertyNames+getOwnPropertySymbols, and node'sutil.inspect: integer-like keys ascending, then string keys in insertion order, then symbol keys in insertion order.Structureis the object's hidden class; its property table stores every named property (strings and symbols alike) in insertion order.getOwnPropertyNamesreorders that table into spec order; walking the table directly does not.forEachPropertyImplis only used when the structure has no accessors, no indexed properties and no custom property lookup, so integer-like keys never reach it (they live in indexed storage and force the slow walk); string-vs-symbol ordering was the only difference between the two walks.console.log/Bun.inspectformatter (ConsoleObject.rs).Bun.inspect(..., { sorted: true })and bun:test's diff/snapshot printer useforEachPropertyOrdered, which sorts by name and is unaffected.node:util.inspectis implemented in JS and was already correct.