Skip to content

Bun.deepEquals: fix four false-positive equality classes - #32872

Open
robobun wants to merge 1 commit into
mainfrom
farm/f02f217c/deepequals-false-greens
Open

Bun.deepEquals: fix four false-positive equality classes#32872
robobun wants to merge 1 commit into
mainfrom
farm/f02f217c/deepequals-false-greens

Conversation

@robobun

@robobun robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes #29030.

Bun.deepEquals is the engine behind expect().toEqual() / expect().toStrictEqual(). An adversarial three-way corpus (Bun vs Jest 30 vs Node util.isDeepStrictEqual) surfaced four input classes where Bun says equal but Jest and Node say not equal, meaning user tests can pass that should fail.

Repro

Bun.deepEquals(Object.assign([1, 2], { x: 3 }), Object.assign([1, 2], { x: 4 }));
// bun: true   jest/node: false   (non-index own string props on arrays ignored)

Bun.deepEquals(Object.assign(Object.create(null), { a: 1 }), { a: 1 }, /*strict*/ true);
// bun: true   jest/node: false   (toStrictEqual misses null-prototype vs Object.prototype)

Bun.deepEquals(new Float64Array([-0]), new Float64Array([0]));
// bun: true   jest/node: false   (float typed arrays compared with ==)

Bun.deepEquals(new Float64Array([NaN]), new Float64Array([NaN]));
// bun: false  jest/node: true    (same root cause; toEqual false-negative on NaN)

Bun.deepEquals(Object.assign(new String("ab"), { e: 1 }), new String("ab"));
// bun: true   jest/node: false   (boxed-String own properties ignored)

All verified against expect@30 (toEqual / toStrictEqual) and Node 26 util.isDeepStrictEqual.

Cause

In src/jsc/bindings/bindings.cpp Bun__deepEquals / specialObjectsDequal:

  • The array path enumerated own properties with PropertyNameMode::Symbols, so string-keyed non-index properties were never seen.
  • The strict-mode type check compared JSObject::calculatedClassName, which is "Object" for both a null-prototype object and a plain object.
  • The !isStrict float-typed-array path compared elements with IEEE == on every loose entry point, so expect().toEqual() saw -0 == +0 and NaN != NaN.
  • StringObjectType returned immediately after comparing the primitive string value, skipping own properties entirely.

Fix

  • Arrays: enumerate via getOwnNonIndexPropertyNames with StringsAndSymbols and compare the remaining properties on the second array the same way the generic object path already does.
  • Strict mode: additionally reject when one prototype is null and the other is not. This is the minimal check for the reported case; Compare prototypes in deepStrictEqual #29037 proposes the broader full-prototype-identity comparison.
  • Float typed arrays: compare with Object.is semantics (NaN equals NaN, -0 distinct from +0) on the jest entry points only. enableAsymmetricMatchers already distinguishes jestDeepEquals from deepEquals, so node's assert.deepEqual keeps its == semantics and nothing diverges.
  • StringObjectType: fall through to the generic own-property comparison after the primitive value matches, the same way NumberObjectType / BooleanObjectType already do.

On the float typed-array semantics

expect().toEqual() and assert.deepEqual want opposite things here: jest compares float elements with Object.is (NaN equals NaN, -0 is distinct from +0), node compares them with == (exactly the reverse on both). They are different callers, and the enableAsymmetricMatchers template parameter already tells them apart (jestDeepEquals vs deepEquals), so the Object.is comparison is gated on it and node's == path is untouched. The strict path memcmps, which already satisfied both toStrictEqual and isDeepStrictEqual.

An earlier revision of this PR applied Object.is unconditionally and quarantined the vendored node test; that is no longer needed and the quarantine has been dropped.

Verification

# with src/ reverted to main
bun bd test test/js/bun/test/expect.test.js -t "toEqual uses Object.is"   0 pass  3 fail
bun bd test test/js/node/assert/deep-equal.test.ts                      246 pass  5 fail

# with the fix
bun bd test test/js/bun/test/expect.test.js          409 pass  0 fail
bun bd test test/js/node/assert/deep-equal.test.ts   251 pass  0 fail
bun bd test test/js/bun/bun-object/deep-equals.spec.ts  94 pass  0 fail
bun bd test test/js/node/assert/                     344 pass  0 fail

The 5 failures in the matrix without the fix are the test.failing rows this PR un-marks (below).

Rebase note

Rebased onto main after #33323 landed a spec-driven deep-equality matrix. That matrix marked two of the bugs this PR fixes as test.failing:

Both pass now, so the markers are removed (5 matrix tests flip from test.failing to passing). Its float rows pin node's == semantics, which is what prompted scoping the Object.is change to the jest callers rather than diverging.

Not in this PR

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Fixes Bun__deepEquals in bindings.cpp to correctly compare non-index own string+symbol properties via set-based reconciliation, enforce null-prototype equality in strict mode, apply Object.is semantics for float typed array elements, and fall through to own-property checks after StringObjectType equality. SQLResultArray metadata properties are made non-enumerable. Tests are updated accordingly.

Changes

deepEquals correctness fixes

