Skip to content

assert: compare AggregateError.errors in deep equality - #35495

Open
robobun wants to merge 2 commits into
mainfrom
farm/48700fcf/aggregate-error-errors-deep-equal
Open

assert: compare AggregateError.errors in deep equality#35495
robobun wants to merge 2 commits into
mainfrom
farm/48700fcf/aggregate-error-errors-deep-equal

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

util.isDeepStrictEqual / assert.deepStrictEqual / assert.deepEqual now compare the non-enumerable .errors array on Error instances, matching Node.js.

Repro

import util from 'node:util';
const mk = msgs => new AggregateError(msgs.map(m => new Error(m)), 'agg');
util.isDeepStrictEqual(mk(['x']), mk(['y']));      // node: false, bun (before): true
util.isDeepStrictEqual(mk(['x']), mk(['x', 'x'])); // node: false, bun (before): true

Any test asserting which errors a Promise.any rejection carries was a false green on Bun regardless of the inner error content.

Cause

AggregateError defines .errors with PropertyAttribute::DontEnum (JSC AggregateError.cpp), so the enumerable-property walk in Bun__deepEquals's ErrorInstanceType handler never visited it. The handler already special-cased the non-enumerable .cause but not .errors.

Node handles both via isEnumerableOrIdentical in lib/internal/util/comparisons.js:

!isEnumerableOrIdentical(val1, val2, 'cause', mode, memos) ||
!isEnumerableOrIdentical(val1, val2, 'errors', mode, memos)

Fix

Add an explicit get-and-recurse for .errors in bindings.cpp right after the .cause block. Unlike .cause, Node does not gate .errors on hasOwn presence (a present-but-undefined .errors is equal to a missing one), so there is no hasProperty check.

Verification

New cases in test/js/node/assert/deep-equal.test.ts (runs through assert.deepStrictEqual, assert.deepEqual, and util.isDeepStrictEqual):

  • AggregateError with deep-equal errors: equal
  • AggregateError with different inner errors: not equal
  • AggregateError with different error counts: not equal
  • AggregateError vs plain Error with same message: not equal
  • Error with non-enumerable own errors vs Error without: not equal

All expectations cross-checked against Node v26.3.0. Fails on canary (9 failures), passes with this change.


[stamp-90s] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 9 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/assert/deep-equal.test.ts
bun test v1.4.0 (cda0d070e)

test/js/node/assert/deep-equal.test.ts:
(pass) assert.deepStrictEqual > rejects 0 and -0 [71.70ms]
(pass) assert.deepStrictEqual > accepts NaN and NaN [5.11ms]
(pass) assert.deepStrictEqual > rejects [0] and [-0] [74.55ms]
(pass) assert.deepStrictEqual > rejects '1' and 1 [12.00ms]
(pass) assert.deepStrictEqual > rejects ['1'] and [1] [8.57ms]
(pass) assert.deepStrictEqual > rejects '+00000000' and false [5.59ms]
(pass) assert.deepStrictEqual > rejects '' and false [5.03ms]
(pass) assert.deepStrictEqual > rejects null and undefined [5.36ms]
(pass) assert.deepStrictEqual > rejects { a: -0 } and { a: 0 } [13.14ms]
(pass) assert.deepStrictEqual > rejects 1n and 1 [6.63ms]
(pass) assert.deepStrictEqual > rejects new String('a') and 'a' [11.02ms]
(pass) assert.deepStrictEqual > accepts two boxed equal strings [6.27ms]
(pass) assert.deepStrictEqual > accepts two boxed equal numbers [5.76ms]
(pass) assert.deepStrictEqual > rejects boxed -0 and boxed 0 [11.49ms]
(pass) assert.
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (cda0d070e)

