Repository navigation
Conversation
The file imported bun:test, so nobody could run its expectations on Node. It now uses node:test and node:assert. No test is added, removed or renamed: the same 478 tests pass on Bun and on Node 26.12.1. A row that Bun gets wrong is an expected failure on Bun and an ordinary test on Node.
… strict deep equality util.isDeepStrictEqual and assert.deepStrictEqual walked the keys of the first value, read it with [[Get]], and took the second value's property from its own-property slot. Node does the reverse, so a Proxy's get trap on the second value never ran and the answer depended on the argument order. Bun::NodeObjEquiv::ownPropertiesEqual is node's key loop. It walks the keys of the second value and reads that value with [[Get]]. It reads the first value with [[Get]] while both values list their keys in the same order, and from its own property descriptor after the first difference. String keys come before symbol keys, and the key counts are compared before a value is read. The generic walk and the named keys of arrays and typed arrays use it. Bun::NodeObjEquiv::arrayElementsEqual is node's element loop. A plain array reads an element that is an accessor, reads the length of the first array again for each element, and goes to sparseArrayEquiv at the first hole of the second array. A pair of arrays that holds a Proxy reads the lengths, the other keys and every element through the traps that node calls. Two objects of one structure compare string-keyed values before symbol-keyed values. A function, or a Proxy of one, is equal to nothing but itself. In node mode the compiled-object walk yields to the generic walk when the two objects do not list the same keys in the same order. assert.partialDeepStrictEqual, loose assert.deepEqual, Bun.deepEquals and expect() do not change.
…h holes, named keys and Proxies
|
Status: draft. It waits for #47605, which rewrites the same lines of How I reproduced it, on the released binaries of Bun 1.4.1 to 1.4.4 and on main: import util from "node:util";
const p = new Proxy({ a: 1 }, { get: (t, k, r) => (k === "a" ? 2 : Reflect.get(t, k, r)) });
console.log(util.isDeepStrictEqual({ a: 1 }, p), util.isDeepStrictEqual(p, { a: 1 }));
// Bun: true false. Node 26.12.1: false false.
CI (build 137077): no test that this PR adds or touches failed. The 28 red test files on Linux and Windows are the same 28 that are red on main (build 137093): 27 under |
Draft: waits for #47605, which rewrites the same lines of
BunCompiledObjectEquality.h.Problem
assert.deepStrictEqual({ a: 1 }, p)passes when thegettrap of the Proxypanswers 2 fora. Node throws.util.isDeepStrictEqualanswers[true, false]by argument order.Bun__deepEquals(bindings.cpp:1484on main) reads the first value with[[Get]]and the second from its own-property slot (node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660). Node'sobjEquivdoes the reverse.Fix
NodeObjEquiv::ownPropertiesEqualis Node's key loop:[[Get]]on the second value, and on the first while the key orders agree, then its descriptor.NodeObjEquiv::arrayElementsEqualis Node's element loop, for plain arrays and for arrays behind a Proxy.test/js/node/assert/deep-equal.test.ts(226 of 1,052 tests fail on main). It now runs undernode --test. Also vendoredtest-assert*.jsandexpect.test.js.Background
getandgetOwnPropertyDescriptortraps can answer differently, so the trap that a comparison calls decides the answer.[[Get]]read only when a Proxy answered: two traps per key where Node calls one, and anode:vmglobal stays wrong.Downsides
assert.deepStrictEqual(reactive, literal)when trap and target disagree and the key orders differ. Bun 1.4.1 to 1.4.4 pass it.assert.deepEqual,Bun.deepEquals,expect(),assert.partialDeepStrictEqual, Map and Set entries, an Error's own keys. Notes lists every site.Notes
Commits. The first moves
deep-equal.test.tsfrom bun:test to node:test and node:assert, with no test added, removed or renamed (478 tests, green on main and on Node). The second is the change and its 574 tests. The third adds bench rows.Order of landing.
bun test --aotfollows #44833's mocks #47605 first. It turns the compiled-object walk to creation order and rewritescompareCompiledObjectProperties, where this PR adds 5 lines. After it merges I rebase and put Node's key order (the keys of the second object, strings before symbols) inside that walk, so that a pair of compiled objects with reordered keys no longer falls back to the generic walk.require.cacheis a Proxy in Bun, and itsgetOwnPropertyDescriptortrap reports no value (src/js/builtins/CommonJS.ts:352). Node's loop reads the first value from its descriptor once the key orders differ, soutil.isDeepStrictEqual(require.cache, copyWithReversedKeys, true)istrueon Node and on main andfalsehere. The two rows with the cache as second value go the other way (wrong on main, right here). WithoutskipPrototypeNode answersfalseon all four rows, because the cache has a null prototype, which Bun does not see through the Proxy: main answerstrueon two of them and this PR on three. The trap has to report the module, which is a change of its own. AOT: hangs, wrong answers, failed builds;bun test --aotfollows #44833's mocks #47605 rewrites that trap too, so it goes after it.assert.partialDeepStrictEqualis not in this PR. An earlier draft moved it onto the same reader, and that madeassert.partialDeepStrictEqual(require.cache, { [file]: module })throw. It follows once step 2 is in.No issue reports this. It came out of a differential run against Node. Related only: #9103 reports a Vue reactive array under
expect().toStrictEqual, which is another walk that this PR does not change (#45251 and #32948 are open on it). The repro of #28647 (a Proxy of an array against the array) passes before and after. #33068 (a JS port of Node's algorithm) is an older open attempt at the same answers.Repro (no dependency, the Proxy unwraps a ref the way a Vue reactive object does):
[false, false][false, false][false, false][true, false][false, false]Only (1) is a regression from a release that was right. Up to 1.4.0 Bun rejected every Proxy against a plain object (row 5), so its answers to (2) to (4) were Node's by accident. (2) and (3) are wrong since 1.4.1.
What changes, stated once. For plain objects, class instances, arrays, and Proxies of those, strict deep equality now lists the keys that Node lists and reads the values that Node reads (
gettraps and getters), as often and in the same order, and so gives Node's answer. An answer can still depend on the argument order where Node's does: of 15,563 seeded pairs that hold a Proxy, the two orders differ on 1,858 on Node, on 6,026 on main and on 1,634 here. The checks that run before the keys (constructor, the prototype,Symbol.toStringTag) and the arms for other kinds of object are as on main. The list is below.What Node does (
lib/internal/util/comparisons.js, v26.12.1, the version Bun reports):keyChecktakesObject.keysof the second value, then of the first, compares the counts, then adds the enumerable symbol keys of the first and of the second.objEquivwalks the second list. Whilekeys1[i] === keys2[i]it comparesa[key]withb[key]. After the first difference it takesObject.getOwnPropertyDescriptor(a, key), needs it to be enumerable, and comparesdescriptor.writable !== undefined ? descriptor.value : a[key]withb[key]. Arrays pass nokeys1, so each of their other keys goes the descriptor way. Then, fori < a.length(read again each time):b[i], thensparseArrayEquivfrom the first index thatbdoes not own, elsea[i], and both again to compare them.What the diff does, by read site (
checkPrototypesis the template flag of the two Node instantiations):listOwnKeys, thenownPropertiesEqual. Two ordinary objects list their keys once each. A type with its own listing (a Proxy) is listed as Node lists it, strings of the second, strings of the first, symbols of the first, symbols of the second, so the same traps run in the same order.!isQuickPropertyAccessAllowedForEnumeration()), string-keyed pairs are compared before symbol-keyed pairs. Plain Bun compared them in insertion order and the compiled-object walk compared strings first, so the two printed different things on main. The new AOT program fails on main for this reason.nodeArraysEqual. The other keys are compared first, and only when a structure has a named property, so a plain array no longer builds two empty key lists. ThenarrayElementsEqual, which reads an accessor element with[[Get]](main treats it as a hole), reads the length of the first array again for each element, and goes tosparseArraysEqualat the first hole of the second array. That walk keeps the index keys of an array of plain elements as numbers.proxiedArraysEqual, the same element loop with a trap for every read. It readslengthwith[[Get]](the change of node:assert: compare a proxied array's length through its get trap #41221, which can close when this merges), asks a Proxy for no descriptor of an index, and with no other key readslengthandsizeof the second array once more, askeyCheckdoes.[1, 2]against a Proxy of[3, 4]that answers 0 forsizeis equal on Node, and here.nonIndexOwnPropertiesEqualcalls the reader with nokeys1.internalBinding('util').getOwnNonIndexProperties(UtilInspect.cpp) and the Proxy arm share one lister,Bun::getOwnNonIndexPropertyNames.falsefor anything thattypeofdoes not call an object.BunCompiledObjectEquality.h): in Node mode the walk returns to the generic path unless both objects list the same keys in the same order. See "Order of landing".Measurements. Release builds of main 3f3a9ff and of this diff on 271b65e (one docs commit later).
perfandvalgrindcannot be installed in my container. Instruction counts come from the same counter (instructions:uthroughperf_event_open, inherited by threads, exact on a calibrated loop): 2,000,000util.isDeepStrictEqualcalls per case, median of 5 runs, PR minus main per call. Spread between runs is under 6 per call.JSON.parseoutput and a literalJSON.parseoutput and a literal[1, 2, 3][1, 2, 3]with 1 named key[1, 2, 3]with 8 named keys[[1, 2], [3, 4], [5, 6], [7, 8]][1, , 3, , 5][1, , 3, , 5]with 1 named keyUint8Array(3)Uint8Array(3)with 1 named keyUint8Array(3)with 8 named keysgettrap, per elementownKeyscalls for each Proxy, agettrap twice per element, a second read of the named keys after a hole.[1, 2, 3]call single-stepped in gdb:mi_malloccalls 2 to 0, indirect calls 4 to 2. An 8-key pair of different structures: indirect calls 12 to 4,mi_malloc2 to 2.bench/snippets/assert.mjs, ns per call, one run each (Node 26.12.1 / Bun 1.3.14 / main / PR): parsed and literal 200 / 299 / 451 / 360. Different key order 297 / 209 / 449 / 373. Arrays 118 / 86 / 108 / 73. Arrays with holes and a named key 496 / 107 / 154 / 214. 1e6 elements and one hole 87 ms / 8.8 ms / 8.6 ms / 6.1 ms. 1,000 elements behind agettrap 239 µs / (throws) / 112 µs / 213 µs.nm -S): the two Node instantiations 17,621 to 18,587 and 13,255 to 14,044 bytes. New:proxiedArraysEqual5,411,sparseArraysEqual3,323,nodeArraysEqual1,152 twice, the shared lister 1,278 (the host function that held it shrinks by 307). The five other instantiations ofBun__deepEqualsare 5,242, 5,682, 5,825, 5,030 and 5,985 bytes on both builds..textgrows by 17,728 bytes and the stripped binary by 16,384 (140,474,656 to 140,491,040).Answers against Node.
comparisons.jsv26.12.1 runs unmodified inside Bun behind a shim of its bindings, so the native answer and Node's algorithm can be compared in one process. 200,000 seeded pairs, both orders, strict: main differs on 14,727 of 400,000 cells, this PR on 1,917. Of those, 1,891 hold a Map or a Set, and 26 are two objects of one structure whose nested getter changes a later key of the parent (below). The log of getters andgettraps differs on 18 cells of 200,000 that hold no Map or Set, the same class.assert.partialDeepStrictEqualchanges on 0 of 120,000 cells.BUN_JSC_validateExceptionChecks=1ondeep-equal.test.ts: 1,052 pass, no unchecked site.Answers that change with no Proxy involved, all to Node's:
{ a: 1, b: undefined }and{ get a() { delete this.b; return 1 }, b: 2 }, and the same on the named keys of an array and of aUint8Array. Node lists the keys before it reads a value.{ a: 1, b: 2 }and{ get a() { throw }, [sym]: 2 }:false, because the string-key counts differ. Main throws the getter's error.node:vmcontext over a sandbox that refers to itself, against a plain object that refers to itself.[1, 2, 3]is equal to[1, <getter that answers 2>, 3], and two arrays whose accessor elements differ are not equal. Main answersfalseandtrue.[1, 2, 3]with a getterxthat pushes a fourth element is not equal to[1, 2, 3]withx: 1.[1]withx: {}against[2]with a throwing getterxthrows, because the other keys of an array come before its elements.Read sites in strict mode that this PR leaves as they are. Each has a row or a test in
deep-equal.test.tsthat is an expected failure on Bun, unless noted.specialObjectsDequalSlow). Node walks own keys there too, butObject.keysof a nativeERR_*error is[]on Bun and["code"]on Node, so that walk would stop comparingcode. node errors: make code an own enumerable property on ERR_* errors and warnings #41737 makescodean own property and is open. node:assert, node:util: compare theerrorsof an Error like Node #35495 touches this arm forerrorsand would need to callownPropertiesEqualafter this.getPrototypeOftrap and Bun does not. No test row here.constructorandSymbol.toStringTagare read fewer times than Node reads them.AssertionErrorfor a Proxy operand.Not strict mode, unchanged: loose
assert.deepEqual(#23877, and the known differences indocs/runtime/nodejs-compat.mdx),assert.partialDeepStrictEqual(#47675 and #45244 are open on it),Bun.deepEqualsandexpect()(#9103, #45251, #32948, #42238, #37797). The matrix records what loose mode answers for every new row.Self-review. Two rounds on two versions of the diff: 32 and 42 concerns. Not done, with the reason:
--aotfor such a pair. It waits for AOT: hangs, wrong answers, failed builds;bun test --aotfollows #44833's mocks #47605 (Order of landing, 1).require.cachedescriptor (Order of landing, 2). It was in an earlier draft and was taken out because it is a change to module loading.Bun__deepEquals: at the top it costs every compared object about 13 instructions.Node 26.3.0 and 26.12.1 agree on every test in the file except one: the global of a
node:vmcontext over a Proxy sandbox lists the sandbox keys on 26.12.1 and none on 26.3.0. The test asserts what 26.12.1 does.Suites run. Debug and ASAN build:
test/js/node/assert/(1,158 pass), 34 vendored files (test-assert*,test-util-isDeepStrictEqual,test-util-inspect*,test-repl-tab-complete*,test-whatwg-url-custom-deepequal),test/js/bun/bun-object/deep-equals*.test.ts,test/js/node/util/node-inspect-tests/, and the AOT program built by hand (a debug build is no canary, sorun-parallel.tsskips it). Release build: the same plustest/js/bun/test/expect.test.js(464 pass),test/js/node/vm/vm.test.ts,test/js/node/util/,test/cli/test/test-aot-deep-equality.test.ts, andtest/aot/run-parallel.tson the new program,typed-objects/deep-equality/main.tsandlibrary-modules/assertions.js.node --test test/js/node/assert/deep-equal.test.tson Node 26.12.1: 1,052 pass.test/aot/builtins/native-contract.test.tsneeds the engine's source folder, which my container does not have. It fails the same way on main.[human-review] gate passed · iteration 4 · 9 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 4
evidence per changed file