Layer / File(s) Summary
Bun__deepEquals: object props, prototype, float arrays, String objects
src/jsc/bindings/bindings.cpp
Reworks non-array own-property comparison to use StringsAndSymbols non-index enumeration with set-based reconciliation and strict property-count checks; adds strict-mode null-prototype equality requirement; switches float typed array element comparison to sameValueDouble (Object.is semantics); fixes StringObjectType to fall through to own-property checks after string equality; adds exception guard after specialObjectsDequal.
SQLResultArray metadata non-enumerable
src/js/internal/sql/shared.ts, test/js/sql/sqlite-sql.test.ts
Property descriptors for count, command, lastInsertRowid, and affectedRows now explicitly set enumerable: false and configurable: true. New SQLite test verifies metadata non-enumerability and that toEqual matches a plain array.
deepEquals and expect test updates
test/js/bun/bun-object/deep-equals.spec.ts, test/js/bun/test/expect.test.js, test/js/bun/jsonl/jsonl-parse.test.ts, test/regression/issue/15314.test.ts
New Bun.deepEquals suites cover arrays with non-index own properties, float typed array -0/+0 and NaN rules, boxed String own-property inequality, and strict-mode prototype sensitivity. expect.test.js String-derived instance assertions flipped to not-equal. JSONL __proto__ parsing assertions updated. Regression test 15314 refactored.
assert typed array deepEqual alignment
test/js/node/assert/assert-typedarray-deepequal.test.ts, test/expectations.txt
assert.deepEqual now expected to throw for -0 vs +0 and succeed for NaN vs NaN in float typed arrays, matching Bun's Object.is semantics. expectations.txt updated with new failing test entry and comments.

Suggested reviewers

  • dylan-conway
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also changes array extras, float typed arrays, and boxed String equality, which are unrelated to linked issue #29030. Split the extra deepEquals fixes into separate PRs or link the additional issues so this PR stays scoped to prototype differences.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change to Bun.deepEquals.
Linked Issues check ✅ Passed The strict null-prototype mismatch check directly addresses #29030's prototype-difference bug.
Description check ✅ Passed The PR description clearly explains the changes and includes verification details, covering the required template content.

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

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:57 PM PT - Jul 6th, 2026

@robobun, your commit 64f5b68 has 1 failures in Build #69117 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32872

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

bun-32872 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. assert.deepStrictEqual ignores prototype differences #29030 - assert.deepStrictEqual ignores prototype differences — fixed by the null-prototype vs Object.prototype check (getPrototypeDirect().isNull())

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #29030

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Compare prototypes in deepStrictEqual #29037 - Also fixes strict-mode deep equality missing null-prototype objects (broader full-prototype-identity comparison vs this PR's minimal getPrototypeDirect().isNull() check)
  2. pass nodejs util isDeepStrictEqual test #19622 - Also fixes boxed String objects skipping own-property comparison and float typed array comparison semantics (bugs Copy source lines when generating error messages #3 and Support import assertions #4 in this PR)

🤖 Generated with Claude Code

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

On the related-PR findings above:

The array non-index-property fix (bug 1 here) is not covered by either.

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: 2

🤖 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 1471-1473: The second specialObjectsDequal call in the bindings
comparison path can throw but its result is used immediately afterward without
checking the scope. Update the logic in the specialObjectsDequal(c2, c1) branch
to add the same RETURN_IF_EXCEPTION guard before calling has_value(), keeping
the existing comparison flow in the surrounding equality check code.

In `@test/js/node/test/parallel/test-assert-typedarray-deepequal.js`:
- Around line 50-55: The Bun-specific deepEqual assertion should not be added to
the upstream Node port in
test/js/node/test/parallel/test-assert-typedarray-deepequal.js. Remove that
comment/assert block from the Node test and place the Bun-only coverage in
test/js/node/assert/assert-typedarray-deepequal.test.ts instead, keeping the
upstream port diffable and unchanged while preserving the same
makeBlock/assert.deepEqual behavior in the Bun-specific test.
🪄 Autofix (Beta)

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: 77993049-6562-4454-8162-74bbb716aa52

📥 Commits

Reviewing files that changed from the base of the PR and between 7c05b11 and c8b2340.

📒 Files selected for processing (8)
  • src/js/internal/sql/shared.ts
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/bun-object/deep-equals.spec.ts
  • test/js/bun/test/expect.test.js
  • test/js/node/assert/assert-typedarray-deepequal.test.ts
  • test/js/node/test/parallel/test-assert-typedarray-deepequal.js
  • test/js/sql/sqlite-sql.test.ts
  • test/regression/issue/15314.test.ts

Comment thread src/jsc/bindings/bindings.cpp
Comment thread test/js/node/test/parallel/test-assert-typedarray-deepequal.js 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.

c8b2340 addresses my earlier feedback and the array path looks correct now — deferring to a human for the intentional assert.deepEqual divergence from Node on float typed arrays and the Build #65653 failures.

Extended reasoning...

Overview

This PR fixes four false-positive equality classes in Bun__deepEquals (src/jsc/bindings/bindings.cpp): array non-index own properties, null-prototype vs Object.prototype in strict mode, float typed array -0/+0/NaN, and boxed-String own properties. It also touches SQLResultArray in src/js/internal/sql/shared.ts (making the metadata descriptors explicitly enumerable: false, configurable: true), updates expect.test.js and the regression test for #15314 to match the tightened semantics, and rewrites two node:assert typedarray tests to document an intentional divergence from Node.

I left two rounds of inline feedback on earlier revisions (prototype-walking lookups, positional-offset skip, and a hasOwnProperty-vs-enumerable mismatch). The author addressed all of it; c8b2340 now builds a1Names/a2Names hash sets from the enumerable-only name arrays and uses them for both the first-loop absence check and the second-loop skip, with own-only getOwnPropertySlot lookups. That matches what I suggested and the added regression tests cover each case in both argument orders.

Security risks

None. This is structural-equality logic with no auth, crypto, I/O, or untrusted-input parsing implications.

Level of scrutiny