test/js/node/assert/deep-equal.test.ts:
(pass) assert.deepStrictEqual > rejects 0 and -0 [1.85ms]
(pass) assert.deepStrictEqual > accepts NaN and NaN [0.10ms]
(pass) assert.deepStrictEqual > rejects [0] and [-0] [2.31ms]
(pass) assert.deepStrictEqual > rejects '1' and 1 [0.36ms]
(pass) assert.deepStrictEqual > rejects ['1'] and [1] [0.18ms]
(pass) assert.deepStrictEqual > rejects '+00000000' and false [0.08ms]
(pass) assert.deepStrictEqual > rejects '' and false [0.08ms]
(pass) assert.deepStrictEqual > rejects null and undefined [0.07ms]
(pass) assert.deepStrictEqual > rejects { a: -0 } and { a: 0 } [0.40ms]
(pass) assert.deepStrictEqual > rejects 1n and 1 [0.13ms]
(pass) assert.deepStrictEqual > rejects new String('a') and 'a' [0.27ms]
(pass) assert.deepStrictEqual > accepts two boxed equal strings [0.12ms]
(pass) assert.deepStrictEqual > accepts two boxed equal numbers [0.09ms]
(pass) assert.deepStrictEqual > rejects boxed -0 and boxed 0 [0.15ms]
(pass) assert.deepStrictEqual > accepts two boxed booleans [0.19ms]
(pass) assert.deepStrictEqual > accepts two boxed symbols [0.20ms]
(pass) assert.deepStrictEqual > accepts two boxe
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/assert/deep-equal.test.ts
bun test v1.4.0 (cda0d070e)

test/js/node/assert/deep-equal.test.ts:
(pass) assert.deepStrictEqual > rejects 0 and -0 [108.02ms]
(pass) assert.deepStrictEqual > accepts NaN and NaN [9.54ms]
(pass) assert.deepStrictEqual > rejects [0] and [-0] [120.18ms]
(pass) assert.deepStrictEqual > rejects '1' and 1 [18.77ms]
(pass) assert.deepStrictEqual > rejects ['1'] and [1] [13.64ms]
(pass) assert.deepStrictEqual > rejects '+00000000' and false [8.26ms]
(pass) assert.deepStrictEqual > rejects '' and false [7.34ms]
(pass) assert.deepStrictEqual > rejects null and undefined [7.96ms]
(pass) assert.deepStrictEqual > rejects { a: -0 } and { a: 0 } [21.82ms]
(pass) assert.deepStrictEqual > rejects 1n and 1 [10.25ms]
(pass) assert.deepStrictEqual > rejects new String('a') and 'a' [16.02ms]
(pass) assert.deepStrictEqual > accepts two boxed equal strings [9.26ms]
(pass) assert.deepStrictEqual > accepts two boxed equal numbers [10.18ms]
(pass) assert.deepStrictEqual > rejects boxed -0 and boxed 0 [16.68ms]
(pass) as
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1315ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/12] cc obj/src/jsc/bindings/node/http/llhttp/http.c.o
[2/12] cc obj/src/jsc/bindings/node/http/llhttp/api.c.o
[3/12] cc obj/src/jsc/bindings/node/http/llhttp/llhttp.c.o
[4/12] gen cpp.rs (cppbind)
[5/11] cxx obj/unified/UnifiedSource-src_jsc_bindings_node_http-0.cpp.o
[6/11] cxx obj/src/jsc/bindings/bindings.cpp.o
[7/11] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[8/11] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[8/11] link bun-profile
[9/11] bun-profile --revision
1.4.0-canary.1+cda0d070e
[11/11] strip bun
[build] done
bun test v1.4.0-canary.1 (cda0d070e)

test/js/node/assert/deep-equal.test.ts:
(pass) assert.deepStrictEqual > rejects 0 and -0 [2.07ms]
(pass) assert.deepStrictEqual > accepts NaN and NaN [0.13ms]
(pass) assert.deepStrictEqual > rejects [0] and [-0] [2.42ms]
(pass) assert.deepStrictEqual > rejects '1' and 1 [0.38ms]
(pass) assert.deepStrictEqual > rejects ['1'] and [1] [0.18ms]
(pass) assert.deepStrictEqual > rejects '+00000000' and false [0.09ms]
(pass) assert.deepStric
... (truncated)
diff hotspot
src/jsc/bindings/bindings.cpp          | 11 +++++++++++
 test/js/node/assert/deep-equal.test.ts | 35 ++++++++++++++++++++++++++++++++++
 2 files changed, 46 insertions(+)

