From 0d742dfa60049fcde43a73fb2b47d683e715fd13 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:56:38 +0000 Subject: [PATCH] deepEquals: pair Set and Map entries one-to-one on node's strict entry points assert.deepStrictEqual and util.isDeepStrictEqual accepted Sets that hold deep-equal objects in different counts, and rejected Maps whose deep-equal keys hold different values. Each entry that the lookup missed scanned the other collection. The two arms now walk the right collection and settle each entry that the left one holds. The entries that are left pair one-to-one, in the probe order of node's setObjectEquiv and mapObjectEquiv: the first open candidate, then the last one, then the rest from the back. The entry points that follow Jest keep their code. --- bench/snippets/assert.mjs | 33 ++ src/jsc/bindings/PendingEntries.h | 74 +++ src/jsc/bindings/bindings.cpp | 288 +++++++--- test/js/node/assert/deep-equal.test.ts | 693 ++++++++++++++++++++++++- 4 files changed, 1000 insertions(+), 88 deletions(-) create mode 100644 src/jsc/bindings/PendingEntries.h diff --git a/bench/snippets/assert.mjs b/bench/snippets/assert.mjs index 8a56d72d459a..576fffd0a90c 100644 --- a/bench/snippets/assert.mjs +++ b/bench/snippets/assert.mjs @@ -36,4 +36,37 @@ bench("deepStrictEqual Map subclass of 10,000 entries", () => { assert.deepStrictEqual(customMap, customMapCopy); }); +const ids = Array.from({ length: 1000 }, (_, i) => i); +const objectSet = order => new Set(order.map(id => ({ id }))); +const objectKeyMap = order => new Map(order.map(id => [{ id }, id])); + +const objects = objectSet(ids); +const sameOrderObjects = objectSet(ids); +bench("deepStrictEqual Set of 1,000 objects", () => { + assert.deepStrictEqual(objects, sameOrderObjects); +}); + +const reversedObjects = objectSet(ids.toReversed()); +bench("deepStrictEqual reversed Set of 1,000 objects", () => { + assert.deepStrictEqual(objects, reversedObjects); +}); + +const objectKeys = objectKeyMap(ids); +const sameOrderObjectKeys = objectKeyMap(ids); +bench("deepStrictEqual Map of 1,000 object keys", () => { + assert.deepStrictEqual(objectKeys, sameOrderObjectKeys); +}); + +const smallSet = objectSet(ids.slice(0, 16)); +const otherSmallSet = objectSet(ids.slice(16, 32)); +bench("notDeepStrictEqual Sets of 16 objects, none equal", () => { + assert.notDeepStrictEqual(smallSet, otherSmallSet); +}); + +const smallMap = objectKeyMap(ids.slice(0, 16)); +const otherSmallMap = objectKeyMap(ids.slice(16, 32)); +bench("notDeepStrictEqual Maps of 16 object keys, none equal", () => { + assert.notDeepStrictEqual(smallMap, otherSmallMap); +}); + await run(); diff --git a/src/jsc/bindings/PendingEntries.h b/src/jsc/bindings/PendingEntries.h new file mode 100644 index 000000000000..ae3bc5404d0c --- /dev/null +++ b/src/jsc/bindings/PendingEntries.h @@ -0,0 +1,74 @@ +#pragma once + +#include "root.h" +#include + +namespace Bun { + +enum class Claim : uint8_t { + Missed, // nothing claimed (also returned when `matches` threw; check the scope first) + Claimed, + Exhausted, // claimed the last open entry +}; + +// One side's entries still needing a counterpart, claimed one at a time while the other side is walked. Plain copies: the caller roots them. +template +struct PendingEntries { + using Queue = WTF::Vector; + + Queue entries; + size_t first { 0 }; + size_t end; + // Node's guess: keep probing the end that hit last, so same-order and reversed inputs stay linear. + bool probeFront { true }; + + explicit PendingEntries(Queue&& queued) + : entries(std::move(queued)) + , end(entries.size()) + { + ASSERT(end); + } + + size_t openCount() const { return end - first; } + + // What a walk leaves behind for an entry known to match nothing, without running the probes. + void recordMiss() { probeFront = true; } + + // Probe order of node's partialObject{Set,Map}Equiv: front guess, back guess, then the rest back to front. + template + Claim claimWith(JSC::ThrowScope& scope, const Matches& matches) + { + ASSERT(first < end); + size_t scanFrom = first; + if (probeFront) { + bool hit = matches(entries[first]); + RETURN_IF_EXCEPTION(scope, Claim::Missed); + if (hit) { + first++; + return first == end ? Claim::Exhausted : Claim::Claimed; + } + if (openCount() == 1) + return Claim::Missed; + probeFront = false; + scanFrom++; + } + size_t i = end - 1; + bool hit = matches(entries[i]); + RETURN_IF_EXCEPTION(scope, Claim::Missed); + if (!hit) { + probeFront = true; + while (!hit && i > scanFrom) { + i--; + hit = matches(entries[i]); + RETURN_IF_EXCEPTION(scope, Claim::Missed); + } + if (!hit) + return Claim::Missed; + entries[i] = entries[end - 1]; + } + end--; + return first == end ? Claim::Exhausted : Claim::Claimed; + } +}; + +} // namespace Bun diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index b246d7e78695..82637de3944f 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -134,6 +134,7 @@ #include "FetchHeaders.h" #include "DOMURL.h" #include "JSDOMURL.h" +#include "PendingEntries.h" #include #include @@ -1984,96 +1985,231 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, return std::nullopt; } -// node's entry points: whether `probed` holds every member of `walked`, or a deep-equal member that `walked` does not hold. `walkedIsRight` keeps each comparison in (left, right) order. `misses` counts the members that needed a deep-equal one. Compiled once for both node modes. -static bool setMembersMatch(const DeepEqualsMode& mode, JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSSet* walked, JSSet* probed, bool walkedIsRight, unsigned& misses) +// One copy of each Map lookup for the paths that run at most once per unsettled entry. +static NEVER_INLINE bool mapHolds(JSC::JSGlobalObject* globalObject, JSMap* map, JSValue key) { - VM& vm = globalObject->vm(); - auto walk = JSSetIterator::create(vm, globalObject->setIteratorStructure(), walked, IterationKind::Keys); - JSValue member; - while (walk->next(globalObject, member)) { - bool has = probed->has(globalObject, member); + return map->has(globalObject, key); +} + +static NEVER_INLINE JSValue mapValueOrUndefined(JSC::JSGlobalObject* globalObject, JSMap* map, JSValue key) +{ + return map->get(globalObject, key); +} + +// node's strict setObjectEquiv / mapObjectEquiv (lib/internal/util/comparisons.js, v26.3.0): the right entries that the lookups of nodeSetEquiv / nodeMapEquiv did not settle pair with left entries one-to-one. Compiled once for both node modes. +struct NodeUnorderedPairing { + const DeepEqualsMode& mode; + JSC::JSGlobalObject* globalObject; + MarkedArgumentBuffer& gcBuffer; + Vector, 16>& stack; + ThrowScope& scope; + JSCell* left; + JSCell* right; + bool isMap; + // The right keys that need a partner. `twins` are the ones among them that `left` also holds, with an unequal value. + MarkedArgumentBuffer candidates {}; + Bun::PendingEntries::Queue open {}; + WTF::HashSet twins {}; + + // node's `typeof value === "object" && value !== null`: any other value equals only an identical one. + static bool isTypeofObject(JSValue value) { return value.isObject() && !value.isCallable(); } + + JSCell* iterate(JSCell* collection) const + { + VM& vm = globalObject->vm(); + if (isMap) + return JSMapIterator::create(vm, globalObject->mapIteratorStructure(), uncheckedDowncast(collection), IterationKind::Entries); + return JSSetIterator::create(vm, globalObject->setIteratorStructure(), uncheckedDowncast(collection), IterationKind::Keys); + } + + // A Set member arrives as `key`, and `value` stays as it was. + bool next(JSCell* iterator, JSValue& key, JSValue& value) const + { + if (isMap) + return uncheckedDowncast(iterator)->nextKeyValue(globalObject, key, value); + return uncheckedDowncast(iterator)->next(globalObject, key); + } + + bool holds(JSCell* collection, JSValue key) const + { + if (isMap) + return mapHolds(globalObject, uncheckedDowncast(collection), key); + return uncheckedDowncast(collection)->has(globalObject, key); + } + + size_t size(JSCell* collection) const + { + if (isMap) + return uncheckedDowncast(collection)->size(); + return uncheckedDowncast(collection)->size(); + } + + bool deepEquals(JSValue leftValue, JSValue rightValue) const + { + return mode.deepEquals(globalObject, leftValue, rightValue, gcBuffer, stack, scope, false); + } + + // Whether the right entry under `rightKey` equals the left entry. node reads `b.get(key2)` at each probe, so the right value is read here and not copied. get() answers undefined for a key that a getter removed. + bool entriesEqual(JSValue key, JSValue value, JSValue rightKey) const + { + bool equal = deepEquals(key, rightKey); RETURN_IF_EXCEPTION(scope, false); - if (has) { - continue; - } + if (!equal || !isMap) + return equal; + JSValue rightValue = mapValueOrUndefined(globalObject, uncheckedDowncast(right), rightKey); + RETURN_IF_EXCEPTION(scope, false); + equal = deepEquals(value, rightValue); + RETURN_IF_EXCEPTION(scope, false); + return equal; + } + + void addCandidate(JSValue key) + { + open.append(candidates.size()); + candidates.appendWithCrashOnOverflow(key); + } - misses++; - auto scan = JSSetIterator::create(vm, globalObject->setIteratorStructure(), probed, IterationKind::Keys); - JSValue candidate; - bool foundMatchingMember = false; - while (scan->next(globalObject, candidate)) { - bool equal = mode.deepEquals(globalObject, walkedIsRight ? candidate : member, walkedIsRight ? member : candidate, gcBuffer, stack, scope, false); + // Queues the right entry (`key`, `value`) of a Map whose left side holds the key with an unequal value. node pairs this entry like any other: its partner can be a left entry under a deep-equal key. False when no left entry is one, as in Maps that differ in one value. + bool addTwin(JSValue key, JSValue value) + { + JSCell* leftIterator = iterate(left); + JSValue leftKey, leftValue; + while (next(leftIterator, leftKey, leftValue)) { + if (leftKey == key) + continue; + // A value that is not an object is the shorter half to compare, and it calls no getter. + bool valueFirst = !leftValue.isObject() || !value.isObject(); + if (!valueFirst) { + // A left entry that `right` holds with the identical value is settled, so it is the partner of no other entry. A Map and its changed copy have such entries only. + JSValue rightValue = mapValueOrUndefined(globalObject, uncheckedDowncast(right), leftKey); + RETURN_IF_EXCEPTION(scope, false); + if (rightValue == leftValue) + continue; + } + bool equal = valueFirst ? deepEquals(leftValue, value) : deepEquals(leftKey, key); + RETURN_IF_EXCEPTION(scope, false); + if (!equal) + continue; + equal = valueFirst ? deepEquals(leftKey, key) : deepEquals(leftValue, value); RETURN_IF_EXCEPTION(scope, false); - if (!equal) { + if (!equal) + continue; + twins.add(key.asCell()); + addCandidate(key); + return true; + } + return false; + } + + // Walks `left`, and every entry that the lookup in `right` does not settle claims a candidate of its own: front guess, back guess, then a scan from the back. The shortcut for Sets of fewer than three members (compareSmallSets) is not ported. + bool claimCandidates() + { + // When no right entry was settled, `right` holds no left entry either, so node skips the lookups (`extraChecks`). + bool lookUp = open.size() != size(left); + Bun::PendingEntries pending(WTF::move(open)); + JSCell* leftIterator = iterate(left); + JSValue key, value; + while (next(leftIterator, key, value)) { + // A left entry that is not an object claims nothing. When `right` does not hold it, a candidate stays open below. + if (!isTypeofObject(key)) continue; + if (lookUp) { + bool held = holds(right, key); + RETURN_IF_EXCEPTION(scope, false); + if (held && !twins.contains(key.asCell())) + continue; } - // A member that both Sets hold is the partner of itself. - bool held = walked->has(globalObject, candidate); + Bun::Claim claim = pending.claimWith(scope, [&](size_t candidate) -> bool { + return entriesEqual(key, value, candidates.at(candidate)); + }); RETURN_IF_EXCEPTION(scope, false); - if (!held) { - foundMatchingMember = true; - break; + if (claim == Bun::Claim::Missed) + return false; + if (claim == Bun::Claim::Exhausted) { + // `right` holds every other left entry, unless a getter changed a size. Then each of them must still be one that `right` holds. + if (size(left) == size(right)) + return true; + while (next(leftIterator, key, value)) { + bool held = holds(right, key); + RETURN_IF_EXCEPTION(scope, false); + if (!held) + return false; + } + return true; } } + // A candidate is open: a left entry that is not an object had no counterpart. When a getter took entries out of `left` instead, node ends the comparison here as equal. + return size(left) != size(right); + } +}; - if (!foundMatchingMember) { +// node's setEquiv: walks the right Set and looks each member up in the left one. +static bool nodeSetEquiv(const DeepEqualsMode& mode, JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSSet* left, JSSet* right) +{ + std::optional pairing; + auto* rightIterator = JSSetIterator::create(globalObject->vm(), globalObject->setIteratorStructure(), right, IterationKind::Keys); + JSValue member; + while (rightIterator->next(globalObject, member)) { + bool held = left->has(globalObject, member); + RETURN_IF_EXCEPTION(scope, false); + if (held) + continue; + // Nothing on the left equals a right member that is not an object. + if (!NodeUnorderedPairing::isTypeofObject(member)) return false; - } + if (!pairing) + pairing.emplace(mode, globalObject, gcBuffer, stack, scope, left, right, false); + pairing->addCandidate(member); } - return true; + if (!pairing) + return true; + return pairing->claimCandidates(); } -// The same for the entries of two Maps. An entry matches the first such deep-equal key, and then the two values must be deep-equal. -static bool mapEntriesMatch(const DeepEqualsMode& mode, JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSMap* walked, JSMap* probed, bool walkedIsRight, unsigned& misses) +// node's mapEquiv: the same for two Maps. A key that both hold is settled when the two values are deep-equal. +static bool nodeMapEquiv(const DeepEqualsMode& mode, JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSMap* left, JSMap* right) { - VM& vm = globalObject->vm(); - auto walk = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), walked, IterationKind::Entries); + std::optional pairing; + auto* rightIterator = JSMapIterator::create(globalObject->vm(), globalObject->mapIteratorStructure(), right, IterationKind::Entries); JSValue key, value; - while (walk->nextKeyValue(globalObject, key, value)) { - JSValue probedValue = probed->get(globalObject, key); + while (rightIterator->nextKeyValue(globalObject, key, value)) { + JSValue leftValue = left->get(globalObject, key); RETURN_IF_EXCEPTION(scope, false); - if (probedValue.isUndefined()) { + bool held = !leftValue.isUndefined(); + if (!held) { // get() answers undefined for an absent key and for a key that holds undefined. - bool has = probed->has(globalObject, key); + held = mapHolds(globalObject, left, key); RETURN_IF_EXCEPTION(scope, false); - if (!has) { - misses++; - auto scan = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), probed, IterationKind::Entries); - JSValue candidateKey; - bool foundMatchingKey = false; - while (scan->nextKeyValue(globalObject, candidateKey, probedValue)) { - bool keysEqual = mode.deepEquals(globalObject, walkedIsRight ? candidateKey : key, walkedIsRight ? key : candidateKey, gcBuffer, stack, scope, false); - RETURN_IF_EXCEPTION(scope, false); - if (!keysEqual) { - continue; - } - // A key that both Maps hold is the partner of itself. - bool held = walked->has(globalObject, candidateKey); - RETURN_IF_EXCEPTION(scope, false); - if (!held) { - foundMatchingKey = true; - break; - } - } - - if (!foundMatchingKey) { - return false; - } - } } - - bool valuesEqual = mode.deepEquals(globalObject, walkedIsRight ? probedValue : value, walkedIsRight ? value : probedValue, gcBuffer, stack, scope, false); - RETURN_IF_EXCEPTION(scope, false); - if (!valuesEqual) { + if (held) { + bool valuesEqual = mode.deepEquals(globalObject, leftValue, value, gcBuffer, stack, scope, false); + RETURN_IF_EXCEPTION(scope, false); + if (valuesEqual) + continue; + } + // Nothing on the left equals a right entry that is not an object, other than an entry under the same key. + if (!NodeUnorderedPairing::isTypeofObject(key)) return false; + if (!pairing) + pairing.emplace(mode, globalObject, gcBuffer, stack, scope, left, right, true); + if (!held) { + pairing->addCandidate(key); + continue; } + bool possible = pairing->addTwin(key, value); + RETURN_IF_EXCEPTION(scope, false); + if (!possible) + return false; } - return true; + if (!pairing) + return true; + return pairing->claimCandidates(); } template std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSCell* _Nonnull c1, JSCell* _Nonnull c2) { + static_assert(!checkPrototypes || (isStrict && !enableAsymmetricMatchers), "NodeUnorderedPairing is node's strict pairing: a loose mode must also pair primitives that are loosely equal, and a matcher can equal more than one entry"); VM& vm = globalObject->vm(); uint8_t c1Type = c1->type(); uint8_t c2Type = c2->type(); @@ -2094,21 +2230,11 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark } if constexpr (checkPrototypes) { - // node walks the right Set and looks each member up in the left one. - unsigned misses = 0; - bool membersMatch = setMembersMatch(deepEqualsMode, globalObject, gcBuffer, stack, scope, set2, set1, true, misses); + bool membersEqual = nodeSetEquiv(deepEqualsMode, globalObject, gcBuffer, stack, scope, set1, set2); RETURN_IF_EXCEPTION(scope, {}); - if (!membersMatch) { + if (!membersEqual) { return false; } - // One member without an identical counterpart has matched the one member left for it. Two or more can match the same one, so then the other direction decides too. - if (misses > 1) { - membersMatch = setMembersMatch(deepEqualsMode, globalObject, gcBuffer, stack, scope, set1, set2, false, misses); - RETURN_IF_EXCEPTION(scope, {}); - if (!membersMatch) { - return false; - } - } // node also compares own enumerable properties of Sets. break; } @@ -2157,21 +2283,11 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark } if constexpr (checkPrototypes) { - // node walks the right Map and looks each key up in the left one. - unsigned misses = 0; - bool entriesMatch = mapEntriesMatch(deepEqualsMode, globalObject, gcBuffer, stack, scope, map2, map1, true, misses); + bool entriesEqual = nodeMapEquiv(deepEqualsMode, globalObject, gcBuffer, stack, scope, map1, map2); RETURN_IF_EXCEPTION(scope, {}); - if (!entriesMatch) { + if (!entriesEqual) { return false; } - // One key without an identical counterpart has matched the one key left for it. Two or more can match the same one, so then the other direction decides too. - if (misses > 1) { - entriesMatch = mapEntriesMatch(deepEqualsMode, globalObject, gcBuffer, stack, scope, map1, map2, false, misses); - RETURN_IF_EXCEPTION(scope, {}); - if (!entriesMatch) { - return false; - } - } // node also compares own enumerable properties of Maps. break; } diff --git a/test/js/node/assert/deep-equal.test.ts b/test/js/node/assert/deep-equal.test.ts index ba5ebae43040..11161a5092d4 100644 --- a/test/js/node/assert/deep-equal.test.ts +++ b/test/js/node/assert/deep-equal.test.ts @@ -32,6 +32,7 @@ const ownSharedConstructor = () => const inheritedSharedConstructor = () => Object.assign(Object.create(inheritsSharedConstructor), { v: 1 }); const nullPrototypeProxy = () => new Proxy({}, { getPrototypeOf: () => null }); const sharedObject = { a: 1 }; +const sharedSetMember = ["x"]; const errorWithEnumerableCode = () => Object.assign(new Error("x"), { code: 1 }); const errorWithHiddenCode = () => Object.defineProperty(Object.assign(new Error("x"), { other: 2 }), "code", { value: 1, enumerable: false }); @@ -908,6 +909,174 @@ const cases: Case[] = [ strict: false, loose: false, }, + // node pairs each entry that the other side does not hold with an entry of its own. + { + name: "sets holding deep-equal objects in another order", + a: () => new Set([{ a: 1 }, { a: 2 }, { a: 3 }]), + b: () => new Set([{ a: 3 }, { a: 1 }, { a: 2 }]), + strict: true, + loose: true, + }, + { + name: "sets holding deep-equal objects in different counts", + a: () => new Set([{ a: 1 }, { a: 1 }, { a: 2 }]), + b: () => new Set([{ a: 1 }, { a: 2 }, { a: 2 }]), + strict: false, + loose: false, + looseBug: "reports equal", + }, + // https://github.com/nodejs/node/issues/53423 + { + name: "sets holding the same array and a deep-equal array", + a: () => new Set([sharedSetMember, ["y"]]), + b: () => new Set([sharedSetMember, ["y"]]), + strict: true, + loose: true, + }, + // https://github.com/oven-sh/bun/issues/34830 + { + name: "maps keyed by deep-equal objects with different values", + a: () => + new Map([ + [/a/, "x"], + [/a/, "y"], + ]), + b: () => + new Map([ + [/a/, "x"], + [/a/, "y"], + ]), + strict: true, + loose: true, + looseBug: "reports not equal", + }, + { + name: "maps keyed by deep-equal objects with the values in the other order", + a: () => + new Map([ + [{ k: 1 }, "x"], + [{ k: 1 }, "y"], + ]), + b: () => + new Map([ + [{ k: 1 }, "y"], + [{ k: 1 }, "x"], + ]), + strict: true, + loose: true, + looseBug: "reports not equal", + }, + { + name: "maps keyed by deep-equal objects with the values in different counts", + a: () => + new Map([ + [{ k: 1 }, "x"], + [{ k: 1 }, "x"], + [{ k: 1 }, "y"], + ]), + b: () => + new Map([ + [{ k: 1 }, "x"], + [{ k: 1 }, "y"], + [{ k: 1 }, "y"], + ]), + strict: false, + loose: false, + }, + // node pairs a key that both maps hold like any other key. + { + name: "maps holding swapped values under a shared key and a deep-equal key", + a: () => + new Map([ + [sharedObject, 1], + [{ a: 1 }, 2], + ]), + b: () => + new Map([ + [sharedObject, 2], + [{ a: 1 }, 1], + ]), + strict: true, + loose: true, + looseBug: "reports not equal", + }, + { + name: "maps holding unequal values under the same number key", + a: () => + new Map([ + [1, { a: 1 }], + [{ k: 1 }, 1], + ]), + b: () => + new Map([ + [1, { a: 2 }], + [{ k: 1 }, 1], + ]), + strict: false, + loose: false, + }, + { + name: "a set with an extra own property, after its members pair off", + a: () => withExtraProperty(new Set([{ a: 1 }, { a: 2 }])), + b: () => new Set([{ a: 2 }, { a: 1 }]), + strict: false, + loose: false, + looseBug: "reports equal", + }, + { + name: "sets whose members pair off, with equal extra own properties", + a: () => withExtraProperty(new Set([{ a: 1 }, { a: 2 }])), + b: () => withExtraProperty(new Set([{ a: 2 }, { a: 1 }])), + strict: true, + loose: true, + }, + { + name: "sets whose members pair off, with unequal own index properties", + a: () => Object.assign(new Set([{ a: 1 }, { a: 2 }]), { 0: 1 }), + b: () => Object.assign(new Set([{ a: 2 }, { a: 1 }]), { 0: 2 }), + strict: false, + loose: false, + looseBug: "reports equal", + }, + { + name: "a map with an extra own property, after its entries pair off", + a: () => + new Map([ + [{ a: 1 }, 1], + [{ a: 2 }, 2], + ]), + b: () => + withExtraProperty( + new Map([ + [{ a: 2 }, 2], + [{ a: 1 }, 1], + ]), + ), + strict: false, + loose: false, + looseBug: "reports equal", + }, + { + name: "maps whose entries pair off, with equal own index properties", + a: () => + Object.assign( + new Map([ + [{ a: 1 }, 1], + [{ a: 2 }, 2], + ]), + { 0: 1 }, + ), + b: () => + Object.assign( + new Map([ + [{ a: 2 }, 2], + [{ a: 1 }, 1], + ]), + { 0: 1 }, + ), + strict: true, + loose: true, + }, { name: "sets holding -0 and 0", a: () => new Set([-0]), b: () => new Set([0]), strict: true, loose: true }, { name: "sets holding NaN", a: () => new Set([NaN]), b: () => new Set([NaN]), strict: true, loose: true }, @@ -1327,8 +1496,8 @@ describe("util.isDeepStrictEqual", () => { ) => void; const scenarios: [string, Mutation, boolean, boolean][] = [ ["the right Map gains a primitive key", (left, right) => right.set("k3", 3), false, false], - // node made its list of the right keys that are objects before this walk. - ["the right Map gains an object key", (left, right) => right.set({}, 3), true, false], + // No left entry is left to pair with the new key. + ["the right Map gains an object key", (left, right) => right.set({}, 3), true, true], ["the left Map gains a primitive key", (left, right) => left.set("k3", 3), true, true], ["the left Map loses the key that is not visited yet", (left, right) => left.delete("k2"), false, false], ["the right Map loses the key that is not visited yet", (left, right) => right.delete("k2"), true, true], @@ -1415,6 +1584,526 @@ describe("util.isDeepStrictEqual", () => { }); }); + // node pairs every Set member and Map entry that the other side does not hold with an entry of its own (setObjectEquiv and mapObjectEquiv in lib/internal/util/comparisons.js). + describe("pairs Set members and Map entries one-to-one", () => { + const skipPrototype = new assert.Assert({ skipPrototype: true }); + const passes = (assertion: () => void) => { + const error = caught(assertion); + if (error !== null && error.code !== "ERR_ASSERTION") throw error; + return error === null; + }; + const entryPoints: [string, (a: unknown, b: unknown) => boolean][] = [ + ["util.isDeepStrictEqual", (a, b) => util.isDeepStrictEqual(a, b)], + ["util.isDeepStrictEqual, skipPrototype", (a, b) => util.isDeepStrictEqual(a, b, true as any)], + ["assert.deepStrictEqual", (a, b) => passes(() => assert.deepStrictEqual(a, b))], + ["Assert({ skipPrototype }).deepStrictEqual", (a, b) => passes(() => skipPrototype.deepStrictEqual(a, b))], + ]; + const shared = { a: 1 }; + const set = (...members: unknown[]) => new Set(members); + const map = (...entries: [unknown, unknown][]) => new Map(entries); + + const pairs: [string, Thunk, Thunk, boolean][] = [ + [ + "Set members in another order", + () => set({ a: 1 }, { a: 2 }, { a: 3 }), + () => set({ a: 3 }, { a: 1 }, { a: 2 }), + true, + ], + [ + "Set members in different counts", + () => set({ a: 1 }, { a: 1 }, { a: 2 }), + () => set({ a: 1 }, { a: 2 }, { a: 2 }), + false, + ], + [ + "a Set member that both sides hold, beside a copy of it", + () => set(shared, { a: 1 }, { b: 1 }), + () => set(shared, { a: 1 }, { b: 1 }), + true, + ], + [ + "a Set member that both sides hold, beside a copy of it on one side only", + () => set(shared, { a: 2 }, 1), + () => set(shared, { a: 1 }, 1), + false, + ], + [ + "a Set with a number that the other side does not hold", + () => set(1, { a: 1 }, { b: 1 }), + () => set({ a: 1 }, { b: 1 }, { c: 1 }), + false, + ], + [ + "a Set of numbers and a Set of boxed numbers", + () => set(1, 2, 3), + () => set(new Number(1), new Number(2), new Number(3)), + false, + ], + [ + "Map keys with the values in the other order", + () => map([{ k: 1 }, "x"], [{ k: 1 }, "y"], [{ k: 2 }, "z"]), + () => map([{ k: 2 }, "z"], [{ k: 1 }, "y"], [{ k: 1 }, "x"]), + true, + ], + [ + "Map keys with the values in different counts", + () => map([{ k: 1 }, "x"], [{ k: 1 }, "x"], [{ k: 1 }, "y"]), + () => map([{ k: 1 }, "x"], [{ k: 1 }, "y"], [{ k: 1 }, "y"]), + false, + ], + [ + "a Map key that both sides hold with different values", + () => map([shared, 1], ["a", 1], ["b", 2]), + () => map([shared, 2], ["a", 1], ["b", 2]), + false, + ], + [ + "a Map key that both sides hold, with its value under a deep-equal key on the other side", + () => map([shared, 1], [{ a: 1 }, 2], ["p", 0]), + () => map([shared, 2], [{ a: 1 }, 1], ["p", 0]), + true, + ], + [ + "the same, with the deep-equal key first", + () => map([{ a: 1 }, 2], [shared, 1]), + () => map([{ a: 1 }, 1], [shared, 2]), + true, + ], + [ + "a Map key that both sides hold, with a partner for its right value only", + () => map([shared, 1], [{ a: 1 }, 2], ["p", 0]), + () => map([shared, 2], [{ a: 1 }, 3], ["p", 0]), + false, + ], + [ + "a Map key that both sides hold with different values, after an equal entry", + () => map([{ a: 1 }, 1], [shared, 1]), + () => map([{ a: 1 }, 1], [shared, 2]), + false, + ], + [ + "a Map key that both sides hold, beside a copy of it on one side only", + () => map([shared, 1], [{ a: 2 }, 1]), + () => map([shared, 1], [{ a: 1 }, 1]), + false, + ], + [ + "a Map with a string key that the other side does not hold", + () => map(["p", 1], [{ k: 1 }, 1]), + () => map(["q", 1], [{ k: 1 }, 1]), + false, + ], + [ + "a Map with a number key and a Map of object keys", + () => map([1, 1], [{ a: 1 }, 1], [{ b: 1 }, 1]), + () => map([{ a: 1 }, 1], [{ b: 1 }, 1], [{ c: 1 }, 1]), + false, + ], + [ + "an undefined value under an object key", + () => map([{ a: 1 }, undefined], [{ a: 2 }, 1]), + () => map([{ a: 2 }, 1], [{ a: 1 }, undefined]), + true, + ], + [ + "an undefined value under an object key that only one side holds", + () => map([{ a: 1 }, undefined], [{ a: 2 }, 1]), + () => map([{ a: 2 }, 1], [{ a: 3 }, undefined]), + false, + ], + ]; + describe.each(entryPoints)("%s", (_, equal) => { + test.each(pairs)("%s", (_, a, b, expected) => { + expect([equal(a(), b()), equal(b(), a())]).toEqual([expected, expected]); + }); + }); + + // node v26.3.0 gives another answer on each of these rows. + describe("where node v26.3.0 differs", () => { + // Its shortcut for Sets of fewer than three members (compareSmallSets) compares the member that both sides hold with the copy. + test("a two-member Set pairs a member that both sides hold with itself", () => { + expect(util.isDeepStrictEqual(set(shared, { a: 1 }), set(shared, { a: 1 }))).toBe(true); + }); + + // The same shortcut reads past the end of the other Set and compares undefined with undefined. + test("a two-member Set does not pair undefined with an object", () => { + expect(util.isDeepStrictEqual(set(shared, undefined), set(shared, { a: 1 }))).toBe(false); + expect(util.isDeepStrictEqual(set(shared, { a: 1 }), set(shared, undefined))).toBe(false); + }); + + // node throws a TypeError in one argument order (nodejs/node#64433, fixed in v26.10.0). + test.each([ + ["a Set", () => set(null, { a: 1 }, { b: 1 }), () => set({ a: 1 }, { b: 1 }, { c: 1 })], + ["a Set", () => set(undefined, shared, { b: 1 }), () => set(shared, { b: 1 }, { c: 1 })], + [ + "a Map", + () => map([null, 1], [{ a: 1 }, 1], [{ b: 1 }, 1]), + () => map([{ a: 1 }, 1], [{ b: 1 }, 1], [{ c: 1 }, 1]), + ], + ])("%s with a null or undefined entry and one of objects only are not equal", (_, a, b) => { + expect([util.isDeepStrictEqual(a(), b()), util.isDeepStrictEqual(b(), a())]).toEqual([false, false]); + }); + + // node compares a key that both Maps hold structurally, and to it a Promise or a WeakMap equals nothing. + test("a Map key that both sides hold equals itself", () => { + const promise = Promise.resolve(); + const weakMap = new WeakMap(); + const make = () => map([promise, 1], [weakMap, 2]); + expect(util.isDeepStrictEqual(make(), make())).toBe(true); + }); + }); + + // To node a callable value is not an object, so it equals only itself. + test("a callable entry equals only itself", () => { + const callable = () => new Proxy(function () {}, {}); + const sharedCallable = callable(); + expect(util.isDeepStrictEqual(set(callable(), { a: 1 }, { a: 2 }), set(callable(), { a: 2 }, { a: 1 }))).toBe( + false, + ); + expect( + util.isDeepStrictEqual(set(sharedCallable, { a: 1 }, { a: 2 }), set(sharedCallable, { a: 2 }, { a: 1 })), + ).toBe(true); + expect(util.isDeepStrictEqual(map([callable(), 1], [{ a: 1 }, 2]), map([callable(), 1], [{ a: 1 }, 2]))).toBe( + false, + ); + }); + + // node probes the first open entry, then the last one, then the rest from the back. Entries in the same order or in the reverse order cost one comparison each, and the counts below are node's. + describe.each([ + ["Set members", (ids: number[], entry: (id: number) => object) => new Set(ids.map(entry))], + ["Map keys", (ids: number[], entry: (id: number) => object) => new Map(ids.map(id => [entry(id), id]))], + [ + "Map values under deep-equal keys", + (ids: number[], entry: (id: number) => object) => new Map(ids.map(id => [{ k: 1 }, entry(id)])), + ], + ])("%s", (_, make) => { + test.each([ + ["in the same order", (ids: number[]) => ids, (n: number) => 2 * n], + ["in the reverse order", (ids: number[]) => ids.toReversed(), (n: number) => 2 * n + 2], + ["rotated by one", (ids: number[]) => [...ids.slice(1), ids[0]], (n: number) => (n * n) / 2 + 2 * n], + ])("%s", (_, permute, expectedReads) => { + for (const n of [4, 64]) { + let reads = 0; + const entry = (id: number) => ({ + get id() { + reads++; + return id; + }, + }); + const ids = Array.from({ length: n }, (_, i) => i); + const a = make(ids, entry); + const b = make(permute(ids), entry); + reads = 0; + expect(util.isDeepStrictEqual(a, b)).toBe(true); + expect(reads).toBe(expectedReads(n)); + } + }); + }); + + // An entry that can have no partner ends the comparison before any other entry is compared. + describe("reads no entry after one that can have no partner", () => { + let reads = 0; + const counted = (id: number) => ({ + get id() { + reads++; + return id; + }, + }); + + test("a number that only the right Set holds", () => { + const left = set(counted(1), counted(2), counted(3)); + const right = set(0, counted(1), counted(2)); + reads = 0; + expect(util.isDeepStrictEqual(left, right)).toBe(false); + expect(reads).toBe(0); + }); + + test("a string key that only the right Map holds", () => { + const left = map([counted(1), 1], [counted(2), 2], [counted(3), 3]); + const right = map(["p", 0], [counted(1), 1], [counted(2), 2]); + reads = 0; + expect(util.isDeepStrictEqual(left, right)).toBe(false); + expect(reads).toBe(0); + }); + + // The left entry under the deep-equal key holds another value, so the first right entry has no partner. + test("a Map key that both sides hold with different values, beside a deep-equal key", () => { + const later = { k: 1 }; + const left = map([shared, 1], [{ a: 1 }, 3], [later, counted(1)]); + const right = map([shared, 2], [{ a: 1 }, 3], [later, counted(1)]); + reads = 0; + expect(util.isDeepStrictEqual(left, right)).toBe(false); + expect(reads).toBe(0); + }); + + // Another left entry holds the right value, but under a key that is not equal. + test("a Map key that both sides hold with different numbers, beside an entry with the other number", () => { + const later = { k: 1 }; + const left = map([shared, 1], [{ b: 1 }, 2], [later, counted(1)]); + const right = map([shared, 2], [{ b: 1 }, 2], [later, counted(1)]); + reads = 0; + expect(util.isDeepStrictEqual(left, right)).toBe(false); + expect(reads).toBe(0); + }); + + // Another left entry has an equal key, but its value is not equal to the right value. + test("a Map key that both sides hold with different objects, beside a deep-equal key", () => { + const later = { k: 1 }; + const left = map([shared, { x: 1 }], [{ a: 1 }, { x: 3 }], [later, counted(1)]); + const right = map([shared, { x: 2 }], [{ a: 1 }, { x: 3 }], [later, counted(1)]); + reads = 0; + expect(util.isDeepStrictEqual(left, right)).toBe(false); + expect(reads).toBe(0); + }); + + // The other entries hold identical values on both sides, as in a Map and its changed copy. They are no partners, so their keys are not compared. + test("a Map and a copy with one other value", () => { + const keys = [{ k: 0 }, counted(1), counted(2)]; + const values = [{ x: 0 }, { x: 1 }, { x: 2 }]; + const left = map([keys[0], values[0]], [keys[1], values[1]], [keys[2], values[2]]); + const right = new Map(left).set(keys[0], { x: 9 }); + reads = 0; + expect(util.isDeepStrictEqual(left, right)).toBe(false); + expect(util.isDeepStrictEqual(right, left)).toBe(false); + expect(reads).toBe(0); + }); + + // No other left entry holds the right value, so no key is compared. + test("a Map key that both sides hold with different numbers", () => { + const left = map([shared, 1], [counted(1), 3], [counted(2), 4]); + const right = map([shared, 2], [counted(1), 3], [counted(2), 4]); + reads = 0; + expect(util.isDeepStrictEqual(left, right)).toBe(false); + expect(reads).toBe(0); + }); + + // Only the two values under the first key are compared: no other left key equals that key. + test("a Map key that both sides hold with different values", () => { + const keys = [{ k: 0 }, { k: 1 }, { k: 2 }]; + const left = map([keys[0], counted(1)], [keys[1], counted(2)], [keys[2], counted(3)]); + const right = map([keys[0], counted(9)], [keys[1], counted(2)], [keys[2], counted(3)]); + reads = 0; + expect(util.isDeepStrictEqual(left, right)).toBe(false); + expect(reads).toBe(2); + }); + }); + + test.each([ + ["plain", (collection: Set | Map) => collection], + ["with an own property", withExtraProperty], + ])("reads a value nested in %s Sets and Maps once per side", (_, decorate) => { + let reads = 0; + const make = () => { + let value: unknown = { + get leaf() { + reads++; + return 1; + }, + }; + for (let depth = 0; depth < 4; depth++) value = decorate(new Map([[decorate(new Set([value])), "as key"]])); + for (let depth = 0; depth < 4; depth++) value = decorate(new Map([["as value", decorate(new Set([value]))]])); + return value; + }; + expect(util.isDeepStrictEqual(make(), make())).toBe(true); + expect(reads).toBe(2); + }); + + // The answer is "a one-to-one pairing of the entries exists". Sets and Maps from a fixed seed check that against a search over every pairing. + test.each(["Set", "Map"] as const)("random %ss get the answer of a search over every pairing", kind => { + let state = kind === "Set" ? 0x5e7 : 0x3a9; + const random = () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const held = [{ a: 1 }, { a: 2 }, [1], new Set([{ a: 1 }])]; + const pool: Thunk[] = [ + () => 1, + () => "a", + () => null, + () => undefined, + () => ({ a: 1 }), + () => ({ a: 2 }), + () => ({ a: 1, b: undefined }), + () => ({}), + () => [1], + () => [], + () => new Date(0), + () => new Number(1), + () => new Set([{ a: 1 }]), + () => new Set([{ a: 1 }, { a: 2 }]), + () => new Set([{ a: 2 }, { a: 1 }]), + () => new Map([[{ a: 1 }, 1]]), + ]; + const pick = () => + random() < 0.3 ? held[Math.floor(random() * held.length)] : pool[Math.floor(random() * pool.length)](); + const entries = (size: number) => { + const made = new Map(); + for (let tries = 0; made.size < size && tries < 40; tries++) + made.set(pick(), kind === "Map" ? pick() : undefined); + return [...made]; + }; + const shuffled = (list: T[]) => { + for (let i = list.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [list[i], list[j]] = [list[j], list[i]]; + } + return list; + }; + const build = (list: [unknown, unknown][]) => + kind === "Map" ? new Map(list) : new Set(list.map(([key]) => key)); + const same = (a: unknown, b: unknown) => Object.is(a, b) || util.isDeepStrictEqual(a, b); + // Whether every left entry can take a right entry of its own (augmenting paths). + const pairingExists = (left: [unknown, unknown][], right: [unknown, unknown][]) => { + if (left.length !== right.length) return false; + const equal = left.map(([key, value]) => + right.map(([rightKey, rightValue]) => same(key, rightKey) && same(value, rightValue)), + ); + const takenBy = new Array(right.length).fill(-1); + const take = (i: number, seen: boolean[]): boolean => { + for (let j = 0; j < right.length; j++) { + if (seen[j] || !equal[i][j]) continue; + seen[j] = true; + if (takenBy[j] < 0 || take(takenBy[j], seen)) { + takenBy[j] = i; + return true; + } + } + return false; + }; + return left.every((_, i) => take(i, new Array(right.length).fill(false))); + }; + + const wrong: string[] = []; + for (let round = 0; round < 120; round++) { + const left = entries(1 + Math.floor(random() * 5)); + // Half of the rounds compare against a copy in another order, so that equal pairs are common. + const right = random() < 0.5 ? entries(left.length) : shuffled(left.map(entry => structuredClone(entry))); + for (const [a, b] of [ + [left, right], + [right, left], + ]) { + const expected = pairingExists(a, b); + if (util.isDeepStrictEqual(build(a), build(b)) !== expected) + wrong.push(`round ${round}: expected ${expected}`); + } + } + expect(wrong).toEqual([]); + }); + + // The first left entry has a getter. It runs in the first comparison of the pairing and changes one of the two collections. + // Each row holds node v26.3.0's answer and then bun's. Where node throws a TypeError, it reads past the end of its list of right entries. There bun answers whether the right collection holds every left entry that has no partner yet. + describe("a getter that changes a collection during the pairing", () => { + type Collection = Set & Map; + type Mutation = (left: Collection, right: Collection) => void; + const last = (collection: Collection) => [...collection.keys()].at(-1); + const setScenarios: [string, Mutation, boolean | "TypeError", boolean][] = [ + // The right members are reachable only through the copy that the pairing made. + ["the right Set is cleared", (left, right) => (right.clear(), Bun.gc(true)), true, true], + ["the right Set gains an object", (left, right) => right.add({ id: 9 }), true, true], + ["the right Set gains a number", (left, right) => right.add(9), true, true], + ["the right Set loses its last member", (left, right) => right.delete(last(right)), true, true], + ["the right Set gains a member of the left Set", (left, right) => right.add(last(left)), true, true], + ["the left Set is cleared", (left, right) => left.clear(), true, true], + ["the left Set loses its last member", (left, right) => left.delete(last(left)), true, true], + ["the left Set gains an object", (left, right) => left.add({ id: 9 }), "TypeError", false], + ["the left Set gains a number", (left, right) => left.add(9), "TypeError", false], + ["the left Set gains a copy of a right member", (left, right) => left.add({ id: 1 }), "TypeError", false], + ["the left Set gains a member of the right Set", (left, right) => left.add(last(right)), "TypeError", true], + ]; + const mapScenarios: [string, Mutation, boolean | "TypeError", boolean][] = [ + // node reads a right value when it compares the entry, so a cleared Map answers undefined. + ["the right Map is cleared", (left, right) => (right.clear(), Bun.gc(true)), false, false], + ["the right Map gains an object key", (left, right) => right.set({ id: 9 }, 9), true, true], + ["the right Map gains a string key", (left, right) => right.set("p", 9), true, true], + ["the right Map loses its last key", (left, right) => right.delete(last(right)), false, false], + ["the right Map changes the value of its last key", (left, right) => right.set(last(right), 99), false, false], + ["the right Map gains a key of the left Map", (left, right) => right.set(last(left), 1), true, true], + ["the left Map is cleared", (left, right) => left.clear(), true, true], + ["the left Map loses its last key", (left, right) => left.delete(last(left)), true, true], + ["the left Map changes the value of its last key", (left, right) => left.set(last(left), 99), false, false], + ["the left Map gains an object key", (left, right) => left.set({ id: 9 }, 9), "TypeError", false], + ["the left Map gains a string key", (left, right) => left.set("p", 9), "TypeError", false], + ["the left Map gains a key of the right Map", (left, right) => left.set(last(right), 1), "TypeError", true], + ]; + function compareWith(kind: "Set" | "Map", mutate: Mutation) { + let mutated = false; + const left = (kind === "Set" ? new Set() : new Map()) as Collection; + const right = (kind === "Set" ? new Set() : new Map()) as Collection; + const add = (collection: Collection, member: object, id: number) => + kind === "Set" ? collection.add(member) : collection.set(member, id); + add( + left, + { + get id() { + if (!mutated) { + mutated = true; + mutate(left, right); + } + return 0; + }, + }, + 0, + ); + for (const id of [1, 2]) add(left, { id }, id); + for (const id of [0, 1, 2]) add(right, { id }, id); + return util.isDeepStrictEqual(left, right); + } + test.each(setScenarios)("%s", (_, mutate, _node, bun) => { + expect(compareWith("Set", mutate)).toBe(bun); + }); + test.each(mapScenarios)("%s", (_, mutate, _node, bun) => { + expect(compareWith("Map", mutate)).toBe(bun); + }); + + // node reads the right value with `get()` when it compares the entry, so the value of a removed key is undefined. + test("a right key that the getter removes has the value undefined", () => { + let removed = false; + const right = map([{ id: 0 }, 0], [{ id: 1 }, 1], [{ id: 2 }, undefined]) as Collection; + const left = map( + [ + { + get id() { + if (!removed) { + removed = true; + right.delete(last(right)); + } + return 0; + }, + }, + 0, + ], + [{ id: 1 }, 1], + [{ id: 2 }, undefined], + ); + expect(util.isDeepStrictEqual(left, right)).toBe(true); + }); + + // After the clear only the copy that the pairing made holds the right members, and 40 members do not fit in its inline storage. + test("the members of a cleared right Set stay alive", () => { + const right = new Set(); + let cleared = false; + const left = new Set([ + { + get id() { + if (!cleared) { + cleared = true; + right.clear(); + Bun.gc(true); + } + return 0; + }, + }, + ]); + for (let id = 1; id < 40; id++) left.add({ id }); + for (let id = 0; id < 40; id++) right.add({ id }); + expect(util.isDeepStrictEqual(left, right)).toBe(true); + }); + }); + }); + test("reads an array's own symbol-keyed getter once per side", () => { const s = Symbol("k"); let calls = 0;