From 0b4f0f8e5df22d0c195b7b7a4a20b896abf7e4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 13:41:22 +0200 Subject: [PATCH 1/2] fix(object): preserve own-key lookup beyond 65536 fields --- benchmarks/wide_object_membership/README.md | 129 +++++++ .../wide_object_membership/baseline.json | 323 ++++++++++++++++++ benchmarks/wide_object_membership/fixed.json | 323 ++++++++++++++++++ benchmarks/wide_object_membership/measure.py | 121 +++++++ .../wide_object_membership/object-assign.ts | 106 ++++++ .../object-has-own-property.ts | 106 ++++++ .../wide_object_membership/object-in.ts | 106 ++++++ changelog.d/10059-wide-object-membership.md | 10 + crates/perry-runtime/src/object/mod.rs | 2 + .../src/object/object_ops/keys_array.rs | 62 ++-- .../object/wide_object_membership_tests.rs | 71 ++++ ...est_gap_wide_object_membership_boundary.ts | 69 ++++ 12 files changed, 1385 insertions(+), 43 deletions(-) create mode 100644 benchmarks/wide_object_membership/README.md create mode 100644 benchmarks/wide_object_membership/baseline.json create mode 100644 benchmarks/wide_object_membership/fixed.json create mode 100644 benchmarks/wide_object_membership/measure.py create mode 100644 benchmarks/wide_object_membership/object-assign.ts create mode 100644 benchmarks/wide_object_membership/object-has-own-property.ts create mode 100644 benchmarks/wide_object_membership/object-in.ts create mode 100644 changelog.d/10059-wide-object-membership.md create mode 100644 crates/perry-runtime/src/object/wide_object_membership_tests.rs create mode 100644 test-files/test_gap_wide_object_membership_boundary.ts diff --git a/benchmarks/wide_object_membership/README.md b/benchmarks/wide_object_membership/README.md new file mode 100644 index 0000000000..63080efbef --- /dev/null +++ b/benchmarks/wide_object_membership/README.md @@ -0,0 +1,129 @@ +# Wide-object membership and `Object.assign` (#10059) + +Current main at `603b074ace01464bc66fc07cc8d532f26ccf5a0f` reproduces both reported +problems. The three TypeScript workloads in this directory are byte-identical +to the issue attachments. The baseline and fixed sweeps use the same source +bytes, Node v26.5.1, sequential size order, and matched Perry compiler/runtime +archives. + +## Change + +`own_key_present` now delegates to the shared keys-array lookup. A complete +shape index can prove both presence and absence, so a missing destination key +in `Object.assign` does not fall through to a scan of every preceding key. +When the index is missing, shortened, or otherwise incomplete, lookup retains +the dense-slot fallback. The keys-array length and slot indexes are already +`u32`, so removing the unrelated 65,536 guard does not widen their +representation. + +`own_key_present_via_index` also consumes the index's explicit `Found`, +`Absent`, and `Unindexed` verdicts. This preserves the exact fallback for an +untrusted miss rather than silently treating an index that declined to answer +as authoritative. + +## Method + +- Apple M1 Max, 10 logical cores, macOS 26.5, arm64; Node v26.5.1; Perry + 0.5.1532. +- Compiler and both matching static archives built together with + `CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release --locked -j 4 + -p perry -p perry-runtime-static -p perry-stdlib-static`. +- Each source compiled with `--no-auto-optimize --no-cache` and an explicit + matching `PERRY_RUNTIME_DIR`. +- Sizes run in their issue order. Each process keeps the original warmup (at + least 200 ms and five runs), seven samples of at least 20 ms, and a 60-second + timeout. +- The host was busy and load varied materially. Exact constant-factor ratios + are descriptive; the checksum repair and slope classification are the + useful results. + +Baseline load average started/ended at `[11.49, 25.14, 30.11]` / `[24.25, +25.97, 30.06]`; fixed at `[23.43, 25.68, 29.88]` / `[43.52, 32.26, 31.92]`. + +## Membership correctness + +Times are median milliseconds. A `no` checksum match is the reported cutoff, +so that timing is not evidence of correct performance. + +| workload | n | Node before | Perry before | checksum | Node after | Perry after | checksum | +|---|---:|---:|---:|:---:|---:|---:|:---:| +| `in` | 100 | 0.0038 | 0.0411 | yes | 0.0134 | 0.0680 | yes | +| `in` | 1,000 | 0.0462 | 0.3969 | yes | 0.0937 | 0.5921 | yes | +| `in` | 10,000 | 0.6241 | 4.1646 | yes | 1.6149 | 8.1271 | yes | +| `in` | 100,000 | 8.2211 | 59.4115 | **no** | 22.6291 | 127.2595 | yes | +| `in` | 1,000,000 | 145.1689 | 653.3018 | **no** | 254.7871 | 2,756.4532 | yes | +| `hasOwnProperty` | 100 | 0.0069 | 0.0890 | yes | 0.0143 | 0.1259 | yes | +| `hasOwnProperty` | 1,000 | 0.0780 | 2.5566 | yes | 0.2123 | 1.5102 | yes | +| `hasOwnProperty` | 10,000 | 0.9632 | 207.5987 | yes | 4.2234 | 15.4140 | yes | +| `hasOwnProperty` | 100,000 | 14.2578 | 47.8556 | **no** | 39.2262 | 184.3443 | yes | +| `hasOwnProperty` | 1,000,000 | 442.9680 | 876.0386 | **no** | 546.1051 | 3,357.0082 | yes | + +After the fix, all membership checksums match Node through one million keys. +The fitted Perry-minus-Node exponent delta is `0.061` for `in` and `-0.049` +for `hasOwnProperty`, consistent with linear work in the number of probes. + +The focused boundary fixture separately confirms Node-equivalent results on a +65,537-key object for present values, present `undefined`, missing and inherited +keys, short and Unicode names, deletion/reinsertion, and direct reads. On the +base build, every own membership result becomes false while direct reads remain +correct. + +## `Object.assign` + +| n | Node before | Perry before | ratio | Node after | Perry after | ratio | +|---:|---:|---:|---:|---:|---:|---:| +| 100 | 0.0298 | 0.1454 | 4.89× | 0.1692 | 0.1790 | 1.06× | +| 1,000 | 0.3838 | 6.4520 | 16.81× | 0.8589 | 2.9133 | 3.39× | +| 10,000 | 9.1615 | 528.0727 | 57.64× | 16.3402 | 30.1276 | 1.84× | + +The Perry-minus-Node fitted exponent delta changes from **0.536 to 0.121**, +below the issue's 0.25 target. All copied-value checksums match Node. + +The compiled boundary fixture also crosses the former cutoff before exercising +strict-set behavior. It matches Node byte-for-byte for non-writable data +properties, getter-only and setter accessors, source getter/setter order, +symbols, and rejection of new keys on a non-extensible target. The base build +fails the non-writable-property case because the false membership answer makes +the precheck treat it as a new property. + +## Artifacts and reproduction + +The pristine base artifact hashes are: + +```text +perry 63cb83cf87044443184687ac5eee3376a21dd46c7a1cbf3074789926f99d7b36 +libperry_runtime.a 3c0467d1d7ebcd8bd53e98c43e027ff7e93b306024f68b9ba453f383fb8a6fb4 +libperry_stdlib.a a13fd68f3b01f814c7fcd031f57b34e978bc00612317f3c20bef68928f7067af +``` + +The fixed artifact hashes are recorded in `fixed.json`; its `artifact_source` +contains the exact runtime diff SHA-256 used for the build. `baseline.json` and +`fixed.json` retain all medians, run counts, checksums, source hashes, ratios, +slopes, host metadata, and load averages. + +Run a sweep with: + +```sh +python3 benchmarks/wide_object_membership/measure.py fixed \ + --compiler target/release/perry \ + --runtime-dir target/release \ + --artifact-source "$(git rev-parse HEAD)" \ + --require-checksum-match \ + --output target/wide-object-membership/fixed +``` + +## Validation + +- Runtime unit suite: 3,525 passed, 4 ignored, zero failures, single-threaded. + This includes the new exact-boundary test and existing object membership, + descriptor, enumeration, tombstone, shape-index, and moving-GC coverage. +- The new 65,537-key compiled fixture matches Node byte-for-byte. A broader + object-named parity sweep completed 19/19 fixtures with no mismatches, + compilation failures, or crashes before an unrelated ext-wrapper fixture + requested its own fresh feature build; the sweep was stopped there to keep + the already-validated compiler and archives coherent. The focused fixture + and all runtime tests completed independently. +- Test-registration and GC runtime-root-holder audits pass. +- `pre-tag-check.sh --quick` passes every check except the pre-existing public + benchmark evidence freshness gate. Its registered public source/harness + inputs are unchanged by this branch, as are all version and release metadata. diff --git a/benchmarks/wide_object_membership/baseline.json b/benchmarks/wide_object_membership/baseline.json new file mode 100644 index 0000000000..72cb490b5d --- /dev/null +++ b/benchmarks/wide_object_membership/baseline.json @@ -0,0 +1,323 @@ +{ + "label": "baseline", + "artifact_source": "603b074ace01464bc66fc07cc8d532f26ccf5a0f", + "generated_at": "2026-09-11T11:28:00Z", + "worktree_head": "603b074ace01464bc66fc07cc8d532f26ccf5a0f", + "node": "v26.5.1", + "host": { + "platform": "macOS-26.5-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpu_count": 10 + }, + "load_start": [ + 11.49169921875, + 25.14453125, + 30.111328125 + ], + "artifacts": { + "perry": "63cb83cf87044443184687ac5eee3376a21dd46c7a1cbf3074789926f99d7b36", + "libperry_runtime.a": "3c0467d1d7ebcd8bd53e98c43e027ff7e93b306024f68b9ba453f383fb8a6fb4", + "libperry_stdlib.a": "a13fd68f3b01f814c7fcd031f57b34e978bc00612317f3c20bef68928f7067af" + }, + "workloads": { + "object-in": { + "source_sha256": "15975126dc0469dcf3db0e9ff9f9eee48c93a302a5bc728af27153bb153a9f01", + "rows": [ + { + "n": 100, + "node": { + "name": "object-in", + "category": "objects", + "n": 100, + "ms_per_run": 0.0037840107832007113, + "runs": 35185, + "checksum": 90862968 + }, + "perry": { + "name": "object-in", + "category": "objects", + "n": 100, + "ms_per_run": 0.041103535934291134, + "runs": 3406, + "checksum": 90862968 + }, + "checksum_match": true, + "ratio": 10.86242568778402 + }, + { + "n": 1000, + "node": { + "name": "object-in", + "category": "objects", + "n": 1000, + "ms_per_run": 0.046248069284063116, + "runs": 3008, + "checksum": 235809596 + }, + "perry": { + "name": "object-in", + "category": "objects", + "n": 1000, + "ms_per_run": 0.39687419607843494, + "runs": 358, + "checksum": 235809596 + }, + "checksum_match": true, + "ratio": 8.581421932249095 + }, + { + "n": 10000, + "node": { + "name": "object-in", + "category": "objects", + "n": 10000, + "ms_per_run": 0.6240618787878789, + "runs": 226, + "checksum": 321931775 + }, + "perry": { + "name": "object-in", + "category": "objects", + "n": 10000, + "ms_per_run": 4.164574799999997, + "runs": 35, + "checksum": 321931775 + }, + "checksum_match": true, + "ratio": 6.673336317367901 + }, + { + "n": 100000, + "node": { + "name": "object-in", + "category": "objects", + "n": 100000, + "ms_per_run": 8.22111133333332, + "runs": 21, + "checksum": 385753825 + }, + "perry": { + "name": "object-in", + "category": "objects", + "n": 100000, + "ms_per_run": 59.41145800000004, + "runs": 7, + "checksum": 0 + }, + "checksum_match": false, + "ratio": 7.226694249852854 + }, + { + "n": 1000000, + "node": { + "name": "object-in", + "category": "objects", + "n": 1000000, + "ms_per_run": 145.16887499999984, + "runs": 7, + "checksum": 605963915 + }, + "perry": { + "name": "object-in", + "category": "objects", + "n": 1000000, + "ms_per_run": 653.3018329999995, + "runs": 7, + "checksum": 0 + }, + "checksum_match": false, + "ratio": 4.500288598365182 + } + ], + "node_slope": 1.1417679214449763, + "perry_slope": 1.0577686733528793 + }, + "object-has-own-property": { + "source_sha256": "25576272e339be30f08fe8f6ae5bd4ceedfe211f9e1584fcaf5bced2bfd61c0a", + "rows": [ + { + "n": 100, + "node": { + "name": "object-has-own-property", + "category": "objects", + "n": 100, + "ms_per_run": 0.006857583476174529, + "runs": 20468, + "checksum": 90862968 + }, + "perry": { + "name": "object-has-own-property", + "category": "objects", + "n": 100, + "ms_per_run": 0.0889683155555555, + "runs": 1574, + "checksum": 90862968 + }, + "checksum_match": true, + "ratio": 12.97371236743385 + }, + { + "n": 1000, + "node": { + "name": "object-has-own-property", + "category": "objects", + "n": 1000, + "ms_per_run": 0.0779792529182892, + "runs": 1798, + "checksum": 235809596 + }, + "perry": { + "name": "object-has-own-property", + "category": "objects", + "n": 1000, + "ms_per_run": 2.5565884999999824, + "runs": 56, + "checksum": 235809596 + }, + "checksum_match": true, + "ratio": 32.7854961970322 + }, + { + "n": 10000, + "node": { + "name": "object-has-own-property", + "category": "objects", + "n": 10000, + "ms_per_run": 0.9632142380952405, + "runs": 151, + "checksum": 321931775 + }, + "perry": { + "name": "object-has-own-property", + "category": "objects", + "n": 10000, + "ms_per_run": 207.5986670000002, + "runs": 7, + "checksum": 321931775 + }, + "checksum_match": true, + "ratio": 215.52699159693412 + }, + { + "n": 100000, + "node": { + "name": "object-has-own-property", + "category": "objects", + "n": 100000, + "ms_per_run": 14.257791500000025, + "runs": 14, + "checksum": 385753825 + }, + "perry": { + "name": "object-has-own-property", + "category": "objects", + "n": 100000, + "ms_per_run": 47.855624999999975, + "runs": 7, + "checksum": 0 + }, + "checksum_match": false, + "ratio": 3.3564542587117994 + }, + { + "n": 1000000, + "node": { + "name": "object-has-own-property", + "category": "objects", + "n": 1000000, + "ms_per_run": 442.9679579999997, + "runs": 7, + "checksum": 605963915 + }, + "perry": { + "name": "object-has-own-property", + "category": "objects", + "n": 1000000, + "ms_per_run": 876.038625000001, + "runs": 7, + "checksum": 0 + }, + "checksum_match": false, + "ratio": 1.9776568692582537 + } + ], + "node_slope": 1.1882475609082985, + "perry_slope": 0.92588479349811 + }, + "object-assign": { + "source_sha256": "23fcd0f39d287f29065a88813d4fa45bb55739bb1fa0079f2763122598619f36", + "rows": [ + { + "n": 100, + "node": { + "name": "object-assign", + "category": "objects", + "n": 100, + "ms_per_run": 0.02977077232142847, + "runs": 4961, + "checksum": 534569475 + }, + "perry": { + "name": "object-assign", + "category": "objects", + "n": 100, + "ms_per_run": 0.1454393478260865, + "runs": 914, + "checksum": 534569475 + }, + "checksum_match": true, + "ratio": 4.885306509881904 + }, + { + "n": 1000, + "node": { + "name": "object-assign", + "category": "objects", + "n": 1000, + "ms_per_run": 0.38377240845070576, + "runs": 427, + "checksum": 965032923 + }, + "perry": { + "name": "object-assign", + "category": "objects", + "n": 1000, + "ms_per_run": 6.452010500000014, + "runs": 24, + "checksum": 965032923 + }, + "checksum_match": true, + "ratio": 16.812074964031066 + }, + { + "n": 10000, + "node": { + "name": "object-assign", + "category": "objects", + "n": 10000, + "ms_per_run": 9.161458333333334, + "runs": 24, + "checksum": 663245961 + }, + "perry": { + "name": "object-assign", + "category": "objects", + "n": 10000, + "ms_per_run": 528.0726670000004, + "runs": 7, + "checksum": 663245961 + }, + "checksum_match": true, + "ratio": 57.640677694144436 + } + ], + "node_slope": 1.2440872546880593, + "perry_slope": 1.7800058852936336 + } + }, + "load_end": [ + 24.25048828125, + 25.9658203125, + 30.05615234375 + ] +} diff --git a/benchmarks/wide_object_membership/fixed.json b/benchmarks/wide_object_membership/fixed.json new file mode 100644 index 0000000000..6ab1dc257c --- /dev/null +++ b/benchmarks/wide_object_membership/fixed.json @@ -0,0 +1,323 @@ +{ + "label": "fixed", + "artifact_source": "603b074ace01464bc66fc07cc8d532f26ccf5a0f + crates-diff-sha256:4b9140796c743681751312f7f212dcf6b065cc9085f96e7ffecac7dc9fbde91e", + "generated_at": "2026-09-11T11:29:13Z", + "worktree_head": "603b074ace01464bc66fc07cc8d532f26ccf5a0f", + "node": "v26.5.1", + "host": { + "platform": "macOS-26.5-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpu_count": 10 + }, + "load_start": [ + 23.4267578125, + 25.67822265625, + 29.88037109375 + ], + "artifacts": { + "perry": "c91fb68f492cc37f16ef2ed58abd94445b1d14608b52d4eb1f46c2fa19e4b39b", + "libperry_runtime.a": "9161c75117de53b743fe9ddf814ebcef22af42ffe0bffae858a6718cbe3b610a", + "libperry_stdlib.a": "5dd1d0d36c914822552c48c94a508998138f71cbc7b975bf7696d145c52494c3" + }, + "workloads": { + "object-in": { + "source_sha256": "15975126dc0469dcf3db0e9ff9f9eee48c93a302a5bc728af27153bb153a9f01", + "rows": [ + { + "n": 100, + "node": { + "name": "object-in", + "category": "objects", + "n": 100, + "ms_per_run": 0.013442297043010944, + "runs": 14749, + "checksum": 90862968 + }, + "perry": { + "name": "object-in", + "category": "objects", + "n": 100, + "ms_per_run": 0.06802321621621792, + "runs": 2382, + "checksum": 90862968 + }, + "checksum_match": true, + "ratio": 5.060386331187737 + }, + { + "n": 1000, + "node": { + "name": "object-in", + "category": "objects", + "n": 1000, + "ms_per_run": 0.09372053333333288, + "runs": 1805, + "checksum": 235809596 + }, + "perry": { + "name": "object-in", + "category": "objects", + "n": 1000, + "ms_per_run": 0.5921323529411746, + "runs": 259, + "checksum": 235809596 + }, + "checksum_match": true, + "ratio": 6.318064269173075 + }, + { + "n": 10000, + "node": { + "name": "object-in", + "category": "objects", + "n": 10000, + "ms_per_run": 1.6149036875000071, + "runs": 109, + "checksum": 321931775 + }, + "perry": { + "name": "object-in", + "category": "objects", + "n": 10000, + "ms_per_run": 8.127096999999972, + "runs": 23, + "checksum": 321931775 + }, + "checksum_match": true, + "ratio": 5.03255832710453 + }, + { + "n": 100000, + "node": { + "name": "object-in", + "category": "objects", + "n": 100000, + "ms_per_run": 22.629083500000007, + "runs": 12, + "checksum": 385753825 + }, + "perry": { + "name": "object-in", + "category": "objects", + "n": 100000, + "ms_per_run": 127.25950000000012, + "runs": 7, + "checksum": 385753825 + }, + "checksum_match": true, + "ratio": 5.623714279016208 + }, + { + "n": 1000000, + "node": { + "name": "object-in", + "category": "objects", + "n": 1000000, + "ms_per_run": 254.78708400000005, + "runs": 7, + "checksum": 605963915 + }, + "perry": { + "name": "object-in", + "category": "objects", + "n": 1000000, + "ms_per_run": 2756.4531659999993, + "runs": 7, + "checksum": 605963915 + }, + "checksum_match": true, + "ratio": 10.818653452621636 + } + ], + "node_slope": 1.0938240054287405, + "perry_slope": 1.1547658333636655 + }, + "object-has-own-property": { + "source_sha256": "25576272e339be30f08fe8f6ae5bd4ceedfe211f9e1584fcaf5bced2bfd61c0a", + "rows": [ + { + "n": 100, + "node": { + "name": "object-has-own-property", + "category": "objects", + "n": 100, + "ms_per_run": 0.0142698116975747, + "runs": 8366, + "checksum": 90862968 + }, + "perry": { + "name": "object-has-own-property", + "category": "objects", + "n": 100, + "ms_per_run": 0.12586871875000033, + "runs": 1270, + "checksum": 90862968 + }, + "checksum_match": true, + "ratio": 8.820629270909931 + }, + { + "n": 1000, + "node": { + "name": "object-has-own-property", + "category": "objects", + "n": 1000, + "ms_per_run": 0.21232860919540295, + "runs": 825, + "checksum": 235809596 + }, + "perry": { + "name": "object-has-own-property", + "category": "objects", + "n": 1000, + "ms_per_run": 1.5101685238095115, + "runs": 136, + "checksum": 235809596 + }, + "checksum_match": true, + "ratio": 7.112411886142603 + }, + { + "n": 10000, + "node": { + "name": "object-has-own-property", + "category": "objects", + "n": 10000, + "ms_per_run": 4.223365666666666, + "runs": 54, + "checksum": 321931775 + }, + "perry": { + "name": "object-has-own-property", + "category": "objects", + "n": 10000, + "ms_per_run": 15.413979499999982, + "runs": 16, + "checksum": 321931775 + }, + "checksum_match": true, + "ratio": 3.649690961324128 + }, + { + "n": 100000, + "node": { + "name": "object-has-own-property", + "category": "objects", + "n": 100000, + "ms_per_run": 39.22624999999999, + "runs": 10, + "checksum": 385753825 + }, + "perry": { + "name": "object-has-own-property", + "category": "objects", + "n": 100000, + "ms_per_run": 184.344333, + "runs": 7, + "checksum": 385753825 + }, + "checksum_match": true, + "ratio": 4.699514559765464 + }, + { + "n": 1000000, + "node": { + "name": "object-has-own-property", + "category": "objects", + "n": 1000000, + "ms_per_run": 546.1050829999995, + "runs": 7, + "checksum": 605963915 + }, + "perry": { + "name": "object-has-own-property", + "category": "objects", + "n": 1000000, + "ms_per_run": 3357.008167, + "runs": 7, + "checksum": 605963915 + }, + "checksum_match": true, + "ratio": 6.147183521088016 + } + ], + "node_slope": 1.1432284230037122, + "perry_slope": 1.0938673550006066 + }, + "object-assign": { + "source_sha256": "23fcd0f39d287f29065a88813d4fa45bb55739bb1fa0079f2763122598619f36", + "rows": [ + { + "n": 100, + "node": { + "name": "object-assign", + "category": "objects", + "n": 100, + "ms_per_run": 0.16917911805555777, + "runs": 2321, + "checksum": 534569475 + }, + "perry": { + "name": "object-assign", + "category": "objects", + "n": 100, + "ms_per_run": 0.17902900892856927, + "runs": 838, + "checksum": 534569475 + }, + "checksum_match": true, + "ratio": 1.0582216705360576 + }, + { + "n": 1000, + "node": { + "name": "object-assign", + "category": "objects", + "n": 1000, + "ms_per_run": 0.8589218749999953, + "runs": 242, + "checksum": 965032923 + }, + "perry": { + "name": "object-assign", + "category": "objects", + "n": 1000, + "ms_per_run": 2.9133103333333312, + "runs": 68, + "checksum": 965032923 + }, + "checksum_match": true, + "ratio": 3.391822257796551 + }, + { + "n": 10000, + "node": { + "name": "object-assign", + "category": "objects", + "n": 10000, + "ms_per_run": 16.340208333333333, + "runs": 24, + "checksum": 663245961 + }, + "perry": { + "name": "object-assign", + "category": "objects", + "n": 10000, + "ms_per_run": 30.127583500000014, + "runs": 9, + "checksum": 663245961 + }, + "checksum_match": true, + "ratio": 1.8437698519755725 + } + ], + "node_slope": 0.992455416377104, + "perry_slope": 1.1130204456468804 + } + }, + "load_end": [ + 43.51611328125, + 32.25537109375, + 31.9189453125 + ] +} diff --git a/benchmarks/wide_object_membership/measure.py b/benchmarks/wide_object_membership/measure.py new file mode 100644 index 0000000000..d275270c47 --- /dev/null +++ b/benchmarks/wide_object_membership/measure.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Compile and run #10059's Node/Perry comparison workloads.""" + +import argparse +import hashlib +import json +import math +import os +from pathlib import Path +import platform +import subprocess +import time + + +WORKLOADS = { + "object-in": [100, 1_000, 10_000, 100_000, 1_000_000], + "object-has-own-property": [100, 1_000, 10_000, 100_000, 1_000_000], + "object-assign": [100, 1_000, 10_000], +} + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def slope(rows: list[dict]) -> float | None: + if len(rows) < 2: + return None + xs = [math.log(row["n"]) for row in rows] + ys = [math.log(row["ms_per_run"]) for row in rows] + mx, my = sum(xs) / len(xs), sum(ys) / len(ys) + return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sum( + (x - mx) ** 2 for x in xs + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("label") + parser.add_argument("--compiler", type=Path, required=True) + parser.add_argument("--runtime-dir", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--artifact-source", required=True) + parser.add_argument("--require-checksum-match", action="store_true") + args = parser.parse_args() + + here = Path(__file__).resolve().parent + args.output.mkdir(parents=True, exist_ok=True) + compiler = args.compiler.resolve() + runtime = args.runtime_dir.resolve() + env = os.environ | { + "PERRY_RUNTIME_DIR": str(runtime), + "PERRY_NO_AUTO_OPTIMIZE": "1", + "TZ": "UTC", + "LC_ALL": "en_US.UTF-8", + } + result = { + "label": args.label, + "artifact_source": args.artifact_source, + "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "worktree_head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "node": subprocess.check_output(["node", "--version"], text=True).strip(), + "host": {"platform": platform.platform(), "machine": platform.machine(), "cpu_count": os.cpu_count()}, + "load_start": os.getloadavg(), + "artifacts": { + name: sha256(runtime / name) + for name in ["perry", "libperry_runtime.a", "libperry_stdlib.a"] + }, + "workloads": {}, + } + + for name, sizes in WORKLOADS.items(): + source = here / f"{name}.ts" + binary = args.output / name + compile_run = subprocess.run( + [str(compiler), "compile", str(source), "--no-auto-optimize", "--no-cache", "-o", str(binary)], + env=env, + capture_output=True, + text=True, + timeout=180, + ) + (args.output / f"{name}.compile.log").write_text(compile_run.stdout + compile_run.stderr) + compile_run.check_returncode() + rows = [] + for n in sizes: + pair = {} + for engine, command in [ + ("node", ["node", "--experimental-strip-types", str(source)]), + ("perry", [str(binary)]), + ]: + run = subprocess.run( + command + [str(n)], env=env, capture_output=True, text=True, timeout=60 + ) + if run.returncode: + raise RuntimeError(f"{name} {engine} n={n}: {run.stderr}{run.stdout}") + pair[engine] = json.loads(run.stdout) + checksum_match = pair["node"]["checksum"] == pair["perry"]["checksum"] + if args.require_checksum_match and not checksum_match: + raise RuntimeError(f"checksum mismatch: {name} n={n}: {pair}") + rows.append({ + "n": n, + "node": pair["node"], + "perry": pair["perry"], + "checksum_match": checksum_match, + "ratio": pair["perry"]["ms_per_run"] / pair["node"]["ms_per_run"], + }) + print(name, n, "ratio", rows[-1]["ratio"], "checksum_match", checksum_match, flush=True) + result["workloads"][name] = { + "source_sha256": sha256(source), + "rows": rows, + "node_slope": slope([row["node"] for row in rows]), + "perry_slope": slope([row["perry"] for row in rows]), + } + args.output.joinpath("results.json").write_text(json.dumps(result, indent=2) + "\n") + + result["load_end"] = os.getloadavg() + args.output.joinpath("results.json").write_text(json.dumps(result, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/wide_object_membership/object-assign.ts b/benchmarks/wide_object_membership/object-assign.ts new file mode 100644 index 0000000000..41e26481ef --- /dev/null +++ b/benchmarks/wide_object_membership/object-assign.ts @@ -0,0 +1,106 @@ +// @runtime {"name": "object-assign", "category": "objects", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/object/alloc.rs", "function": "js_object_assign_one"}, {"file": "crates/perry-runtime/src/object/alloc.rs", "function": "object_assign_throw_if_set_rejected"}, {"file": "crates/perry-runtime/src/object/object_ops/keys_array.rs", "function": "own_key_present"}], "hypothesis": "Each Object.assign insertion checks whether its new key already exists; own_key_present indexes hits but lets misses fall through to a full scan of the growing destination, causing quadratic key comparisons.", "notes": "n is object width. Copying is the measured builtin workload; the source is created in setup. The empty destination allocation is part of Object.assign({}, source)-style use. Cap 10k keys limits wide-object setup.", "asynchronous": false, "output_stderr": false, "fresh_input": false, "sizes": [100, 1000, 10000]} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +type Row = { [key: string]: number }; +type Input = { o: Row, keys: string[], misses: string[], values: number[] }; +function setup(n: number): Input { + const o: Row = {}; + const keys: string[] = []; + const misses: string[] = []; + const values = numbers(n); + for (let i = 0; i < n; i++) { + const key = "field_" + i; + keys.push(key); + misses.push("missing_" + key); + o[key] = values[i]; + } + return { o, keys, misses, values }; +} + +function run(input: Input): number { + const target: Row = {}; + Object.assign(target, input.o); + let h = 0; + for (let i = 0; i < input.keys.length; i++) h = (h * 31 + target[input.keys[i]]) % 1000000007; + return h; +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + const preparedInput = setup(n); + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + for (let i = 1; i < samples.length; i++) { + const v = samples[i]; + let j = i - 1; + while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } + samples[j + 1] = v; + } + console.log(JSON.stringify({name: "object-assign", category: "objects", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/wide_object_membership/object-has-own-property.ts b/benchmarks/wide_object_membership/object-has-own-property.ts new file mode 100644 index 0000000000..c42b155747 --- /dev/null +++ b/benchmarks/wide_object_membership/object-has-own-property.ts @@ -0,0 +1,106 @@ +// @runtime {"name": "object-has-own-property", "category": "objects", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/object/object_ops/has_own.rs", "function": "js_object_has_own"}, {"file": "crates/perry-runtime/src/object/object_ops/keys_array.rs", "function": "own_key_present"}, {"file": "crates/perry-runtime/src/object/object_ops/keys_array.rs", "function": "own_key_present_via_index"}], "hypothesis": "own_key_present returns false before lookup when the object has more than 65,536 keys, matching the all-false result at large widths.", "notes": "n distinct present keys and n absent keys; setup builds all property names outside timing.", "asynchronous": false, "output_stderr": false, "fresh_input": false} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +type Row = { [key: string]: number }; +type Input = { o: Row, keys: string[], misses: string[], values: number[] }; +function setup(n: number): Input { + const o: Row = {}; + const keys: string[] = []; + const misses: string[] = []; + const values = numbers(n); + for (let i = 0; i < n; i++) { + const key = "field_" + i; + keys.push(key); + misses.push("missing_" + key); + o[key] = values[i]; + } + return { o, keys, misses, values }; +} + +function run(input: Input): number { + let h = 0; + for (let i = 0; i < input.keys.length; i++) { + h = (h * 31 + (input.o.hasOwnProperty(input.keys[i]) ? 3 : 0) + (input.o.hasOwnProperty(input.misses[i]) ? 7 : 0)) % 1000000007; + } + return h; +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + const preparedInput = setup(n); + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + for (let i = 1; i < samples.length; i++) { + const v = samples[i]; + let j = i - 1; + while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } + samples[j + 1] = v; + } + console.log(JSON.stringify({name: "object-has-own-property", category: "objects", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/wide_object_membership/object-in.ts b/benchmarks/wide_object_membership/object-in.ts new file mode 100644 index 0000000000..986abdded6 --- /dev/null +++ b/benchmarks/wide_object_membership/object-in.ts @@ -0,0 +1,106 @@ +// @runtime {"name": "object-in", "category": "objects", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/object/field_get_set/has_property.rs", "function": "js_object_has_property"}, {"file": "crates/perry-runtime/src/object/object_ops/keys_array.rs", "function": "own_key_present"}, {"file": "crates/perry-runtime/src/object/object_ops/keys_array.rs", "function": "own_key_present_via_index"}], "hypothesis": "The ordinary membership path and its indexed helper reject objects above 65,536 own keys, so present-key probes can all return false beyond that source-defined cutoff.", "notes": "n distinct present keys and n absent keys; setup builds all property names outside timing.", "asynchronous": false, "output_stderr": false, "fresh_input": false} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +type Row = { [key: string]: number }; +type Input = { o: Row, keys: string[], misses: string[], values: number[] }; +function setup(n: number): Input { + const o: Row = {}; + const keys: string[] = []; + const misses: string[] = []; + const values = numbers(n); + for (let i = 0; i < n; i++) { + const key = "field_" + i; + keys.push(key); + misses.push("missing_" + key); + o[key] = values[i]; + } + return { o, keys, misses, values }; +} + +function run(input: Input): number { + let h = 0; + for (let i = 0; i < input.keys.length; i++) { + h = (h * 31 + (input.keys[i] in input.o ? 3 : 0) + (input.misses[i] in input.o ? 7 : 0)) % 1000000007; + } + return h; +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + const preparedInput = setup(n); + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + for (let i = 1; i < samples.length; i++) { + const v = samples[i]; + let j = i - 1; + while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } + samples[j + 1] = v; + } + console.log(JSON.stringify({name: "object-in", category: "objects", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/changelog.d/10059-wide-object-membership.md b/changelog.d/10059-wide-object-membership.md new file mode 100644 index 0000000000..e1c609b4bf --- /dev/null +++ b/changelog.d/10059-wide-object-membership.md @@ -0,0 +1,10 @@ +Own-property membership no longer stops recognizing keys when an object grows +past 65,536 properties. The shared keys-array lookup now uses its complete +shape index to prove both hits and misses, while retaining a dense-slot fallback +when the index is missing, incomplete, or stale. + +This also removes the growing destination scan from ordinary missing-key checks +inside `Object.assign`: after the small-object threshold, each new key is +rejected or appended using the maintained index instead of comparing it with +every key already copied. Strict-set behavior for descriptors, accessors, +symbols, and non-extensible targets is unchanged. diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 40182500ff..cbb0c76f17 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1825,6 +1825,8 @@ mod tests; mod tombstone_tests; #[cfg(test)] mod transition_ic_tests; +#[cfg(test)] +mod wide_object_membership_tests; /// The named-property bag for a cell that has no inline slot layout of its own, /// creating it on first write. diff --git a/crates/perry-runtime/src/object/object_ops/keys_array.rs b/crates/perry-runtime/src/object/object_ops/keys_array.rs index 286b78aa65..24cd7d729d 100644 --- a/crates/perry-runtime/src/object/object_ops/keys_array.rs +++ b/crates/perry-runtime/src/object/object_ops/keys_array.rs @@ -356,15 +356,24 @@ pub(crate) unsafe fn own_key_present_via_index( Some(h) if h.obj_type == crate::gc::GC_TYPE_ARRAY => {} _ => return None, } - let key_count = crate::array::js_array_length(keys) as usize; - if key_count < super::super::KEYS_INDEX_THRESHOLD as usize || key_count > 65536 { + let key_count = crate::array::js_array_length(keys); + if key_count < super::super::KEYS_INDEX_THRESHOLD { return None; } let name_ptr = (key as *const u8).add(std::mem::size_of::()); let name_len = (*key).byte_len as usize; let name_bytes = std::slice::from_raw_parts(name_ptr, name_len); let key_hash = super::super::key_bytes_hash(name_ptr, name_len); - Some(super::super::keys_index_lookup(obj, keys, name_bytes, key_hash).is_some()) + match super::super::shapes::shape_slot_lookup_verdict( + keys, name_bytes, key_hash, key_count, true, + ) { + super::super::shapes::KeysIndexVerdict::Found(_) => Some(true), + super::super::shapes::KeysIndexVerdict::Absent => Some(false), + // A shortened or otherwise incomplete index cannot prove absence. + // Preserve the caller's exact fallback instead of turning a stale miss + // into a false negative. + super::super::shapes::KeysIndexVerdict::Unindexed => None, + } } /// Helper: does `key` appear in `obj.keys_array`? @@ -420,44 +429,11 @@ pub(crate) unsafe fn own_key_present( if (*keys_gc).obj_type != crate::gc::GC_TYPE_ARRAY { return false; } - let key_count = crate::array::js_array_length(keys) as usize; - if key_count > 65536 { - return false; - } - // #5736: a wide object — e.g. a barrel `export *` namespace with thousands - // of re-exported bindings — made this an O(n) keys_array scan, so callers - // that re-check every own key in a loop (`Object.values` / `Object.entries`, - // which call `own_key_present` per key) ran O(n²). Probe the shared - // wide-object key→slot index first: a hit is O(1). A miss falls through to - // the linear scan below, so an absent key — or a present key whose index - // entry was dropped as stale — is still answered correctly. The index is an - // accelerator, never authoritative (it revalidates every hit against the - // live slot via `js_string_key_matches`), and is the same map the read-path - // getter maintains for these objects. - if key_count >= super::super::field_get_set::WIDE_KEY_INDEX_MIN_KEYS { - let key_bytes_ptr = (key as *const u8).add(std::mem::size_of::()); - let key_len = (*key).byte_len as usize; - let key_bytes = std::slice::from_raw_parts(key_bytes_ptr, key_len); - if super::super::field_get_set::wide_key_index_lookup( - keys as usize, - key_bytes, - key, - keys, - key_count, - ) - .is_some() - { - return true; - } - } - let (slots, slot_len) = super::super::keys_array_dense_slots(keys); - for i in 0..key_count.min(slot_len) { - let stored = JSValue::from_bits((*slots.add(i)).to_bits()); - // #1781: SSO-aware match — `hasOwnProperty("id")` previously - // returned false when "id" lived as an inline SSO key. - if crate::string::js_string_key_matches(stored, key) { - return true; - } - } - false + let key_count = crate::array::js_array_length(keys); + // The shared helper distinguishes a complete index miss from an index that + // cannot answer. Complete misses are authoritative, so Object.assign's + // growing destination does not scan every preceding key before appending; + // stale/incomplete indexes retain the dense-slot correctness fallback. + // Slots and counts are u32 throughout, so there is no 65,536-key ceiling. + super::super::keys_find_slot_by_key_ptr(keys, key_count, key).is_some() } diff --git a/crates/perry-runtime/src/object/wide_object_membership_tests.rs b/crates/perry-runtime/src/object/wide_object_membership_tests.rs new file mode 100644 index 0000000000..d3be6230eb --- /dev/null +++ b/crates/perry-runtime/src/object/wide_object_membership_tests.rs @@ -0,0 +1,71 @@ +//! Regression coverage for #10059's 65,536-own-key cutoff. + +use super::*; + +fn key(name: &str) -> *mut crate::StringHeader { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) +} + +unsafe fn assert_membership(obj: *mut ObjectHeader, name: &str, expected: bool) { + let probe = key(name); + assert_eq!( + own_key_present(obj, probe), + expected, + "own_key_present({name:?})" + ); + assert_eq!( + own_key_present_via_index(obj, probe), + Some(expected), + "indexed membership({name:?})" + ); +} + +/// Before #10059, both membership helpers returned false as soon as the +/// object's key count became 65,537. Build through the exact boundary once and +/// assert the last key at each size, a present-undefined key, short and Unicode +/// keys, an absent key, and index invalidation after delete/reinsert. +#[test] +fn own_key_membership_crosses_65536_without_a_cutoff() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let object = scope.root_raw_mut_ptr(js_object_alloc(0, 0)); + + for (name, value) in [("s", 1.0), ("κey", 2.0)] { + let property = key(name); + js_object_set_field_by_name(object.get_raw_mut_ptr(), property, value); + } + + for i in 0..65_535u32 { + let name = format!("field_{i}"); + let property = key(&name); + let value = if i == 42 { + f64::from_bits(crate::value::TAG_UNDEFINED) + } else { + f64::from(i) + }; + js_object_set_field_by_name(object.get_raw_mut_ptr(), property, value); + + let count = i + 3; // two leading keys plus this insertion + if matches!(count, 65_535 | 65_536 | 65_537) { + assert_membership(object.get_raw_mut_ptr(), &name, true); + } + } + + let obj = object.get_raw_mut_ptr(); + for present in ["s", "κey", "field_0", "field_42", "field_65534"] { + assert_membership(obj, present, true); + } + assert_membership(obj, "missing_field", false); + + let victim = key("field_32768"); + assert_eq!(js_object_delete_field(obj, victim), 1); + assert_membership(object.get_raw_mut_ptr(), "field_32768", false); + assert_membership(object.get_raw_mut_ptr(), "field_32769", true); + + let victim = key("field_32768"); + js_object_set_field_by_name(object.get_raw_mut_ptr(), victim, 32768.0); + assert_membership(object.get_raw_mut_ptr(), "field_32768", true); + assert_membership(object.get_raw_mut_ptr(), "missing_field", false); + } +} diff --git a/test-files/test_gap_wide_object_membership_boundary.ts b/test-files/test_gap_wide_object_membership_boundary.ts new file mode 100644 index 0000000000..22851df2cc --- /dev/null +++ b/test-files/test_gap_wide_object_membership_boundary.ts @@ -0,0 +1,69 @@ +const prototype = { inherited: undefined }; +const target: Record = Object.create(prototype); +const events: string[] = []; + +Object.defineProperty(target, "locked", { + value: 1, + writable: false, + enumerable: true, + configurable: true, +}); +Object.defineProperty(target, "getOnly", { + get() { return 2; }, + enumerable: true, + configurable: true, +}); +Object.defineProperty(target, "withSetter", { + set(value) { events.push("set:" + value); }, + enumerable: true, + configurable: true, +}); +target.s = 3; +target["κey"] = 4; + +for (let i = 0; i < 65532; i++) { + target["field_" + i] = i === 42 ? undefined : i; +} + +for (const key of ["locked", "s", "κey", "field_42", "field_65531"]) { + console.log(key, target[key], key in target, target.hasOwnProperty(key)); +} +console.log("missing", target.missing, "missing" in target, target.hasOwnProperty("missing")); +console.log("inherited", target.inherited, "inherited" in target, target.hasOwnProperty("inherited")); + +delete target.field_32768; +console.log("deleted", "field_32768" in target, "field_32769" in target); +target.field_32768 = 32768; +console.log("reinserted", target.field_32768, target.hasOwnProperty("field_32768")); + +try { + Object.assign(target, { locked: 9 }); + console.log("locked no throw"); +} catch (error) { + console.log("locked", error instanceof TypeError, target.locked); +} +try { + Object.assign(target, { getOnly: 9 }); + console.log("getOnly no throw"); +} catch (error) { + console.log("getOnly", error instanceof TypeError, target.getOnly); +} + +const source = Object.defineProperty({}, "withSetter", { + get() { events.push("get"); return 7; }, + enumerable: true, +}); +Object.assign(target, source); +console.log("accessors", events.join(",")); + +const symbol = Symbol("wide"); +Object.assign(target, { [symbol]: 11 }); +console.log("symbol", target[symbol], Object.getOwnPropertySymbols(target).length); + +Object.preventExtensions(target); +try { + Object.assign(target, { newKey: 12 }); + console.log("nonextensible no throw"); +} catch (error) { + console.log("nonextensible", error instanceof TypeError, "newKey" in target); +} From 3919bcf1faafb084d51a66e986235ed3536a00b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 13:42:24 +0200 Subject: [PATCH 2/2] docs(changelog): key wide-object fragment to PR 10070 --- ...-wide-object-membership.md => 10070-wide-object-membership.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10059-wide-object-membership.md => 10070-wide-object-membership.md} (100%) diff --git a/changelog.d/10059-wide-object-membership.md b/changelog.d/10070-wide-object-membership.md similarity index 100% rename from changelog.d/10059-wide-object-membership.md rename to changelog.d/10070-wide-object-membership.md