gate history · 2 passed · 1 rejected · iteration 2

evidence per changed file
file                                    reads  edits  tests
src/jsc/bindings/bindings.cpp               2      2      0
test/js/node/assert/deep-equal.test.ts      3      1      0

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fbc5109d-55c9-4f60-af9f-36f818d145da

📥 Commits

Reviewing files that changed from the base of the PR and between fdac5e7 and cda0d07.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/node/assert/deep-equal.test.ts

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:18 AM PT - Jul 25th, 2026

@robobun, your commit cda0d07 has 1 failures in Build #80106 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35495

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

bun-35495 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node v26 compat, combined: CLI flags, async_hooks ids, deep-equality fidelity, console/util/dns/path fixes and 37 upstream tests #35391 - Also adds AggregateError.errors comparison in specialObjectsDequal in bindings.cpp, as part of a broader node v26 compat PR
  2. Implement Node's deep equality algorithm for node:assert #33068 - Reimplements Node's deep equality algorithm in TypeScript, including AggregateError.errors handling

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Re the duplicate check: both flagged PRs do cover this, but neither is a near-term merge.

This PR is the minimal standalone fix (14 lines in bindings.cpp) so the false-green can be closed independently of those larger efforts. It follows the same pattern the existing .cause handling already uses in the same function, so whichever of the larger PRs lands later will subsume it cleanly.

@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 — small Node-compat fix that mirrors the existing .cause block for .errors.

What was reviewed:

  • Exception handling: each get() and the recursive Bun__deepEquals call is followed by RETURN_IF_EXCEPTION(scope, {}), matching the neighboring .cause pattern.
  • vm.propertyNames->errors is a valid CommonIdentifier (already used at bindings.cpp:4598).
  • Double-visit when .errors is user-defined as enumerable: harmless (same as existing .cause behavior — both checks agree).
  • Tests slot into the existing Case[] matrix and exercise all three entry points; expectations stated as cross-checked against Node v26.3.0.
Extended reasoning...

Overview