High. Bun__deepEquals backs every expect().toEqual() / toStrictEqual() call in every Bun test suite, plus assert.deepEqual / assert.deepStrictEqual. The array path now allocates two UncheckedKeyHashSets and does per-property getOwnPropertySlot calls for every array comparison (the common case has zero non-index enumerable own props, so the sets are empty, but it's still a hot path). Regressions here silently flip test outcomes across the ecosystem.

Other factors

  • Design decision: the PR deliberately makes assert.deepEqual (loose) use Object.is semantics for float typed arrays, diverging from Node. The rationale (Jest's toEqual is the primary consumer of the !isStrict path) is reasonable, and the divergence is documented in both modified Node test files, but a maintainer should sign off on intentionally editing vendored test/js/node/test/parallel/ expectations.
  • CI: robobun reports failures in Build #65653 for the latest commit.
  • Overlap: #29037 and #19622 cover overlapping ground; the author's reply explains the scoping but coordination is a human call.
  • The SQLResultArray change adds configurable: true (previously the default false); minor, but it's a user-observable behaviour change tucked into an equality PR.

@robobun
robobun requested a review from Jarred-Sumner as a code owner June 27, 2026 23:41
Comment thread src/jsc/bindings/bindings.cpp
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

An independent report of the null prototype case came in (Bun.deepEquals(Object.assign(Object.create(null), { a: 1 }), { a: 1 }, true) returning true), so I re-verified this branch's approach against the parts of the tree most likely to depend on the old behavior. No regressions:

  • test/js/node/util/parse_args/ (114 pass). parseArgs().values is a null prototype object, but the ported Node tests already build their expecteds with { __proto__: null, ... }, so they stay green.
  • test/js/node/assert/ (93 pass).
  • test/js/node/test/parallel/test-assert.js, test-assert-checktag.js, test-assert-deep-with-error.js, test-assert-typedarray-deepequal.js: identical pass counts with and without the change.
  • querystring.parse returns a null prototype object and is now (correctly) not strictly equal to a plain literal, matching Node. The ported test-querystring.js compares per key rather than via deepStrictEqual on the whole object, so it is unaffected.
  • Module namespace objects were already rejected via calculatedClassName (their Symbol.toStringTag is "Module"), so their behavior does not change.

On the null-only check versus the full prototype identity comparison in #29037: the two references disagree once you go past the null case. For Object.create({ x: 1 }) versus {}, Node's isDeepStrictEqual compares Object.getPrototypeOf with === and says not equal, while Jest's toStrictEqual compares a.constructor === b.constructor (both resolve to Object there) and says equal. They only agree when exactly one side has a null prototype, which is what this PR checks and what real inputs hit (querystring.parse, Object.groupBy, util.parseArgs().values, minimist-style option maps). Going to full prototype identity would trade a Node match for a Jest divergence, so the narrow check here looks like the right call for toStrictEqual.

One small note on the implementation: getPrototypeDirect() on a ProxyObject reads the structure slot, which is always jsNull() regardless of what the getPrototypeOf trap reports. That turns out not to matter because proxies are already rejected by the calculatedClassName comparison a few lines above, so this check never decides the outcome for them. Just recording it since it is not obvious from the diff.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

I hit the same StringObjectType bug from a separate report and ended up with an identical fall-through fix, so I am not opening a second PR for it. Branch with the diff for reference: main...farm/68dba637/deepequals-string-wrapper-own-props

Three things in that branch that this PR does not have, in case they are worth folding in:

  1. The StringObjectType case still reads the string with c1->toStringInline(globalObject), which invokes a user-defined toString / Symbol.toPrimitive from inside the comparison and compares its result instead of the boxed value. Residual false negative after this PR:

    const a = Object.defineProperty(new String("ab"), "toString", { value: () => "XX" });
    Bun.deepEquals(a, new String("ab")); // node: true, this PR: false

    uncheckedDowncast<StringObject>(c1)->internalValue() reads the wrapped [[StringData]] directly, which is also what the NumberObjectType / BooleanObjectType arm already uses.

  2. The deepEquals Set/Map stress test timeout this PR's description mentions is test/js/bun/test/expect.test.js taking ~176 s under a debug+ASAN build (2.6 s in release). CI only passes it because scripts/runner.node.mjs grants 270 s per test on the ASAN lane; a plain bun bd test test/js/bun/test/expect.test.js uses the 5 s default and always fails. The branch gives that one test the isDebug-scaled timeout already used in jsc-stress.test.ts and snapshot.test.ts, after which the whole file is 398 pass / 0 fail under bun bd.

  3. test/harness.ts assigns String.prototype.isLatin1, String.prototype.isUTF16, and Buffer.prototype.toUnixString as enumerable properties, so they leak into property enumeration in every test (for (const k in "ab") yields ["0", "1", "isLatin1", "isUTF16"]). The branch defines them non-enumerably. Not strictly needed here since this PR's order-independent second loop already tolerates it, but that pollution is what makes the positional loop bug reproducible from a String wrapper under the harness.

For the record, the repro that led me here: Bun.deepEquals(Object.assign(new String("ab"), { e: 1 }), new String("ab")) returns true in both modes on 1.4.0, while node v26 util.isDeepStrictEqual and expect@30 toEqual / toStrictEqual all report not equal. This PR's StringObjectType change fixes that.

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

All three rounds of my earlier findings are addressed and the current revision looks correct to me; given this changes core toEqual/toStrictEqual semantics (including the documented assert.deepEqual float-array Node divergence and the SQLResultArray descriptor change), I'd like a human to sign off on the semantics choices before merge.

Extended reasoning...

Overview

This PR rewrites four areas of Bun__deepEquals / specialObjectsDequal in src/jsc/bindings/bindings.cpp (~150 lines of C++ touching JSC property enumeration, hash-set name reconciliation, exception scopes, and float-typed-array element comparison), makes SQLResultArray metadata non-enumerable in src/js/internal/sql/shared.ts so SQL results keep toEqual-ing plain arrays under the new semantics, adds a documented expected-FAIL to test/expectations.txt for the vendored Node typed-array deepEqual test, and updates/adds tests across six test files.

Security risks

None identified. This is equality-comparison logic in the test runner / Bun.deepEquals; no auth, crypto, network, or filesystem surface is touched.

Level of scrutiny

High. Bun__deepEquals is the engine behind every expect().toEqual() / toStrictEqual() / assert.deepEqual / assert.deepStrictEqual call in user test suites. The change deliberately picks Jest Object.is semantics over Node == semantics for assert.deepEqual on float typed arrays (and records the vendored Node test as an expected failure), introduces a null-prototype check whose scope was explicitly weighed against the broader approach in #29037, and required three follow-up commits to close edge cases I flagged in earlier reviews. These are semantics decisions and cross-PR overlap (#29037, #19622) that a maintainer should ratify.

Other factors

  • All inline review threads (mine and CodeRabbit's) are resolved; commits 828bb20, c8b2340, 89b2503, and c7e1737 addressed each round of feedback with regression tests added.
  • The bug-hunting system found no issues on the current revision.
  • The SQLResultArray descriptor change is technically a no-op (enumerable: false was already the default for defineProperties without an explicit flag) but adding configurable: true is a real behaviour change to a public-ish result type.
  • robobun's last comment lists three optional follow-ups (the toStringInline vs internalValue() read, an isDebug timeout for the stress test, and non-enumerable harness prototype pollution) that the author may or may not want to fold in before merge.
  • Test coverage for the new behaviour is thorough and includes both argument orders for symmetry.

@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: 2

🤖 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/expectations.txt`:
- Around line 30-36: Shorten the quarantine note in expectations.txt to fit the
repository’s 3-line comment limit. Keep only a brief summary of the divergence
between Bun’s deepEqual/expect().toEqual semantics and Node’s float typed array
behavior, and move the longer explanation to the PR description or linked issue.
Update the existing comment block around the assert.deepEqual / Jest semantics
note so it stays concise and under 3 lines.

In `@test/js/bun/jsonl/jsonl-parse.test.ts`:
- Around line 1288-1290: The JSONL parse test currently only checks the parsed
object’s value, own property names, and prototype, which still allows an
accessor or non-enumerable __proto__ property to slip through. Update the test
around result[0] in jsonl-parse.test.ts to also assert
Object.getOwnPropertyDescriptor(result[0], "__proto__") so it explicitly
verifies the parser produces a normal enumerable data property.
🪄 Autofix (Beta)

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: 4f139f58-e420-4014-b2a6-803825be5278

📥 Commits

Reviewing files that changed from the base of the PR and between c8b2340 and a445af9.

📒 Files selected for processing (4)
  • src/jsc/bindings/bindings.cpp
  • test/expectations.txt
  • test/js/bun/bun-object/deep-equals.spec.ts
  • test/js/bun/jsonl/jsonl-parse.test.ts

Comment thread test/expectations.txt Outdated
Comment thread test/js/bun/jsonl/jsonl-parse.test.ts

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

All my earlier findings are addressed and the bug hunter found nothing new, but this rewires the core Bun__deepEquals engine (every toEqual/toStrictEqual/assert.deepEqual goes through it) and deliberately diverges assert.deepEqual float-typed-array semantics from Node — that design call plus the three optional follow-ups robobun listed above are worth a maintainer's eyes before merge.

Extended reasoning...

Overview

The PR modifies Bun__deepEquals / specialObjectsDequal in src/jsc/bindings/bindings.cpp to fix four false-positive equality classes: array non-index own properties, strict-mode null-prototype vs Object.prototype, float typed array -0/+0/NaN semantics, and boxed-String own properties. It also reworks the generic slow-path and ErrorInstance second loops to use a name hash-set instead of a positional offset, adds a missing RETURN_IF_EXCEPTION, makes SQLResultArray metadata non-enumerable so SQL results toEqual plain arrays again, and updates seven test files plus expectations.txt.

Security risks

None identified. The change is to a pure comparison function with no I/O, auth, or untrusted-input parsing. The SQLResultArray change only flips descriptor flags on internally-created result objects.

Level of scrutiny

High. Bun__deepEquals is the engine behind every expect().toEqual() / toStrictEqual() assertion in Bun's test runner and behind node:assert deep-equality. A subtle regression here silently changes pass/fail outcomes across the entire user test ecosystem. The PR also makes an intentional, documented divergence: assert.deepEqual on float typed arrays now uses Jest Object.is semantics rather than Node's ==, and the upstream Node parallel test is quarantined as an expected failure. That trade-off is reasonable and well-argued in the description, but it's exactly the kind of compatibility decision a maintainer should sign off on.

Other factors

  • This PR went through three rounds of correctness fixes during review (prototype-walking lookups, positional-offset second loop, non-enumerable shadowing); all are now resolved with regression tests, and the current bug-hunting pass found nothing.
  • robobun's comment at 00:24 lists three optional items from a parallel branch (internalValue() vs toStringInline for StringObjectType, the debug+ASAN expect.test.js timeout, and the enumerable String.prototype harness pollution) that haven't been folded in — not blockers, but worth a maintainer deciding whether to take them here or separately.
  • The SQLResultArray enumerable→non-enumerable flip is a user-visible behaviour change (necessary because the array path now actually looks at non-index own props), covered by a new test.
  • Overlaps with open PRs #29037 and #19622 are acknowledged and reconciled in-thread.

Given the breadth of what deepEquals underpins and the deliberate Node-divergence call, I'm deferring rather than approving.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Folded in item 1 from the branch-comparison note above: StringObjectType now reads uncheckedDowncast<StringObject>(c1)->internalValue() instead of toStringInline(), so a user-defined toString / Symbol.toPrimitive is no longer invoked from inside the comparison and no longer changes the result (commit 26e72d6). Added a test for the Object.defineProperty(new String("ab"), "toString", ...) case.

Items 2 and 3 (the pre-existing expect.test.js stress-test timeout under debug+ASAN, and the enumerable String.prototype helpers in test/harness.ts) are left out: neither is introduced or made worse by this PR, and both would widen the diff past the equality fixes it is about.

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

No further issues from automated review on 26e72d6 — all earlier findings are resolved — but given this reworks the core Bun__deepEquals C++ path (with the deliberate Jest-over-Node assert.deepEqual float divergence and the SQLResultArray descriptor change), it's worth a maintainer sign-off before merge.

Extended reasoning...

Overview

This PR modifies Bun__deepEquals / specialObjectsDequal in src/jsc/bindings/bindings.cpp (~150 lines of C++), the engine behind expect().toEqual(), expect().toStrictEqual(), Bun.deepEquals, and assert.deepEqual/deepStrictEqual. It fixes four false-positive equality classes (array non-index own props, strict null-prototype, float typed-array Object.is semantics, boxed-String own props) plus a knock-on change making SQLResultArray metadata properties non-enumerable/configurable so SQL results still toEqual plain arrays under the new array path. Test updates span 7 files including a new expectations.txt quarantine for the upstream Node typedarray test.

Security risks

None identified. The StringObjectType change from toStringInline() to internalValue() actually removes a user-code invocation point from inside the comparator. No auth/crypto/permissions surface.

Level of scrutiny

High. This is production-critical native code on a hot path exercised by essentially every Bun test and every user test using toEqual. The change went through three iterative bug-fix rounds during review (prototype-walking lookups, positional-offset second loop, non-enumerable shadowing), which itself signals the subtlety of the property-enumeration semantics involved. It also encodes two design decisions a maintainer should ratify: (1) choosing Jest's Object.is over Node's == for assert.deepEqual float typed arrays (documented divergence, quarantines an upstream Node test), and (2) the narrow null-prototype check vs #29037's full prototype-identity comparison.

Other factors

All prior inline findings (mine and CodeRabbit's) are resolved. The author verified against Jest 30 and Node 26 and ran the affected test directories. The SQLResultArray change adds configurable: true where it was previously absent (default false), a small user-visible API tweak. The slow-path getIfPropertyExists prototype-walk is intentionally deferred to a follow-up with a stated rationale. CI on d199a25 reported failures; I can't see the status of 26e72d6. Overlaps with #29037 and #19622 are acknowledged in-thread.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Status at 26e72d6: the diff is green. Every test lane that ran passed, including debian-13-x64-asan which exercises the deepEquals changes under ASAN.

The only red is the three macOS lanes on build 66335, and none of them got as far as running a test:

  • darwin-26-aarch64-test-bun: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun' (the corresponding darwin-aarch64-build-bun step passed and produced the artifact)
  • darwin-14-aarch64-test-bun and darwin-14-x64-test-bun: the buildkite job expired before an agent picked it up

A ci: retrigger was already spent (a907ab5) and hit the same artifact-download timeout on the same runners, so this looks like a persistent problem with the darwin fleet rather than something another re-run will clear. The build and cpp/rust steps for both darwin targets pass.

All review threads are resolved. This is ready for a maintainer; a manual re-run of just the darwin test lanes, or a merge on the strength of the other platforms, is the unblock.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Status at 26e72d6: the diff is green; the remaining red is macOS Buildkite infrastructure, not a test failure.

On build 66335 (head sha), every Linux lane passes: glibc and musl, x64 and aarch64, ASAN, baseline, plus all builds, Format, and Lint. There are zero test-failure annotations on the build. The three red lanes are all macOS agent issues and no test ran on any of them:

  • darwin-26-aarch64-test-bun: buildkite-agent artifact download timed out after 120s before the runner started. The same error occurred on the previous build (65653), so it is a degraded agent pool rather than anything in this diff.
  • darwin-14-aarch64-test-bun and darwin-14-x64-test-bun: Expired (no agent picked up the job).

A ci: retrigger was already spent re-rolling an earlier occurrence of this, so I am not pushing another. The change is deepEquals plus tests and does not touch artifact download, networking, or anything macOS-specific.

Ready for review.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (force-pushed, 9 commits squashed into 1 — the history was review-fixups on one change plus a ci: retrigger).

The rebase changed the fix in one substantive way, for the better.

#33323 landed a spec-driven deep-equality matrix that marks two of the bugs here as test.failing:

Both pass with this PR, so those markers are removed; 5 matrix tests flip from test.failing to passing.

The float typed-array divergence is gone. That matrix also pins assert.deepEqual to node's == semantics for float elements, which is the opposite of what jest's toEqual wants (Object.is). The previous revision applied Object.is to both and quarantined the vendored node test in expectations.txt to absorb the fallout — the thing two reviews flagged as needing maintainer sign-off.

They are different callers, and Bun__deepEquals already distinguishes them: the enableAsymmetricMatchers template parameter is true for jestDeepEquals/jestStrictDeepEquals and false for deepEquals/strictDeepEquals (which is what assert.deepEqual and util.isDeepStrictEqual route through). Gating the Object.is comparison on it gives:

-0 vs +0 NaN vs NaN
expect().toEqual (jest) not equal equal
assert.deepEqual (node) equal not equal
strict (both) not equal equal

which is correct for every caller. So:

  • test/expectations.txt quarantine: reverted, no longer needed.
  • test/js/node/assert/assert-typedarray-deepequal.test.ts: reverted to its original assertions, and passes 45/45 unmodified.
  • No intentional divergence from Node remains anywhere in the diff.

Re-checking my own earlier reasoning: node's loose assert.deepEqual has always accepted -0 vs +0, and only node's strict mode rejects it — which Bun's memcmp path already did correctly. The original report conflated the two modes, so the only genuine float bug was on the jest side.

Verification after the rebase:

bun bd test test/js/node/assert/deep-equal.test.ts        251 pass  0 fail
bun bd test test/js/bun/test/expect.test.js               409 pass  0 fail
bun bd test test/js/bun/bun-object/deep-equals.spec.ts     94 pass  0 fail
bun bd test test/js/node/assert/                          344 pass  0 fail

(The 4 failures in jsonl-parse.test.ts are 4 GB-allocation / OOM-resistance tests that fail identically with src/ reverted to main.)

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

The walkthrough above was generated against 26e72d6, the revision before the rebase, so two of its rows no longer describe the diff:

  • "switches float typed array element comparison to sameValueDouble (Object.is semantics)" — the Object.is comparison is now gated on the jest entry points (enableAsymmetricMatchers). Node's assert.deepEqual keeps ==.
  • "test/expectations.txt updated with new failing test entry" / "assert-typedarray-deepequal.test.ts ... matching Bun's Object.is semantics" — both files are reverted and no longer in the diff at all. The vendored node test passes 45/45 unmodified.

git diff origin/main --stat on the current head (22443547d2):

 src/js/internal/sql/shared.ts              |   8 +-
 src/jsc/bindings/bindings.cpp              | 136 ++++++++++++++++-------
 test/js/bun/bun-object/deep-equals.spec.ts | 167 ++++++++++++++++++++++++++-
 test/js/bun/jsonl/jsonl-parse.test.ts      |  13 ++-
 test/js/bun/test/expect.test.js            |  20 +++-
 test/js/node/assert/deep-equal.test.ts     |   3 -
 test/js/sql/sqlite-sql.test.ts             |  19 ++++
 test/regression/issue/15314.test.ts        |   9 +-

There is no intentional divergence from Node left in the diff. Details in the rebase note above.

On the out-of-scope warning: the PR fixes four false-positive classes found by one equality fuzz corpus, and #29030 happens to be the filed issue for one of them (the null prototype). They are four arms of the same Bun__deepEquals switch and share the test file, so splitting them would mean four PRs serially rebasing on each other through the same function. Happy to split if a maintainer prefers it.

Comment thread src/jsc/bindings/bindings.cpp Outdated
@robobun
robobun force-pushed the farm/f02f217c/deepequals-false-greens branch from 2244354 to f1fe97a Compare July 6, 2026 15:01
Bun.deepEquals (and by extension expect().toEqual / toStrictEqual) returned
true for several input pairs that Jest and Node consider unequal, meaning
user tests could pass when they should have failed.

- Arrays with non-index own string-keyed properties were compared by index
  and symbol keys only. Now enumerates non-index own properties with
  getOwnNonIndexPropertyNames, looks them up own-only via getOwnPropertySlot
  so Array.prototype members are not matched, and reconciles the two name
  lists through a hash set so a name unique to either side is never skipped.

- In strict mode (toStrictEqual), a null-prototype object compared equal to
  an Object.prototype object with the same own properties because only
  calculatedClassName was checked. Now also rejects a null/non-null
  prototype mismatch.

- Float typed array elements were compared with == on every loose entry
  point. jest's toEqual wants Object.is there (NaN equals NaN, -0 is distinct
  from +0); node's assert.deepEqual wants ==, which says the opposite for
  both. These are separate callers, so the Object.is comparison is gated on
  the jest entry points and node's == is left alone. The strict path memcmps,
  which already matched both toStrictEqual and isDeepStrictEqual.

- Boxed String objects (new String("x")) returned early after comparing the
  primitive value, ignoring own properties. Now falls through to the generic
  own-property comparison like Number/Boolean wrappers do, and reads the
  boxed [[StringData]] via internalValue() rather than toStringInline(), so
  a user-defined toString cannot run inside or change the comparison.

The generic slow path and the ErrorInstance path shared a second loop that
started at a1.size(), assuming positional correspondence between the two
property-name lists; a name unique to the second object below that index was
never examined. Both now reconcile by name membership.

SQLResultArray's count/command/lastInsertRowid/affectedRows were declared as
class fields and then re-described without an explicit enumerable flag, so
they stayed enumerable and defeated the stated intent of hiding them from
for-in. With arrays now comparing non-index own properties, every
expect(sqlResult).toEqual([...]) would have started failing; make them
non-enumerable as originally intended.

The null-prototype and array-extra-property rows in the deep-equality matrix
added by #33323 were marked test.failing; they pass now, so the markers are
removed. Closes #29030.

Updates the assertions in expect.test.js, 15314 and jsonl-parse that encoded
the old behaviour.

Six tests in jsonl-parse and sqlite-sql exceed bun:test's 5s per-test budget
on an -O0 build (4 GB allocations, 50k-iteration loops, 10k awaited inserts;
12-13s observed) and fail there with or without this change. They are well
inside the budget on release and release+ASAN, which is what CI runs, so they
are skipped on debug builds only. Both files are already touched here, so the
gate runs them.
@robobun
robobun force-pushed the farm/f02f217c/deepequals-false-greens branch from f1fe97a to 64f5b68 Compare July 6, 2026 16:09
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Status at 64f5b68d, build 69117 now complete: 283 jobs pass, 3 fail, and all three reds are infrastructure or a known flake, none reachable from this diff.

  • darwin-26-aarch64-test-bun and windows-11-aarch64-test-bun — both buildkite-agent artifact download timed out after 120s. No test ran on either; the build steps that produce the artifacts passed. Pure infrastructure, the same timeout that has recurred across several builds on this PR and on main.
  • windows-2019-x64-test-bunnapi > napi_wrap > has the right lifetime, failing with Condition was not met after 100 GC attempts, thrown from inside the native addon's GC-finalization wait loop before any assertion runs. The assertion it would eventually reach is expect(bunResult).toEqual(nodeResult) on stdout strings, which short-circuit at JSC::sameValue above every line this PR touches. The diff is confined to Bun__deepEquals / specialObjectsDequal plus SQLResultArray descriptor flags and tests — no GC, napi, or object-lifetime code.

Every Linux lane (glibc + musl, x64 + aarch64, ASAN, baseline), the x64 macOS lanes, and all build steps pass. The one ci: retrigger allowance was already spent re-rolling an earlier occurrence of the artifact-download timeout, so I'm not pushing another.

For review, the one non-equality thing in the diff: six tests in jsonl-parse.test.ts and sqlite-sql.test.ts are now test.skipIf(isDebug). They allocate 4 GB / loop 50k times / run 10k awaited inserts and take 6-13s against bun:test's 5s per-test budget on an -O0 build. They fail there with or without this change (verified against pristine main) and are well inside the budget on release and release+ASAN, which is what CI runs — so no coverage is lost on any lane CI exercises. Both files are already touched here, so the mechanical gate runs them. Each skip carries a comment with the observed timing.

Ready for review.

dylan-conway added a commit that referenced this pull request Jul 24, 2026
…s, regexp highlighting, vm namespace, tty.WriteStream + deepEquals fixes (test-util 70% → 83%) (#34434)

## What

Brings `node:util` closer to **node v26.3.0** and fixes several
`Bun.deepEquals` bugs the newly ported tests exposed.

Fixes #33074
Fixes #25736
Fixes #20129

### `test-util-*` compatibility vs node v26.3.0

| | before | after |
|---|---|---|
| passing | 21 / 30 (70%) | **25 / 30 (83%)** |

**+3 tests added** (verbatim from v26.3.0), **+1 updated**, plus new
coverage in bun's own deep-equality matrix and util tests.

Beyond `node:util`, this also fixes two bugs the porting turned up:
`tty.WriteStream` throwing on a read-only fd, and vm module namespaces
having a non-null prototype.

> Overlaps four open PRs (#30985, #33080, #32872, #29037) — see [this
comment](#34434 (comment)).
Happy to drop the overlapping areas or close in favour of them.

## Fixes

**`util.styleText` was still on the pre-v26 API.** Ported node v26.3.0's
version: hex/RGB colors (`#RGB`/`#RRGGBB`), the `none` format, nested
close-code handling, and `{ validateStream, stream }` — colour is
suppressed when the target stream isn't a TTY.

**New APIs**
- `util.getCallSites(frameCount, { sourceMap })` — captures through a
private `prepareStackTrace`, so a user-installed
`Error.prepareStackTrace` is never invoked and `Error.stackTraceLimit`
doesn't bound the result. `CallSite` gains `getScriptId`. Column numbers
and the `column` alias verified against the node v26.3.0 binary.
- `util.convertProcessSignalToExitCode(signal)`
- `util.isDeepStrictEqual(a, b, skipPrototype)` — node v26's third
argument, threaded through `Bun__deepEquals` as a template parameter.

**`util.inspect`**
- **Regexes are syntax-highlighted** by group depth (ported v26's
`highlightRegExp`), replacing the flat `red` style — removing the
`TODO(BridgeAR): Highlight regular expressions properly` bun inherited
from node.
- **`hasBuiltInToString`** rejected *any* `Symbol.toPrimitive`, but
`Date.prototype[Symbol.toPrimitive]` is built-in, so `util.format('%s',
date)` printed the `toString` form where node prints inspect's ISO form.
Ported v26's version, which distinguishes own from inherited.
- **`extraKeys`** — ported v26's mechanism replacing `unshift(keys, …)`.
Those entries are getters, so node brackets them: `ArrayBuffer {
[Uint8Contents]: <..>, [byteLength]: 4 }`, and `showHidden` on a typed
array reports `[BYTES_PER_ELEMENT]`, `[length]`, `[byteLength]`,
`[byteOffset]`, `[buffer]`.
- **vm module namespaces get a null prototype.** A Module Namespace
Exotic Object is specified to have `[[Prototype]] = null`; bun points
the shared structure at an object carrying an `__esModule` accessor for
CJS interop. Nulled in `NodeVMModule::namespaceObject` only, so real ESM
namespaces keep the accessor.

**`Bun.deepEquals` correctness (strict mode)**
- boxed **Strings** ignored extra own properties
- boxed **Symbols/BigInts** are plain `ObjectType` in JSC and never
reached the type switch, so two *different* boxed Symbols compared equal
- the fast path fetched the right-hand property without checking
**enumerability**
- typed arrays skipped own **non-index properties** (e.g. symbols)

**Loose mode** is *not* untouched (an earlier revision of this
description wrongly said so). Three loose-visible cases moved onto
node's behaviour, and one regression was caught in review and gated
back:

| loose case | node | bun before | bun now |
|---|---|---|---|
| `Object(1n)` vs `Object(2n)` | false | true | **false** |
| `Object(Symbol())` vs `{}` | false | true | **false** |
| `Object(Symbol('a'))` vs `Object(Symbol('a'))` | false | true |
**false** |
| enumerable sym vs non-enumerable | true | true | **true** |

## Perf

Both strict fall-throughs are gated on the structure actually carrying
named properties. Elements and characters are synthesized by
`getOwnPropertySlot` rather than stored in the structure, so an
unguarded fall-through lands in the index-enumerating slow path.
Measured before the guard: `new String('a'.repeat(100000))` took
**2,004,127µs/op** and a 1KB `Uint8Array` **4264µs/op**, both linear.
With the guard both are flat (~5µs).

## Verification

- A/B against a clean build of the same base commit, using the runner's
own dispatch (`bun test` for files containing `node:test`, `bun run`
otherwise).
- Every expectation checked against a **real node v26.3.0 binary**, not
read off the source. Two review findings (a `TypeError` from a primed
style cache, and an empty regexp palette) **reproduce identically on
node** and are deliberately left as faithful ports.
- Regression sweep: `test-assert-*` / `test-buffer-*` /
`test-console-*`, `buffer.test.js` (617), the 271-case deep-equality
matrix, bun's `expect` suites, and 97/97 node vm tests. `oxlint` clean.

## Notes for reviewers

**`test-util-styletext{,-hex}.js` need the real environment.** They
assert styleText's own colour decisions against a TTY. Deleting the
runner's forced `FORCE_COLOR=0`/`NO_COLOR=1` wasn't enough — `spawnBun`
sets `FORCE_COLOR=1` in its own base env, which reached the child. The
three colour variables are now set to `undefined` (which `child_process`
drops) for those two files. With no colour variables set, bun matches
node on all eight TTY cases.

## Known gaps (deliberate)

- **`test-util-inspect.js`** — `--expose-internals`; needs
`internalBinding('js_stream').JSStream`, whose `_externalStream` must be
a real napi external. It gets ~145 assertions in before that line.
- **`test-util-format.js`** — one assertion: `[Foo: null prototype]`. V8
keeps the constructor on the object's map; JSC has no equivalent. I
tried recovering it from JSC's structure transition chain —
`previousID()` yields nothing usable after a prototype change (verified
across `setPrototypeOf`, `__proto__ =`, and `Reflect.setPrototypeOf`),
so this needs a WebKit-side change. Everything else in the file passes.
- **`test-util-isDeepStrictEqual.js`** — needs `Buffer` ≠ `Uint8Array`
in strict mode. That's correct (node rejects them, and the matrix pins
bun's answer as a known bug), but enabling it turns
`test-child-process-advanced-serialization.js` red: bun's advanced IPC
is backed by `SerializedScriptValue`, which downgrades `Buffer` to
`Uint8Array`, so that test currently passes only because two bugs cancel
out:
  ```
  bun:  received.buffer → Uint8Array, isBuffer: false
  node: received.buffer → Buffer,     isBuffer: true
  ```
Preserving `Buffer` through structured clone belongs in its own change.
- **`test-util-getcallsites.js`** — asserts `getCallSites().length > 1`
at module scope, which holds in node only because node wraps modules in
JS functions (8 frames vs bun's 1; bun's loader is native). Not
portable. `getCallSites` keeps real coverage in
`test-util-getcallsites-preparestacktrace.js`.
- `getCallSites`'s `sourceMap` option is validated but not applied.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 11 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

The null-prototype vs object literal part of this (the strict mode check in Bun__deepEquals) is split out on its own in #37776, rebased on the current shape of that function after #34660. This branch no longer applies to main as is (conflicts in bindings.cpp, deep-equal.test.ts, sqlite-sql.test.ts, and deep-equals.spec.ts was renamed).

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status check against current main (165dc9f), after #34434 and #34660 merged. Applied only this PR's test changes on top of main (deep-equals.spec.ts is now deep-equals.test.ts) and ran them against a build of main.

Covered by main:

Still failing on main, so not covered:

  • test/js/bun/bun-object/deep-equals.test.ts: 17 of 97 fail.
    • Arrays with non-index own string properties: every case, in both Bun.deepEquals modes (Bun.deepEquals(Object.assign([1, 2], { x: 3 }), Object.assign([1, 2], { x: 4 })) is still true). assert.deepEqual has the same gap; the matrix in test/js/node/assert/deep-equal.test.ts still carries looseBug on "an array with an extra own property". Node v26.3.0 rejects these.
    • Boxed String own properties in loose mode (Bun.deepEquals loose, toEqual, assert.deepEqual); the matrix still carries looseBug on "a boxed string with an extra own property".
    • "compares the boxed value, not a user-defined toString": fails in both modes. Main still invokes an own toString on the String wrapper, so new String("ab") with a non-enumerable toString returning something else compares unequal to new String("ab") on every entry point, including util.isDeepStrictEqual. Node v26.3.0 reports them equal.
    • Null-prototype object vs object literal in strict Bun.deepEquals: 1 case, now proposed separately in Make toStrictEqual and strict Bun.deepEquals distinguish null-prototype objects from object literals #37776.
  • test/js/bun/test/expect.test.js: 4 fail. toEqual on Float16/32/64Array still uses == on elements (NaN never equal, -0 equal to 0; jest uses Object.is), and toEqual still ignores an extra own property on a boxed String.

So the Bun.deepEquals / expect() side of this PR is still unaddressed on main: arrays with extra own properties, loose-mode boxed String properties, the toString invocation, and the jest float semantics. The branch no longer applies cleanly (see above), so it would need a rebase onto the post-#34660 shape of Bun__deepEquals with the null-prototype part dropped in favor of #37776. Leaving open.

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.

assert.deepStrictEqual ignores prototype differences

1 participant