Repository navigation
Conversation
…kup, pair off the rest in one pass setSubset compared every expected member against every actual member still unclaimed, and mapSubset rescanned its list of claimed identity keys with sameValue once per expected entry, so both were quadratic in the collection size whatever it held: a Set of 100k integers took several seconds and a Map of 10k string keys a quarter of a second. Port the shape of node's setEquiv/mapEquiv instead. Expected members actual holds by identity, and primitive Map keys, are settled by one hash lookup each; only the expected objects left over are queued, and actual is walked once, each entry claiming a queued one in node's probe order (front guess, back guess, then the rest) with swap-removal. Sets also get node's identity reservation: a member both sides hold is paired with itself rather than claimed by some structural subset of it.
|
Updated 6:05 PM PT - Aug 14th, 2026
❌ @robobun, your commit 235f97c has 5 failures in
🧪 To try this PR locally: bunx bun-pr 38554That installs a local version of the PR into your bun-38554 --bun |
|
Warning Review limit reached
Next review available in: 11 seconds 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 (3)
Comment |
|
Status: ready for review (a81f038; 235f97c is an empty commit to re-run CI after the first build for a81f038 expired without an agent). Reproduced on the pre-change build with the snippet from the report ( CI on the re-run (build 96714): 165 jobs green. What is red does not involve this change: the Windows lanes could not get an agent, and one x64-asan shard has timeouts / RSS failures in |
…ys the way node does A Set member that can match nothing (a primitive or function only actual holds) was skipped without touching the probe direction; node probes it, so the member after it is tried against the front of the window again, and the outcome can differ. Record the reset instead of skipping it. Map keys were classified with isObject(), which admits functions; node uses typeof, so function keys are settled by lookup like primitives and function-keyed actual entries are passed over. Use the file's isSpecialValue predicate in all four places.
There was a problem hiding this comment.
The stray-member/function-key divergence I flagged is fixed in 406614f — the reproducer and its mirror are now in the test table, and the isSpecialValue gate is applied consistently on both passes for both containers. No further issues found this pass.
Given this is a ~200-line algorithmic rewrite in JSC-binding C++ (raw JSValues held in a WTF::Vector alongside the gcBuffer roots, exception propagation through the claimWith lambda, node-compat greedy pairing whose result depends on probe order), a human look is still worthwhile before merge.
What was reviewed
PendingExpected::claimWithprobe order vs node'sdirectionbit — front/back/scan sequence, swap-remove, and the single-entry and post-back-miss boundary cases.- GC rooting: every
queued.appendis preceded by agcBuffer.appendof the same values, so theWTF::Vector<JSValue>window only ever holds already-rooted copies; the swap-remove reorders within that. - Exception paths:
RETURN_IF_EXCEPTIONafter eachmatches()inclaimWithand after eachclaimWith/has/getat the call sites; the throwing-getter tests exercise all four comparison sites. - The
unvisited < openCount()early-exit readssize()fresh each iteration, so a getter that mutates the collection mid-comparison can't underflowunvisited.
Extended reasoning...
Overview
Rewrites setSubset and mapSubset in src/jsc/modules/NodeUtilTypesModule.cpp to mirror node's setEquiv/mapEquiv: a first pass over expected settles primitives/identity by hash lookup and queues the leftover objects into a PendingExpected window; a second pass over actual claims from that window using node's front/back/scan probe order with swap-remove. Adds a new PendingExpected<Entry> template and moves isSpecialValue up-file. Roughly 200 net lines of new C++ plus ~270 lines of new tests in test/js/node/assert/deep-equal.test.ts.
Security risks
None. This is assert.partialDeepStrictEqual, a test-utility comparison. No untrusted-input parsing, no I/O, no auth. The user-code re-entry surface (getters during compareBranch) already existed and is exercised by the new exception-propagation tests.
Level of scrutiny
High. It is a full algorithm replacement in C++ with JSC GC and exception-scope obligations, and correctness here means "byte-identical to node's greedy pairing", which is order-sensitive: my earlier review found a real regression (isObject() gate skipping stray Set members without resetting probeFront, plus function-key misclassification for Maps), now fixed and covered by the stray-member test block. The author's randomized validation (8,694 instances byte-identical to node, plus 194 stray-sensitive instances that went 112→0 divergences after the fix) is strong evidence, but the probe-order logic in claimWith is subtle enough that a maintainer familiar with node's comparisons.js should confirm the loop bounds independently.
Other factors
- All prior review threads are resolved: the bug I raised is fixed with tests; the comment-cop long-comment flags were trimmed in d3000dd.
- Test coverage is thorough: table-driven Set/Map cases, the stray-member asymmetry (primitive and function, Set vs Map), permuted ordering, self-referential members, exception propagation from all four comparison sites, getter-count bounds for same/reversed order, and a spawned child that times out on the old quadratic path.
- GC discipline looks correct (every queued JSValue is appended to
gcBufferbefore being copied into the plainWTF::Vector), and exception checks follow every VM-entering call, but a run underBUN_JSC_validateExceptionChecks=1would be worth confirming given theRETURN_IF_EXCEPTIONinside theclaimWithlambda — the removed code had explicitly avoided the macro inside a lambda. - Not simple/mechanical, so not auto-approvable per the guidelines even with no findings.
|
Ran the new test block and the randomized comparison script under |
…share the pairing window Object keys that both Maps hold went through the structural window like any other key, which made an identity-keyed Map quadratic when the two sides were built in different orders and, unlike every earlier Bun release, made its result depend on insertion order. Settle such keys by lookup in the first pass, as the Set path and node's own setEquiv do, and reserve their actual entries so the leftover keys cannot claim them. The window itself moves to PendingEntries.h so the strict deepEquals engine can use the same one: its result now says whether anything was claimed, an exhausted window reports zero open entries, and a stray member records its miss through a method instead of poking the flag. Tests: the two Map shapes where this differs from node, the reservation, a getter count showing shared keys are never compared structurally, and a GC test that empties the expected collection from inside a getter and reuses the freed cells, which fails (Set) or trips UBSan (Map) without the gcBuffer rooting of the pending copies.
There was a problem hiding this comment.
The stray-member fix in 406614f looks right — recordMiss() correctly models Node's post-miss direction = 1 state without running the doomed comparisons, and both passes now use isSpecialValue so function keys go through the lookup path. No new issues found this round.
A human should still weigh in on a81f038's deliberate divergence from Node for Map keys both sides hold by identity (the two "(node: rejected)" / "(node: accepted)" test cases): settling them by identity is what Bun already did and avoids Node's order-dependent quadratic behavior, but it's a compat call.
What was reviewed
- Verified
claimWith's probe/swap-remove matches Node'spartialObject{Set,Map}Equivwindow, including theopenCount() == 1early return andscanFrombounds. - Checked GC rooting: every queued
JSValue/PendingMapEntryfield and everysettledKeys/reservedcell is appended togcBufferbefore landing in the plainWTF::Vector; iterators and per-iteration values are stack-visible. - Traced exception propagation through the
matcheslambda →claimWith's per-callRETURN_IF_EXCEPTION→ caller check; thesize > visitedguard prevents underflow when a getter shrinks the collection mid-walk.
Extended reasoning...
Overview
Rewrites setSubset and mapSubset in src/jsc/modules/NodeUtilTypesModule.cpp to eliminate quadratic behavior in assert.partialDeepStrictEqual on Sets and Maps, adds a new PendingEntries.h header implementing Node's front/back-probe-then-scan pairing window, and adds ~370 lines of tests to test/js/node/assert/deep-equal.test.ts. The old code rescanned all actual entries per expected entry (n²); the new code settles primitive/identity content by hash lookup and pairs off the object residue with Node's greedy window walk.
Security risks
None. This is a pure comparison function reachable only via assert.partialDeepStrictEqual / util internals. No untrusted-input parsing, no I/O, no auth surface. The only user-code reentry is via getters during compareBranch, which the existing code already exercised.
Level of scrutiny
High. This is native C++ touching JSC iteration, GC rooting (JSValues copied into plain WTF::Vectors that are not GC roots — kept alive via the shared MarkedArgumentBuffer), and exception propagation through lambdas. The algorithm itself is subtle enough that my first-pass review found a real regression (the isObject() gate broke Node's probe-direction reset for stray Set members). That was fixed and covered with tests plus 8.6k randomized instances cross-checked against Node, but the class of bug — greedy pairing where an off-by-one in probe order changes the answer — is easy to reintroduce.
Other factors
- The a81f038 commit deliberately keeps Bun's existing behavior for Map keys both sides hold by identity, rather than adopting Node's queue-them-too approach. The PR documents this as two test cases that intentionally disagree with Node ("node: rejected", "node: accepted"). The rationale (Node's path is order-dependent and quadratic when reordered; Bun's identity settlement matches what Node itself does for Sets) is sound, but a maintainer should confirm the compat tradeoff is acceptable — this is exactly the kind of "Never change a Bun-native default to fix Node compatibility" boundary REVIEW.md calls out.
- Test coverage is thorough: table of pairing cases, the stray-member asymmetry (Set probes vs Map skips, primitive and function), permuted orders exercising swap-remove, self-reference, exception propagation from every comparison site, a GC-safety test that empties the expected collection mid-walk, getter-count bounds proving linearity, and a spawned 100k-int / 12k-key perf regression guard. The author ran under
BUN_JSC_validateExceptionChecks=1clean. - All prior review threads (my bug report, comment-cop) are resolved.
|
For whoever reviews the one compat call in here (Map keys that both Maps hold by identity), the two options in short:
Everything else in the PR is node's behaviour either way. |
There was a problem hiding this comment.
I re-reviewed after a81f038 and didn't find any bugs — the stray-member and function-key issues from my earlier comment are fixed and covered by the new test rows. Deferring to a human for the compat call the author flagged (settle identity-held Map keys first vs. exact node parity), and because this is ~200 lines of new C++ pairing logic with GC-visible state plus a new shared header.
Extended reasoning...
Overview
Rewrites setSubset and mapSubset in src/jsc/modules/NodeUtilTypesModule.cpp from an O(n²) rescan into node's two-pass shape (hash-lookup settle, then front/back-probe window over the residue), adds a new templated header src/jsc/bindings/PendingEntries.h implementing that window, and adds ~360 lines of tests to test/js/node/assert/deep-equal.test.ts.
What was checked this run
- GC rooting: every
JSValueplaced inqueued/settledKeys/reservedis first appended to the sharedgcBuffer, so the rawWTF::Vector<JSValue>andHashSet<JSCell*>never hold the only reference; the new GC test exercises exactly this. - Exception propagation:
RETURN_IF_EXCEPTIONafter everyhas/get/compareBranch, andclaimWithre-checksscopeafter eachmatches()call before touching the result; the PR reportsBUN_JSC_validateExceptionChecks=1runs clean. - The earlier stray-member /
isObject()-vs-isSpecialValuebug I flagged is fixed:setSubsetnow callspending.recordMiss()for a stray special member andmapSubsetgates both passes onisSpecialValue, with the four-way stray tests pinning both directions. - The
unvisited < openCount()early-exit uses a clamped subtraction (size > visited ? size - visited : 0) so a getter that shrinksactualmid-walk cannot underflow.
Security risks
None — this is a pure comparison algorithm on user-supplied JS values with no I/O, parsing, or privilege boundaries.
Level of scrutiny
High. This is native JSC-facing C++ that holds raw cell pointers across calls that can run user JS (getters), introduces a new header intended to be reused by sibling PR #38510, and deliberately diverges from Node.js on one axis (identity-held Map keys). The author explicitly asked a reviewer to make that compat call.
Other factors
- Test coverage is thorough (table cases, stray-member asymmetry, permuted order, cycles, exception propagation, getter-count bounds, GC stress, and a spawned perf regression guard), and the description reports the full assert suite plus node's upstream
test-assert-partial-deep-equal.jspassing. - The one open design question (last robobun comment) is a maintainer decision, not a code defect, so it should not be resolved by auto-approval.
…, by the rule of the entry point The Set and Map arms of specialObjectsDequal keep the SameValueZero probe loop. The first entry the probe cannot settle goes to one helper that is compiled once and takes the mode at runtime. The helper finishes the walk. assert.deepStrictEqual and util.isDeepStrictEqual pair every such entry with a right entry of its own, in the probe order of node's setEquiv and mapEquiv. The window type is PendingEntries.h, the same file as in #38554. The other entry points keep Jest's rule: every left entry needs some equal right entry. The first leftover walks the right side as before. Later ones look beside the last hit in a copy of the right entries the left side lacks. A Map entry now matches on its key and its value together. Outside node's modes a Set or a Map always joins the cycle stack. The node arms return after one pass when neither collection has own properties.
…y points, in one pass assert.deepStrictEqual and util.isDeepStrictEqual hand the first entry that the lookup cannot settle to one helper, compiled once for both node modes. It pairs every such entry with a right entry of its own, in the probe order of node's setObjectEquiv and mapObjectEquiv. The window type is PendingEntries.h, the same file as in #38554. Bun__deepEquals no longer runs a Set or Map arm a second time with the operands swapped on these entry points. The arms return after one pass when neither collection has own properties. node's entry points record every entry comparison on the cycle stack.
Problem
assert.partialDeepStrictEqualis quadratic on Sets and Maps whatever they contain. Release build, two equal collections: a Set of 100k integers takes 4.8 s (node: ~30 ms), a Map of 10k string keys 275 ms (node: ~10 ms); every doubling costs 4x.setSubset(src/jsc/modules/NodeUtilTypesModule.cpp) has no hash path at all: for each expected member it walks every actual member from index 0, skipping the ones already claimed, untilcompareBranchmatches. Primitives go through that walk too, and in the common same-order case the skips alone are n*n/2.mapSubsetdoes look keys up, but records each claimed key in aMarkedArgumentBufferand rescans that list withsameValue(identityKeyUsed) once per expected entry, so a Map with n primitive keys does n*n/2 string comparisons; its object-key path also rescans actual from index 0 and callsidentityKeyUsedper candidate.partialDeepStrictEqual(new Set([x, w, 1]), new Set([{ a: 1 }, x]))threw (withx = { a: 1, b: 2 },w = { a: 1, c: 3 }). Node passes it.Fix
setEquiv/mapEquiv(lib/internal/util/comparisons.js, v26.3.0 L628-L921):JSSet::has) is done; a special member (node'stypeoftest, the file'sisSpecialValue: primitives, null, functions) it does not hold fails the comparison, since nothing else can match it. Map: every key is looked up (JSMap::get, plushaswhen the stored value isundefined) and its value compared; a special key that fails this fails the comparison, an object key that fails it is queued, and an object key that passes reserves its actual entry. Only the queued objects go on to pass two.PendingEntries::claimWith, new header src/jsc/bindings/PendingEntries.h) in node's probe order: the front of the open window while that keeps hitting, the back while that keeps hitting, otherwise a scan of the rest; a claimed entry is swap-removed; the pass stops as soon as the window is empty or fewer actual entries remain than are open. A stray Set member (special, not held by expected) records a miss, which is all node's probes of it leave behind; a stray Map key is passed over, as in node.compareBranchof anything against a special value is true only for SameValueZero pairs (whathas/gettest; Sets and Map keys normalize -0), a special Map key already had to be held with a matching value, and object keys both Maps hold were already settled by lookup on main and in every release.{ a: 1 }fits every actual object witha: 1), so every implementation here is a greedy pairing and the question is only whose order to use. For everything except Map keys both sides hold it is node's, down to which entry is claimed when several fit: on 4,000 randomized contended instances without shared keys (strays included) plus the hand-written cases, this branch's Set and Map results are identical to node v26.3.0's, and so are its Set results on 4,000 more instances with shared members. (Node's separate shortcut for Sets with fewer than three actual members is not mirrored; it contradicts node's general path.)test/js/node/assert/deep-equal.test.ts,describe("assert.partialDeepStrictEqual on Sets and Maps")(every expectation checked against node except the two rows that say so): a table of Set/Map pairing cases including the shared-key shapes and the reservation, the stray-member cases (the same four objects pair off differently as Set members and as Map keys, with a primitive and with a function as the stray), a permuted-order pairing test, self-referential members, exceptions propagating from each comparison site, getter counts (same-order and reversed-order residues are linear, and contents both sides hold are never read at all), a GC test in which a getter empties the expected collection entry by entry, collects, and reuses the freed cells (without thegcBufferappends that root the pending copies it fails for Sets and trips UBSan for Maps), and a spawned child comparing a 100k-integer Set and a 12k-string-key Map, which on main is still running when its 60 s timeout kills it and finishes in a few seconds here. Against main 6 of the new tests fail (identity case, the two Map stray cases, both reversed getter counts, the spawned child); with the fix the file passes (356 tests).assert.test.cjs,regression/issue/24338,regression/issue/28522,assert-typedarray-deepequal.test.ts, the vendoredtest-assert-class.js,test-assert-typedarray-deepequal.js,test-assert-deep-with-error.js,test-util-isDeepStrictEqual.js(all pass), the new tests and the corpus underBUN_JSC_validateExceptionChecks=1(clean), and node's upstreamtest/parallel/test-assert-partial-deep-equal.js(140 cases, not vendored), which passes except its File/Blob prototype case, a pre-existing unrelated difference (Object.getPrototypeOf(File.prototype)is notBlob.prototypein Bun).bun bd), main -> this branch: Set of 20k integers 2114 ms -> 63 ms; Map of 4k string keys 9456 ms -> 34 ms; Set of 2k objects with expected reversed 106.7 s -> 147 ms; Map of 2k object keys with expected reversed 100.1 s -> 104 ms; same-order 2k objects unchanged (~120 ms). Release build: Set of 100k integers 4.8 s -> 34 ms; Map of 10k string keys 275 ms -> 11 ms. Identity-keyed Map with expected shuffled, this branch: 10k keys 60 ms, 20k 113 ms, and with one structural entry added (so the reservation set is built and consulted) 109 ms / 240 ms, measured in a run where the 20k-integer Set took ~115 ms; main is linear here too.pairOffEntries).PendingEntriesis in a header, returnsMissed/Claimed/Exhaustedand leaves an exhausted window at zero open entries, so that engine can drive it too (strict: a miss is final; matcher mode: fall back, then sweep what is still open); whichever lands second can drop its copy.Background
assert.partialDeepStrictEqual(actual, expected)): expected must be contained in actual. For Sets and Maps every expected member/entry needs a distinct counterpart in actual, and an object member matches with the same partial semantics, soSet([{ a: 1, b: 2 }])containsSet([{ a: 1 }]).compareBranchin this file is that comparison; it short-circuits on SameValueZero, so a member compared with itself always matches.JSSet::has/JSMap::getuse the collection's own hash table: SameValueZero, i.e. identity for objects and value equality for primitives (-0is stored as+0). That settles primitives and identical objects in O(1); structurally equal but distinct objects can only be found by comparing.partialObjectSetEquiv/partialObjectMapEquiv): the leftover expected objects sit in an array with an open range and a direction bit. Since a matching actual entry usually arrives at one end of the range, node tries the end that hit last and falls back to a scan; a scan hit is replaced with the range's last entry and the range shrinks, so claimed entries are never compared again. The direction bit is state: an entry that misses everything flips it back to the front, which is why a stray member between the objects can change which pairing the walk finds, and why the Set walk (node probes strays) and the Map walk (node skips non-object keys) treat strays differently.PendingEntriesis that array, range and bit.gcBufferis theMarkedArgumentBufferthe whole comparison shares to keep every value it has copied out of a collection alive across the user code (getters) comparisons can run. The queued entries and the settled keys are appended to it; the window itself is a plainWTF::Vectorand the reservation set holds raw cell pointers, neither of which the GC sees, which is what the GC test checks.Earlier revisions
isObject(), which admits functions; review caught both (new Set([{c:3,w:1}, 7, {a:1,b:2}, {b:2,y:1}])againstnew Set([{a:1}, {b:2}, {c:3}])passes in node and on main but threw there). Fixed in 406614f with the stray-member cases.[review] gate passed · iteration 1 · 3 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 1
evidence per changed file