Adds a 14-line block to the ErrorInstanceType arm of specialObjectsDequal in src/jsc/bindings/bindings.cpp that fetches .errors from both sides and recurses into Bun__deepEquals, exactly mirroring the adjacent .cause handling (minus the strict-mode hasProperty gate, which the PR body justifies against Node's isEnumerableOrIdentical). Five new cases are appended to the cases matrix in test/js/node/assert/deep-equal.test.ts, which fans them out across assert.deepStrictEqual, assert.deepEqual, util.isDeepStrictEqual, and their negations.

Security risks

None. Pure comparison logic on already-held JS values; no I/O, allocation, or trust-boundary changes.

Level of scrutiny

Low-to-medium. This is a targeted Node-compat correctness fix in a well-understood code path. The new block is a near-verbatim copy of the block directly above it, uses the same gcBuffer/stack/scope threading, and every JSC call that can throw is guarded. vm.propertyNames->errors is confirmed to exist (used elsewhere in the same file). The one semantic difference from .cause — no hasProperty presence check — is documented in the code comment and PR body, and the "Error with non-enumerable own errors vs Error without" test case pins the resulting behavior.

Other factors

  • The "AggregateError vs plain Error" case would already fail on errorType()/name before reaching .errors, but it's still a useful regression guard.
  • If a user makes .errors enumerable, it will be compared twice (once here, once in the enumerable loop) — redundant but correct, and the same is already true for .cause.
  • Tests follow repo conventions: added to the existing table-driven file rather than a new file, no sleeps, no network, and the matrix runner already asserts ERR_ASSERTION on the negative path.
  • No prior human review comments; no outstanding feedback to address.

Node's util.isDeepStrictEqual and assert.deepStrictEqual compare the
non-enumerable own .errors array on Error instances the same way they
compare .cause. Bun compared .cause explicitly but skipped .errors, so
two AggregateErrors wrapping entirely different inner errors (or a
different number of them) compared as deep-equal.

The .errors property is defined with PropertyAttribute::DontEnum by
JSC's AggregateError constructor, so the enumerable-property walk in
Bun__deepEquals never visited it. Add an explicit get-and-recurse for
.errors right after the .cause block. Unlike .cause, Node does not gate
.errors on hasOwn presence (a present-but-undefined .errors equals a
missing one), so there is no hasProperty check.
@robobun
robobun force-pushed the farm/48700fcf/aggregate-error-errors-deep-equal branch from 898644e to 7cb74ba Compare July 25, 2026 03:19
Comment thread src/jsc/bindings/bindings.cpp Outdated

@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 — mirrors the existing .cause handling in the same block, with proper exception checks.

What was reviewed:

  • The new .errors block follows the adjacent .cause pattern (get() + RETURN_IF_EXCEPTION + recursive Bun__deepEquals); vm.propertyNames->errors is already used elsewhere in this file.
  • Checked whether omitting the hasProperty gate (unlike .cause) diverges from Node in strict mode — ruled out; missing and present-undefined both read as jsUndefined() and compare equal, matching the PR's Node cross-check.
  • Test cases cover equal/unequal inner errors, differing counts, AggregateError vs plain Error, and a non-enumerable own .errors on a plain Error.
Extended reasoning...

Overview

Adds an 11-line block to the ErrorInstanceType case of specialObjectsDequal in src/jsc/bindings/bindings.cpp that fetches .errors from both operands and recurses into Bun__deepEquals, exactly mirroring the .cause block immediately above it. Five new table-driven cases are added to test/js/node/assert/deep-equal.test.ts, which fans them out across assert.deepStrictEqual, assert.deepEqual, and util.isDeepStrictEqual.

Security risks

None. This is a read-only comparison path in node:assert/node:util; no new allocation, no untrusted-length arithmetic, no I/O.

Level of scrutiny

Low-to-moderate. The change is confined to the Node-compat deep-equality handler, is a direct copy of the adjacent .cause handling (same get / RETURN_IF_EXCEPTION / recurse sequence), and vm.propertyNames->errors is a pre-existing common identifier already used at bindings.cpp:4595. The one intentional divergence from .cause — no hasProperty presence gate in strict mode — was raised by a finder and ruled out: both a missing property and a present-undefined property read back as jsUndefined() via get(), so they compare equal, which matches the Node behavior the PR description documents and cross-checked.

Other factors

Tests were verified by the author's gate to fail on canary (9 failures under ASAN debug) and pass with the fix on both debug+ASAN and release. The comment-cop feedback (extraneous comment block) was addressed in cda0d07 and the thread is resolved. The duplicate-PR bot flagged two larger in-flight efforts; the author's rationale for landing this minimal fix independently is reasonable and doesn't affect correctness here.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: build #80106 is done.

  • All 13 build-cpp lanes passed (bindings.cpp compiles on every platform), each after 2 queue-expiration retries.
  • 6 build-bun lanes failed with "Sibling step build-cpp errored, nothing to link": the Rust link step polled build-cpp while it was mid-retry and bailed before the retry succeeded. Pure retry-race from the queue backlog; the Rust compile itself finished cleanly on all of them.
  • Test lanes that ran all passed test/js/node/assert/deep-equal.test.ts. Only unrelated test annotation is the known-flaky test-fastutf8stream-reopen.js.
  • 54 test jobs show waiting_failed because their build dependency is one of the 6 above.

The earlier full run at 6dc2328 (build #79928, identical diff plus a since-removed code comment) also passed deep-equal.test.ts on every lane.

Diff is ready; the red is queue-backlog infra. Happy to rebase or retrigger if preferred.

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.

2 participants