diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bc032341a3..87507b5254 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -790,6 +790,21 @@ jobs: python3 scripts/global_sink_isolation.py --self-test python3 scripts/global_sink_isolation.py + # #10944's asserted-global ratchet, merge-base half. The gate above + # fails on a NEW bare process-global that a test asserts on; this + # rejects the other way round it -- a diff that adds one and records it + # in the baseline in the same commit. Same shape as the raw-handle-debt + # rule above, and it exists for the same reason: the ratchet measured + # only against its own file can be raised by the PR that needs raising. + - name: asserted-global baseline may not rise vs. the merge base + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null \ + || git fetch --no-tags --depth=1 origin "$BASE_SHA" + python3 scripts/global_sink_isolation.py --asserted-no-raise-vs "$BASE_SHA" + # --------------------------------------------------------------------------- # check: clippy (both scopes) + API-docs drift, in ONE job. Was three jobs # (Clippy x2 matrix, api-docs-drift); merged because each job is a runner diff --git a/CLAUDE.md b/CLAUDE.md index 29565e9e18..73b1ed97e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1635 +**Current Version:** 0.5.1636 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 012e3efde5..23ae07de62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5532,7 +5532,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "base64 0.22.1", @@ -5596,7 +5596,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-dispatch", "serde", @@ -5604,7 +5604,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "cc", "libc", @@ -5613,7 +5613,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "aho-corasick", "anyhow", @@ -5630,7 +5630,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "perry-hir", @@ -5638,7 +5638,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "perry-hir", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "perry-dispatch", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "perry-hir", @@ -5663,7 +5663,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "base64 0.22.1", @@ -5675,7 +5675,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "perry-hir", @@ -5683,7 +5683,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "async-trait", "clap", @@ -5707,14 +5707,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "serde", "serde_json", @@ -5722,7 +5722,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1635" +version = "0.5.1636" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5733,7 +5733,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "clap", @@ -5748,7 +5748,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "block2", "objc2", @@ -5758,7 +5758,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "argon2", "perry-ffi", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "bcrypt", "perry-ffi", @@ -5775,7 +5775,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-ffi", "rusqlite", @@ -5783,7 +5783,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-ffi", "scraper", @@ -5791,7 +5791,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-ffi", "rust_decimal", @@ -5799,7 +5799,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5807,7 +5807,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-ffi", "perry-runtime", @@ -5815,7 +5815,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "bytes", "lazy_static", @@ -5828,7 +5828,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "base64 0.22.1", "bytes", @@ -5860,7 +5860,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "lazy_static", "perry-ffi", @@ -5870,7 +5870,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "bson", "futures-util", @@ -5882,7 +5882,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "bytes", "perry-ffi", @@ -5897,7 +5897,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "lettre", "perry-ffi", @@ -5907,7 +5907,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "notify", "perry-ffi", @@ -5919,7 +5919,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-ffi", "printpdf", @@ -5927,7 +5927,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "fast_image_resize", "image", @@ -5938,7 +5938,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "lazy_static", "perry-ffi", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "perry-ffi", @@ -5967,7 +5967,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-ffi", "perry-runtime", @@ -5976,7 +5976,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "futures-util", "lazy_static", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "brotli", "flate2", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6009,7 +6009,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "perry-api-manifest", @@ -6029,11 +6029,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1635" +version = "0.5.1636" [[package]] name = "perry-parser" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "perry-diagnostics", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perex", "regex", @@ -6054,7 +6054,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "ahash", "base64 0.22.1", @@ -6112,14 +6112,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6202,21 +6202,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "dirs", "perry-ffi", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "base64 0.22.1", "jni", @@ -6241,7 +6241,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "rand 0.10.2", "serde", @@ -6251,7 +6251,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "base64 0.22.1", "block2", @@ -6291,7 +6291,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "base64 0.22.1", "block2", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1635" +version = "0.5.1636" [[package]] name = "perry-ui-test" @@ -6319,11 +6319,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1635" +version = "0.5.1636" [[package]] name = "perry-ui-tvos" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "base64 0.22.1", "block2", @@ -6340,7 +6340,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "base64 0.22.1", "block2", @@ -6357,7 +6357,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "block2", "libc", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "base64 0.22.1", "libc", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "base64 0.22.1", "libc", @@ -6403,7 +6403,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "anyhow", "base64 0.22.1", @@ -6418,7 +6418,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1635" +version = "0.5.1636" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index cbd4182139..bc4527a9d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1635" +version = "0.5.1636" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10868-object-dictionary-mode.md b/changelog.d/10868-object-dictionary-mode.md new file mode 100644 index 0000000000..3540083225 --- /dev/null +++ b/changelog.d/10868-object-dictionary-mode.md @@ -0,0 +1,93 @@ +Add object dictionary mode: a receiver can carry its own ordered key list +instead of interning a layout (#10868 step 2.5, stage 1). Default off. + +**Why it lands before the content key.** Step 2.5 makes shape identity +canonical, so equal layouts intern to one shared record. Today 97.8% of shape +records are retired because a private shape dies with its object; a shared +record cannot be retired by ownership, so a workload producing unboundedly many +distinct key lists — a `Map`-like object built by name, a per-request object +keyed by user input — would accumulate interned shapes for the life of the +process. There is a cost half too: under one canonical keys array per layout an +append can no longer mutate in place, so an object whose key list is unique to +it pays a copy of length *k* per append, i.e. O(k²) over *k* appends. +Dictionary mode bounds both, and it is the only part of that work that touches +none of the content key's files. + +**The representation.** A dictionary-mode receiver's ShapeId describes no keys +at all (`keys = NULL`, `logical_key_count = 0`, the live inline bound frozen at +the latch, a `semantic_generation` from a third namespace), and its real +ordered key list is a private `GC_TYPE_ARRAY` in a new +`ObjectMeta::dictionary_keys`. **Values do not move**: the key at position *i* +still reads inline slot *i* below the live bound and the object-owned spill +buffer at or above it. The mode relocates names, never values, which is what +lets the existing read, write, delete and enumeration code run on a dictionary +object unmodified. + +A shape that claimed a key list the object no longer matched would be a silent +wrong value in every consumer that trusted it; a shape that claims *nothing* is +merely incomplete, so an unbranched consumer produces a missing property, which +a differential test against node catches on its first row. That is why the +shape goes keyless rather than stale. + +**The branch is one function.** `object_keys_array` is the sole runtime +derivation of a receiver's ordered key list, so branching it there gives every +enumeration walk, `in`/`hasOwn`, `delete`, `JSON.stringify`, spread and +`Object.assign` node-identical behaviour with no second implementation of key +order, hole skipping or integer-key ordering. It costs nothing on an ordinary +receiver: a nonzero `keys` word returns before the branch. + +Six fast paths did have to be taught, and five of them are the same defect: +`keys.is_null()` was read as "this receiver has no own properties". On a +dictionary object that is false, and two of the five (`ic_miss`'s inherited-read +primer and the own-field shadowing scan in `native_call_method`) would have +produced a **wrong value**, not a slow one — an own property answered from the +prototype chain, and a vtable method winning over an own field. Each now +declines, which is always correct because the generic path reaches the same +list through `object_keys_array`. + +**Identity.** One ShapeId per dictionary object, drawn once at the latch — +O(1) per object against today's O(k). Appends mint nothing: the array's address +is not a fact of a shape whose `keys` word is NULL, and an append moves no +value, so a cache primed on the receiver stays correct. A republication that is +not an append (a compacting delete, which shifts values) does draw a fresh +generation, which is what invalidates those caches. Two dictionary objects must +never share an id — a compiled IC compares ShapeIds and nothing else — so the +draw comes from a third generation namespace, disjoint by construction from the +`SHAPE_SEMANTIC_NEXT` counter (bit 63 clear, aborts far below 2^62) and from +`deterministic_semantic_generation` (bit 63 set): dictionary draws set bit 62 +and clear bit 63. `dictionary_generation_namespaces_are_disjoint` asserts it. + +**GC.** `dictionary_keys` is a traced, rewritten child edge exactly like +`spill` (#6812): one `visit` in the `GcRewriteDescriptorKind::ObjectMeta` arm of +`visit_gc_rewrite_slot_descriptors`, which is the single enumerator the +non-copying minor mark, the full mark, the copying-nursery evacuation, the +whole-heap rewrite and the dirty-slot rescan all drive — mark, move and +remembered-set coverage from one line. Nothing in the tree enumerates +`ObjectMeta`'s fields (no derive, no registry; `validate_gc_type_info` pairs the +type KINDS, never the slot lists), which is how `expando` came to be missing +from the second, production-unreachable enumerator in `gc/layout.rs` — now +commented rather than left to be discovered again. +`test_object_meta_dictionary_keys_survive_copied_minor_move` is the sabotage +target: remove the `visit` and it reddens. + +**The latch is stubbed off** and can only fire when explicitly armed. The +production trigger belongs to the content key: the condition that matters is +"this object's key list is unique to it", which is not answerable until +identity is content-keyed. Off, the predicate is one relaxed load and a +compare. Armed by `PERRY_OBJECT_DICTIONARY_MIN_KEYS=` (value-parsed, not +presence-parsed — #7991 shipped a knob that `=0` turned on) or by +`test_arm_latch`, and `[object-dictionary] armed=… candidates=… latches=… +publications=… regenerations=…` prints under `PERRY_GC_DIAG` with zeros +included, so `armed=false` ("off"), `armed=true candidates=0` ("armed and never +reached" — the bug shape) and `armed=true candidates>0 latches=0` ("reached and +declined") are three distinguishable states rather than one silent zero. + +`ObjectMeta` moved to `object/meta_record.rs` with its `offset_of!` pins: the +sixteenth word took `object/mod.rs` past the 2,000-line gate, and the record +and the transition cache were the two regions in that file owned by different +lanes. + +A receiver already carrying tombstones is refused, because `hole_count` is a +fact a keyless shape does not carry and latching over one would drop it. +`a_receiver_with_holes_is_refused` states that as a decision rather than an +accident. diff --git a/changelog.d/10925-sab-gc-header.md b/changelog.d/10925-sab-gc-header.md new file mode 100644 index 0000000000..5091242650 --- /dev/null +++ b/changelog.d/10925-sab-gc-header.md @@ -0,0 +1,19 @@ +`SharedArrayBuffer` no longer lets one buffer's contents decide another +buffer's type. A SAB was handed to JavaScript as the address of a block with no +GC header in front of it, and several runtime paths read the eight bytes before +a value as its header. Those bytes are usually the tail of the previous SAB's +data, which a program can write through an ordinary `Uint8Array`. So writing a +byte into one SAB's own memory could make `Array.isArray` answer `true` for a +different SAB, and could make a `Map.prototype.get.call(sab, …)` brand check +follow fabricated pointers and crash. The answers also depended on how the +binary happened to be linked. + +A SAB's backing now carries a real GC header, so every one of those reads gets +the honest kind and takes the ordinary buffer path. `Array.isArray(sab)` is +`false`, a collection method called on a SAB throws the `TypeError` node throws, +and none of it depends on neighbouring memory. + +Sharing is unchanged: the backing is still one process-global, never-freed +allocation, two views over a SAB still alias the same bytes, a worker still sees +writes through a captured or module-level SAB, and `Atomics.wait` / `notify` +still rendezvous across agents on the same physical address. diff --git a/changelog.d/10931-prototype-divergence-generation.md b/changelog.d/10931-prototype-divergence-generation.md new file mode 100644 index 0000000000..b29d0ca0d3 --- /dev/null +++ b/changelog.d/10931-prototype-divergence-generation.md @@ -0,0 +1,23 @@ +A prototype divergence now mints a **deterministic** generation keyed on a +stable prototype serial (#10868 lever iv). + +Two receivers that diverge the same way from the same predecessor previously +minted two generations, and so two ShapeIds, for what is one shape. The +generation is now derived from `(predecessor, prototype serial, link kind)`, so +the same divergence merges and only genuinely different divergences fork. + +The key is the prototype's **identity**, not its state, and not its address. +`ObjectMeta` gains `proto_serial`, assigned once by `mark_object_as_prototype` +and never changed: an address moves under the collector (and keying on one is +an address-keyed derived structure), while a ShapeId is shared by distinct +prototypes — unsound, since §3 needs the receiver's shape to determine its +prototype — and changes whenever the prototype gains a key, so two receivers +diverging to the same prototype before and after that would fork. + +Every deterministic generation sets bit 63 so it can never alias a +counter-allocated one, and a missing predecessor or serial declines to the +always-correct unique-generation path. `prototype_generation_tests` pins both +halves, per §17's rule that a check which cannot fire is not a check: distinct +prototypes must not collapse (across 4096 serials, not just two), and the same +divergence from the same predecessor must merge — otherwise the lever removes +none of the 48,197 mints it exists to remove. diff --git a/changelog.d/10933-integrity-op-ownership-gate.md b/changelog.d/10933-integrity-op-ownership-gate.md new file mode 100644 index 0000000000..9b4c9087b0 --- /dev/null +++ b/changelog.d/10933-integrity-op-ownership-gate.md @@ -0,0 +1,14 @@ +`Object.freeze`, `Object.seal` and `Object.preventExtensions` no longer write +into memory in front of values that have no GC header. They recorded their +flags in the eight bytes preceding the value, which is where a real object +keeps its header — but several values perry hands to JavaScript have no header +there, so the write landed in whatever the allocator had put in front of them. +Freezing one particular value (the placeholder object perry returns for an +unresolved module or an unknown method) wrote into read-only memory and +crashed the process; freezing a `Symbol.for(...)` silently modified a +neighbouring allocation. + +The three operations now check that the value is one the allocator actually +owns before recording anything, instead of checking only that its address is +large enough. A value that fails the check is left alone and returned, which +is what already happened for the handles the old check did catch. diff --git a/changelog.d/10936-region-guard-add-tree.md b/changelog.d/10936-region-guard-add-tree.md new file mode 100644 index 0000000000..26c2168020 --- /dev/null +++ b/changelog.d/10936-region-guard-add-tree.md @@ -0,0 +1,26 @@ +Read regions over a `+` tree are guarded **once** (#10884 step 4b, slice 1). + +A single-entry run of accesses over which one receiver's `(pointer, ShapeId)` +pair is held, entered through one shape compare whose failure leaves for a +generic copy and never rejoins: + +``` +[R1] guard tag test + unmask + ONE ShapeId compare +[R2] load every key's slot, from one atomic region word +[R3] verify every leaf is a primitive Number +[R4] use fold the tree with fadd +``` + +The region does not compute the wrong answer when an operand is unfriendly — it +declines *before* computing one. Hoisting every leaf above the additions is +exactly what #10904 got wrong; it is legal here because R3 proves no addition +can reach `ToPrimitive`, and a failed check discards the loaded values and +lowers the tree afresh in source order in the generic copy. That re-evaluation +is only sound because every admitted leaf is effect-free: a read of the guarded +receiver, a local, or a numeric literal. + +The expected id and every key's slot live in one atomic word, and every refusal +path yields the **empty** word — a wrongly packed slot is a wrong value, while +an empty word is only a missed fast path. `region_guard_pack_tests` pins that, +including that the empty word can never match a live receiver because its low +half is not a ShapeId. diff --git a/changelog.d/10938-parity-normalise-dictionary-counters.md b/changelog.d/10938-parity-normalise-dictionary-counters.md new file mode 100644 index 0000000000..1612695fc5 --- /dev/null +++ b/changelog.d/10938-parity-normalise-dictionary-counters.md @@ -0,0 +1,21 @@ +Taught the parity harness's output normaliser about the `[object-dictionary]` +counter row (#10868 step 2.5 stage 1). + +`gc/schedule.rs` prints that row beside the `[gc-schedule]` lines, so it appears +under exactly the fixtures that declare +`// parity-env: … PERRY_GC_SCHEDULE_SEED=…` — 13 of them today. The normaliser +already strips `^\[gc-schedule\]` for the documented reason that Node prints no +such thing and every such fixture would otherwise diff as an output mismatch, +but the new row carries a different prefix, so the existing rule did not cover +it and those fixtures went red. + +`test_gap_dynamic_import_alias_binding` was the first to surface it, and it +surfaced misleadingly: the harness's truncated view showed +`object object object object function` for *both* Node and Perry, because the +only difference was four lines further down (#796). Reproducing outside the +harness shows the program output is byte-identical and the whole delta is +instrument noise. + +Same reasoning as the `[gc-schedule]` rule, same treatment. A crash under the +instrument is still caught — abnormal exits are detected from the exit status, +before either comparison runs. diff --git a/changelog.d/10947-global-sink-isolation-ratchet.md b/changelog.d/10947-global-sink-isolation-ratchet.md new file mode 100644 index 0000000000..c144c99908 --- /dev/null +++ b/changelog.d/10947-global-sink-isolation-ratchet.md @@ -0,0 +1,24 @@ +Ratcheted bare process-global statics that tests assert on (#10944). + +`scripts/global_sink_isolation.py` gains a second rule, recorded as a baseline +that may only shrink — the same shape as `raw_handle_debt.py` and +`unrooted_local_shape.py`, including their merge-base half. + +The problem is measured, not suspected: + +``` +--test-threads=1 4215 passed; 0 failed +parallel (x6) 4198-4205 passed; 10-17 failed, a DIFFERENT set each run +``` + +Zero of those are genuine failures — every one is a test's assertion disturbed +by another test's increment, always off by exactly one. But the population is +not enumerable by inspection: six parallel runs *after* three modules were +converted still produced 17 distinct names, some of which no earlier run had +shown. Converting them all at once would mean editing modules owned by several +lanes, so today's 62 are recorded and only **additions** fail. Each entry gets +converted by whoever owns the file, and no new instance arrives quietly. + +The existing rule covers tables the GC guards clear; this one covers the much +larger class the module docs already argue for — "a new sink cannot be added +quietly, and a new *reader* never has to remember anything." diff --git a/changelog.d/10948-keys-array-front-offset.md b/changelog.d/10948-keys-array-front-offset.md new file mode 100644 index 0000000000..81873d1797 --- /dev/null +++ b/changelog.d/10948-keys-array-front-offset.md @@ -0,0 +1,20 @@ +Fixed four sites computing a keys array's element base as `header + 8` +(#10939). + +`array_front_offset` is `array_physical_capacity - capacity`, so logical element +zero sits past the header for any ordered keys array whose front has been +consumed — a dense-queue shift, a `GC_ARRAY_NAMED_PROPS` reserve, #9019's +reserved-floor seed, or a size-class round-up on its own. Four sites computed +the base by hand instead of asking `keys_array_dense_slots` / +`array_elements_ptr`: + +* `object_ops/keys_array.rs` — clone-before-mutate for `defineProperty` +* `field_set_by_name/tail.rs` ×2 — clone-before-push on `[[Set]]` growth +* `field_get_set/ic_miss.rs` — the key scan on the miss path (read only) + +The three copy sites are worse than a bad read. Their destination publishes its +prefix as a region the collector walks as heap pointers, so copying from the +wrong base does not merely lose a key — it promises the collector that +`ArrayHeader` and front-reserve words are pointers. Two symptoms, in order: a +missing property now, and a SIGSEGV inside a later collection whose backtrace +names something unrelated (one landed in a `URLSearchParams` shape probe). diff --git a/changelog.d/10949-zero-slot-fixture-named-store-floor.md b/changelog.d/10949-zero-slot-fixture-named-store-floor.md new file mode 100644 index 0000000000..dd4dd571e3 --- /dev/null +++ b/changelog.d/10949-zero-slot-fixture-named-store-floor.md @@ -0,0 +1,21 @@ +Fixed a zero-slot GC test fixture having no room for the named-store floor +(#10941). + +`alloc_{nursery,old}_test_object(0)` allocated exactly an `ObjectHeader` and +left the receiver unstamped, on the reasoning recorded above it that "a +zero-slot fixture needs no descriptor at all — the derived bound is 0 either +way". + +A named-property write does not respect that bound. The inline/overflow +boundary is `max(object_live_slot_count(obj), INLINE_SLOT_FLOOR)` and the floor +is 2, so the first two keys written to a zero-slot fixture store into inline +slots 0 and 1 of an object that has none — and those two words are the next +cell. Every caller before #10938 only ever set a `[[Prototype]]`, so nothing had +written a named property and the hazard was invisible; it presents as a wrong +read now and a SIGSEGV somewhere unrelated later. + +Both fixtures now allocate `max(field_count, INLINE_SLOT_FLOOR)` slots while +**publishing** the bound as `field_count`, so the collector still traces exactly +`field_count` slots and the descriptor-count accounting the original comment +protects is unchanged. `gc::tests::zero_slot_fixture` asserts the allocation for +both fixtures and reddens by name if it regresses. diff --git a/changelog.d/inherited-read-cache-shared-entry-test.md b/changelog.d/inherited-read-cache-shared-entry-test.md new file mode 100644 index 0000000000..7ad9a47f11 --- /dev/null +++ b/changelog.d/inherited-read-cache-shared-entry-test.md @@ -0,0 +1,53 @@ +`a_second_receiver_of_the_same_shape_shares_the_entry` was dormant from the day +it was written, and #10931 woke it up red — intermittently, which is the part +worth reading. + +The inherited-read cache keys an entry on `(class id, ShapeId, key)`, so two +receivers that genuinely have one shape must be served by one entry. The test +for that claim guarded its assertion with `if (*first).parent_class_id == +(*second).parent_class_id`. That word IS the runtime `ShapeId` after shape +stamping (`shapes::object_shape_stamp` reads it), and until #10931 a prototype +divergence drew a fresh generation from the monotonic counter, so the two were +never equal and the body never ran. #10931 makes the same divergence from the +same predecessor to the same prototype mint ONE ShapeId — the test's subject +finally exists — and the assertion then failed. + +Neither the cache nor #10931 is at fault; the fixture was, in two independent +ways, and both are now fixed by construction order. + +**The validity word.** Instrumented, every field of the recorded entry matched +the second receiver exactly: class id `0x0`, ShapeId `0x80002367` on both, +identical recorded prototype bits (`0x7ffd02304e400008`), identical slot index +(231), the same interned key pointer. Only `validity` differed, by one — `6347` +recorded against `6348` live. The test linked `second`'s prototype AFTER +priming `first`, and `Object.setPrototypeOf` is a semantic property event: it +bumps `prop_plan_epoch`, which bumps the single validity word every entry is +re-proved against. The test was retiring the entry it then asked for. + +**The transition cache.** With that fixed the test still failed 2 runs in 6 of +the *same binary*, and the instrumented predecessors say why: the two receivers +did not share a ShapeId because they did not share a keys array +(`keys=0x331a4630158` vs `0x331a46aca78`), so `second` entered the prototype +divergence from a different predecessor (`0x801626e1` vs `0x80161526`) and +correctly got a different successor. A ShapeId's identity includes the keys +array ADDRESS, and two objects share one only when the second's key-add hits +`object::transition_cache_lookup` — a 16384-entry direct-mapped table hashed on +`(predecessor ShapeId, the interned key's address)`. An unrelated entry +colliding in that slot evicts the edge; the second receiver then mints its own +keys array and its own ShapeId. That is legal — a transition-cache miss costs a +duplicate shape, never a wrong answer — but it is address-keyed, so whether it +collides varies with heap placement run to run. **"Two objects built the same +way have one shape" is a best-effort optimization, not a runtime guarantee**, +and a test may only rest on it when nothing can run between the two key-adds. + +Both key-adds now happen back to back, and both prototype links precede the +prime — which is also what every real receiver population looks like +(`several_object_create_receivers_do_not_evict_each_other` already built its +eight receivers up front for the same reason). The second receiver is then +served from the first's entry. + +The `if` is gone. The shape merge is an explicit assertion now, with the class +id beside it, so the test states its own premise and can never go quiet again; +and the hit is counted rather than inferred from the value, per this file's +rule that a fall-through to the chain walk returns the same `7.0` and is +invisible in a program's output. diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 7b9eabb904..3c984f33b5 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -427,6 +427,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let hir = live_cjs_hir.as_ref(); let progress = CompileProgress::new(&hir.name, module_callable_count(hir)); let triple = opts.target.clone().unwrap_or_else(default_target_triple); + // `PERRY_REGION_DIAG=1`: report step 4b's regions and the statement-level + // runs it does not reach, when this module's codegen ends. + let _region_diag = crate::expr::region_read_run::ModuleDiag::start(hir); let fp_flags = crate::block::FpFlags::new(opts.fast_math, opts.fp_contract_mode); // #5334 lever B: decide ONCE, up front, whether this module is large enough diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index fd5d16655d..987b72f46c 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -1271,6 +1271,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let materialization_hazard = add_operands_have_pod_materialization_hazard(ctx, left, right); if !(both_numeric || boolean_numeric_add) || materialization_hazard { + // Step 4b stage 1 (#10884): a run of reads of one receiver + // inside this tree is guarded ONCE, and its leaves are + // verified primitive before any addition — which is what + // licenses the fold #10904 had to decline. + if !materialization_hazard { + if let Some(value) = + super::region_read_run::try_lower_region_add_tree(ctx, expr)? + { + return Ok(value); + } + } if dynamic_add_tree_benefits_shared_guard(expr) && !materialization_hazard { return lower_guarded_numeric_add(ctx, expr); } diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 88c168d226..f6ac50e645 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -2994,6 +2994,7 @@ pub(crate) mod calls; mod child_proc; mod closure; mod compare; +pub(crate) mod region_read_run; pub(crate) use compare::lower_string_literal_strict_eq; #[cfg(test)] mod compare_tests; diff --git a/crates/perry-codegen/src/expr/region_read_run.rs b/crates/perry-codegen/src/expr/region_read_run.rs new file mode 100644 index 0000000000..99dae59c5d --- /dev/null +++ b/crates/perry-codegen/src/expr/region_read_run.rs @@ -0,0 +1,601 @@ +//! Step 4b, stage 1, slice 1 — a read region formed over a `+` tree (#10884). +//! +//! # What a region is +//! +//! A region is defined by the CFG, not by an expression form: a single-entry +//! run of accesses over which one receiver's `(unmasked pointer, ShapeId)` pair +//! is held, entered through ONE shape compare, whose failure leaves for a +//! generic copy of the whole run and never rejoins it (design doc §L7.1–L7.3). +//! +//! This file forms the first slice of that: the runs that already sit inside +//! one `+` tree (`h += o.a + o.b + o.c`), because that is where the leaves are +//! already collected. The same program spelled across statements +//! (`const a = o.a; const b = o.b; h += a + b;`) is the next slice and is NOT +//! formed here — it is measured as a negative control so the gap is known. +//! +//! # The rule (design doc §L7.3), and why it is sound +//! +//! ```text +//! [R1] guard tag test + unmask + ONE ShapeId compare ─┐ the only two +//! [R2] load every key's slot, from one atomic region word │ bail edges, +//! [R3] verify every leaf value is a primitive Number ─┘ before any effect +//! [R4] use fold the tree with `fadd` +//! ``` +//! +//! **The region does not compute the right answer when an operand is +//! unfriendly; it declines before computing one.** Hoisting every leaf above +//! the additions is exactly what #10904 did wrong. It is legal here *because* +//! R3 proves every leaf is a Number, so no addition can reach `ToPrimitive` +//! and therefore no user code can run between a leaf's source position and +//! where it was read. The proof is ordered load → check → use: a failed check +//! discards the loaded values and lowers the tree afresh in the generic copy, +//! in source order, so a wrong hoist is never observed. +//! +//! Discarding and re-evaluating is only legal because every leaf admitted here +//! is effect-free to evaluate: a read of the guarded receiver (the shape proves +//! it is an own data property, so no getter), a local, or a numeric literal. +//! That is precisely what #10921 could not assume for an arbitrary tree. +//! +//! # Supplier +//! +//! The expected ShapeId is learned (supplier (b), §L14.18.4): a per-region +//! atomic word primed on a miss by `js_region_guard_prime`. The id and every +//! key's slot live in ONE word so a concurrent prime can never pair one shape's +//! id with another's slots. A link-time constant (step 4) would replace the +//! word load and nothing else. +//! +//! # The miss side is today's code +//! +//! Every failure edge lands in the generic copy, which is the post-#10921 +//! lowering of the same tree. A mispredicted region therefore costs a few +//! compares on top of what the tree costs without regions — never a cliff +//! (#10503's 18× is what a miss into the by-name ladder costs; this never goes +//! there). Priming is bounded to `PRIME_ATTEMPTS` per region for the life of +//! the process, so a polymorphic or inherited-read site stops paying for it. + +use std::cell::Cell; + +use anyhow::Result; +use perry_hir::{BinaryOp, Expr}; + +use super::{lower_expr, FnCtx}; +use crate::nanbox::POINTER_MASK_I64; +use crate::types::{DOUBLE, I1, I32, I64, PTR}; + +/// Must equal `perry_runtime::object::shapes::REGION_GUARD_MAX_KEYS`. +const MAX_KEYS: usize = 5; +/// Must equal the runtime's slot width. +const SLOT_BITS: u32 = 6; +/// `REGION_GUARD_WORD_EMPTY`: low half `u32::MAX`, never a live ShapeId. +const EMPTY_WORD: &str = "4294967295"; +/// Primes attempted per region before it stops trying (process lifetime). +const PRIME_ATTEMPTS: &str = "8"; +/// A small-handle band sits under the pointer tag; its ids are not addresses. +const SMALL_HANDLE_MAX: &str = "1048575"; + +thread_local! { + /// Non-zero while the generic copy of a region is being lowered. The + /// generic copy lowers the SAME tree through the ordinary dispatch, which + /// would otherwise form the same region again inside itself. + static SUPPRESS: Cell = const { Cell::new(0) }; + static REGIONS_FORMED: Cell = const { Cell::new(0) }; + static READS_COVERED: Cell = const { Cell::new(0) }; +} + +struct Suppressed; +impl Suppressed { + fn enter() -> Self { + SUPPRESS.with(|s| s.set(s.get() + 1)); + Suppressed + } +} +impl Drop for Suppressed { + fn drop(&mut self) { + SUPPRESS.with(|s| s.set(s.get() - 1)); + } +} + +fn disabled() -> bool { + matches!( + std::env::var("PERRY_REGION_READS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) +} + +enum Leaf<'a> { + /// A read of the region's receiver; the index names its key. + Region(usize), + /// A local or a numeric literal — effect-free to evaluate twice. + Other(&'a Expr), +} + +struct Plan<'a> { + receiver: u32, + keys: Vec<&'a str>, + leaves: Vec>, +} + +fn add_leaves<'a>(expr: &'a Expr, out: &mut Vec<&'a Expr>) { + if let Expr::Binary { + op: BinaryOp::Add, + left, + right, + } = expr + { + add_leaves(left, out); + add_leaves(right, out); + } else { + out.push(expr); + } +} + +/// Slice 1's admission: every leaf is effect-free to evaluate, and at least two +/// of them read ONE local receiver by a static key. +fn plan(expr: &Expr) -> Option> { + let mut leaves = Vec::new(); + add_leaves(expr, &mut leaves); + let mut receiver: Option = None; + let mut keys: Vec<&str> = Vec::new(); + let mut region_reads = 0usize; + let mut out = Vec::with_capacity(leaves.len()); + for leaf in leaves { + match leaf { + Expr::PropertyGet { + object, property, .. + } => { + let Expr::LocalGet(id) = object.as_ref() else { + return None; + }; + match receiver { + None => receiver = Some(*id), + Some(r) if r == *id => {} + Some(_) => return None, + } + let key = match keys.iter().position(|k| *k == property.as_str()) { + Some(i) => i, + None => { + keys.push(property.as_str()); + keys.len() - 1 + } + }; + region_reads += 1; + out.push(Leaf::Region(key)); + } + Expr::LocalGet(_) | Expr::Number(_) | Expr::Integer(_) => out.push(Leaf::Other(leaf)), + _ => return None, + } + } + if region_reads < 2 || keys.len() > MAX_KEYS { + return None; + } + Some(Plan { + receiver: receiver?, + keys, + leaves: out, + }) +} + +/// Rebuild the tree's shape with `fadd`, consuming leaf values in leaf order. +fn fold(ctx: &mut FnCtx<'_>, expr: &Expr, values: &[String], next: &mut usize) -> String { + if let Expr::Binary { + op: BinaryOp::Add, + left, + right, + } = expr + { + let l = fold(ctx, left, values, next); + let r = fold(ctx, right, values, next); + return ctx.block().fadd(&l, &r); + } + let v = values[*next].clone(); + *next += 1; + v +} + +/// Lower `expr` as a read region, or return `None` to use the ordinary path. +pub(crate) fn try_lower_region_add_tree( + ctx: &mut FnCtx<'_>, + expr: &Expr, +) -> Result> { + if SUPPRESS.with(|s| s.get()) > 0 || disabled() { + return Ok(None); + } + // Profiling builds record guard pass/fail on the per-access towers; a read + // served before them would change a signal that must stay byte-identical. + if crate::expr::typed_feedback_emission_enabled() { + return Ok(None); + } + let Some(plan) = plan(expr) else { + return Ok(None); + }; + REGIONS_FORMED.with(|c| c.set(c.get() + 1)); + READS_COVERED.with(|c| { + c.set( + c.get() + + plan + .leaves + .iter() + .filter(|l| matches!(l, Leaf::Region(_))) + .count() as u64, + ) + }); + + // Region state: one atomic word (id + slots) and a prime-attempt counter. + let site = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let base = crate::expr::inline_cache_global_name(ctx, site); + let word_g = format!("@{base}_region"); + let tries_g = format!("@{base}_region_tries"); + ctx.typed_parse_rodata.push(format!( + "{word_g} = private global i64 {EMPTY_WORD}, align 8" + )); + ctx.typed_parse_rodata + .push(format!("{tries_g} = private global i32 0, align 4")); + + // The non-receiver leaves first. They are effect-free, and lowering them + // before the receiver means nothing that could allocate runs between the + // receiver's unmask and its slot loads. + let mut other_values: Vec> = Vec::with_capacity(plan.leaves.len()); + let mut other_needs_test: Vec = Vec::with_capacity(plan.leaves.len()); + for leaf in &plan.leaves { + match leaf { + Leaf::Other(e) => { + other_values.push(Some(lower_expr(ctx, e)?)); + other_needs_test.push(!crate::type_analysis::expr_produces_canonical_raw_f64( + ctx, e, + )); + } + Leaf::Region(_) => { + other_values.push(None); + other_needs_test.push(true); + } + } + } + + let handle_idx = ctx.new_block("region.handle"); + let r1_idx = ctx.new_block("region.r1"); + let r2_idx = ctx.new_block("region.r2"); + let fold_idx = ctx.new_block("region.fold"); + let miss_idx = ctx.new_block("region.miss"); + let prime_idx = ctx.new_block("region.prime"); + let generic_idx = ctx.new_block("region.generic"); + let merge_idx = ctx.new_block("region.merge"); + let handle_l = ctx.block_label(handle_idx); + let r1_l = ctx.block_label(r1_idx); + let r2_l = ctx.block_label(r2_idx); + let fold_l = ctx.block_label(fold_idx); + let miss_l = ctx.block_label(miss_idx); + let prime_l = ctx.block_label(prime_idx); + let generic_l = ctx.block_label(generic_idx); + let merge_l = ctx.block_label(merge_idx); + + // R1, part 1: the receiver is a heap object pointer. + let recv = lower_expr(ctx, &Expr::LocalGet(plan.receiver))?; + let bits = ctx.block().bitcast_double_to_i64(&recv); + let top = ctx.block().lshr(I64, &bits, "48"); + let is_ptr = ctx.block().icmp_eq(I64, &top, "32765"); // 0x7FFD, the pointer tag + ctx.block().cond_br(&is_ptr, &handle_l, &generic_l); + + ctx.current_block = handle_idx; + let handle = ctx.block().and(I64, &bits, POINTER_MASK_I64); + let real = ctx.block().icmp_ugt(I64, &handle, SMALL_HANDLE_MAX); + ctx.block().cond_br(&real, &r1_l, &generic_l); + + // R1, part 2: ONE shape compare against the learned region word. By + // #10828's rule 3 only a GC_TYPE_OBJECT carrying that shape can match, so + // this compare is the whole receiver classification. + ctx.current_block = r1_idx; + let word_ptr = word_g.clone(); + let word = ctx.block().load_atomic_monotonic(I64, &word_ptr, 8); + let expected = ctx.block().trunc(I64, &word, I32); + let sid_addr = ctx.block().add(I64, &handle, "4"); + let sid_ptr = ctx.block().inttoptr(I64, &sid_addr); + let sid = ctx.block().load(I32, &sid_ptr); + let hit = ctx.block().icmp_eq(I32, &sid, &expected); + ctx.block().cond_br(&hit, &r2_l, &miss_l); + + // R2 + R3: every key's slot from the same word, then prove every leaf is a + // Number before any addition runs. + ctx.current_block = r2_idx; + let header = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let fields = ctx.block().add(I64, &handle, &header); + let fields_ptr = ctx.block().inttoptr(I64, &fields); + let mut key_values: Vec = Vec::with_capacity(plan.keys.len()); + for i in 0..plan.keys.len() { + let shift = (32 + SLOT_BITS * i as u32).to_string(); + let shifted = ctx.block().lshr(I64, &word, &shift); + let slot = ctx.block().and(I64, &shifted, "63"); + let field_ptr = ctx.block().gep(DOUBLE, &fields_ptr, &[(I64, &slot)]); + key_values.push(ctx.block().load(DOUBLE, &field_ptr)); + } + let mut leaf_values: Vec = Vec::with_capacity(plan.leaves.len()); + for (i, leaf) in plan.leaves.iter().enumerate() { + leaf_values.push(match leaf { + Leaf::Region(k) => key_values[*k].clone(), + Leaf::Other(_) => other_values[i].clone().expect("lowered above"), + }); + } + let mut all_num: Option = None; + for (value, needs) in leaf_values.iter().zip(other_needs_test.iter()) { + if !needs { + continue; + } + let is_num = crate::stmt::emit_js_value_is_number(ctx, value); + all_num = Some(match all_num { + Some(prev) => ctx.block().and(I1, &prev, &is_num), + None => is_num, + }); + } + match all_num { + Some(cond) => ctx.block().cond_br(&cond, &fold_l, &generic_l), + None => ctx.block().br(&fold_l), + } + + // R4: nothing can call user code now, so the tree folds to `fadd`s. + ctx.current_block = fold_idx; + let fast = fold(ctx, expr, &leaf_values, &mut 0); + let fast_end = ctx.block().label.clone(); + ctx.block().br(&merge_l); + + // Miss: prime at most PRIME_ATTEMPTS times for the life of the process. + ctx.current_block = miss_idx; + let tries = ctx.block().load(I32, &tries_g); + let may_prime = ctx.block().icmp_ult(I32, &tries, PRIME_ATTEMPTS); + ctx.block().cond_br(&may_prime, &prime_l, &generic_l); + + ctx.current_block = prime_idx; + let next_tries = ctx.block().add(I32, &tries, "1"); + ctx.block().store(I32, &next_tries, &tries_g); + let mut key_bits: Vec = Vec::with_capacity(MAX_KEYS); + for i in 0..MAX_KEYS { + if let Some(key) = plan.keys.get(i) { + let idx = ctx.strings.intern(key); + let handle_global = format!("@{}", ctx.strings.entry(idx).handle_global); + let boxed = ctx.block().load(DOUBLE, &handle_global); + key_bits.push(ctx.block().bitcast_double_to_i64(&boxed)); + } else { + key_bits.push("0".to_string()); + } + } + let n = plan.keys.len().to_string(); + // The runtime packs AND publishes: a cache word's store belongs to the + // code that owns its memory ordering (`js_region_guard_prime`), the same + // split the property IC uses. Emitting the store here instead cost a real + // program: `store atomic` parses in the textual backend but not in + // perry's native IR construction, which every large module takes, so tsc + // failed codegen in 20 of 50 units while every fixture built. + ctx.block().call( + I64, + "js_region_guard_prime", + &[ + (PTR, &word_ptr), + (I32, &sid), + (I32, &n), + (I64, &key_bits[0]), + (I64, &key_bits[1]), + (I64, &key_bits[2]), + (I64, &key_bits[3]), + (I64, &key_bits[4]), + ], + ); + ctx.block().br(&generic_l); + + // Generic copy: the same tree through the ordinary dispatch, in source + // order — the code this region replaces. + ctx.current_block = generic_idx; + crate::expr::emit_versioned_loop_callback_deopt(ctx); + let slow = { + let _suppressed = Suppressed::enter(); + lower_expr(ctx, expr)? + }; + let slow_end = ctx.block().label.clone(); + ctx.block().br(&merge_l); + + ctx.current_block = merge_idx; + Ok(Some( + ctx.block() + .phi(DOUBLE, &[(&fast, &fast_end), (&slow, &slow_end)]), + )) +} + +/// `PERRY_REGION_DIAG=1`: per module, how many regions slice 1 formed, how many +/// reads they cover, and — the number that sizes slice 2 on real code — how +/// many runs of two or more consecutive same-receiver reads sit across +/// STATEMENTS where this slice does not reach them. +pub(crate) struct ModuleDiag { + census: Option<(u64, u64)>, + name: String, +} + +impl ModuleDiag { + pub(crate) fn start(hir: &perry_hir::Module) -> Self { + REGIONS_FORMED.with(|c| c.set(0)); + READS_COVERED.with(|c| c.set(0)); + let on = std::env::var("PERRY_REGION_DIAG").ok().as_deref() == Some("1"); + ModuleDiag { + census: on.then(|| statement_run_census(hir)), + name: hir.name.clone(), + } + } +} + +impl Drop for ModuleDiag { + fn drop(&mut self) { + if let Some((runs, reads)) = self.census { + eprintln!( + "[perry region] module={} regions={} reads_covered={} statement_runs_uncovered={} statement_reads_uncovered={}", + self.name, + REGIONS_FORMED.with(|c| c.get()), + READS_COVERED.with(|c| c.get()), + runs, + reads + ); + } + } +} + +fn statement_run_census(hir: &perry_hir::Module) -> (u64, u64) { + let mut acc = (0u64, 0u64); + census_stmts(&hir.init, &mut acc); + for f in &hir.functions { + census_stmts(&f.body, &mut acc); + } + for c in &hir.classes { + for m in c + .methods + .iter() + .chain(c.static_methods.iter()) + .chain(c.constructor.iter()) + { + census_stmts(&m.body, &mut acc); + } + } + acc +} + +/// A statement-level read: `let x = .`. +fn let_read_receiver(stmt: &perry_hir::Stmt) -> Option { + let perry_hir::Stmt::Let { + init: Some(Expr::PropertyGet { object, .. }), + .. + } = stmt + else { + return None; + }; + match object.as_ref() { + Expr::LocalGet(id) => Some(*id), + _ => None, + } +} + +fn census_stmts(stmts: &[perry_hir::Stmt], acc: &mut (u64, u64)) { + use perry_hir::Stmt; + let mut run_receiver: Option = None; + let mut run_len = 0u64; + let flush = |len: u64, acc: &mut (u64, u64)| { + if len >= 2 { + acc.0 += 1; + acc.1 += len; + } + }; + for stmt in stmts { + match let_read_receiver(stmt) { + Some(r) if run_receiver == Some(r) => run_len += 1, + Some(r) => { + flush(run_len, acc); + run_receiver = Some(r); + run_len = 1; + } + None => { + flush(run_len, acc); + run_receiver = None; + run_len = 0; + } + } + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + census_stmts(then_branch, acc); + if let Some(eb) = else_branch { + census_stmts(eb, acc); + } + } + Stmt::For { body, .. } | Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + census_stmts(body, acc) + } + Stmt::Try { + body, + catch, + finally, + } => { + census_stmts(body, acc); + if let Some(c) = catch { + census_stmts(&c.body, acc); + } + if let Some(f) = finally { + census_stmts(f, acc); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + census_stmts(&case.body, acc); + } + } + _ => {} + } + } + flush(run_len, acc); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(recv: u32, key: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(recv)), + property: key.to_string(), + byte_offset: 0, + } + } + fn add(l: Expr, r: Expr) -> Expr { + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(l), + right: Box::new(r), + } + } + + #[test] + fn two_reads_of_one_receiver_form_a_region() { + let e = add(Expr::LocalGet(9), add(read(1, "a"), read(1, "b"))); + let p = plan(&e).expect("h + (o.a + o.b) is a slice-1 region"); + assert_eq!(p.receiver, 1); + assert_eq!(p.keys, vec!["a", "b"]); + } + + /// A repeated key is one slot, read twice. + #[test] + fn a_repeated_key_shares_its_slot() { + let e = add(add(read(1, "c"), read(1, "a")), read(1, "c")); + let p = plan(&e).unwrap(); + assert_eq!(p.keys, vec!["c", "a"]); + } + + /// One read is not a run: the ordinary tower serves it unchanged. + #[test] + fn a_single_read_is_not_a_region() { + assert!(plan(&add(Expr::LocalGet(9), read(1, "a"))).is_none()); + } + + /// Two receivers need two guards; slice 1 takes one. + #[test] + fn two_receivers_are_declined() { + assert!(plan(&add(read(1, "a"), read(2, "a"))).is_none()); + } + + /// A call leaf is not effect-free, so re-evaluating it in the generic copy + /// after a failed check could run it twice. Must decline. + #[test] + fn a_leaf_that_is_not_effect_free_is_declined() { + let call = Expr::Call { + callee: Box::new(Expr::LocalGet(7)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }; + assert!(plan(&add(add(read(1, "a"), read(1, "b")), call)).is_none()); + } + + #[test] + fn more_keys_than_one_word_holds_are_declined() { + let mut e = read(1, "k0"); + for k in ["k1", "k2", "k3", "k4", "k5"] { + e = add(e, read(1, k)); + } + assert!(plan(&e).is_none()); + } +} diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 76d435f7da..7736c5eff5 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1181,6 +1181,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // element-shape loop clone's shape-keyed preheader resolves each tracked // property once against the shape the runtime just proved. module.declare_function("js_shape_ordinary_inline_slot_for_key", I32, &[I32, I64]); + // Step 4b stage 1: pack a read region's guard word (ShapeId + each key's + // inline slot) on a region miss. `(shape_id, n, k0..k4) -> word`. + module.declare_function( + "js_region_guard_prime", + I64, + &[PTR, I32, I32, I64, I64, I64, I64, I64], + ); module.declare_function( "js_gc_typed_shape_id_for_keys", I32, diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 816c4c5651..facc2f81c4 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -1834,6 +1834,11 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera .with_meta_slot2(crate::regex::regex_program_slot(user_ptr)) } GcLayoutSlotKind::ObjectMeta => { + // DIVERGENT AND UNREACHABLE (#10868 step 2.5 stage 1): the + // authoritative meta enumerator is layout_slot_visit ObjectMeta + // rewrite arm, which does not delegate here. This iterator has + // four edge sources and the record has six pointer words, so + // expando and dictionary_keys are absent below. // Prototype and the private-evaluation brand are explicit prefix // edges. Keep the brand out of the payload selection: its class // object can be reachable only through this metadata record, so diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index 85ddbe7316..0724a83ee4 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -456,6 +456,15 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( // The Array-subclass elements store (0 = none): a raw-pointer child // edge traced and rewritten exactly like `spill`. visit(fixed_slot(&mut (*meta).elements as *mut u64)); + // #10868 step 2.5 stage 1: a dictionary-mode receiver's private + // ordered key list. Reachable ONLY through this record, so an + // unvisited edge here collects a live object's own property NAMES + // — the same shape as the spill hazard above (#6812). This single + // `visit` is mark, evacuation-rewrite and dirty-slot-rescan + // coverage at once, because this function is the one enumerator + // all three drive; `dictionary_keys_survive_a_moving_collection` + // reddens if it is removed. + visit(fixed_slot(&mut (*meta).dictionary_keys as *mut u64)); // A fresh class object stored as an instance's private evaluation // brand is a NaN-boxed child edge and moves with the meta record. visit(fixed_slot( diff --git a/crates/perry-runtime/src/gc/schedule.rs b/crates/perry-runtime/src/gc/schedule.rs index b645095f03..770a3430bd 100644 --- a/crates/perry-runtime/src/gc/schedule.rs +++ b/crates/perry-runtime/src/gc/schedule.rs @@ -673,6 +673,17 @@ pub(crate) fn report_exit_summary() { super::instruments::moved_objects_total(), super::instruments::loop_polls_reached(), ); + // #10868 step 2.5 stage 1. Printed here rather than from + // `emit_incremental_liveness_diag` because this summary has an + // `libc::atexit` backstop as well as the teardown funnel, and because + // appending to `gc/mod.rs` changes a source whose hash pins + // `census.rs:PASS1_MARKED`'s non-moving-snapshot window. + // + // Always printed, zeros included. `armed=false` is "the mode is off"; + // `armed=true candidates=0` is "armed and NEVER REACHED", the bug shape; + // `armed=true candidates>0 latches=0` is "reached and declined". One + // number could not tell those apart. + eprintln!("{}", crate::object::dictionary::dictionary_counters_line()); } /// Async-signal-safety is irrelevant on the panic path, so this half can format diff --git a/crates/perry-runtime/src/gc/tests/copying/latch.rs b/crates/perry-runtime/src/gc/tests/copying/latch.rs index bafa500250..88b464e752 100644 --- a/crates/perry-runtime/src/gc/tests/copying/latch.rs +++ b/crates/perry-runtime/src/gc/tests/copying/latch.rs @@ -319,6 +319,17 @@ fn pin_object_non_young_call_sites_are_never_young() { Eden/FromSurvivor" ); + // `shared_sab.rs` pins the SAB backing block: a process-global + // `alloc_zeroed` with no `GC_FLAG_ARENA`, so it is malloc space by + // construction and the latch must stay disarmed for it (#10925). + let sab = crate::shared_sab::alloc_shared_sab(64); + let sab_header = (sab as usize - crate::gc::GC_HEADER_SIZE) as *mut GcHeader; + assert!( + !crate::gc::pin::pin_constrains_copying_minor_for_tests(sab_header), + "the SAB backing block is a process-global alloc_zeroed; if it were \ + ever young, pin_object_non_young there would be memory corruption" + ); + // Control: a plain nursery object IS young, so the predicate the two // assertions above rely on is not vacuously false for everything. let young = young_leaf(); diff --git a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs index 7db3e75042..986efec54a 100644 --- a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs +++ b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs @@ -646,6 +646,122 @@ fn test_dom_exception_set_cleared_with_error_side_tables() { /// record, AND the prototype object; the header's meta edge and the /// record's prototype slot must both be rewritten so the moved owner still /// resolves the moved prototype. +/// #10868 step 2.5 stage 1: a dictionary-mode receiver's private ordered key +/// list is a traced, REWRITTEN child edge of its meta record, like `spill`. +/// +/// The pin for the one `visit` in `layout_slot_visit`'s `ObjectMeta` arm — +/// the single enumerator the minor mark, the full mark, the copying-nursery +/// evacuation, the whole-heap rewrite and the dirty-slot rescan all drive. +/// Removing it is SILENT everywhere else: nothing enumerates `ObjectMeta`'s +/// fields (no derive, no registry; `validate_gc_type_info` pairs the type +/// KINDS, never the slot lists), which is how `expando` came to be missing +/// from the second enumerator in `gc/layout.rs`. Sabotage-verified: with the +/// `visit` removed the key list is never evacuated and the "must itself +/// move" assertion below reddens. +#[test] +fn test_object_meta_dictionary_keys_survive_copied_minor_move() { + let _guard = CopyingNurseryTestGuard::new(2); + let _restore = { + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + crate::object::dictionary::test_arm_latch(None); + } + } + Restore + }; + + // EIGHT slots, not zero. `alloc_nursery_test_object(0)` allocates a + // payload of exactly `size_of::()` with no inline slots and + // leaves the header unstamped; every sibling fixture only ever sets a + // PROTOTYPE on it, so nothing has written a named property to one before. + // A named store lands in inline slot 0 or 1 — `alloc_limit` is + // `max(live, INLINE_SLOT_FLOOR)` and the floor is 2 — which on a + // zero-slot allocation is the NEXT CELL. That corrupted the heap and + // SIGSEGV'd a later read, with a backtrace deep inside an unrelated + // URLSearchParams shape probe. + let (owner, _) = unsafe { alloc_nursery_test_object(8) }; + let old_owner = owner as usize; + unsafe { + for i in 0..6 { + let name = format!("gcdict_{i:02}"); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + crate::object::js_object_set_field_by_name(owner, key, i as f64); + } + assert!( + crate::object::dictionary::latch_object_to_dictionary(owner), + "test premise: the receiver must latch" + ); + } + let old_keys = unsafe { crate::object::object_keys_array(owner) } as usize; + assert_ne!(old_keys, 0, "test premise: the private key list exists"); + assert_eq!( + crate::array::js_array_length(old_keys as *mut crate::array::ArrayHeader), + 6, + "test premise: it holds the receiver's six keys" + ); + + // Read every value back BEFORE the collection. Without this the test + // cannot tell "the move lost it" from "the latch never stored it", and + // those need different fixes. + for i in 0..6 { + let name = format!("gcdict_{i:02}"); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let value = f64::from_bits(crate::object::js_object_get_field_by_name(owner, key).bits()); + assert_eq!( + value, i as f64, + "test premise: key {i} reads back after the latch" + ); + } + assert!( + unsafe { crate::object::dictionary::is_dictionary(owner) }, + "test premise: READING a dictionary receiver must not un-latch it. \ + The by-name read path stamps the receiver's shape to key its field \ + cache, and for a dictionary receiver that republishes the private \ + key list as a shape — a mode that survives writes and reverts on \ + the first read." + ); + + js_shadow_slot_set(0, ptr_bits(old_owner)); + + let _ = gc_collect_minor(); + + let new_owner = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!(new_owner, old_owner, "test premise: the owner must move"); + let new_owner = new_owner as *mut crate::object::ObjectHeader; + + assert!( + unsafe { crate::object::dictionary::is_dictionary(new_owner) }, + "the moved receiver must still be in dictionary mode" + ); + let new_keys = unsafe { crate::object::object_keys_array(new_owner) } as usize; + assert_ne!( + new_keys, 0, + "the meta record's dictionary_keys slot was not marked: the key list \ + was collected out from under a live object" + ); + assert_ne!( + new_keys, old_keys, + "test premise: the key list must itself move, or this test cannot \ + distinguish a marked edge from a REWRITTEN one" + ); + assert_eq!( + crate::array::js_array_length(new_keys as *mut crate::array::ArrayHeader), + 6, + "the rewritten key list must still hold the receiver's six keys" + ); + // The names survived; so must the values they address. + for i in 0..6 { + let name = format!("gcdict_{i:02}"); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let value = + f64::from_bits(crate::object::js_object_get_field_by_name(new_owner, key).bits()); + assert_eq!(value, i as f64, "key {i} lost its value across the move"); + } + + js_shadow_slot_set(0, 0); +} + #[test] fn test_object_meta_prototype_survives_copied_minor_move() { let _guard = CopyingNurseryTestGuard::new(2); diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 7093b9a5cc..76a5fee78d 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -99,4 +99,5 @@ mod u8_inline_cache; mod weak_read_barrier; mod young_leaf_route; mod young_log_tests; +mod zero_slot_fixture; mod zero_slot_skip; diff --git a/crates/perry-runtime/src/gc/tests/support.rs b/crates/perry-runtime/src/gc/tests/support.rs index e187ce488e..3a03334299 100644 --- a/crates/perry-runtime/src/gc/tests/support.rs +++ b/crates/perry-runtime/src/gc/tests/support.rs @@ -852,7 +852,18 @@ pub(super) unsafe fn alloc_old_test_object( crate::object::shapes::shape_descriptor_ensure(std::ptr::null(), 0, field_count) .expect("shape id range exhausted in a test fixture") }; - let payload = std::mem::size_of::() + field_count as usize * 8; + // #10941: a named-property write does not respect "the derived bound is + // 0". The inline/overflow boundary is + // `max(object_live_slot_count(obj), INLINE_SLOT_FLOOR)` with a floor of 2, + // so the first two keys written to a zero-slot fixture store into inline + // slots 0 and 1 of an object that has none — those two words belong to the + // NEXT OBJECT. It presents as a wrong read now and a SIGSEGV somewhere + // unrelated during the next collection. Allocate to the floor; the + // PUBLISHED bound stays `field_count`, so the collector still traces + // exactly `field_count` slots and the descriptor-count accounting sibling + // tests assert on is unchanged. + let allocated_slots = std::cmp::max(field_count as usize, crate::object::INLINE_SLOT_FLOOR); + let payload = std::mem::size_of::() + allocated_slots * 8; let obj = crate::arena::arena_alloc_gc_old(payload, 8, GC_TYPE_OBJECT) as *mut crate::object::ObjectHeader; (*obj).class_id = 0; @@ -860,7 +871,7 @@ pub(super) unsafe fn alloc_old_test_object( (*obj).meta = std::ptr::null_mut(); let fields = (obj as *mut u8).add(std::mem::size_of::()) as *mut u64; - for i in 0..field_count as usize { + for i in 0..allocated_slots { *fields.add(i) = 0; } (obj, fields) @@ -880,7 +891,18 @@ pub(super) unsafe fn alloc_nursery_test_object( crate::object::shapes::shape_descriptor_ensure(std::ptr::null(), 0, field_count) .expect("shape id range exhausted in a test fixture") }; - let payload = std::mem::size_of::() + field_count as usize * 8; + // #10941: a named-property write does not respect "the derived bound is + // 0". The inline/overflow boundary is + // `max(object_live_slot_count(obj), INLINE_SLOT_FLOOR)` with a floor of 2, + // so the first two keys written to a zero-slot fixture store into inline + // slots 0 and 1 of an object that has none — those two words belong to the + // NEXT OBJECT. It presents as a wrong read now and a SIGSEGV somewhere + // unrelated during the next collection. Allocate to the floor; the + // PUBLISHED bound stays `field_count`, so the collector still traces + // exactly `field_count` slots and the descriptor-count accounting sibling + // tests assert on is unchanged. + let allocated_slots = std::cmp::max(field_count as usize, crate::object::INLINE_SLOT_FLOOR); + let payload = std::mem::size_of::() + allocated_slots * 8; let obj = crate::arena::arena_alloc_gc(payload, 8, GC_TYPE_OBJECT) as *mut crate::object::ObjectHeader; (*obj).class_id = 0; @@ -888,7 +910,7 @@ pub(super) unsafe fn alloc_nursery_test_object( (*obj).meta = std::ptr::null_mut(); let fields = (obj as *mut u8).add(std::mem::size_of::()) as *mut u64; - for i in 0..field_count as usize { + for i in 0..allocated_slots { *fields.add(i) = 0; } (obj, fields) diff --git a/crates/perry-runtime/src/gc/tests/zero_slot_fixture.rs b/crates/perry-runtime/src/gc/tests/zero_slot_fixture.rs new file mode 100644 index 0000000000..059a2daaea --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/zero_slot_fixture.rs @@ -0,0 +1,66 @@ +//! #10941: a zero-slot test fixture must still have room for a named store. +//! +//! `alloc_{nursery,old}_test_object(0)` allocated exactly an `ObjectHeader` +//! and left the receiver unstamped, on the reasoning that "the derived bound +//! is 0 either way". A named-property write does not respect that bound: the +//! inline/overflow boundary is +//! `max(object_live_slot_count(obj), INLINE_SLOT_FLOOR)` and the floor is 2, +//! so the first two keys stored into a zero-slot fixture land in inline slots +//! 0 and 1 of an object that has none — THE NEXT CELL. +//! +//! Every caller before PR #10938 only ever set a `[[Prototype]]` on one, so +//! nothing had written a named property to one and the hazard was invisible. +//! It presents as a wrong read now and a SIGSEGV inside an unrelated +//! collection later, which is why this is pinned structurally: the assertions +//! below are about the ALLOCATION, and they cannot pass vacuously. +//! +//! An end-to-end pin — write six named properties to a zero-slot fixture and +//! read them back — was written and then deliberately dropped: without the +//! fix it does not fail, it DUMPS CORE, which under `--test-threads=1` takes +//! the other ~4,200 results in the process with it. The structural assertions +//! redden with a message instead, and they redden for the same reason. + +use super::support::{alloc_nursery_test_object, alloc_old_test_object}; + +unsafe fn payload_bytes(obj: *mut crate::object::ObjectHeader) -> usize { + let header = (obj as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + (*header).size as usize - crate::gc::GC_HEADER_SIZE +} + +fn floor_bytes() -> usize { + std::mem::size_of::() + crate::object::INLINE_SLOT_FLOOR * 8 +} + +#[test] +fn a_zero_slot_nursery_fixture_has_room_for_the_named_store_floor() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + let (obj, _) = alloc_nursery_test_object(0); + let payload = payload_bytes(obj); + assert!( + payload >= floor_bytes(), + "a named-property write on this fixture stores into inline slots 0 \ + and 1 — the store path's floor is \ + `max(object_live_slot_count(obj), INLINE_SLOT_FLOOR)` and never \ + zero — but the allocation carries {payload} payload bytes against \ + the {} it would need. Those words are the NEXT CELL (#10941).", + floor_bytes() + ); + } +} + +#[test] +fn a_zero_slot_old_fixture_has_room_for_the_named_store_floor() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + let (obj, _) = alloc_old_test_object(0); + let payload = payload_bytes(obj); + assert!( + payload >= floor_bytes(), + "the old-generation twin of the nursery fixture has the same hole: \ + {payload} payload bytes against the {} a named store needs \ + (#10941).", + floor_bytes() + ); + } +} diff --git a/crates/perry-runtime/src/intl/segments_view.rs b/crates/perry-runtime/src/intl/segments_view.rs index 4a5f02543b..856fffe505 100644 --- a/crates/perry-runtime/src/intl/segments_view.rs +++ b/crates/perry-runtime/src/intl/segments_view.rs @@ -38,11 +38,46 @@ const CURSOR_FIELDS: u32 = 5; // --- counters (PERRY_SEGVIEW_DIAG=1) --------------------------------------- -static OPENS: AtomicU64 = AtomicU64::new(0); -static DECLINE_NOT_SEGMENTER: AtomicU64 = AtomicU64::new(0); -static DECLINE_NOT_GRAPHEME: AtomicU64 = AtomicU64::new(0); -static DECLINE_SEGMENT_PATCHED: AtomicU64 = AtomicU64::new(0); -static DECLINE_NOT_STRING: AtomicU64 = AtomicU64::new(0); +per_test_global! { + // #10944: a test asserts this counter's value, and libtest runs tests + // in one process — any sibling touching the same path made the + // assertion fail by one. `per_test_global!` gives each test thread its + // own instance in a TEST build and expands to the plain `static`, + // byte for byte, outside one. + static OPENS: AtomicU64 = AtomicU64::new(0); +} +per_test_global! { + // #10944: a test asserts this counter's value, and libtest runs tests + // in one process — any sibling touching the same path made the + // assertion fail by one. `per_test_global!` gives each test thread its + // own instance in a TEST build and expands to the plain `static`, + // byte for byte, outside one. + static DECLINE_NOT_SEGMENTER: AtomicU64 = AtomicU64::new(0); +} +per_test_global! { + // #10944: a test asserts this counter's value, and libtest runs tests + // in one process — any sibling touching the same path made the + // assertion fail by one. `per_test_global!` gives each test thread its + // own instance in a TEST build and expands to the plain `static`, + // byte for byte, outside one. + static DECLINE_NOT_GRAPHEME: AtomicU64 = AtomicU64::new(0); +} +per_test_global! { + // #10944: a test asserts this counter's value, and libtest runs tests + // in one process — any sibling touching the same path made the + // assertion fail by one. `per_test_global!` gives each test thread its + // own instance in a TEST build and expands to the plain `static`, + // byte for byte, outside one. + static DECLINE_SEGMENT_PATCHED: AtomicU64 = AtomicU64::new(0); +} +per_test_global! { + // #10944: a test asserts this counter's value, and libtest runs tests + // in one process — any sibling touching the same path made the + // assertion fail by one. `per_test_global!` gives each test thread its + // own instance in a TEST build and expands to the plain `static`, + // byte for byte, outside one. + static DECLINE_NOT_STRING: AtomicU64 = AtomicU64::new(0); +} static DECLINE_NOT_UTF8: AtomicU64 = AtomicU64::new(0); static DECLINE_EMPTY: AtomicU64 = AtomicU64::new(0); static NEXTS: AtomicU64 = AtomicU64::new(0); diff --git a/crates/perry-runtime/src/json_tape/cached_read.rs b/crates/perry-runtime/src/json_tape/cached_read.rs index d4058f46f8..84e63b4d63 100644 --- a/crates/perry-runtime/src/json_tape/cached_read.rs +++ b/crates/perry-runtime/src/json_tape/cached_read.rs @@ -159,8 +159,14 @@ mod tests { use std::sync::atomic::{AtomicU32, Ordering}; - // The runtime suite is serial. This witness holds no managed values. - static ROOTED_READS: AtomicU32 = AtomicU32::new(0); + // #10944: this used to say "the runtime suite is serial", and it is not — + // libtest runs tests in one process on many threads, so a sibling test + // taking the same safepoint bumped this witness and the assertions below + // failed by exactly one. `per_test_global!` gives each test thread its own + // counter. The witness still holds no managed values. + per_test_global! { + static ROOTED_READS: AtomicU32 = AtomicU32::new(0); + } fn count_rooted_reads(point: JsonTapeSafepoint, _: usize) { if point == JsonTapeSafepoint::LazyGetHeaderRooted { diff --git a/crates/perry-runtime/src/object/dictionary.rs b/crates/perry-runtime/src/object/dictionary.rs new file mode 100644 index 0000000000..8a30171ca1 --- /dev/null +++ b/crates/perry-runtime/src/object/dictionary.rs @@ -0,0 +1,714 @@ +//! Dictionary mode: an object that carries its own key list instead of +//! pointing at a layout the shape table owns (#10868 step 2.5, stage 1). +//! +//! # Why this exists +//! +//! Today 97.8 % of shape records are retired, because a private shape dies +//! with the one object that carries it. Step 2.5 makes shape identity +//! canonical: equal layouts intern to one shared `ShapeId`. A shared record +//! cannot be retired by ownership, so a workload that produces unboundedly +//! many distinct key lists — a `Map`-like object built by name with thousands +//! of keys, a per-request object keyed by user input — would accumulate +//! interned shapes for the life of the process. There is also a cost half: +//! under one canonical keys array per layout an append can no longer mutate +//! in place, so an object whose key list is unique to it pays a copy of +//! length *k* per append, i.e. **O(k²)** over *k* appends. +//! +//! Dictionary mode bounds both. An object whose keys have stopped being worth +//! interning keeps them itself, and stops minting shapes for them. +//! +//! # The representation, and why the shape stays honest +//! +//! A dictionary-mode object is one whose ShapeId describes **no keys at all**: +//! +//! ```text +//! keys = NULL, logical_key_count = 0, hole_count = 0, +//! live_inline_slot_count = the inline bound, object_kind = Ordinary, +//! semantic_generation = a draw from the DICTIONARY namespace below +//! ``` +//! +//! and whose real ordered key list is a private, object-owned `GC_TYPE_ARRAY` +//! in [`ObjectMeta::dictionary_keys`]. **Values do not move**: the key at +//! position *i* still reads inline slot *i* below the live bound and the +//! object-owned spill buffer at or above it, exactly as for any other object. +//! Dictionary mode changes where the *names* live, never where the *values* +//! live, which is what lets the existing read, write, delete and enumeration +//! code work on a dictionary object unmodified. +//! +//! The alternative — leaving the object's old key list published on its shape +//! and treating the meta record as an overlay — was rejected. A shape that +//! claims a key list the object no longer matches is a **silent wrong value** +//! in every consumer that trusts it. A shape that claims *nothing* is merely +//! incomplete, and the failure mode of a consumer nobody branched is a missing +//! property, which a differential test against node catches on the first row. +//! `keys = NULL` with a nonzero live bound is an existing, legal fact +//! combination (`shape_descriptor_ensure(std::ptr::null(), 0, field_count)`); +//! `shape_descriptor_ensure_with_holes` rejects only the opposite pairing. +//! +//! # The one branch that does the work +//! +//! [`crate::object::object_keys_array`] is the *sole* runtime derivation of an +//! object's ordered key list — every enumeration walk, the `in`/`hasOwn` +//! predicate, `delete`, `JSON.stringify`, spread and `Object.assign` reach it +//! through that one function. Branching it when the shape publishes no keys +//! gives all of them node-identical behaviour with no second implementation of +//! key order, hole skipping or integer-key ordering. The branch costs nothing +//! on an ordinary object: the descriptor is already loaded, and a nonzero +//! `keys` word returns before this module is consulted. +//! +//! # Identity: one ShapeId per dictionary object, and why not one globally +//! +//! A compiled inline cache compares ShapeIds and nothing else. Two dictionary +//! objects sharing one id would let a cache primed on the first read a slot of +//! the second — a wrong value, not a slow one. So each dictionary object draws +//! its own generation, once, at the latch. +//! +//! That bounds mints at **O(1) per object** against today's O(k), which is the +//! property step 2.5 needs. An append to a dictionary object mints **nothing**: +//! the array grows in place or reallocates, and neither is a fact of a shape +//! whose `keys` word is NULL. Only a change to the live inline bound, or a +//! republication that is not an append (a compacting delete, which moves +//! values and must therefore invalidate caches), draws a fresh generation. +//! +//! # The generation namespace +//! +//! Two namespaces already exist and are disjoint by construction: the +//! `SHAPE_SEMANTIC_NEXT` counter (bit 63 clear, and it aborts far below 2⁶²) +//! and `deterministic_semantic_generation` (bit 63 set). Dictionary draws are +//! **bit 63 clear, bit 62 set**, which is disjoint from both. +//! [`dictionary_generation_namespaces_are_disjoint`] asserts it. +//! +//! # GC +//! +//! `dictionary_keys` is a traced, rewritten child edge exactly like +//! `ObjectMeta::spill` (#6812): one `visit` in the +//! `GcRewriteDescriptorKind::ObjectMeta` arm of +//! `visit_gc_rewrite_slot_descriptors`, which is the single enumerator used by +//! the non-copying minor mark, the full mark, the copying-nursery evacuation, +//! the whole-heap rewrite and the dirty-slot rescan — mark, move and +//! remembered-set coverage from one place. Every store to the word is followed +//! by `runtime_write_barrier_slot`. Membership is proved by sabotage: remove +//! the `visit` and `dictionary_keys_survive_a_moving_collection` fails. + +use super::dictionary_counters::*; +use super::{shapes, ObjectHeader, ObjectMeta}; +use crate::array::ArrayHeader; +use std::sync::atomic::Ordering; + +/// Dictionary generations set bit 62 and clear bit 63. See the module docs. +pub(crate) const DICTIONARY_GENERATION_TAG: u64 = 1 << 62; + +/// Monotonic within the dictionary namespace. Starts at 1 so a generation is +/// never bare `DICTIONARY_GENERATION_TAG`, which keeps "tagged" and "drawn" +/// distinguishable in a dump. + +/// Is the latch armed at all? Tri-state so the default-off path is ONE relaxed +/// load and a compare — a default-off knob must cost nothing when off. +/// `-1` unresolved, `0` off, `1` on. + +/// Lowest key count at which an armed latch fires, so a fixture can trip it +/// part-way through a growth loop and observe both regimes in one run. + +/// Remaining canonical LAYOUT IDS, as published by the interning allocator. +/// +/// Step 2.5 packs a layout id into 24 bits of `ShapeRecord`'s padding hole, so +/// the id space is ample (16.7 M against tsc's 10,867 distinct layouts) but +/// **bounded**. An object that cannot be given a layout id cannot be interned +/// at all, and dictionary mode is the only place left for it — so exhaustion +/// is a SECOND, independent latch trigger, and unlike the growth trigger it is +/// a correctness requirement rather than a policy. +/// +/// `u64::MAX` means "no bound published yet", which is the state on this tree: +/// interning does not exist, so nothing calls [`note_layout_id_budget`] and +/// the trigger is unreachable in production. It is reachable in a TEST, +/// which is the point — an untestable branch in a latch is the +/// check-that-cannot-fail pattern this campaign has now paid for five times. + +/// Latches caused by trigger 2 rather than trigger 1, counted separately +/// because the two mean completely different things to an operator: growth is +/// a workload shape, exhaustion is a resource running out. + +/// Publish the remaining layout-id budget. Called by the interning allocator +/// (lane 8's side of the seam); `0` means it could not hand out an id. +/// +/// Arming is folded in here rather than tested separately in the predicate so +/// the default-off path stays exactly **one relaxed load**: exhaustion arms +/// the same switch the growth trigger uses, with a threshold of zero keys, and +/// the predicate then attributes the latch by reading this budget. +pub fn note_layout_id_budget(remaining: u64) { + LAYOUT_ID_BUDGET.store(remaining, Ordering::Relaxed); + if remaining == 0 { + LATCH_MIN_KEYS.store(0, Ordering::Relaxed); + LATCH_ARMED.store(1, Ordering::Relaxed); + } +} + +/// Has the layout-id space run out? +pub fn dictionary_layout_ids_exhausted() -> bool { + LAYOUT_ID_BUDGET.load(Ordering::Relaxed) == 0 +} + +/// Latches attributed to layout-id exhaustion (trigger 2). +pub fn dictionary_exhaustion_latches() -> u64 { + EXHAUSTION_LATCHES.load(Ordering::Relaxed) +} + +/// Restore the "no bound published" state. Test-only: the budget is a +/// process-global, and a test that leaves it at zero arms the latch for every +/// later test in the same process. +#[cfg(test)] +pub(crate) fn test_clear_layout_id_budget() { + LAYOUT_ID_BUDGET.store(u64::MAX, Ordering::Relaxed); + EXHAUSTION_LATCHES.store(0, Ordering::Relaxed); +} + +/// Questions asked of [`should_latch_to_dictionary`] WHILE ARMED — the +/// denominator for [`dictionary_latches`]. +/// +/// Without it a run reporting `latches=0` cannot be told apart from a run in +/// which the predicate was never reached at all, which is the shape of gate +/// that cannot fail (the `gc_schedule_safepoints` precedent in +/// `gc/schedule.rs`). The three states are distinguishable: +/// +/// * `armed=false` — the mechanism is off. Nothing is claimed. +/// * `armed=true candidates=0` — armed and NEVER REACHED. This is the bug +/// shape: the knob was set and the call site is not on the path. +/// * `armed=true candidates>0 latches=0` — reached, and every candidate +/// declined. +/// +/// The counters are plain atomics with no `#[cfg(feature)]` on them, exactly +/// like `gc/instruments.rs`, so "not compiled in" is not a state this +/// instrument can be in. + +/// Objects that actually converted. + +/// Key-list republications absorbed by a dictionary object. This is the number +/// that says the mode is doing its job: each of these would have minted a +/// ShapeId on an ordinary object. + +/// Of those, the ones that still had to draw a fresh generation (a live-bound +/// change, or a republication that was not an append). + +/// Resolve the knob once. Value-parsed, not presence-parsed: #7991 shipped a +/// knob that `PERRY_GC_DIAG=0` turned ON. +#[cold] +#[inline(never)] +fn resolve_latch_arming() -> bool { + let mut armed = false; + // Trigger 2, injectable. A fixture that really exhausts a 24-bit layout-id + // space is impractical, so the budget is a number the allocator PUBLISHES + // and anyone can inject — which is the only thing that makes the + // exhaustion arm reachable by a test at all. + if let Ok(raw) = std::env::var("PERRY_OBJECT_DICTIONARY_LAYOUT_ID_BUDGET") { + if let Ok(budget) = raw.trim().parse::() { + note_layout_id_budget(budget); + armed |= budget == 0; + } + } + // Trigger 1. Value-parsed, not presence-parsed: #7991 shipped a knob that + // `PERRY_GC_DIAG=0` turned ON. + if let Ok(raw) = std::env::var("PERRY_OBJECT_DICTIONARY_MIN_KEYS") { + if let Ok(min_keys) = raw.trim().parse::() { + LATCH_MIN_KEYS.store(min_keys, Ordering::Relaxed); + armed = true; + } + } + LATCH_ARMED.store(i8::from(armed), Ordering::Relaxed); + armed +} + +/// Was the latch mechanism armed this run? Half of the false-zero verdict. +pub fn dictionary_latch_armed() -> bool { + match LATCH_ARMED.load(Ordering::Relaxed) { + -1 => resolve_latch_arming(), + 0 => false, + _ => true, + } +} + +/// How many `should_latch_to_dictionary` questions were asked while armed. +pub fn dictionary_latch_candidates() -> u64 { + LATCH_CANDIDATES.load(Ordering::Relaxed) +} + +/// How many objects converted to dictionary mode this run. +pub fn dictionary_latches() -> u64 { + LATCHES.load(Ordering::Relaxed) +} + +/// Key-list republications a dictionary object absorbed. +pub fn dictionary_publications() -> u64 { + PUBLICATIONS.load(Ordering::Relaxed) +} + +/// How many of those still drew a generation. +pub fn dictionary_regenerations() -> u64 { + REGENERATIONS.load(Ordering::Relaxed) +} + +/// One line for the exit dump. Always emitted when diagnostics are on — a +/// present-and-zero row, never an absent one. +pub fn dictionary_counters_line() -> String { + format!( + "[object-dictionary] armed={} layout_ids_exhausted={} candidates={} latches={} \ + exhaustion_latches={} publications={} regenerations={}", + dictionary_latch_armed(), + dictionary_layout_ids_exhausted(), + dictionary_latch_candidates(), + dictionary_latches(), + dictionary_exhaustion_latches(), + dictionary_publications(), + dictionary_regenerations() + ) +} + +/// Arm or disarm the latch from a test. Returns the previous minimum, if armed. +#[cfg(test)] +pub(crate) fn test_arm_latch(min_keys: Option) -> Option { + let was = if LATCH_ARMED.load(Ordering::Relaxed) == 1 { + Some(LATCH_MIN_KEYS.load(Ordering::Relaxed)) + } else { + None + }; + match min_keys { + Some(n) => { + LATCH_MIN_KEYS.store(n, Ordering::Relaxed); + LATCH_ARMED.store(1, Ordering::Relaxed); + } + None => LATCH_ARMED.store(0, Ordering::Relaxed), + } + was +} + +#[cfg(test)] +pub(crate) fn test_reset_counters() { + LATCH_CANDIDATES.store(0, Ordering::Relaxed); + LATCHES.store(0, Ordering::Relaxed); + PUBLICATIONS.store(0, Ordering::Relaxed); + REGENERATIONS.store(0, Ordering::Relaxed); +} + +/// Should this receiver stop interning its key list? +/// +/// TWO independent triggers, not one: +/// +/// 1. **Unbounded key growth.** An interned shape is shared and cannot be +/// retired by ownership the way today's private ones are (97.8% of records +/// are retired today), so a receiver whose key list is unique to it and +/// grows without bound must stop interning. Policy. +/// 2. **Layout-id exhaustion** ([`note_layout_id_budget`]). The canonical +/// layout id is 24 bits; when none is left the receiver cannot be interned +/// at all and dictionary mode is the only place for it. Correctness, not +/// policy — which is why it ignores the key-count threshold. +/// +/// **Stubbed: it can only answer `true` when the latch is explicitly armed.** +/// The production trigger belongs to the content-key work (L8.3.13): the +/// condition that matters is "this object's key list is unique to it", and +/// that is not answerable until shape identity is content-keyed — today +/// `fresh_keys_new_list` is 0.1 % of mints, so a predicate written against +/// today's facts would be measuring the transition cache, not the program. +/// Until then the latch is driven directly, by the tests and by +/// `PERRY_OBJECT_DICTIONARY_MIN_KEYS`, and the counters above say which. +/// +/// Off, this is one relaxed load and a compare. +#[inline] +pub(crate) fn should_latch_to_dictionary(logical_key_count: u32) -> bool { + match LATCH_ARMED.load(Ordering::Relaxed) { + 0 => return false, + -1 => { + if !resolve_latch_arming() { + return false; + } + } + _ => {} + } + LATCH_CANDIDATES.fetch_add(1, Ordering::Relaxed); + // Trigger 2: no layout id is available, so this receiver cannot be + // interned at all. It latches whatever its key count is. + if LAYOUT_ID_BUDGET.load(Ordering::Relaxed) == 0 { + EXHAUSTION_LATCHES.fetch_add(1, Ordering::Relaxed); + return true; + } + // Trigger 1: unbounded growth of a key list unique to this receiver. + u64::from(logical_key_count) >= LATCH_MIN_KEYS.load(Ordering::Relaxed) +} + +/// The object's private key list, or null when it has none. +/// +/// Only reached from [`crate::object::object_keys_array`], and only once the +/// shape has already said it publishes no keys, so the validation below is +/// off the path every ordinary receiver takes. +/// +/// The header check is not defensive padding. `object_keys_array` takes a +/// `*const ObjectHeader` that may in fact alias an `ErrorHeader`, +/// `RegExpHeader`, `MapHeader` or `DateCell` — `shape_word_is_writable` +/// exists for exactly that aliasing — and those cells carry their metadata +/// edge at their OWN offset, not at `ObjectHeader`'s +8. Reading `.meta` +/// blind would classify another layout's bytes as a pointer and dereference +/// it, which is `try_read_gc_header`'s whole reason for existing (#340/#341). +#[inline] +pub(crate) unsafe fn keys_array(obj: *const ObjectHeader) -> *mut ArrayHeader { + match meta_of(obj) { + Some(meta) => (*meta).dictionary_keys as usize as *mut ArrayHeader, + None => std::ptr::null_mut(), + } +} + +/// The receiver's `ObjectMeta`, or `None` unless this really is a live +/// `GC_TYPE_OBJECT` cell that has one. See [`keys_array`] for why the type +/// check is load-bearing rather than defensive. +#[inline] +unsafe fn meta_of(obj: *const ObjectHeader) -> Option<*mut ObjectMeta> { + if obj.is_null() { + return None; + } + let header = crate::value::addr_class::try_read_gc_header(obj as usize)?; + if header.obj_type != crate::gc::GC_TYPE_OBJECT + || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + { + return None; + } + let meta = (*obj).meta; + if meta.is_null() { + None + } else { + Some(meta) + } +} + +/// Is this receiver in dictionary mode? +/// +/// The discriminator is a SHAPE fact first — "my shape publishes no keys" — +/// and the meta word second. Both are required. An ordinary keyless receiver +/// (`{}` before its first key) satisfies the first and not the second. A +/// receiver whose shape has REGAINED a key list satisfies the second and not +/// the first, and is correctly no longer a dictionary: that is what a +/// compacting delete does when it republishes through the ordinary path, so +/// the mode un-latches itself rather than needing a separate exit. +#[inline] +pub(crate) unsafe fn is_dictionary(obj: *const ObjectHeader) -> bool { + let Some(meta) = meta_of(obj) else { + return false; + }; + if (*meta).dictionary_keys == 0 { + return false; + } + shapes::object_shape_descriptor(obj).is_some_and(|descriptor| descriptor.keys == 0) +} + +/// Store the private key list, with the barrier that makes it a remembered +/// old→young edge. Never allocates. +#[inline] +unsafe fn store_keys_array(meta: *mut ObjectMeta, keys: *mut ArrayHeader) { + let bits = keys as usize as u64; + // GC_STORE_AUDIT(BARRIERED): metadata-record slot store + object barrier, + // exactly as `object/spill.rs` does for `ObjectMeta::spill`. + (*meta).dictionary_keys = bits; + crate::gc::runtime_write_barrier_slot( + meta as usize, + &(*meta).dictionary_keys as *const _ as usize, + bits, + ); +} + +/// A fresh generation in the dictionary namespace. +fn next_generation() -> u64 { + let n = DICTIONARY_GENERATION_NEXT.fetch_add(1, Ordering::Relaxed); + debug_assert!( + n < DICTIONARY_GENERATION_TAG, + "dictionary generation counter left its namespace" + ); + DICTIONARY_GENERATION_TAG | n +} + +/// Mint and stamp a keyless dictionary shape carrying `live_inline_slot_count`. +/// +/// Mint-then-stamp, like every other publisher in this tree: the mint inserts +/// into a `HashMap` and can therefore collect and MOVE the receiver, so the +/// predecessor stays stamped across it (and still describes the payload +/// correctly, because the live bound has not changed yet) and the receiver is +/// re-resolved through a handle before the single `parent_class_id` store. +unsafe fn restamp_dictionary_shape(obj: *mut ObjectHeader, live_inline_slot_count: u32) -> u32 { + let scope = crate::gc::RuntimeHandleScope::new(); + let handle = scope.root_raw_mut_ptr(obj); + // The mint is the allocating half; the receiver is never nameable across + // it (#7341), so there is no pre-call address left to stamp. + let (id, obj) = handle.across_mut::(|| { + shapes::publish_shape_result(shapes::shape_descriptor_ensure_with_holes( + std::ptr::null(), + 0, + live_inline_slot_count, + next_generation(), + shapes::ShapeObjectKind::Ordinary, + 0, + )) + }); + shapes::stamp_object_shape_id_with_carrier_note(obj, id); + id +} + +/// Convert `obj` to dictionary mode. Idempotent; returns whether the receiver +/// is in dictionary mode afterwards. +/// +/// Refused for anything that is not an ordinary shaped object, and for a +/// receiver that already carries tombstones: a hole is counted in the shape's +/// `hole_count`, a fact a keyless dictionary shape does not carry, so latching +/// over one would drop the count. Compacting the holes away at the latch would +/// have to move values and is not worth it for stage 1 — such a receiver simply +/// stays ordinary. Recorded rather than left implicit because it is a real +/// limit on when the latch can fire. +pub(crate) unsafe fn latch_object_to_dictionary(obj: *mut ObjectHeader) -> bool { + if obj.is_null() { + return false; + } + if is_dictionary(obj) { + return true; + } + if !crate::object::object_is_regular(obj) { + return false; + } + let Some(descriptor) = shapes::object_shape_descriptor(obj) else { + return false; + }; + if descriptor.hole_count != 0 { + return false; + } + if descriptor.logical_key_count == 0 { + // Nothing to carry — and an allocator's FIRST key publication reaches + // the latch site before the receiver's payload is initialized, so + // converting here would allocate (and therefore collect) over slots + // the shape already claims are live. + return false; + } + let live_inline_slot_count = descriptor.live_inline_slot_count; + let key_count = descriptor.logical_key_count; + + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + + // 1. The typed-layout descriptor is keyed by the keys edge, and this + // receiver is about to stop having one. Invalidate while the + // predecessor stamp is still authoritative, exactly as + // `set_object_keys_array_with_live` does for a pointer change. + // The reload is discarded: step 2 allocates again and reloads through + // the same handle, so a name bound here would be stale before it is read. + let ((), _) = obj_handle + .across_mut::(|| crate::object::mark_object_dynamic_shape_unknown(obj)); + + // 2. A PRIVATE copy of the key list, with slack so the first appends + // after the latch do not immediately reallocate. The source may be + // shared (`GC_FLAG_SHAPE_SHARED`) with every sibling of its layout and + // a dictionary receiver mutates its array in place, so the copy is + // unconditional rather than conditional on the flag: after the latch + // the array has exactly one owner by construction. + let (cloned, obj) = obj_handle.across_mut::(|| { + crate::array::js_array_alloc_pointer_elements(key_count + 4) + }); + if cloned.is_null() { + return false; + } + let cloned_handle = scope.root_raw_mut_ptr(cloned); + + // 3. The metadata record, LAST of the three allocating steps. + // + // The order matters and is not cosmetic: a meta record is reachable + // only through its owner and is not a thing to hand the root scanner, + // so this step must be the one after which nothing allocates. An + // earlier draft allocated the record second and rooted it across the + // clone's allocation — that SIGSEGV'd the whole test binary. + let ((meta, obj), cloned) = cloned_handle.across_mut::(|| { + obj_handle.across_mut::(|| crate::object::object_meta_ensure(obj)) + }); + if meta.is_null() { + return false; + } + + // 4. Fill the copy. Allocates nothing, which is what lets the SOURCE be + // resolved here rather than carried across an allocation. + copy_key_list_into(obj, cloned, key_count); + + // 5. Install the private list BEFORE the shape stops publishing the old + // one. There is no window in which the receiver's keys are + // unreachable: across step 6's mint the old array is still on the + // shape and the new one is already a traced child edge of the record. + store_keys_array(meta, cloned); + + // 6. Publish the keyless dictionary shape. `meta` is not read after this + // point, so the mint's allocation cannot hand us a stale record. + restamp_dictionary_shape(obj, live_inline_slot_count); + + LATCHES.fetch_add(1, Ordering::Relaxed); + true +} + +/// Copy the receiver's current ordered key list into `dst`. **Allocates +/// nothing**, which is what lets the source be resolved here rather than +/// carried across the allocation that produced `dst`. +/// +/// Both sides go through the element accessors, never through +/// `dst as *mut u8 + size_of::()`. A keys array's elements do +/// not necessarily start at its header + 8: `clean_arr_ptr` resolves a +/// grow-forward pointer, and `array_elements_ptr` accounts for the FRONT +/// RESERVE that #9019's reserved-floor arrays are born with. Hand-computing +/// the offset copies header words and reserve slack into the clone as if they +/// were key pointers — which reads as a missing property before the next +/// collection and SIGSEGVs during it, because the clone claims an all-pointer +/// layout and the collector believes it. Both symptoms were observed here +/// before this used the accessor. +unsafe fn copy_key_list_into(obj: *const ObjectHeader, dst: *mut ArrayHeader, key_count: u32) { + let source = shapes::object_shape_descriptor(obj) + .map(|descriptor| descriptor.keys as usize as *mut ArrayHeader) + .unwrap_or(std::ptr::null_mut()); + let (src, src_len) = crate::object::keys_array_dense_slots(source); + let count = std::cmp::min(key_count as usize, src_len); + let out = crate::array::array_elements_ptr(dst as *const ArrayHeader); + for i in 0..count { + // GC_STORE_AUDIT(INIT): the clone is unpublished and its all-pointer + // layout covers only the prefix `length` exposes, which is set below. + *out.add(i) = (*src.add(i)).to_bits(); + } + (*dst).length = count as u32; +} + +/// Absorb a key-list publication for a dictionary receiver. +/// +/// This is the whole point of the mode: on an ordinary receiver every one of +/// these calls mints a ShapeId. Here a SAME-ARRAY publication mints nothing — +/// the array's address is not a fact of a shape whose `keys` word is NULL, an +/// in-place append moves no value, and an in-place tombstone leaves every +/// surviving slot where it was, so a cache primed on this receiver stays +/// correct either way. +/// +/// A publication that swaps the array in DOES draw a fresh generation. The +/// case that forces it is the compacting delete, which allocates a fresh +/// array and shifts every value after the hole down one slot: a cache holding +/// `(this ShapeId, key) -> slot` would then read the wrong value, and the new +/// identity is what invalidates it. A reallocating `js_array_push` also swaps +/// the array and does not move values, so it pays a generation it does not +/// strictly need — that is O(log k) draws over k appends against O(k), and +/// buying the difference would need the previously published length kept +/// somewhere, which is a word this record does not have to spare. The test +/// asserts the bound is sub-linear, not that it is zero. +pub(crate) unsafe fn publish_keys( + obj: *mut ObjectHeader, + keys: *mut ArrayHeader, + live_inline_slot_count: u32, +) { + PUBLICATIONS.fetch_add(1, Ordering::Relaxed); + let meta = (*obj).meta; + debug_assert!(!meta.is_null(), "publish_keys on a receiver with no meta"); + let previous = (*meta).dictionary_keys as usize as *mut ArrayHeader; + let swapped = keys != previous; + + // A swap is not automatically a value move. The [[Set]] growth path + // clones before pushing whenever the array carries `GC_FLAG_SHAPE_SHARED` + // — and the transition cache STAMPS that flag on any array it caches an + // edge through, including this receiver's private one — so an ordinary + // append arrives here as a new pointer. That clone preserves order and + // indices, so no value moved and no cache is stale; regenerating for it + // would make the mode draw one identity per append, which is the exact + // cost it exists to remove (measured: 23 draws for 24 appends). + // + // The compacting delete, which allocates a fresh array AND shifts every + // value after the hole down one slot, is the case that must regenerate. + // The two are told apart by length, and the length is readable because + // the predecessor array is still alive and still carries its pre-append + // length — a clone-and-push does not mutate the array it cloned FROM. + let previous_len = if previous.is_null() { + 0 + } else { + crate::array::keys_array_len_capped_to_capacity(previous) + }; + let next_len = if keys.is_null() { + 0 + } else { + crate::array::keys_array_len_capped_to_capacity(keys) + }; + // Strictly SHORTER, not "not longer". The [[Set]] growth path publishes + // the clone BEFORE it pushes, so an ordinary append arrives here twice — + // once at equal length (the clone) and once one longer (the push) — and + // treating the equal-length publication as a possible move drew one + // identity per append, which is what the mode exists to stop (measured: + // 23 draws for 24 appends). An equal-length swap is clone-before-mutate + // and preserves every index; the assertion below is what keeps that a + // checked invariant rather than a comment. + let values_may_have_moved = swapped && next_len < previous_len; + debug_assert!( + !swapped || next_len != previous_len || key_lists_match(previous, keys, next_len), + "an equal-length keys-array swap reordered a dictionary receiver's \ + keys: every cached (ShapeId, key) -> slot for it is now wrong" + ); + + if swapped { + store_keys_array(meta, keys); + } + let live_changed = shapes::object_shape_descriptor(obj) + .is_none_or(|descriptor| descriptor.live_inline_slot_count != live_inline_slot_count); + if values_may_have_moved || live_changed { + REGENERATIONS.fetch_add(1, Ordering::Relaxed); + restamp_dictionary_shape(obj, live_inline_slot_count); + } +} + +/// Do two keys arrays hold the same `count` entries in the same order? +/// +/// Debug-only support for [`publish_keys`]' equal-length rule. Cheap to state +/// and impossible to get wrong by accident, which is the point: the rule is +/// what lets an append avoid drawing a shape identity, so an unchecked +/// assumption there is a silent wrong VALUE on the next cached read. +#[cfg(debug_assertions)] +unsafe fn key_lists_match(a: *mut ArrayHeader, b: *mut ArrayHeader, count: usize) -> bool { + if a.is_null() || b.is_null() { + return a == b; + } + let (pa, la) = crate::object::keys_array_dense_slots(a); + let (pb, lb) = crate::object::keys_array_dense_slots(b); + if la < count || lb < count { + return false; + } + (0..count).all(|i| (*pa.add(i)).to_bits() == (*pb.add(i)).to_bits()) +} + +#[cfg(not(debug_assertions))] +unsafe fn key_lists_match(_a: *mut ArrayHeader, _b: *mut ArrayHeader, _count: usize) -> bool { + true +} + +/// The parity invariant for a dictionary receiver, in place of the ordinary +/// one (`shapes::debug_assert_object_shape_parity_for_keys`, which compares the +/// published `keys` word against `object_keys_array` and would see NULL against +/// the private list). +/// +/// Debug builds only. +#[inline] +pub(crate) unsafe fn debug_assert_dictionary_parity(obj: *const ObjectHeader) { + if !cfg!(debug_assertions) { + return; + } + let Some(meta) = meta_of(obj) else { + debug_assert!( + false, + "a dictionary receiver must be a live GC_TYPE_OBJECT with a meta" + ); + return; + }; + debug_assert!( + (*meta).dictionary_keys != 0, + "a dictionary receiver must carry a key list" + ); + let Some(descriptor) = shapes::object_shape_descriptor(obj) else { + debug_assert!(false, "a dictionary receiver must be stamped"); + return; + }; + debug_assert!( + descriptor.keys == 0 && descriptor.logical_key_count == 0, + "a dictionary receiver's shape must publish no keys" + ); + debug_assert!( + descriptor.semantic_generation & DICTIONARY_GENERATION_TAG != 0 + && descriptor.semantic_generation & (1u64 << 63) == 0, + "a dictionary receiver's generation must come from the dictionary namespace \ + ({:#x})", + descriptor.semantic_generation + ); +} diff --git a/crates/perry-runtime/src/object/dictionary_counters.rs b/crates/perry-runtime/src/object/dictionary_counters.rs new file mode 100644 index 0000000000..fc964ec451 --- /dev/null +++ b/crates/perry-runtime/src/object/dictionary_counters.rs @@ -0,0 +1,22 @@ +//! Dictionary-mode counters and latch state (#10868 step 2.5 stage 1). +//! +//! Their own file because `gc_runtime_root_holders`' rule B only clears a +//! static integer table in a file that never calls an allocator, and +//! `dictionary.rs` allocates. None of these words ever holds a GC pointer; +//! keeping them somewhere the gate can see that is cheaper than an entry in +//! `gc_runtime_root_holders.json` that a reader has to take on trust. + +use std::sync::atomic::{AtomicI8, AtomicU64}; + +pub(super) static LAYOUT_ID_BUDGET: AtomicU64 = AtomicU64::new(u64::MAX); +pub(super) static EXHAUSTION_LATCHES: AtomicU64 = AtomicU64::new(0); +pub(super) static LATCH_ARMED: AtomicI8 = AtomicI8::new(-1); +pub(super) static LATCH_MIN_KEYS: AtomicU64 = AtomicU64::new(0); +pub(super) static LATCH_CANDIDATES: AtomicU64 = AtomicU64::new(0); +pub(super) static LATCHES: AtomicU64 = AtomicU64::new(0); +pub(super) static PUBLICATIONS: AtomicU64 = AtomicU64::new(0); +pub(super) static REGENERATIONS: AtomicU64 = AtomicU64::new(0); + +/// Monotonic within the dictionary generation namespace. Starts at 1 so a +/// drawn generation is never bare `DICTIONARY_GENERATION_TAG`. +pub(super) static DICTIONARY_GENERATION_NEXT: AtomicU64 = AtomicU64::new(1); diff --git a/crates/perry-runtime/src/object/dictionary_tests.rs b/crates/perry-runtime/src/object/dictionary_tests.rs new file mode 100644 index 0000000000..0efc85f768 --- /dev/null +++ b/crates/perry-runtime/src/object/dictionary_tests.rs @@ -0,0 +1,389 @@ +//! Unit pins for dictionary mode (#10868 step 2.5 stage 1). +//! +//! The ORDER semantics are pinned differentially against node by +//! `tests/parity/test_parity_dictionary_mode_order.ts`; what lives here is +//! what a differential test cannot see: the shape-identity namespace, the +//! mint COUNT the mode exists to bound, and the latch instrument's three +//! states. +//! +//! The GC edge has its own pin next to its sibling: +//! `gc::tests::dead_owner_side_tables::test_object_meta_dictionary_keys_survive_copied_minor_move`. + +use super::dictionary; +use super::{js_object_alloc, js_object_get_field_by_name, js_object_set_field_by_name}; + +/// Restores the latch arming on scope exit (panic included) so a failing test +/// cannot leak an armed latch into unrelated tests on the same process. +fn scopeguard_latch() -> impl Drop { + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + dictionary::test_arm_latch(None); + dictionary::test_clear_layout_id_budget(); + } + } + Restore +} + +unsafe fn set_key(obj: *mut super::ObjectHeader, name: &str, value: f64) { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + js_object_set_field_by_name(obj, key, value); +} + +unsafe fn get_key(obj: *mut super::ObjectHeader, name: &str) -> f64 { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let bits = js_object_get_field_by_name(obj, key); + f64::from_bits(bits.bits()) +} + +/// The three semantic-generation namespaces must be pairwise disjoint, or two +/// receivers that are semantically different can be handed one ShapeId. +/// +/// `SHAPE_SEMANTIC_NEXT` draws have bit 63 clear and the counter aborts far +/// below 2^62; `deterministic_semantic_generation` sets bit 63 (pinned by +/// `tombstone_tests::the_delete_successor_generation_is_deterministic_not_a_counter_draw`); +/// dictionary draws set bit 62 and clear bit 63. +#[test] +fn dictionary_generation_namespaces_are_disjoint() { + // Named *_BIT, not DETERMINISTIC: `global_sink_isolation.py` resolves an + // asserted identifier by NAME across the crate with no scope awareness, + // so a test-local const sharing a name with `stub_diag.rs`'s real + // `static DETERMINISTIC` is reported as a new asserted process-global. + const DETERMINISTIC_BIT: u64 = 1 << 63; + assert_eq!( + dictionary::DICTIONARY_GENERATION_TAG & DETERMINISTIC_BIT, + 0, + "a dictionary generation must not land in the deterministic namespace" + ); + assert_ne!( + dictionary::DICTIONARY_GENERATION_TAG, + 0, + "a dictionary generation must be distinguishable from a counter draw" + ); + // A counter draw that reached bit 62 would collide. `alloc_shape_id` parks + // at 2^30 ids and fail-stops long before the generation counter could get + // within thirty-two orders of magnitude of this, but state the bound + // rather than trust the comment. + assert!( + dictionary::DICTIONARY_GENERATION_TAG > (1u64 << 40), + "the dictionary namespace must sit far above any reachable counter draw" + ); +} + +/// The latch converts, and the conversion is visible in exactly the three +/// places it should be: the shape publishes no keys, the meta record carries +/// the list, and `object_keys_array` still answers with it. +#[test] +fn the_latch_moves_the_key_list_into_the_meta_record() { + let _global = crate::gc::global_side_table_test_lock(); + let _restore = scopeguard_latch(); + unsafe { + let obj = js_object_alloc(0, 0); + for i in 0..6 { + set_key(obj, &format!("dictlatch_{i:02}"), i as f64); + } + assert!( + !dictionary::is_dictionary(obj), + "test premise: the latch is off by default" + ); + let before = super::object_keys_array(obj); + assert!(!before.is_null(), "test premise: the receiver has keys"); + + assert!( + dictionary::latch_object_to_dictionary(obj), + "the latch must convert an ordinary shaped receiver" + ); + + assert!(dictionary::is_dictionary(obj)); + let descriptor = + super::shapes::object_shape_descriptor(obj).expect("a dictionary receiver is stamped"); + assert_eq!(descriptor.keys, 0, "the shape must publish no keys"); + assert_eq!(descriptor.logical_key_count, 0); + assert_ne!( + descriptor.semantic_generation & dictionary::DICTIONARY_GENERATION_TAG, + 0, + "the dictionary shape must draw from the dictionary namespace" + ); + + let after = super::object_keys_array(obj); + assert!(!after.is_null(), "the key list must still be reachable"); + assert_ne!( + after, before, + "the latch must take a PRIVATE copy: the source may be shared with \ + every sibling of its layout, and a dictionary receiver mutates \ + its array in place" + ); + assert_eq!(crate::array::js_array_length(after), 6); + + // Values do not move: the mode relocates names, not values. + for i in 0..6 { + assert_eq!(get_key(obj, &format!("dictlatch_{i:02}")), i as f64); + } + // And it keeps working across the boundary. + for i in 6..14 { + set_key(obj, &format!("dictlatch_{i:02}"), i as f64); + } + for i in 0..14 { + assert_eq!( + get_key(obj, &format!("dictlatch_{i:02}")), + i as f64, + "key {i} read back wrong after the latch" + ); + } + assert_eq!( + crate::array::js_array_length(super::object_keys_array(obj)), + 14 + ); + } +} + +/// **P1, and the must-fail control.** The property the mode exists for: +/// appends to a latched receiver stop minting shape identities. +/// +/// Measured on this lane's OWN counters rather than on the global +/// `SHAPE_ID_NEXT`. That counter is process-wide and every test running +/// beside this one draws from it, so a bound written against it is a bound on +/// the whole suite's concurrency, not on this object — it would be flaky in +/// one direction and vacuous in the other. `publications` counts the +/// key-list republications this receiver absorbed (one per append) and +/// `regenerations` counts the ones that still had to draw an identity, so the +/// ratio is exactly the claim and nothing else can move it. +/// +/// The global counter still appears once, as the PREMISE: an ordinary +/// receiver really does mint per append. Without that the comparison would +/// be against an unknown. +/// +/// **Must-fail control:** neuter the latch — make `latch_object_to_dictionary` +/// return `false`, or make `is_dictionary` return `false` — and `publications` +/// goes to 0 while the receiver mints per append exactly like the control. +/// A latch whose removal changes nothing is not a latch. +#[test] +fn appends_after_the_latch_mint_no_shape_ids() { + let _global = crate::gc::global_side_table_test_lock(); + let _restore = scopeguard_latch(); + const APPENDS: u32 = 24; + unsafe { + // Premise: an ordinary receiver mints at least one id per append. + let control = js_object_alloc(0, 0); + for i in 0..4 { + set_key(control, &format!("dictctl_{i:02}"), i as f64); + } + let before = super::shapes::test_shape_id_counter(); + for i in 4..(4 + APPENDS) { + set_key(control, &format!("dictctl_{i:02}"), i as f64); + } + let unlatched = super::shapes::test_shape_id_counter() - before; + assert!( + unlatched >= APPENDS, + "test premise: an ordinary receiver mints at least one id per \ + append (saw {unlatched} over {APPENDS}); without it the bound \ + below is vacuous" + ); + + // The claim. + let obj = js_object_alloc(0, 0); + for i in 0..4 { + set_key(obj, &format!("dictarm_{i:02}"), i as f64); + } + assert!(dictionary::latch_object_to_dictionary(obj)); + dictionary::test_reset_counters(); + for i in 4..(4 + APPENDS) { + set_key(obj, &format!("dictarm_{i:02}"), i as f64); + } + let publications = dictionary::dictionary_publications(); + let regenerations = dictionary::dictionary_regenerations(); + + assert!( + publications >= u64::from(APPENDS), + "test premise: every append must reach `publish_keys` (saw \ + {publications} for {APPENDS} appends). Zero here is what the \ + must-fail control produces, so a green run with zero would mean \ + the test had stopped testing." + ); + // A same-array append mints nothing; only a reallocation does, and + // `js_array_push` grows geometrically. What must not survive is + // linearity. + assert!( + regenerations * 4 < publications, + "a latched receiver must draw asymptotically fewer identities \ + than it takes appends ({regenerations} draws for {publications} \ + appends). Equal counts mean the latch is doing nothing." + ); + // Every append still landed, and reads still resolve. + for i in 0..(4 + APPENDS) { + assert_eq!(get_key(obj, &format!("dictarm_{i:02}")), i as f64); + } + } +} + +/// **P5.** The instrument must distinguish "off", "armed and never reached" +/// and "armed, reached, declined" — the false-zero rule. A counter that only +/// says `0` cannot tell an operator which of the three happened. +#[test] +fn the_latch_counters_distinguish_never_fired_from_never_armed() { + let _global = crate::gc::global_side_table_test_lock(); + let _restore = scopeguard_latch(); + unsafe { + // Off: the predicate answers false and records nothing. + dictionary::test_arm_latch(None); + dictionary::test_reset_counters(); + assert!(!dictionary::dictionary_latch_armed()); + let obj = js_object_alloc(0, 0); + for i in 0..4 { + set_key(obj, &format!("dictcnt_{i:02}"), i as f64); + } + assert_eq!( + dictionary::dictionary_latch_candidates(), + 0, + "an unarmed latch must not even count: a default-off knob costs \ + one relaxed load and nothing else" + ); + assert_eq!(dictionary::dictionary_latches(), 0); + + // Armed above every key count this loop reaches: REACHED and DECLINED. + dictionary::test_arm_latch(Some(1_000_000)); + dictionary::test_reset_counters(); + let declined = js_object_alloc(0, 0); + for i in 0..4 { + set_key(declined, &format!("dictdec_{i:02}"), i as f64); + } + assert!( + dictionary::dictionary_latch_candidates() > 0, + "armed with candidates=0 is the bug shape: the knob is set and the \ + call site is not on the path" + ); + assert_eq!( + dictionary::dictionary_latches(), + 0, + "the threshold was above every key count in this loop" + ); + assert!(!dictionary::is_dictionary(declined)); + + // Armed at a reachable threshold: FIRED. + dictionary::test_arm_latch(Some(3)); + dictionary::test_reset_counters(); + let fired = js_object_alloc(0, 0); + for i in 0..6 { + set_key(fired, &format!("dictfire_{i:02}"), i as f64); + } + assert!( + dictionary::dictionary_latches() > 0, + "a latch that cannot be observed to fire is documentation" + ); + assert!( + dictionary::is_dictionary(fired), + "the receiver that tripped the threshold must be in dictionary mode" + ); + for i in 0..6 { + assert_eq!(get_key(fired, &format!("dictfire_{i:02}")), i as f64); + } + assert!( + dictionary::dictionary_publications() > 0, + "the publications counter is what says the mode absorbed work \ + that would otherwise have minted" + ); + } +} + +/// A receiver carrying tombstones is refused, because `hole_count` is a fact a +/// keyless dictionary shape does not carry and latching over one would drop +/// it. Stated as a test so the limit is a decision, not an accident. +#[test] +fn a_receiver_with_holes_is_refused() { + let _global = crate::gc::global_side_table_test_lock(); + let _restore = scopeguard_latch(); + unsafe { + let obj = js_object_alloc(0, 0); + for i in 0..20 { + set_key(obj, &format!("dicthole_{i:02}"), i as f64); + } + // Two deletes: the first transfers ownership of a shared keys array, + // only the second can tombstone (see `tombstone_tests`). + for name in ["dicthole_11", "dicthole_07"] { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) + as *const crate::StringHeader; + super::delete_rest::js_object_delete_field(obj, key); + } + if super::shapes::object_shape_hole_count(obj) == 0 { + // The tombstone lane is flag-gated; without a hole there is + // nothing for this test to assert and it must not pretend. + return; + } + assert!( + !dictionary::latch_object_to_dictionary(obj), + "a receiver with tombstones must be refused, not silently latched \ + with its hole count dropped" + ); + } +} + +/// **Trigger 2.** Layout-id exhaustion is a correctness trigger, not a policy +/// one: an object the interning allocator cannot give a layout id to cannot be +/// interned at all, so dictionary mode is the only place left for it. +/// +/// A fixture that really exhausts a 24-bit id space is impractical, so the +/// budget is injected — which is the whole reason it is a published number +/// rather than an internal counter. Without this the exhaustion arm would be +/// a branch no test can reach, which is the shape of check that cannot fail. +#[test] +fn layout_id_exhaustion_latches_whatever_the_key_count() { + let _global = crate::gc::global_side_table_test_lock(); + let _restore = scopeguard_latch(); + unsafe { + // A budget that is merely LOW must not latch anything: the trigger is + // exhaustion, not pressure. Without this half the test would pass on a + // predicate that latched on any published budget at all. + dictionary::test_arm_latch(None); + dictionary::test_clear_layout_id_budget(); + dictionary::note_layout_id_budget(1); + assert!(!dictionary::dictionary_layout_ids_exhausted()); + let spare = js_object_alloc(0, 0); + set_key(spare, "dictx_low", 1.0); + assert!( + !dictionary::is_dictionary(spare), + "a low budget is not an exhausted one" + ); + + // Exhausted: the next publication latches, whatever the key count, + // and is attributed to trigger 2. + dictionary::test_reset_counters(); + let obj = js_object_alloc(0, 0); + set_key(obj, "dictx_a", 1.0); + set_key(obj, "dictx_b", 2.0); + assert!( + !dictionary::is_dictionary(obj), + "test premise: nothing has latched while ids are available" + ); + + dictionary::note_layout_id_budget(0); + assert!(dictionary::dictionary_layout_ids_exhausted()); + set_key(obj, "dictx_c", 3.0); + + // Each step separately, so a failure names which one broke rather + // than leaving "it did not latch" to be bisected by hand. + assert!( + dictionary::dictionary_latch_candidates() > 0, + "the predicate was never reached: the latch site is not on the \ + add-key path" + ); + assert!( + dictionary::dictionary_exhaustion_latches() > 0, + "the predicate was reached but did not attribute to exhaustion" + ); + assert!( + dictionary::dictionary_latches() > 0, + "the predicate said yes and the conversion refused" + ); + assert!( + dictionary::is_dictionary(obj), + "the conversion reported success and the receiver is not in \ + dictionary mode" + ); + // And it is a working object, not just a converted one. + set_key(obj, "dictx_d", 4.0); + assert_eq!(get_key(obj, "dictx_a"), 1.0); + assert_eq!(get_key(obj, "dictx_b"), 2.0); + assert_eq!(get_key(obj, "dictx_c"), 3.0); + assert_eq!(get_key(obj, "dictx_d"), 4.0); + } +} diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 7327252226..0709b934c2 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1573,12 +1573,25 @@ pub(crate) fn get_field_by_name_object_tail( // #8113: the live inline-slot bound is a parameter now. // This is a READ path — it must not change the bound, so it // republishes exactly what the receiver already carries. - let id = super::super::shapes::stamp_object_shape( - obj as *mut ObjectHeader, - keys, - key_count as u32, - live_slots, - ); + // #10868 step 2.5 stage 1: a dictionary receiver + // publishes NO keys, and `keys` here is the private list + // out of its `ObjectMeta`. Re-stamping would hand it back + // a shape claiming that list — silently UN-LATCHING it on + // the first read, which is how a mode that works under + // writes still reverts under reads. Take the `id == 0` + // fallback below instead: the entry is then keyed on the + // keys-array address, which for a dictionary receiver is + // per-object by construction. + let id = if crate::object::dictionary::is_dictionary(obj as *const _) { + 0 + } else { + super::super::shapes::stamp_object_shape( + obj as *mut ObjectHeader, + keys, + key_count as u32, + live_slots, + ) + }; let store_key = if id != 0 { id as usize } else { keys_id }; let store_idx = (store_key.wrapping_add(key_hash as usize)) % super::FIELD_CACHE_SIZE; diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index d8d5e7b568..5749b19c03 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -1086,6 +1086,15 @@ pub(super) fn get_field_ic_miss_impl( let value = js_object_get_field_by_name(obj, key); return f64::from_bits(value.bits()); }; + // #10868 step 2.5 stage 1: "no keys array" implies "no own + // properties" for every receiver EXCEPT a dictionary-mode one, + // whose key list lives in its `ObjectMeta`. Priming the + // inherited-read cache on that claim would answer an OWN property + // from the prototype chain — a wrong value, not a slow one. + if crate::object::dictionary::is_dictionary(obj) { + let value = js_object_get_field_by_name(obj, key); + return f64::from_bits(value.bits()); + } let keys = shape.keys as usize as *mut crate::array::ArrayHeader; if keys.is_null() || (keys as usize) <= 0x10000 { if diag { @@ -1119,8 +1128,15 @@ pub(super) fn get_field_ic_miss_impl( super::get_field_by_name::get_field_by_name_past_inherited_cache(obj, key); return f64::from_bits(value.bits()); } - let key_count = shape.logical_key_count as usize; - let keys_data = (keys as *const u8).add(8) as *const f64; + // #10939: `header + 8` is not where a keys array's elements + // start — `array_front_offset` is nonzero for any array with a + // front reserve. Scanning from the wrong base compares header and + // reserve words against the key, so an own property misses here + // and is answered from the prototype chain instead. `keys` came + // out of a LIVE descriptor on this straight-line path, which is + // exactly what the `_resolved` accessor is for. + let (keys_data, keys_slots) = crate::object::keys_array_dense_slots_resolved(keys); + let key_count = std::cmp::min(shape.logical_key_count as usize, keys_slots); let alloc_limit = shape.live_inline_slot_count as usize; for i in (0..key_count).rev() { // #10595: back-to-front so a shadowed field's most-derived slot wins; see keys_lookup.rs. diff --git a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs index 3e9772c2a9..0525f1ebc9 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs @@ -87,6 +87,12 @@ pub(crate) unsafe fn try_existing_own_data_overwrite( if shape.object_kind != crate::object::shapes::ShapeObjectKind::Ordinary { return false; } + // #10868 step 2.5 stage 1: this path takes its bound from the descriptor + // and its keys pointer from `object_keys_array`; for a dictionary-mode + // receiver those name different arrays. + if crate::object::dictionary::is_dictionary(obj) { + return false; + } let live_slots = shape.live_inline_slot_count; let Some(key_gc) = crate::value::addr_class::try_read_gc_header(key_addr) else { diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index a929895ab1..572ee64224 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -646,24 +646,36 @@ pub(crate) fn set_field_by_name_object_tail( // that starts with `{}` and sets the same first key hits the // fast path above instead of allocating a fresh 4-elem // keys_array here. - transition_cache_insert( - if record_array_tail { - obj as *const ObjectHeader - } else { - std::ptr::null() - }, - prev_shape_id, - interned_key, - new_keys as usize, - 0, - super::shapes::object_shape_stamp(obj), - ); + // #10868 step 2.5 stage 1: a dictionary receiver's keys array is PRIVATE, + // and `transition_cache_insert` stamps `GC_FLAG_SHAPE_SHARED` on whatever + // it caches — which makes the very next append clone the array instead of + // extending it, i.e. O(k) copying per append, the cost this mode exists to + // remove. A dictionary receiver publishes no shape transitions, so the + // entry could never be hit by anyone else either. + if !crate::object::dictionary::is_dictionary(obj) { + transition_cache_insert( + if record_array_tail { + obj as *const ObjectHeader + } else { + std::ptr::null() + }, + prev_shape_id, + interned_key, + new_keys as usize, + 0, + super::shapes::object_shape_stamp(obj), + ); + } // #6804: birth-stamp the new dynamic shape (once per shape // birth — the transition edge above serves the siblings). // #6759 C3 rung 1: no `class_id == 0` gate — a keyless class // instance gaining its first by-name property is stamped like // any other receiver. - super::shapes::stamp_object_shape(obj, new_keys, 1, 1); + // #10868 step 2.5 stage 1: same un-latch hazard as the read + // path's field-cache stamp — this publishes an explicit keys edge. + if !crate::object::dictionary::is_dictionary(obj) { + super::shapes::stamp_object_shape(obj, new_keys, 1, 1); + } return; } @@ -798,13 +810,33 @@ pub(crate) fn set_field_by_name_object_tail( refresh_roots_after_alloc!(); let keys = crate::object::object_keys_array(obj); prev_keys_usize = keys as usize; - let src_data = (keys as *const u8).add(8) as *const f64; - let dst_data = (cloned as *mut u8).add(8) as *mut f64; - for i in 0..key_count { + // #10939: a keys array's elements do not necessarily start + // at `header + 8`. `keys_array_dense_slots` resolves a + // grow-forward pointer and adds `array_front_offset`, which is + // nonzero for any array with a front reserve — #9019's + // reserved-floor keys arrays are BORN with leading holes, and a + // size-class round-up alone can make it nonzero. The clone + // declares every published slot a pointer, so copying from the + // wrong base does not merely read the wrong bytes: it promises + // the collector that `ArrayHeader` and reserve words are heap + // pointers. A missing property now, a SIGSEGV inside the next + // collection later, with a backtrace naming something else. + let (src_data, src_len) = crate::object::keys_array_dense_slots(keys); + let dst_data = + crate::array::array_elements_ptr(cloned as *const crate::array::ArrayHeader); + // A source shorter than the shape's count means the shape is already + // lying; copy what exists rather than publishing uninitialised words + // as traced pointers. + let copied = std::cmp::min(key_count, src_len); + debug_assert_eq!( + copied, key_count, + "the shape's key count outruns its keys array" + ); + for i in 0..copied { // GC_STORE_AUDIT(INIT): cloned keys array is unpublished; layout is rebuilt before publication. - *dst_data.add(i) = *src_data.add(i); + *dst_data.add(i) = (*src_data.add(i)).to_bits(); } - (*cloned).length = key_count as u32; + (*cloned).length = copied as u32; super::rebuild_array_layout_from_slots(cloned); set_object_keys_array(obj, cloned); cloned @@ -837,18 +869,26 @@ pub(crate) fn set_field_by_name_object_tail( overflow_set(obj as usize, new_index, vbits); refresh_roots_after_alloc!(); mirror_class_object_static_write(obj, key, value); - transition_cache_insert( - if record_array_tail { - obj as *const ObjectHeader - } else { - std::ptr::null() - }, - prev_shape_id, - interned_key, - new_keys as usize, - new_index as u32, - super::shapes::object_shape_stamp(obj), - ); + // #10868 step 2.5 stage 1: a dictionary receiver's keys array is PRIVATE, + // and `transition_cache_insert` stamps `GC_FLAG_SHAPE_SHARED` on whatever + // it caches — which makes the very next append clone the array instead of + // extending it, i.e. O(k) copying per append, the cost this mode exists to + // remove. A dictionary receiver publishes no shape transitions, so the + // entry could never be hit by anyone else either. + if !crate::object::dictionary::is_dictionary(obj) { + transition_cache_insert( + if record_array_tail { + obj as *const ObjectHeader + } else { + std::ptr::null() + }, + prev_shape_id, + interned_key, + new_keys as usize, + new_index as u32, + super::shapes::object_shape_stamp(obj), + ); + } keys_index_insert( crate::object::object_keys_array(obj), (new_index + 1) as u32, @@ -885,18 +925,26 @@ pub(crate) fn set_field_by_name_object_tail( js_object_set_field(obj, new_index as u32, JSValue::from_bits(value.to_bits())); refresh_roots_after_alloc!(); mirror_class_object_static_write(obj, key, value); - transition_cache_insert( - if record_array_tail { - obj as *const ObjectHeader - } else { - std::ptr::null() - }, - prev_shape_id, - interned_key, - new_keys as usize, - new_index as u32, - super::shapes::object_shape_stamp(obj), - ); + // #10868 step 2.5 stage 1: a dictionary receiver's keys array is PRIVATE, + // and `transition_cache_insert` stamps `GC_FLAG_SHAPE_SHARED` on whatever + // it caches — which makes the very next append clone the array instead of + // extending it, i.e. O(k) copying per append, the cost this mode exists to + // remove. A dictionary receiver publishes no shape transitions, so the + // entry could never be hit by anyone else either. + if !crate::object::dictionary::is_dictionary(obj) { + transition_cache_insert( + if record_array_tail { + obj as *const ObjectHeader + } else { + std::ptr::null() + }, + prev_shape_id, + interned_key, + new_keys as usize, + new_index as u32, + super::shapes::object_shape_stamp(obj), + ); + } // #6759 C1 note: `keys_index_insert` delegates to the keys-keyed // shape records and takes the POST-append keys_array — with the // C3a migration above, an owned grow lands the append on the @@ -1011,13 +1059,33 @@ pub(crate) fn set_field_by_name_object_tail( refresh_roots_after_alloc!(); let keys = crate::object::object_keys_array(obj); prev_keys_usize = keys as usize; - let src_data = (keys as *const u8).add(8) as *const f64; - let dst_data = (cloned as *mut u8).add(8) as *mut f64; - for i in 0..key_count { + // #10939: a keys array's elements do not necessarily start + // at `header + 8`. `keys_array_dense_slots` resolves a + // grow-forward pointer and adds `array_front_offset`, which is + // nonzero for any array with a front reserve — #9019's + // reserved-floor keys arrays are BORN with leading holes, and a + // size-class round-up alone can make it nonzero. The clone + // declares every published slot a pointer, so copying from the + // wrong base does not merely read the wrong bytes: it promises + // the collector that `ArrayHeader` and reserve words are heap + // pointers. A missing property now, a SIGSEGV inside the next + // collection later, with a backtrace naming something else. + let (src_data, src_len) = crate::object::keys_array_dense_slots(keys); + let dst_data = + crate::array::array_elements_ptr(cloned as *const crate::array::ArrayHeader); + // A source shorter than the shape's count means the shape is already + // lying; copy what exists rather than publishing uninitialised words + // as traced pointers. + let copied = std::cmp::min(key_count, src_len); + debug_assert_eq!( + copied, key_count, + "the shape's key count outruns its keys array" + ); + for i in 0..copied { // GC_STORE_AUDIT(INIT): cloned keys array is unpublished; layout is rebuilt before publication. - *dst_data.add(i) = *src_data.add(i); + *dst_data.add(i) = (*src_data.add(i)).to_bits(); } - (*cloned).length = key_count as u32; + (*cloned).length = copied as u32; super::rebuild_array_layout_from_slots(cloned); set_object_keys_array(obj, cloned); cloned @@ -1075,18 +1143,26 @@ pub(crate) fn set_field_by_name_object_tail( // The cached target is stamped `GC_FLAG_SHAPE_SHARED` by // `transition_cache_insert`, which triggers clone-on-extend // on either object if someone later appends past this key. - transition_cache_insert( - if record_array_tail { - obj as *const ObjectHeader - } else { - std::ptr::null() - }, - prev_shape_id, - interned_key, - new_keys as usize, - new_index as u32, - super::shapes::object_shape_stamp(obj), - ); + // #10868 step 2.5 stage 1: a dictionary receiver's keys array is PRIVATE, + // and `transition_cache_insert` stamps `GC_FLAG_SHAPE_SHARED` on whatever + // it caches — which makes the very next append clone the array instead of + // extending it, i.e. O(k) copying per append, the cost this mode exists to + // remove. A dictionary receiver publishes no shape transitions, so the + // entry could never be hit by anyone else either. + if !crate::object::dictionary::is_dictionary(obj) { + transition_cache_insert( + if record_array_tail { + obj as *const ObjectHeader + } else { + std::ptr::null() + }, + prev_shape_id, + interned_key, + new_keys as usize, + new_index as u32, + super::shapes::object_shape_stamp(obj), + ); + } return; } // First, add the key to the keys array (may reallocate) @@ -1121,17 +1197,25 @@ pub(crate) fn set_field_by_name_object_tail( refresh_roots_after_alloc!(); mirror_class_object_static_write(obj, key, value); // Record the shape transition — see above for semantics. - transition_cache_insert( - if record_array_tail { - obj as *const ObjectHeader - } else { - std::ptr::null() - }, - prev_shape_id, - interned_key, - new_keys as usize, - new_index as u32, - super::shapes::object_shape_stamp(obj), - ); + // #10868 step 2.5 stage 1: a dictionary receiver's keys array is PRIVATE, + // and `transition_cache_insert` stamps `GC_FLAG_SHAPE_SHARED` on whatever + // it caches — which makes the very next append clone the array instead of + // extending it, i.e. O(k) copying per append, the cost this mode exists to + // remove. A dictionary receiver publishes no shape transitions, so the + // entry could never be hit by anyone else either. + if !crate::object::dictionary::is_dictionary(obj) { + transition_cache_insert( + if record_array_tail { + obj as *const ObjectHeader + } else { + std::ptr::null() + }, + prev_shape_id, + interned_key, + new_keys as usize, + new_index as u32, + super::shapes::object_shape_stamp(obj), + ); + } } } diff --git a/crates/perry-runtime/src/object/inherited_read_cache.rs b/crates/perry-runtime/src/object/inherited_read_cache.rs index 5ac55a6198..8a81d680f1 100644 --- a/crates/perry-runtime/src/object/inherited_read_cache.rs +++ b/crates/perry-runtime/src/object/inherited_read_cache.rs @@ -816,6 +816,12 @@ unsafe fn inherited_read_cache_walk( note.hops = hops; note.hop_count = hop_count as u8; + // #10868 step 2.5 stage 1: a dictionary-mode hop keeps its own keys + // in its `ObjectMeta`, so reading them off its shape would walk PAST + // an own property and cache a farther-up value. + if crate::object::dictionary::is_dictionary(next) { + return None; + } let keys = shape.keys as usize as *const crate::array::ArrayHeader; if !keys.is_null() { if let Some(slot) = diff --git a/crates/perry-runtime/src/object/inherited_read_cache_tests.rs b/crates/perry-runtime/src/object/inherited_read_cache_tests.rs index bb6bc6f09b..f287283bd3 100644 --- a/crates/perry-runtime/src/object/inherited_read_cache_tests.rs +++ b/crates/perry-runtime/src/object/inherited_read_cache_tests.rs @@ -91,28 +91,89 @@ fn a_repeated_inherited_read_is_served_by_the_cache() { } } +/// An entry is keyed on a SHAPE, not on a receiver, so two receivers that +/// genuinely have one shape must be served by one entry. +/// +/// The construction order below is the test, not incidental setup. Two +/// receivers built "the same way" do NOT automatically have one ShapeId, and +/// the two things that decide it are both ordering-sensitive: +/// +/// 1. **The key-add must reuse the first receiver's edge.** A ShapeId's +/// identity includes the keys ARRAY ADDRESS, and two objects share one only +/// when the second's key-add hits `object::transition_cache_lookup` — a +/// 16384-entry DIRECT-MAPPED table hashed on `(predecessor ShapeId, the +/// interned key's address)`. A collision from an unrelated entry evicts the +/// edge, the second receiver mints its own keys array and its own ShapeId, +/// and nothing is wrong: a transition-cache miss costs a duplicate shape, +/// never a wrong answer. But it is address-keyed, so whether it collides +/// varies with heap placement RUN TO RUN. With an unrelated call between +/// the two `set`s this test failed 2 runs in 6 of the same binary. Both +/// key-adds therefore happen back to back, with only an allocation between +/// them, so the edge the first one inserts is certain to still be there. +/// 2. **Both prototype links must precede the prime.** `Object.setPrototypeOf` +/// is a semantic property event: it bumps `prop_plan_epoch`, which bumps the +/// one validity word every entry is re-proved against +/// (`object::proto_validity`, check 1). Linking `second` AFTER priming +/// `first` retires the entry that was just made. Measured, every other field +/// of the entry matched the second receiver exactly — same class id `0x0`, +/// same ShapeId, same recorded prototype bits, same slot index — and only +/// `validity` differed, by one. The miss that followed said nothing +/// whatever about entry sharing. +/// +/// The ShapeId merge is then asserted rather than assumed. #10931 mints a +/// prototype divergence's generation as a pure function of `(predecessor +/// ShapeId, the prototype's stable serial, link kind)`, so with (1) holding, +/// these two land on ONE ShapeId. Before it, each drew a fresh value from the +/// monotonic counter and they never could, which is why this test was written +/// with an `if (*first).parent_class_id == (*second).parent_class_id` guard +/// around its assertion. That word IS the ShapeId +/// ([`shapes::object_shape_stamp`] reads it), the two were never equal, and the +/// body never ran: the test was dormant from the day it was written. It is an +/// assertion now, so it can never go quiet again. #[test] fn a_second_receiver_of_the_same_shape_shares_the_entry() { let _scope = PrimeScope::new(); unsafe { let proto = crate::object::js_object_alloc(0, 4); set(proto, "irc_a", 7.0); - let first = crate::object::js_object_alloc(0, 4); - set(first, "irc_own", 1.0); - crate::object::js_object_set_prototype_of(boxed(first), boxed(proto)); - let k = key("irc_a"); - inherited_read_cache_prime(first, k).expect("prime"); // A second receiver reaching the SAME prototype through the same // operation: same class id, same recorded prototype bits, same shape. + let first = crate::object::js_object_alloc(0, 4); let second = crate::object::js_object_alloc(0, 4); + set(first, "irc_own", 1.0); set(second, "irc_own", 1.0); + crate::object::js_object_set_prototype_of(boxed(first), boxed(proto)); crate::object::js_object_set_prototype_of(boxed(second), boxed(proto)); - if (*first).parent_class_id == (*second).parent_class_id { - let value = inherited_read_cache_hit(second, k) - .expect("two receivers with one shape must share one entry"); - assert_eq!(f64::from_bits(value.bits()), 7.0); - } + + assert_eq!( + (*first).class_id, + (*second).class_id, + "the two receivers must share a class id, or the entry index \ + separates them for a reason that has nothing to do with shape" + ); + assert_eq!( + shapes::object_shape_stamp(first), + shapes::object_shape_stamp(second), + "#10931: the same divergence from the same predecessor to the same \ + prototype must mint ONE ShapeId. Two here and this test has no \ + subject — the assertions below would be asking whether two \ + DIFFERENT shapes share an entry, which they must not" + ); + + let k = key("irc_a"); + inherited_read_cache_prime(first, k).expect("prime"); + let hits_before = inherited_read_cache_hits(); + let value = inherited_read_cache_hit(second, k) + .expect("two receivers with one shape must share one entry"); + assert_eq!(f64::from_bits(value.bits()), 7.0); + assert_eq!( + inherited_read_cache_hits(), + hits_before + 1, + "the second receiver was answered without the cache hitting — a \ + fall-through to the chain walk returns the same 7.0 and is \ + invisible in a program's output" + ); } } diff --git a/crates/perry-runtime/src/object/keys_front_offset_tests.rs b/crates/perry-runtime/src/object/keys_front_offset_tests.rs new file mode 100644 index 0000000000..6e7e79c378 --- /dev/null +++ b/crates/perry-runtime/src/object/keys_front_offset_tests.rs @@ -0,0 +1,103 @@ +//! #10939: an ordered keys array's elements do not start at `header + 8`. +//! +//! `array_front_offset` is `array_physical_capacity - capacity`, so logical +//! element zero sits past the header for any array whose front has been +//! consumed — a dense-queue shift, a `GC_ARRAY_NAMED_PROPS` reserve, #9019's +//! reserved-floor seed, or a size-class round-up on its own. Four sites +//! hand-computed the offset instead of asking `keys_array_dense_slots` / +//! `array_elements_ptr`, and all four sit on a clone-before-mutate path for a +//! keys array. +//! +//! This pins the `[[Set]]` growth site end to end, which is the one a plain +//! program reaches. The failure is worse than losing a key: the clone's +//! published prefix is a region the collector walks as heap pointers, so +//! copying from the wrong base hands it `ArrayHeader` and reserve words to +//! trace — a missing property now, a SIGSEGV inside an unrelated collection +//! later. + +use super::{js_object_alloc, js_object_set_field_by_name, object_keys_array}; + +/// The receiver's ordered key list, decoded. +unsafe fn key_names(obj: *mut super::ObjectHeader) -> Vec { + let keys = object_keys_array(obj); + let (slots, len) = super::keys_array_dense_slots(keys); + if slots.is_null() { + return Vec::new(); + } + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let mut out = Vec::new(); + for i in 0..len { + let value = crate::JSValue::from_bits((*slots.add(i)).to_bits()); + if let Some(bytes) = crate::string::js_string_key_bytes(value, &mut sso) { + out.push(String::from_utf8_lossy(bytes).into_owned()); + } + } + out +} + +unsafe fn push_name( + keys: *mut crate::array::ArrayHeader, + name: &str, +) -> *mut crate::array::ArrayHeader { + let s = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + crate::array::js_array_push(keys, crate::JSValue::string_ptr(s)) +} + +/// A keys array whose FRONT has been consumed must survive the +/// clone-before-push that a `[[Set]]` append performs on a shared key list. +#[test] +fn a_keys_array_with_a_consumed_front_survives_the_clone_before_push() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + // A real runtime path produces the offset: `shift_dense` hands the + // vacated slot to the front offset instead of moving the survivors. + // Building the header by hand would prove only that the hand-built + // header is wrong. + let mut keys = crate::array::js_array_alloc(4); + for name in ["fo_dropped", "fo_a", "fo_b", "fo_c"] { + keys = push_name(keys, name); + } + crate::array::js_array_shift_f64(keys); + + assert_eq!( + crate::array::js_array_length(keys), + 3, + "premise: the shift consumed exactly one element" + ); + let hand_computed = + (keys as *const u8).add(std::mem::size_of::()) as usize; + let accessor = + crate::array::array_elements_ptr(keys as *const crate::array::ArrayHeader) as usize; + assert_ne!( + accessor, hand_computed, + "premise: the front must be consumed, or `header + 8` and the \ + element accessor name the same address and this test proves \ + nothing" + ); + + // Install it as a receiver's ordered key list, and mark it SHARED — + // the bit the transition cache stamps, and the only thing that sends + // the next append down the clone-before-push branch. + let obj = js_object_alloc(0, 8); + super::set_object_keys_array_with_live(obj, keys, 3); + let keys_gc = (keys as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + (*keys_gc).gc_flags |= crate::gc::GC_FLAG_SHAPE_SHARED; + assert_eq!( + key_names(obj), + vec!["fo_a", "fo_b", "fo_c"], + "premise: the receiver starts with the three surviving keys" + ); + + // The append that clones. + let added = crate::string::js_string_from_bytes(b"fo_d".as_ptr(), 4); + js_object_set_field_by_name(obj, added, 4.0); + + assert_eq!( + key_names(obj), + vec!["fo_a", "fo_b", "fo_c", "fo_d"], + "the clone copied from `header + 8` instead of the array's element \ + base: the consumed front slot came back as a key and the last \ + real key was dropped (#10939)" + ); + } +} diff --git a/crates/perry-runtime/src/object/meta_accessors.rs b/crates/perry-runtime/src/object/meta_accessors.rs index 71d428e175..c94254374a 100644 --- a/crates/perry-runtime/src/object/meta_accessors.rs +++ b/crates/perry-runtime/src/object/meta_accessors.rs @@ -30,14 +30,18 @@ pub(crate) unsafe fn object_meta_ensure_for_cell(user_ptr: usize) -> Option<*mut (*meta).flags = 0; (*meta).spill = 0; (*meta).private_evaluation_brand = 0; + (*meta).proto_serial = 0; (*meta).native_state = 0; (*meta).array_subclass_named_prefix_token = 0; + #[cfg(feature = "shape-mint-diag")] + crate::object::shape_mint_census::note_event("ObjectMeta record born"); (*meta).array_tail_object_hot = 0; (*meta).array_subclass_dense_key = 0; (*meta).array_subclass_dense_slots = 0; (*meta).array_subclass_dense_bounds = 0; (*meta).expando = 0; (*meta).elements = 0; + (*meta).dictionary_keys = 0; // GC_STORE_AUDIT(BARRIERED): header-slot store followed by an object-slot // barrier, exactly as `object_meta_ensure` does for an `ObjectHeader`. *slot = meta; @@ -75,14 +79,18 @@ pub(crate) unsafe fn object_meta_ensure(obj: *mut ObjectHeader) -> *mut ObjectMe (*meta).flags = 0; (*meta).spill = 0; (*meta).private_evaluation_brand = 0; + (*meta).proto_serial = 0; (*meta).native_state = 0; (*meta).array_subclass_named_prefix_token = 0; + #[cfg(feature = "shape-mint-diag")] + crate::object::shape_mint_census::note_event("ObjectMeta record born"); (*meta).array_tail_object_hot = 0; (*meta).array_subclass_dense_key = 0; (*meta).array_subclass_dense_slots = 0; (*meta).array_subclass_dense_bounds = 0; (*meta).expando = 0; (*meta).elements = 0; + (*meta).dictionary_keys = 0; // GC_STORE_AUDIT(BARRIERED): meta-record edge is a header-slot store // followed by an object-slot barrier, mirroring `set_object_keys_array`. (*obj).meta = meta; diff --git a/crates/perry-runtime/src/object/meta_record.rs b/crates/perry-runtime/src/object/meta_record.rs new file mode 100644 index 0000000000..d296084353 --- /dev/null +++ b/crates/perry-runtime/src/object/meta_record.rs @@ -0,0 +1,236 @@ +//! The per-object metadata record (`ObjectMeta`) and the offsets emitted code +//! addresses it by. +//! +//! Split out of `object/mod.rs` for the 2,000-line gate, which the record's +//! sixteenth word (#10868 step 2.5 stage 1's `dictionary_keys`) took past the +//! cap. The struct and its `offset_of!` pins are one unit — the pins exist +//! precisely because `perry-codegen` reaches words 4, 6, 7 and 12 by index — +//! so they move together or the contract is split from the thing it +//! constrains. +//! +//! Moving it also makes `object/mod.rs` a file one lane at a time no longer +//! has to share: the record and the transition cache were the two regions in +//! it owned by different lanes of the One Path campaign. + +/// #6759 Phase B: per-object metadata record, reached from +/// [`ObjectHeader::meta`] in two dependent loads (no side-table probe). +/// +/// GC-arena allocated (`GC_TYPE_OBJECT_META`). Its header slot is a traced + +/// rewritten child edge (the record is reachable ONLY through its owner), +/// so liveness, evacuation, and death all ride the ordinary GC — no manual +/// free paths, no owner registry, and no stale-address hazard: the record +/// dies with (and only with) its owner. +/// +/// Only the authoritative `GC_TYPE_OBJECT` kind has this layout. RegExp uses +/// its own GC kind and slot descriptor, so no ObjectHeader consumer needs to +/// inspect its native payload to disambiguate the two. +/// +/// The shipped Phase B record holds the custom `[[Prototype]]`, the Phase C2 +/// per-key descriptor summaries, object flags, and owned spill storage. The +/// RFC also sketched an exotic-kind tag here, but Date/RegExp/Error/Promise/ +/// Map/Set/Temporal have distinct cell layouts rather than an `ObjectHeader`; +/// representing their kind here first requires header unification. Their +/// expando payloads therefore remain in the per-thread `RuntimeState` with GC +/// rekey/prune defenses instead of being described as the next incremental +/// `ObjectMeta` migration. +#[repr(C)] +pub struct ObjectMeta { + /// Custom `[[Prototype]]` recorded by a user-facing operation or runtime + /// prototype wiring: the NaN-boxed proto bits, + /// `crate::value::TAG_NULL` for an explicit null prototype, or 0 when + /// unset (fall back to default prototype resolution). + pub prototype: u64, + /// #6759 Phase C2: Bloom summary of the string keys with a customized + /// property descriptor (non-default writable/enumerable/configurable) + /// installed on THIS object — bit `key_bytes_hash(key) & 63` per key. + /// Monotonic (descriptor removal never clears a bit — another key may + /// share it; a spurious bit just costs one table probe). A clear bit is + /// authoritative: no `property_descriptors` entry `(owner, key)` can + /// exist for a key whose bit is clear, so the hot paths skip the + /// side-table probe (and its per-call `String` build) entirely. POD — + /// the GC trace arm visits the record's three child edges explicitly. + pub attr_key_bits: u64, + /// Same summary for accessor descriptors (`get`/`set` installs) — the + /// `accessor_descriptors` table twin of `attr_key_bits`. + pub accessor_key_bits: u64, + /// Object-only state and compact scalar proof payloads. Bit 0 records + /// prototype-semantic divergence (including runtime wiring); bit 3 records + /// that a user-facing operation chose the prototype. Keeping those signals + /// separate prevents internal wiring from masquerading as + /// `Object.setPrototypeOf`. #8690 reserves bits 1..2 and 8..63 for the + /// packed Array-subclass numeric-prefix proof (kind, verified bound, and + /// ShapeId); + /// its address-reuse-safe authority is a type-specific GcHeader bit. + /// In particular, GcHeader bit 12 is `GC_OBJ_TYPED_LAYOUT_INTACT`, so + /// using that word for prototype divergence made every typed-layout + /// object appear to have a custom prototype. + pub flags: u64, + /// #6812: object-owned overflow storage — a `GC_TYPE_ARRAY` buffer + /// (`*mut ArrayHeader` bits, 0 = none) holding the NaN-boxed values of + /// properties whose field index is at or past the inline alloc_limit, + /// indexed by ABSOLUTE field index (the inline region's entries stay + /// hole/undefined, mirroring the retired side-table Vec's fillers). + /// A traced child edge exactly like `prototype`: the buffer lives and + /// moves with this record, which lives and moves with its owner — no + /// pointer-keyed side state, no owner re-keying on evacuation, no + /// per-object finalization. + pub spill: u64, + /// Fresh ClassDefinitionEvaluation identity for instances constructed + /// from a heap class object. This is object metadata rather than an own + /// property: private branding must not consume a user field slot, alter + /// the ShapeId/key order, or become visible to enumeration. + pub private_evaluation_brand: u64, + /// Exact class-declared named-prefix identity for an Array-subclass + /// receiver. Numeric tail mutations change the ordinary ShapeId on every + /// push/pop even though the named slots before that tail remain fixed. + /// Property-read PICs may use this nonzero scalar as a second identity + /// only after `array_subclass_named_prefix_token` has proved the current + /// keys against the class's registered allocation keys. Generic shape or + /// semantic transitions clear it; the exact learned numeric-tail + /// transition is the only publisher that deliberately preserves it. + pub array_subclass_named_prefix_token: u64, + /// Native pointer to this receiver's per-thread [`ObjectHotTables`]. + /// Array-subclass tail transitions are agent-local: their ShapeIds and + /// rooted key arrays belong to the same thread that owns the object. Once + /// a transition is learned, caching that stable heap allocation here lets + /// every later push/pop reach the full historical shape lattice without a + /// Darwin TLS/TSD lookup first. + /// + /// This is NOT a managed-heap edge and the ObjectMeta slot visitors must + /// deliberately ignore it. Perry workers deep-copy values into independent + /// arenas rather than sharing ObjectHeaders, so an object cannot carry the + /// pointer into another agent. The RuntimeState allocation outlives every + /// object in that thread. + pub array_tail_object_hot: u64, + /// Move-stable, receiver-local cache of the Array-subclass dense layout. + /// `array_subclass_dense_key` is `(class_id << 32) | ShapeId`; the two + /// payload words use the same packing as `array::subclass`'s global + /// collision cache. They contain scalar slot indices only, never managed + /// pointers. A generic semantic/structural mutation publishes a new + /// ShapeId before it becomes observable, so a stale payload misses by key + /// without a pointer-side-table invalidation walk. Exact learned numeric + /// tail transitions update these words directly. + pub array_subclass_dense_key: u64, + pub array_subclass_dense_slots: u64, + pub array_subclass_dense_bounds: u64, + /// #6759 phase 1: named own properties for a cell that has no + /// `keys_array`/inline-slot layout of its own — a NaN-boxed pointer to an + /// ordinary object used as the property bag, or 0 when the owner has none. + /// + /// An `ErrorHeader` (and every other exotic cell) cannot store named + /// properties inline, which is why they lived in `ERROR_USER_PROPS`, keyed + /// by the owner's ADDRESS and needing four GC hooks of their own — + /// rekey-on-evacuation, finalize, dead-sweep and a root scanner — plus the + /// long-standing bug that a recycled address inherited the previous + /// tenant's properties. + /// + /// Hanging the bag off the metadata record instead makes it an ordinary + /// child edge: it moves with its owner, dies with its owner, and needs no + /// address bookkeeping at all. + pub expando: u64, + /// Elements backing store of a `class X extends Array` instance: a + /// `GC_TYPE_ARRAY` (`*mut ArrayHeader` bits, 0 = none) holding the + /// instance's indexed elements and `length`, exactly as a plain Array + /// does — so `push`/`pop`/`obj[i]` are element operations instead of + /// property-shape transitions (`array/subclass_elements.rs`). A traced + /// child edge exactly like `spill`: lives and moves with this record. + /// Installed by `js_array_subclass_init` under + /// `array_subclass_elements_enabled()`; never present otherwise. + pub elements: u64, + /// #10287 exact identity for the overwhelmingly common case of an object + /// carrying descriptors for exactly ONE key. `descriptor_key_count` is 0 + /// (none recorded), 1 (`descriptor_key_hash` is the full + /// `key_bytes_hash` of that single key) or 2 (more than one distinct key + /// — consult the Bloom summaries and then the tables). + /// + /// The 64-bit Bloom above answers "maybe" for about one key in 64, and a + /// maybe costs far more than a table probe: the store it rejects takes + /// the slow path, which appends to a PRIVATE keys array and drops the + /// receiver off the shared transition chain for the rest of its life. + /// zod installs exactly one descriptor per schema (`_zod`), so a single + /// full-width compare here answers every store on those objects exactly, + /// with no table probe and no string rebuild. + /// + /// Maintained by the same writer as the Bloom bits + /// (`note_meta_descriptor_key`), so it inherits that function's + /// invariant: every descriptor-table insert for a meta-capable owner + /// records its key here first. + pub descriptor_key_hash: u64, + /// Distinct descriptor-key count, saturating at 2. See + /// [`ObjectMeta::descriptor_key_hash`]. + pub descriptor_key_count: u64, + /// #10868 step 2.5 stage 1: the ordered own-key list of a DICTIONARY-MODE + /// receiver — a `GC_TYPE_ARRAY` (`*mut ArrayHeader` bits, 0 = none) + /// private to this object, holding the same NaN-boxed key strings (and + /// `TAG_HOLE` tombstones) an ordinary receiver keeps in its shape's keys + /// array. Values are NOT moved by the mode: the key at position `i` still + /// reads inline slot `i` below the live bound and `spill` at or above it. + /// + /// A traced child edge exactly like `spill` (#6812): it lives and moves + /// with this record, which lives and moves with its owner — no + /// address-keyed side state, no owner re-keying on evacuation. + /// + /// Nonzero means "this receiver's keys are here" only in combination with + /// a shape that publishes none; `dictionary::is_dictionary` is the single + /// spelling of the test. See `object/dictionary.rs`. + pub dictionary_keys: u64, + /// #10868 lever (iv): a stable identity for an object that is some other + /// object's prototype, assigned once by `mark_object_as_prototype` and + /// never changed. 0 = none assigned. + /// + /// Why a serial and not the prototype's address or ShapeId: the address + /// moves under the collector (and keying on it is an address-keyed derived + /// structure); the ShapeId is shared by distinct prototypes (unsound — §3 + /// needs the receiver's shape to determine its prototype) AND changes + /// whenever the prototype gains a key (so two receivers diverging to the + /// same prototype before and after that would fork). The receiver's + /// generation must key on the prototype's IDENTITY, not its STATE. + /// + /// Placed before `native_state` so every codegen-addressed offset above is + /// unchanged and `native_state` stays the last word (#340/#341). + pub proto_serial: u64, + /// #340/#341 honest tags: packed state for a runtime class whose instances + /// are ORDINARY objects rather than small registry handles + /// (`TextEncoder` / `TextDecoder` today; the other twelve families follow). + /// + /// The layout is private to the owning family — `text.rs` packs + /// `(present | encoding index | fatal | ignoreBOM)` here — and `0` means + /// "this object has no native state". It lives in the meta record rather + /// than an inline slot for the same reason as + /// [`ObjectMeta::private_evaluation_brand`]: it must not consume a user + /// field slot, alter the ShapeId or key order, or become visible to + /// enumeration. An inline slot would also be handed to the first user + /// expando (`decoder.mine = 1`) by the slot allocator and overwritten. + /// + /// POD. Never a managed-heap edge — the GC trace arm visits this record's + /// child edges explicitly and this word is not one of them, exactly like + /// `array_tail_object_hot`. + /// + /// LAST FIELD ON PURPOSE: this record carries `offset_of!` assertions for + /// the words codegen and the spill lanes address by index, so a new field + /// may only be appended. + pub native_state: u64, +} + +// #6812 spill lanes: the versioned write-loop emitter +// (perry-codegen/src/stmt/loops.rs) addresses `meta.spill` at word 4 of the +// ObjectMeta record and buffer elements one word past the ArrayHeader. Keep +// codegen and these structs in lock-step. +const _: () = assert!(std::mem::offset_of!(ObjectMeta, spill) == 32); +const _: () = assert!(std::mem::offset_of!(ObjectMeta, array_subclass_named_prefix_token) == 48); +const _: () = assert!(std::mem::offset_of!(ObjectMeta, array_tail_object_hot) == 56); +// The Array-subclass elements store: codegen's inline `elem.*` tiers load +// `ObjectHeader.meta` then this word (perry-codegen `expr/index_get` and +// `property_get/composed_ics.rs`). Keep in lock-step. +const _: () = assert!(std::mem::offset_of!(ObjectMeta, elements) == 96); +// #10868 step 2.5 stage 1. Not addressed by index from emitted code, so this +// pin is documentation of where it landed rather than an ABI contract — the +// contract it must not break is the `native_state`-is-last assertion below, +// which is what forbids inserting it ahead of one of the offsets above. +const _: () = assert!(std::mem::offset_of!(ObjectMeta, dictionary_keys) == 120); +// #340/#341: `native_state` must stay the LAST word. The offsets above are +// addressed by index from emitted code, so a field inserted mid-struct moves +// them silently; this pins the append instead of trusting the comment. +const _: () = assert!( + std::mem::offset_of!(ObjectMeta, native_state) + 8 == std::mem::size_of::() +); diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 5f5e4302ea..c1e8bef481 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -87,6 +87,10 @@ mod dataview_proto_thunks; mod date_proto_thunks; mod delete_rest; pub(crate) mod descriptors; +pub(crate) mod dictionary; +mod dictionary_counters; +#[cfg(test)] +mod dictionary_tests; mod disposable_proto_thunks; pub(crate) mod exotic_expando; pub(crate) mod field_get_set; @@ -1503,9 +1507,22 @@ pub struct ObjectHeader { /// complete descriptor. #[inline] pub(crate) unsafe fn object_keys_array(obj: *const ObjectHeader) -> *mut ArrayHeader { - shapes::object_shape_descriptor(obj) - .map(|descriptor| descriptor.keys as usize as *mut ArrayHeader) - .unwrap_or(std::ptr::null_mut()) + let Some(descriptor) = shapes::object_shape_descriptor(obj) else { + return std::ptr::null_mut(); + }; + if descriptor.keys != 0 { + return descriptor.keys as usize as *mut ArrayHeader; + } + // The shape publishes no keys. Either the receiver genuinely has none, or + // it is in DICTIONARY MODE and carries its own ordered list (#10868 step + // 2.5 stage 1, `object/dictionary.rs`). This is the single derivation of + // "the receiver's keys" in the runtime, which is why one branch here gives + // every enumeration walk, `in`/`hasOwn`, `delete` and `JSON.stringify` + // node-identical behaviour on a dictionary object with no second + // implementation of key order. An ordinary receiver never reaches this + // line — the nonzero `keys` word returns above — so the branch costs + // nothing on the path that matters. + dictionary::keys_array(obj) } /// Return the two shape facts needed together by callback-free serializers. @@ -1523,176 +1540,6 @@ pub(crate) unsafe fn object_keys_and_live_slots( }) } -/// #6759 Phase B: per-object metadata record, reached from -/// [`ObjectHeader::meta`] in two dependent loads (no side-table probe). -/// -/// GC-arena allocated (`GC_TYPE_OBJECT_META`). Its header slot is a traced + -/// rewritten child edge (the record is reachable ONLY through its owner), -/// so liveness, evacuation, and death all ride the ordinary GC — no manual -/// free paths, no owner registry, and no stale-address hazard: the record -/// dies with (and only with) its owner. -/// -/// Only the authoritative `GC_TYPE_OBJECT` kind has this layout. RegExp uses -/// its own GC kind and slot descriptor, so no ObjectHeader consumer needs to -/// inspect its native payload to disambiguate the two. -/// -/// The shipped Phase B record holds the custom `[[Prototype]]`, the Phase C2 -/// per-key descriptor summaries, object flags, and owned spill storage. The -/// RFC also sketched an exotic-kind tag here, but Date/RegExp/Error/Promise/ -/// Map/Set/Temporal have distinct cell layouts rather than an `ObjectHeader`; -/// representing their kind here first requires header unification. Their -/// expando payloads therefore remain in the per-thread `RuntimeState` with GC -/// rekey/prune defenses instead of being described as the next incremental -/// `ObjectMeta` migration. -#[repr(C)] -pub struct ObjectMeta { - /// Custom `[[Prototype]]` recorded by a user-facing operation or runtime - /// prototype wiring: the NaN-boxed proto bits, - /// `crate::value::TAG_NULL` for an explicit null prototype, or 0 when - /// unset (fall back to default prototype resolution). - pub prototype: u64, - /// #6759 Phase C2: Bloom summary of the string keys with a customized - /// property descriptor (non-default writable/enumerable/configurable) - /// installed on THIS object — bit `key_bytes_hash(key) & 63` per key. - /// Monotonic (descriptor removal never clears a bit — another key may - /// share it; a spurious bit just costs one table probe). A clear bit is - /// authoritative: no `property_descriptors` entry `(owner, key)` can - /// exist for a key whose bit is clear, so the hot paths skip the - /// side-table probe (and its per-call `String` build) entirely. POD — - /// the GC trace arm visits the record's three child edges explicitly. - pub attr_key_bits: u64, - /// Same summary for accessor descriptors (`get`/`set` installs) — the - /// `accessor_descriptors` table twin of `attr_key_bits`. - pub accessor_key_bits: u64, - /// Object-only state and compact scalar proof payloads. Bit 0 records - /// prototype-semantic divergence (including runtime wiring); bit 3 records - /// that a user-facing operation chose the prototype. Keeping those signals - /// separate prevents internal wiring from masquerading as - /// `Object.setPrototypeOf`. #8690 reserves bits 1..2 and 8..63 for the - /// packed Array-subclass numeric-prefix proof (kind, verified bound, and - /// ShapeId); - /// its address-reuse-safe authority is a type-specific GcHeader bit. - /// In particular, GcHeader bit 12 is `GC_OBJ_TYPED_LAYOUT_INTACT`, so - /// using that word for prototype divergence made every typed-layout - /// object appear to have a custom prototype. - pub flags: u64, - /// #6812: object-owned overflow storage — a `GC_TYPE_ARRAY` buffer - /// (`*mut ArrayHeader` bits, 0 = none) holding the NaN-boxed values of - /// properties whose field index is at or past the inline alloc_limit, - /// indexed by ABSOLUTE field index (the inline region's entries stay - /// hole/undefined, mirroring the retired side-table Vec's fillers). - /// A traced child edge exactly like `prototype`: the buffer lives and - /// moves with this record, which lives and moves with its owner — no - /// pointer-keyed side state, no owner re-keying on evacuation, no - /// per-object finalization. - pub spill: u64, - /// Fresh ClassDefinitionEvaluation identity for instances constructed - /// from a heap class object. This is object metadata rather than an own - /// property: private branding must not consume a user field slot, alter - /// the ShapeId/key order, or become visible to enumeration. - pub private_evaluation_brand: u64, - /// Exact class-declared named-prefix identity for an Array-subclass - /// receiver. Numeric tail mutations change the ordinary ShapeId on every - /// push/pop even though the named slots before that tail remain fixed. - /// Property-read PICs may use this nonzero scalar as a second identity - /// only after `array_subclass_named_prefix_token` has proved the current - /// keys against the class's registered allocation keys. Generic shape or - /// semantic transitions clear it; the exact learned numeric-tail - /// transition is the only publisher that deliberately preserves it. - pub array_subclass_named_prefix_token: u64, - /// Native pointer to this receiver's per-thread [`ObjectHotTables`]. - /// Array-subclass tail transitions are agent-local: their ShapeIds and - /// rooted key arrays belong to the same thread that owns the object. Once - /// a transition is learned, caching that stable heap allocation here lets - /// every later push/pop reach the full historical shape lattice without a - /// Darwin TLS/TSD lookup first. - /// - /// This is NOT a managed-heap edge and the ObjectMeta slot visitors must - /// deliberately ignore it. Perry workers deep-copy values into independent - /// arenas rather than sharing ObjectHeaders, so an object cannot carry the - /// pointer into another agent. The RuntimeState allocation outlives every - /// object in that thread. - pub array_tail_object_hot: u64, - /// Move-stable, receiver-local cache of the Array-subclass dense layout. - /// `array_subclass_dense_key` is `(class_id << 32) | ShapeId`; the two - /// payload words use the same packing as `array::subclass`'s global - /// collision cache. They contain scalar slot indices only, never managed - /// pointers. A generic semantic/structural mutation publishes a new - /// ShapeId before it becomes observable, so a stale payload misses by key - /// without a pointer-side-table invalidation walk. Exact learned numeric - /// tail transitions update these words directly. - pub array_subclass_dense_key: u64, - pub array_subclass_dense_slots: u64, - pub array_subclass_dense_bounds: u64, - /// #6759 phase 1: named own properties for a cell that has no - /// `keys_array`/inline-slot layout of its own — a NaN-boxed pointer to an - /// ordinary object used as the property bag, or 0 when the owner has none. - /// - /// An `ErrorHeader` (and every other exotic cell) cannot store named - /// properties inline, which is why they lived in `ERROR_USER_PROPS`, keyed - /// by the owner's ADDRESS and needing four GC hooks of their own — - /// rekey-on-evacuation, finalize, dead-sweep and a root scanner — plus the - /// long-standing bug that a recycled address inherited the previous - /// tenant's properties. - /// - /// Hanging the bag off the metadata record instead makes it an ordinary - /// child edge: it moves with its owner, dies with its owner, and needs no - /// address bookkeeping at all. - pub expando: u64, - /// Elements backing store of a `class X extends Array` instance: a - /// `GC_TYPE_ARRAY` (`*mut ArrayHeader` bits, 0 = none) holding the - /// instance's indexed elements and `length`, exactly as a plain Array - /// does — so `push`/`pop`/`obj[i]` are element operations instead of - /// property-shape transitions (`array/subclass_elements.rs`). A traced - /// child edge exactly like `spill`: lives and moves with this record. - /// Installed by `js_array_subclass_init` under - /// `array_subclass_elements_enabled()`; never present otherwise. - pub elements: u64, - /// #10287 exact identity for the overwhelmingly common case of an object - /// carrying descriptors for exactly ONE key. `descriptor_key_count` is 0 - /// (none recorded), 1 (`descriptor_key_hash` is the full - /// `key_bytes_hash` of that single key) or 2 (more than one distinct key - /// — consult the Bloom summaries and then the tables). - /// - /// The 64-bit Bloom above answers "maybe" for about one key in 64, and a - /// maybe costs far more than a table probe: the store it rejects takes - /// the slow path, which appends to a PRIVATE keys array and drops the - /// receiver off the shared transition chain for the rest of its life. - /// zod installs exactly one descriptor per schema (`_zod`), so a single - /// full-width compare here answers every store on those objects exactly, - /// with no table probe and no string rebuild. - /// - /// Maintained by the same writer as the Bloom bits - /// (`note_meta_descriptor_key`), so it inherits that function's - /// invariant: every descriptor-table insert for a meta-capable owner - /// records its key here first. - pub descriptor_key_hash: u64, - /// Distinct descriptor-key count, saturating at 2. See - /// [`ObjectMeta::descriptor_key_hash`]. - pub descriptor_key_count: u64, - /// #340/#341 honest tags: packed state for a runtime class whose instances - /// are ORDINARY objects rather than small registry handles - /// (`TextEncoder` / `TextDecoder` today; the other twelve families follow). - /// - /// The layout is private to the owning family — `text.rs` packs - /// `(present | encoding index | fatal | ignoreBOM)` here — and `0` means - /// "this object has no native state". It lives in the meta record rather - /// than an inline slot for the same reason as - /// [`ObjectMeta::private_evaluation_brand`]: it must not consume a user - /// field slot, alter the ShapeId or key order, or become visible to - /// enumeration. An inline slot would also be handed to the first user - /// expando (`decoder.mine = 1`) by the slot allocator and overwritten. - /// - /// POD. Never a managed-heap edge — the GC trace arm visits this record's - /// child edges explicitly and this word is not one of them, exactly like - /// `array_tail_object_hot`. - /// - /// LAST FIELD ON PURPOSE: this record carries `offset_of!` assertions for - /// the words codegen and the spill lanes address by index, so a new field - /// may only be appended. - pub native_state: u64, -} - pub(crate) mod meta_flags; pub(crate) use meta_flags::{ OBJECT_META_FLAG_CLASS_EVALUATION_PROTO, OBJECT_META_FLAG_EXOTIC_READ_RECEIVER, @@ -1700,6 +1547,9 @@ pub(crate) use meta_flags::{ OBJECT_META_FLAG_USER_PROTO_OVERRIDE, }; +pub(crate) mod meta_record; +pub use meta_record::ObjectMeta; + /// Authoritative ordinary-object discriminator. RegExp has its own GC kind, /// and heap class-expression values carry their kind in the immutable ShapeId /// descriptor. #8113 deleted the legacy `ObjectHeader::object_type` ABI mirror, @@ -1732,23 +1582,6 @@ pub(crate) unsafe fn object_is_shaped(obj: *const ObjectHeader) -> bool { && header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 } -// #6812 spill lanes: the versioned write-loop emitter -// (perry-codegen/src/stmt/loops.rs) addresses `meta.spill` at word 4 of the -// ObjectMeta record and buffer elements one word past the ArrayHeader. Keep -// codegen and these structs in lock-step. -const _: () = assert!(std::mem::offset_of!(ObjectMeta, spill) == 32); -const _: () = assert!(std::mem::offset_of!(ObjectMeta, array_subclass_named_prefix_token) == 48); -const _: () = assert!(std::mem::offset_of!(ObjectMeta, array_tail_object_hot) == 56); -// The Array-subclass elements store: codegen's inline `elem.*` tiers load -// `ObjectHeader.meta` then this word (perry-codegen `expr/index_get` and -// `property_get/composed_ics.rs`). Keep in lock-step. -const _: () = assert!(std::mem::offset_of!(ObjectMeta, elements) == 96); -// #340/#341: `native_state` must stay the LAST word. The offsets above are -// addressed by index from emitted code, so a field inserted mid-struct moves -// them silently; this pins the append instead of trusting the comment. -const _: () = assert!( - std::mem::offset_of!(ObjectMeta, native_state) + 8 == std::mem::size_of::() -); const _: () = assert!(std::mem::offset_of!(ObjectHeader, meta) == 8); const _: () = assert!(std::mem::size_of::() == 8); @@ -1803,6 +1636,13 @@ unsafe fn set_object_keys_array_with_live( // `is_shape_id` says so, for class instances too, so an instance still // carrying its allocation-time `parent_class_id` (never in the ShapeId // range) is left alone. + // #10868 step 2.5 stage 1: a dictionary-mode receiver absorbs the + // publication into its own record and mints nothing. That is the bound + // the mode exists to provide; see `object/dictionary.rs`. + if dictionary::is_dictionary(obj) { + dictionary::publish_keys(obj, keys_array, live_inline_slot_count); + return; + } let predecessor = shapes::object_shape_descriptor(obj); let keys_changed = predecessor .map(|descriptor| descriptor.keys != keys_array as u64) @@ -1830,6 +1670,15 @@ unsafe fn set_object_keys_array_with_live( // `publish_object_shape_from` and every other post-birth publish now // route through — this call site no longer needs to remember the note. shapes::publish_object_shape_from(obj, predecessor, keys_array, live_inline_slot_count); + // #10868 step 2.5 stage 1. The predicate is stubbed off (see + // `dictionary::should_latch_to_dictionary`, one relaxed load when off); + // armed, this is where a receiver stops interning its key list. + if !keys_array.is_null() { + let key_count = crate::array::keys_array_len_capped_to_capacity(keys_array) as u32; + if dictionary::should_latch_to_dictionary(key_count) { + dictionary::latch_object_to_dictionary(obj); + } + } } #[inline] @@ -1892,6 +1741,8 @@ pub(super) unsafe fn mark_object_dynamic_shape_unknown(obj: *mut ObjectHeader) { /// #9180: the receiver `[[Set]]` own-key probe, split out to keep `tests.rs` /// under the 2000-line cap. #[cfg(test)] +mod keys_front_offset_tests; +#[cfg(test)] mod own_key_probe_tests; #[cfg(test)] mod restricted_function_store_tests; diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index eab4bf66aa..4f421df4e2 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -162,6 +162,12 @@ unsafe fn class_vtable_fast_guard(object: f64, method_bytes: &[u8]) -> Option<(u // tower's field lookup. ShapeId supplies both the moving root and its exact // logical length; the ObjectHeader mirrors are compatibility scratch only. let descriptor = crate::object::shapes::object_shape_descriptor(obj)?; + // #10868 step 2.5 stage 1: a dictionary-mode receiver's own fields are in + // its `ObjectMeta`, so a null `keys` word would make this shadowing scan + // vacuously true and let a vtable method win over an own field. + if crate::object::dictionary::is_dictionary(obj) { + return None; + } let keys = descriptor.keys as usize as *mut ArrayHeader; if !keys.is_null() { let keys_ptr = keys as usize; @@ -2180,6 +2186,16 @@ pub unsafe extern "C-unwind" fn js_native_call_method( let Some(descriptor) = crate::object::shapes::object_shape_descriptor(obj) else { return crate::object::null_stub_value(); }; + // #10868 step 2.5 stage 1: see the shadowing scan above. + if crate::object::dictionary::is_dictionary(obj) { + // #10924 replaced the header-less `NullObjectBytes` static with a + // real GC object; #10938 was written before that landed and still + // spelled the old static here. Reinstating it would give + // dictionary-mode receivers exactly the #10917 bug the replacement + // removed -- brand probes reading the `.rodata` bytes in front of + // a header-less value. + return crate::object::null_stub_value(); + } let keys = descriptor.keys as usize as *mut ArrayHeader; if !keys.is_null() { 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 8a277c521a..e799516874 100644 --- a/crates/perry-runtime/src/object/object_ops/keys_array.rs +++ b/crates/perry-runtime/src/object/object_ops/keys_array.rs @@ -298,14 +298,32 @@ unsafe fn ensure_key_in_keys_array_inner( let cloned = crate::array::js_array_alloc_pointer_elements(key_count as u32 + 4); refresh_define_property_roots!(); let keys = crate::object::object_keys_array(obj); - let src_data = (keys as *const u8).add(8) as *const f64; - let dst_data = (cloned as *mut u8).add(8) as *mut f64; - for i in 0..key_count { - // GC_STORE_AUDIT(INIT): cloned keys array is unpublished and its - // all-pointer layout covers only the prefix published by length. - *dst_data.add(i) = *src_data.add(i); + // #10939: a keys array's elements do not necessarily start + // at `header + 8`. `keys_array_dense_slots` resolves a + // grow-forward pointer and adds `array_front_offset`, which is + // nonzero for any array with a front reserve — #9019's + // reserved-floor keys arrays are BORN with leading holes, and a + // size-class round-up alone can make it nonzero. The clone + // declares every published slot a pointer, so copying from the + // wrong base does not merely read the wrong bytes: it promises + // the collector that `ArrayHeader` and reserve words are heap + // pointers. A missing property now, a SIGSEGV inside the next + // collection later, with a backtrace naming something else. + let (src_data, src_len) = crate::object::keys_array_dense_slots(keys); + let dst_data = crate::array::array_elements_ptr(cloned as *const crate::array::ArrayHeader); + // A source shorter than the shape's count means the shape is already + // lying; copy what exists rather than publishing uninitialised words + // as traced pointers. + let copied = std::cmp::min(key_count, src_len); + debug_assert_eq!( + copied, key_count, + "the shape's key count outruns its keys array" + ); + for i in 0..copied { + // GC_STORE_AUDIT(INIT): cloned keys array is unpublished and its all-pointer layout covers only the prefix published by length. + *dst_data.add(i) = (*src_data.add(i)).to_bits(); } - (*cloned).length = key_count as u32; + (*cloned).length = copied as u32; set_object_keys_array(obj, cloned); cloned } else { diff --git a/crates/perry-runtime/src/object/object_ops_frozen.rs b/crates/perry-runtime/src/object/object_ops_frozen.rs index 3d7760610c..835cb1a549 100644 --- a/crates/perry-runtime/src/object/object_ops_frozen.rs +++ b/crates/perry-runtime/src/object/object_ops_frozen.rs @@ -126,6 +126,29 @@ unsafe fn test_integrity_level_proxy(obj_value: f64, frozen: bool) -> bool { true } +/// #10933: may an integrity op write `OBJ_FLAG_*` into this value's header? +/// +/// The band check these call sites used to rely on keeps small registry ids +/// out, and says nothing about whether `value - 8` is a header at all. Several +/// values perry hands to JS have none: a registered symbol is a +/// `Box::into_raw`'d `SymbolHeader`, the unresolved-namespace stub is a +/// `.rodata` static (where the write FAULTS), and the async-hook handles are +/// bare `Box`es. Writing through those corrupted whatever the allocator had +/// put in front of them — measured: `Object.freeze(Symbol.for(x))` set +/// `0x7` six bytes before the symbol on 30 of 32 probes. +/// +/// The right question is ownership, not magnitude, and +/// `try_read_tracked_gc_header` is the funnel that answers it: it proves the +/// allocator owns this address on THIS thread (arena membership or the +/// gc_malloc registry) instead of trusting `addr - 8` to be a header. A value +/// it refuses takes the no-op path these functions already have for a handle +/// (`Object.freeze(handle)` returns the handle — +/// `test_gap_handle_band_object_ops`). +#[inline] +unsafe fn integrity_flags_are_writable(obj: *const ObjectHeader) -> bool { + !obj.is_null() && crate::value::addr_class::try_read_tracked_gc_header(obj as usize).is_some() +} + #[no_mangle] pub extern "C" fn js_object_freeze(obj_value: f64) -> f64 { crate::array::subclass_elements::deopt_value(obj_value); @@ -136,12 +159,14 @@ pub extern "C" fn js_object_freeze(obj_value: f64) -> f64 { } unsafe { let obj = extract_obj_ptr(obj_value); - // Reject the WHOLE handle band, not a bare `> 0x10000` floor: a - // common-band registry id (crypto `Hash`, `Blob`, …) can sit above - // 0x10000, and the `gc_header_for(obj)` write just below would store into - // unmapped memory (SIGSEGV) — `Object.freeze(handle)` is a no-op that - // returns the handle (test_gap_handle_band_object_ops `Object.freeze(blob)`). - if !obj.is_null() && crate::value::addr_class::is_above_handle_band(obj as usize) { + // #10933 replaced the handle-band check here with an OWNERSHIP check. + // The band rejected a common-band registry id (crypto `Hash`, `Blob`, + // …), which is why it was written; it could not reject a real address + // whose `- 8` is not a header, and the `gc_header_for(obj)` write just + // below then stored into memory belonging to something else. The + // no-op-and-return-the-value behaviour for a rejected receiver is + // unchanged (`test_gap_handle_band_object_ops` `Object.freeze(blob)`). + if integrity_flags_are_writable(obj) { let gc = gc_header_for(obj); (*gc)._reserved |= crate::gc::OBJ_FLAG_FROZEN | crate::gc::OBJ_FLAG_SEALED @@ -251,7 +276,7 @@ pub extern "C" fn js_object_seal(obj_value: f64) -> f64 { if crate::typedarray_props::typed_array_addr_from_value(obj_value).is_some() { unsafe { let obj = extract_obj_ptr(obj_value); - if !obj.is_null() && (obj as usize) > 0x10000 { + if integrity_flags_are_writable(obj) { let gc = gc_header_for(obj); (*gc)._reserved |= crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND; } @@ -260,7 +285,7 @@ pub extern "C" fn js_object_seal(obj_value: f64) -> f64 { } unsafe { let obj = extract_obj_ptr(obj_value); - if !obj.is_null() && (obj as usize) > 0x10000 { + if integrity_flags_are_writable(obj) { let gc = gc_header_for(obj); (*gc)._reserved |= crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND; // TypedArray receivers: GC flags only — see `js_object_freeze`. @@ -355,7 +380,7 @@ pub extern "C" fn js_object_prevent_extensions(obj_value: f64) -> f64 { } unsafe { let obj = extract_obj_ptr(obj_value); - if !obj.is_null() && (obj as usize) > 0x10000 { + if integrity_flags_are_writable(obj) { // Typed arrays use a side table for extensibility. Include Perry's // BufferHeader-backed Uint8Array: lookup_typed_array_kind can // never recognise it, and setting only the Buffer's GC flag is @@ -633,3 +658,79 @@ pub extern "C" fn js_object_is_extensible(obj_value: f64) -> f64 { } } } + +#[cfg(test)] +mod header_gate_tests { + //! #10933: `Object.freeze` / `seal` / `preventExtensions` used to write + //! `OBJ_FLAG_*` into `(value - 8) + 2` for ANY pointer-tagged value above + //! the handle band, with nothing establishing that the value HAS a header. + //! Several values perry hands to JS do not: a registered symbol is a + //! `Box::into_raw`'d `SymbolHeader`, the unresolved-namespace stub is a + //! `.rodata` static (where the write faults), and the async handles are + //! bare `Box`es. The write then lands in memory that belongs to something + //! else. + //! + //! MUST-FAIL before the gate: measured on v0.5.1633, 30 of 32 registered + //! symbols had the word at `sym - 8` change, `0x...0000 -> 0x...00070000`, + //! which is `FROZEN|SEALED|NO_EXTEND` landing in `_reserved`. + + /// The bytes in front of a header-less value must be untouched by all + /// three integrity operations. + #[test] + fn integrity_ops_do_not_write_in_front_of_a_header_less_value() { + // Registered / well-known symbols are the header-less population: + // a fresh `Symbol()` goes through `gc_malloc` and DOES carry a header. + let mut syms: Vec = Vec::new(); + for i in 0..32 { + let name = format!("freezeGate{i}"); + let ptr = crate::symbol::well_known_symbol(&name); + assert!( + !ptr.is_null(), + "the probe needs real symbols to be meaningful" + ); + syms.push(ptr as usize); + } + let before: Vec = syms + .iter() + .map(|a| unsafe { std::ptr::read_volatile((*a - 8) as *const u64) }) + .collect(); + + for a in &syms { + let boxed = f64::from_bits(crate::value::JSValue::pointer(*a as *const u8).bits()); + super::js_object_freeze(boxed); + super::js_object_seal(boxed); + super::js_object_prevent_extensions(boxed); + } + + let mut changed = Vec::new(); + for (i, a) in syms.iter().enumerate() { + let now = unsafe { std::ptr::read_volatile((*a - 8) as *const u64) }; + if now != before[i] { + changed.push(format!("sym[{i}] {:#018x} -> {:#018x}", before[i], now)); + } + } + assert!( + changed.is_empty(), + "an integrity op wrote in front of a header-less value ({} of {}):\n {}", + changed.len(), + syms.len(), + changed.join("\n ") + ); + } + + /// And the ops still WORK on a real object, so the gate is not a blanket + /// no-op: a test that passed by disabling the feature would be worthless. + #[test] + fn integrity_ops_still_apply_to_a_real_object() { + let obj = crate::object::js_object_alloc(0, 0); + assert!(!obj.is_null()); + let boxed = f64::from_bits(crate::value::JSValue::pointer(obj as *const u8).bits()); + super::js_object_freeze(boxed); + let gc = unsafe { crate::object::object_ops::gc_header_for(obj) }; + assert_ne!( + unsafe { (*gc)._reserved } & crate::gc::OBJ_FLAG_FROZEN, + 0, + "freeze must still mark a real object" + ); + } +} diff --git a/crates/perry-runtime/src/object/proto_validity.rs b/crates/perry-runtime/src/object/proto_validity.rs index 271a1905f0..caa2ff05d2 100644 --- a/crates/perry-runtime/src/object/proto_validity.rs +++ b/crates/perry-runtime/src/object/proto_validity.rs @@ -89,15 +89,29 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -/// Monotonic counter standing for "nothing structural has changed on any -/// object somebody inherits from, and no semantic property event has -/// happened". Starts at 1 so a zeroed cache entry never matches. -static PROTO_VALIDITY: AtomicU64 = AtomicU64::new(1); +per_test_global! { + // #10944: a test asserts this counter's value, and libtest runs tests + // in one process — any sibling touching the same path made the + // assertion fail by one. `per_test_global!` gives each test thread its + // own instance in a TEST build and expands to the plain `static`, + // byte for byte, outside one. + /// Monotonic counter standing for "nothing structural has changed on any + /// object somebody inherits from, and no semantic property event has + /// happened". Starts at 1 so a zeroed cache entry never matches. + static PROTO_VALIDITY: AtomicU64 = AtomicU64::new(1); +} -/// Has any object ever been marked as a prototype? Until it has, -/// [`note_object_shape_stamped`] cannot possibly need to bump, so it does not -/// read the object's `GcHeader` at all. -static ANY_PROTOTYPE_MARKED: AtomicBool = AtomicBool::new(false); +per_test_global! { + // #10944: a test asserts this counter's value, and libtest runs tests + // in one process — any sibling touching the same path made the + // assertion fail by one. `per_test_global!` gives each test thread its + // own instance in a TEST build and expands to the plain `static`, + // byte for byte, outside one. + /// Has any object ever been marked as a prototype? Until it has, + /// [`note_object_shape_stamped`] cannot possibly need to bump, so it does not + /// read the object's `GcHeader` at all. + static ANY_PROTOTYPE_MARKED: AtomicBool = AtomicBool::new(false); +} /// The current validity word. One relaxed load. #[inline] @@ -137,16 +151,38 @@ pub(crate) fn any_prototype_marked() -> bool { /// # Safety /// `obj` must be a live heap address whose `GcHeader` precedes it; callers /// have already read that header to classify the object. +/// +/// Returns the prototype's stable serial (#10868 lever iv), assigning one on +/// first mark. It is read from the meta pointer `ensure_meta_for_mark` returns +/// AFTER its allocation, so a caller can carry it as a plain `u64` without +/// re-reading through a pointer the allocation may have moved. #[inline] -pub(crate) unsafe fn mark_object_as_prototype(obj: usize) { +pub(crate) unsafe fn mark_object_as_prototype(obj: usize) -> Option { if let Some(meta) = ensure_meta_for_mark(obj) { ANY_PROTOTYPE_MARKED.store(true, Ordering::Relaxed); // GC_STORE_AUDIT(POINTER_FREE): scalar classification bit in the meta // record's flags word, never a heap reference. (*meta).flags |= crate::object::OBJECT_META_FLAG_IS_PROTOTYPE; + // GC_STORE_AUDIT(POINTER_FREE): a scalar serial, never a reference. + if (*meta).proto_serial == 0 { + (*meta).proto_serial = PROTOTYPE_SERIAL_NEXT.fetch_add(1, Ordering::Relaxed); + } + let serial = (*meta).proto_serial; + #[cfg(feature = "shape-mint-diag")] + crate::object::shape_mint_census::note_event("object marked prototype"); + return Some(serial); } + None } +/// Next prototype serial. Starts at 1 so 0 can mean "none assigned"; a `u64` +/// counter cannot be exhausted by any real program. +static PROTOTYPE_SERIAL_NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// The serial a `[[Prototype]]` of NULL stands for. Distinct from every +/// assigned serial, and from 0 ("none"). +pub(crate) const NULL_PROTOTYPE_SERIAL: u64 = u64::MAX; + /// Mark a receiver a shape-keyed read cache must refuse whatever its ShapeId /// says: `process.env` or an `arguments` object, whose reads are answered by /// something other than the object's shape. Called from the single writer of diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index 8294c76fe0..b28c59e84b 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -395,14 +395,23 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: // kind missing from this funnel costs a cache hit and can never leave a // stale entry (`object::proto_validity`). Marking allocates a meta record, // so it happens BEFORE this function takes any raw pointer of its own. - unsafe { + // + // #10868 lever (iv): the mark returns the prototype's stable serial, read + // from the meta pointer AFTER the mark's allocation, and it is carried as a + // plain u64 to the divergence below rather than re-read through a pointer + // that allocation may have moved. + let prototype_serial: Option = unsafe { let prototype = crate::value::JSValue::from_bits(proto_bits); if prototype.is_pointer() { crate::object::proto_validity::mark_object_as_prototype( prototype.as_pointer::() as usize, - ); + ) + } else if proto_bits == crate::value::TAG_NULL { + Some(crate::object::proto_validity::NULL_PROTOTYPE_SERIAL) + } else { + None } - } + }; if !ARRAY_TARGET_PROTO_RECORDED.load(Ordering::Relaxed) && obj_ptr >= crate::gc::GC_HEADER_SIZE + 0x1000 && crate::value::addr_class::is_above_handle_band(obj_ptr) @@ -470,7 +479,25 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: proto_bits, ); if prototype_diverged { - crate::object::shapes::transition_object_shape_semantics(obj); + #[cfg(feature = "shape-mint-diag")] + crate::object::shape_mint_census::note_proto_divergence( + crate::object::shapes::object_shape_stamp(obj), + proto_bits, + ); + // A prototype without a serial (not a meta-capable object) keeps + // the unique-generation transition: correct, just unmerged. + match prototype_serial { + Some(serial) => { + crate::object::shapes::transition_object_shape_semantics_for_prototype( + obj, + serial, + link_kind as u8, + ); + } + None => { + crate::object::shapes::transition_object_shape_semantics(obj); + } + } } return; } diff --git a/crates/perry-runtime/src/object/reserved_floor.rs b/crates/perry-runtime/src/object/reserved_floor.rs index 2d7a41781b..09ab5fcfad 100644 --- a/crates/perry-runtime/src/object/reserved_floor.rs +++ b/crates/perry-runtime/src/object/reserved_floor.rs @@ -108,6 +108,12 @@ unsafe fn stamp_reserved_floor_shape( if !shapes::shape_word_is_writable(obj) { return 0; } + // #10868 step 2.5 stage 1: a floor restamp republishes an explicit keys + // edge, which a dictionary-mode receiver does not have; publishing one + // would hand it back a shape that claims a key list it does not match. + if crate::object::dictionary::is_dictionary(obj) { + return 0; + } let lineage = shapes::object_shape_descriptor(obj); let live = lineage .as_ref() diff --git a/crates/perry-runtime/src/object/shape_mint_census.rs b/crates/perry-runtime/src/object/shape_mint_census.rs index 20565c0d35..5f33498ddc 100644 --- a/crates/perry-runtime/src/object/shape_mint_census.rs +++ b/crates/perry-runtime/src/object/shape_mint_census.rs @@ -122,6 +122,22 @@ struct Census { age_hist: [u64; 8], mint_index_of: HashMap, define_outcomes: HashMap<&'static str, u64>, + /// Lever (iv): the predecessor ShapeId at every prototype divergence. If + /// this set is about as large as the number of divergences, the + /// predecessors have already forked and a deterministic generation merges + /// nothing until identity is canonical; if it is small, it pays now. + proto_div_preds: HashSet, + proto_div_count: u64, + /// Distinct (predecessor, prototype) pairs — what a deterministic + /// generation would actually collapse to. Keyed on the prototype's BITS for + /// this one measurement only: an object prototype that moves under the + /// collector counts twice, so this is an UPPER bound. A null prototype is + /// the constant TAG_NULL and is exact. + proto_div_pairs: HashSet<(u32, u64)>, + proto_div_null: u64, + /// Free-form event tallies (meta records born, objects marked prototype): + /// the cost drivers for where a prototype serial would live. + events: HashMap<&'static str, u64>, } static CENSUS: OnceLock> = OnceLock::new(); @@ -252,6 +268,35 @@ pub(crate) fn note_define_outcome(what: &'static str) { } } +/// Lever (iv): one prototype divergence, recorded with its predecessor. +#[cfg_attr(not(feature = "shape-mint-diag"), allow(dead_code))] +#[inline] +pub(crate) fn note_proto_divergence(predecessor: u32, proto_bits: u64) { + if !armed() { + return; + } + if let Ok(mut c) = census().lock() { + c.proto_div_count += 1; + c.proto_div_preds.insert(predecessor); + c.proto_div_pairs.insert((predecessor, proto_bits)); + if proto_bits == crate::value::TAG_NULL { + c.proto_div_null += 1; + } + } +} + +/// A free-form event tally. +#[cfg_attr(not(feature = "shape-mint-diag"), allow(dead_code))] +#[inline] +pub(crate) fn note_event(what: &'static str) { + if !armed() { + return; + } + if let Ok(mut c) = census().lock() { + *c.events.entry(what).or_insert(0) += 1; + } +} + #[inline] pub(crate) fn note_memo_hit() { if armed() { @@ -515,6 +560,21 @@ pub(crate) fn dump() { out.push_str(&format!(" {what:<40} {n:>12}\n")); } } + out.push_str(&format!( + " lever (iv) prototype divergences: {} distinct predecessor ShapeIds: {}\n \ + distinct (predecessor, prototype) pairs: {} (upper bound) to a NULL prototype: {}\n", + c.proto_div_count, + c.proto_div_preds.len(), + c.proto_div_pairs.len(), + c.proto_div_null, + )); + if !c.events.is_empty() { + let mut ev: Vec<(&&str, &u64)> = c.events.iter().collect(); + ev.sort_unstable_by(|a, b| b.1.cmp(a.1)); + for (what, n) in ev { + out.push_str(&format!(" event {what:<36} {n:>12}\n")); + } + } out.push_str(" by cause:\n"); let mut causes: Vec<(&MintCause, &u64)> = c.by_cause.iter().collect(); causes.sort_unstable_by(|a, b| b.1.cmp(a.1)); diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 1e9bdd83e4..c7a073e740 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -1074,6 +1074,111 @@ pub extern "C" fn js_shape_ordinary_inline_slot_for_key(shape_id: u32, key_bits: static KEEP_JS_SHAPE_ORDINARY_INLINE_SLOT_FOR_KEY: extern "C" fn(u32, u64) -> i32 = js_shape_ordinary_inline_slot_for_key; +/// The empty region guard word: its low 32 bits are `u32::MAX`, which is never +/// a live ShapeId, so an unprimed region's shape compare can only miss. +pub const REGION_GUARD_WORD_EMPTY: u64 = 0xFFFF_FFFF; +/// Most distinct keys one region word can carry (6 bits each above the id). +pub const REGION_GUARD_MAX_KEYS: u32 = 5; +const REGION_GUARD_SLOT_BITS: u32 = 6; +const REGION_GUARD_SLOT_MAX: i32 = (1 << REGION_GUARD_SLOT_BITS) - 1; + +/// Step 4b: pack a region guard word — one ShapeId and the inline slot of each +/// of the region's keys — or return [`REGION_GUARD_WORD_EMPTY`]. +/// +/// A read region compares the receiver's ShapeId against the low 32 bits ONCE +/// and then loads every key's slot out of the high 32 bits, so the id and the +/// slots must be published as a single atomic word. Two separately stored +/// words could tear under a concurrent prime and pair one shape's id with +/// another shape's slots — a wrong value, silently. +/// +/// Each slot comes from [`js_shape_ordinary_inline_slot_for_key`], which +/// answers only when slot k provably IS key position k (ordinary kind, no +/// semantic generation, no tombstones, every key inline). Anything it refuses — +/// an absent key, an inherited key, an accessor, a spilled key — makes the +/// whole word empty, so the region never takes its fast copy for that shape +/// and every read keeps its ordinary tower. Refusing is always correct. +/// +/// `keys` are the whole NaN-boxed key values as codegen loaded them from the +/// string pool, in the region's key order; `n` of them are meaningful. +#[no_mangle] +pub extern "C" fn js_region_guard_pack( + shape_id: u32, + n: u32, + k0: u64, + k1: u64, + k2: u64, + k3: u64, + k4: u64, +) -> u64 { + if !is_shape_id(shape_id) || n == 0 || n > REGION_GUARD_MAX_KEYS { + return REGION_GUARD_WORD_EMPTY; + } + let keys = [k0, k1, k2, k3, k4]; + let mut word = u64::from(shape_id); + for (i, &key) in keys.iter().enumerate().take(n as usize) { + let slot = js_shape_ordinary_inline_slot_for_key(shape_id, key); + if !(0..=REGION_GUARD_SLOT_MAX).contains(&slot) { + return REGION_GUARD_WORD_EMPTY; + } + word |= (slot as u64) << (32 + REGION_GUARD_SLOT_BITS * i as u32); + } + word +} + +/// Compute a region's guard word and publish it, for a read region's miss +/// path (#10884). +/// +/// The store lives here rather than in emitted IR for the reason +/// [`crate::object::field_get_set::ic_miss`]'s `prime_get` does it here: a +/// cache word is published by the runtime, which owns its memory ordering. +/// Relaxed is enough — this publishes a numeric layout fact, not an object — +/// and the single word is what makes a concurrent prime unable to pair one +/// shape's id with another shape's slots. (Emitting `store atomic` from +/// codegen also does not survive perry's own native IR construction path, +/// which real modules take.) +/// +/// `word` is an aligned, live site word. A shape whose layout the region +/// cannot encode publishes nothing, so the site keeps missing and the bounded +/// attempt counter in the emitted code retires it. +/// +/// # Safety +/// +/// `word` must be null or point to a live, 8-byte-aligned `AtomicU64`. +#[no_mangle] +pub unsafe extern "C" fn js_region_guard_prime( + word: *const core::sync::atomic::AtomicU64, + shape_id: u32, + n: u32, + k0: u64, + k1: u64, + k2: u64, + k3: u64, + k4: u64, +) -> u64 { + let packed = js_region_guard_pack(shape_id, n, k0, k1, k2, k3, k4); + if word.is_null() || packed == REGION_GUARD_WORD_EMPTY { + return REGION_GUARD_WORD_EMPTY; + } + (*word).store(packed, core::sync::atomic::Ordering::Relaxed); + packed +} + +/// Keepalive anchor — `js_region_guard_prime` is called only from generated +/// code (a read region's miss path), so the auto-optimize whole-program build +/// would otherwise dead-strip it. +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_REGION_GUARD_PRIME: unsafe extern "C" fn( + *const core::sync::atomic::AtomicU64, + u32, + u32, + u64, + u64, + u64, + u64, + u64, +) -> u64 = js_region_guard_prime; + /// Mint a process-global ShapeId for a codegen-registered typed layout and /// install its structural descriptor in the current agent. Unlike /// [`shape_id_for_keys_ensure`], this deliberately does not canonicalise by @@ -1774,6 +1879,85 @@ fn deterministic_semantic_generation( Some(x | (1 << 63)) } +/// #10868 lever (iv): semantic generation for a PROTOTYPE divergence, as a +/// pure function of *(predecessor ShapeId, the prototype's stable serial, link +/// kind)* — the prototype twin of [`deterministic_semantic_generation`]. +/// +/// Two receivers that diverge the same way from the same predecessor land on +/// the same successor, instead of each taking a fresh value from the +/// `SHAPE_SEMANTIC_NEXT` counter. On one tsc `transpileModule` that site minted +/// 48,197 shapes from 78 predecessors and at most 97 (predecessor, prototype) +/// pairs. +/// +/// Soundness: two receivers with DIFFERENT prototypes carry different serials, +/// so they get different generations and different ShapeIds, and a +/// shape-keyed inherited-read cache can never serve one receiver's holder for +/// the other. `PROTOTYPE_DOMAIN` keeps this input space disjoint from the +/// descriptor generation's; bit 63 is set like every deterministic generation. +fn deterministic_prototype_generation( + prev_shape_id: u32, + prototype_serial: u64, + link_kind: u8, +) -> Option { + if prev_shape_id == 0 || prototype_serial == 0 { + return None; + } + const PROTOTYPE_DOMAIN: u64 = 0x5052_4F54_4F54_5950; // "PROTOTYP" + let mut x = prototype_serial.wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ (u64::from(prev_shape_id) << 32 | u64::from(prev_shape_id)) + ^ (u64::from(link_kind) << 24) + ^ PROTOTYPE_DOMAIN; + x ^= x >> 30; + x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9); + x ^= x >> 27; + x = x.wrapping_mul(0x94d0_49bb_1331_11eb); + x ^= x >> 31; + Some(x | (1 << 63)) +} + +#[cfg(test)] +pub(crate) fn test_deterministic_prototype_generation( + prev_shape_id: u32, + prototype_serial: u64, + link_kind: u8, +) -> Option { + deterministic_prototype_generation(prev_shape_id, prototype_serial, link_kind) +} + +/// [`transition_object_shape_semantics`] for a PROTOTYPE divergence whose +/// prototype has a stable serial. Falls back to the unique-generation +/// transition, which is always correct, when there is no predecessor. +#[cfg_attr(feature = "shape-mint-diag", track_caller)] +pub(crate) unsafe fn transition_object_shape_semantics_for_prototype( + obj: *mut crate::object::ObjectHeader, + prototype_serial: u64, + link_kind: u8, +) -> u32 { + if obj.is_null() || !shape_word_is_writable(obj) { + return 0; + } + crate::array::clear_array_subclass_named_prefix_token(obj); + let current = object_shape_descriptor(obj).unwrap_or_else(|| { + synchronize_object_shape_descriptor(obj); + object_shape_descriptor(obj).expect("shape synchronization must publish a descriptor") + }); + let Some(generation) = + deterministic_prototype_generation(object_shape_stamp(obj), prototype_serial, link_kind) + else { + return transition_object_shape_semantics(obj); + }; + let id = publish_shape_result(shape_descriptor_ensure_with_generation( + current.keys as usize as *mut ArrayHeader, + current.logical_key_count, + current.live_inline_slot_count, + generation, + current.object_kind, + )); + stamp_object_shape_id_with_carrier_note(obj, id); + debug_assert_object_shape_parity(obj); + id +} + /// [`transition_object_shape_semantics`] for a DATA-descriptor install, whose /// successor is shared by every receiver that performs the same install over /// the same predecessor facts (#10287). @@ -1975,6 +2159,15 @@ pub(crate) unsafe fn debug_assert_object_shape_parity_for_keys( if !cfg!(debug_assertions) { return; } + // #10868 step 2.5 stage 1: a dictionary-mode receiver deliberately + // publishes NO keys while `object_keys_array` answers with the private + // list in its `ObjectMeta`, so the comparison below is false by + // construction for it. Its invariant is stricter, and lives with the mode + // that owns it. + if crate::object::dictionary::is_dictionary(obj) { + crate::object::dictionary::debug_assert_dictionary_parity(obj); + return; + } let id = object_shape_stamp(obj); if id != 0 { let key_count = if keys.is_null() { diff --git a/crates/perry-runtime/src/object/shapes_tests.rs b/crates/perry-runtime/src/object/shapes_tests.rs index 5fd8e5fa71..ba427d0b89 100644 --- a/crates/perry-runtime/src/object/shapes_tests.rs +++ b/crates/perry-runtime/src/object/shapes_tests.rs @@ -1219,3 +1219,218 @@ mod issue_10595_tests { } } } + +/// #10868 lever (iv): the prototype-divergence generation. +#[cfg(test)] +mod prototype_generation_tests { + use super::*; + + const PREV: u32 = 0x8000_1234; + const USER: u8 = 3; // PrototypeLinkKind::UserOverride + + /// THE UNSOUND CASE. Two receivers with the same predecessor that diverge + /// to two DIFFERENT prototypes must get different generations, or they get + /// the same ShapeId and a shape-keyed inherited-read cache serves one + /// receiver's holder for the other — a silent wrong value. This is the + /// case the prototype's ShapeId would have got wrong (distinct prototypes + /// can share a shape), and the one a broken serial would get wrong. + #[test] + fn different_prototypes_get_different_generations() { + let p = test_deterministic_prototype_generation(PREV, 1, USER).unwrap(); + let q = test_deterministic_prototype_generation(PREV, 2, USER).unwrap(); + assert_ne!(p, q, "two distinct prototypes collapsed to one generation"); + // Across a spread of serials, not just the first two. + let mut seen = std::collections::HashSet::new(); + for serial in 1..=4096u64 { + let g = test_deterministic_prototype_generation(PREV, serial, USER).unwrap(); + assert!( + seen.insert(g), + "serial {serial} collided with an earlier serial" + ); + } + } + + /// The other half, per §17: a check that cannot FIRE is not a check. Two + /// receivers diverging the SAME way from the SAME predecessor must land on + /// the SAME generation, or lever (iv) merges nothing and the 48,197 mints + /// it exists to remove are still minted. + #[test] + fn the_same_divergence_from_the_same_predecessor_merges() { + let a = test_deterministic_prototype_generation(PREV, 7, USER); + let b = test_deterministic_prototype_generation(PREV, 7, USER); + assert!(a.is_some()); + assert_eq!(a, b); + } + + /// Different predecessors, and different link kinds (which set different + /// meta flags on the receiver), stay distinct. + #[test] + fn predecessor_and_link_kind_both_separate() { + let base = test_deterministic_prototype_generation(PREV, 7, USER).unwrap(); + let other_prev = test_deterministic_prototype_generation(PREV + 1, 7, USER).unwrap(); + let other_kind = test_deterministic_prototype_generation(PREV, 7, 2).unwrap(); + assert_ne!(base, other_prev); + assert_ne!(base, other_kind); + } + + /// Every deterministic generation sets bit 63, so it can never alias a + /// counter-allocated one; and a missing predecessor or serial declines to + /// the always-correct unique-generation path. + #[test] + fn bit_63_and_the_declines() { + let g = test_deterministic_prototype_generation(PREV, 7, USER).unwrap(); + assert_ne!(g & (1 << 63), 0); + assert_eq!(test_deterministic_prototype_generation(0, 7, USER), None); + assert_eq!(test_deterministic_prototype_generation(PREV, 0, USER), None); + } + + /// The null prototype has its own serial, distinct from every assigned + /// one, so `setPrototypeOf(o, null)` and `setPrototypeOf(o, P)` never merge. + #[test] + fn a_null_prototype_is_its_own_identity() { + let null = test_deterministic_prototype_generation( + PREV, + crate::object::proto_validity::NULL_PROTOTYPE_SERIAL, + USER, + ) + .unwrap(); + for serial in 1..=64u64 { + let g = test_deterministic_prototype_generation(PREV, serial, USER).unwrap(); + assert_ne!(g, null); + } + } +} + +/// Step 4b stage 1: the region guard word. Every refusal path must yield the +/// EMPTY word, because a wrongly packed slot is a wrong value and an empty word +/// is only a missed fast path. +#[cfg(test)] +mod region_guard_pack_tests { + use super::*; + + fn key_bits(name: &str) -> u64 { + let s = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + f64::from_bits(crate::value::STRING_TAG | (s as u64 & crate::value::POINTER_MASK)).to_bits() + } + + fn shape_for(class_id: u32, packed: &[u8], count: u32) -> u32 { + let keys = crate::object::js_build_class_keys_array( + class_id, + count, + packed.as_ptr(), + packed.len() as u32, + ); + js_object_shape_id_for_keys(keys as usize as u64, count) + } + + #[test] + fn prime_publishes_the_packed_word_and_nothing_else() { + let _lock = crate::gc::global_side_table_test_lock(); + let shape = shape_for(0x0C3C_8202, b"pa\0pb", 2); + let word = core::sync::atomic::AtomicU64::new(REGION_GUARD_WORD_EMPTY); + let published = unsafe { + js_region_guard_prime(&word, shape, 2, key_bits("pa"), key_bits("pb"), 0, 0, 0) + }; + assert_ne!(published, REGION_GUARD_WORD_EMPTY, "the shape packs"); + assert_eq!( + word.load(core::sync::atomic::Ordering::Relaxed), + published, + "prime publishes the word it packed" + ); + + // A shape the region cannot encode leaves the site untouched, so the + // emitted code keeps missing and its bounded counter retires it. + let site = core::sync::atomic::AtomicU64::new(REGION_GUARD_WORD_EMPTY); + let refused = unsafe { + js_region_guard_prime(&site, shape, 2, key_bits("pa"), key_bits("nope"), 0, 0, 0) + }; + assert_eq!(refused, REGION_GUARD_WORD_EMPTY, "an absent key refuses"); + assert_eq!( + site.load(core::sync::atomic::Ordering::Relaxed), + REGION_GUARD_WORD_EMPTY, + "a refused prime publishes nothing" + ); + + // A null site is a no-op, not a fault. + assert_eq!( + unsafe { + js_region_guard_prime( + core::ptr::null(), + shape, + 2, + key_bits("pa"), + key_bits("pb"), + 0, + 0, + 0, + ) + }, + REGION_GUARD_WORD_EMPTY + ); + } + + #[test] + fn packs_the_shape_id_and_each_keys_slot() { + let _lock = crate::gc::global_side_table_test_lock(); + let shape = shape_for(0x0C3C_8201, b"ra\0rb\0rc\0rd", 4); + let word = js_region_guard_pack( + shape, + 3, + key_bits("rc"), + key_bits("ra"), + key_bits("rd"), + 0, + 0, + ); + assert_ne!( + word, REGION_GUARD_WORD_EMPTY, + "an ordinary inline shape must pack" + ); + assert_eq!(word as u32, shape, "the low 32 bits are the ShapeId"); + let slot = |i: u32| ((word >> (32 + 6 * i)) & 63) as u32; + assert_eq!( + (slot(0), slot(1), slot(2)), + (2, 0, 3), + "slots follow the region's key order" + ); + } + + /// Fails if a key the shape does not own were packed: the region would then + /// load some other field's slot for it. + #[test] + fn an_absent_key_empties_the_whole_word() { + let _lock = crate::gc::global_side_table_test_lock(); + let shape = shape_for(0x0C3C_8202, b"sa\0sb", 2); + let word = js_region_guard_pack(shape, 2, key_bits("sa"), key_bits("zz"), 0, 0, 0); + assert_eq!(word, REGION_GUARD_WORD_EMPTY); + } + + #[test] + fn a_non_shape_id_and_an_out_of_range_count_are_refused() { + let _lock = crate::gc::global_side_table_test_lock(); + let shape = shape_for(0x0C3C_8203, b"ta\0tb", 2); + assert_eq!( + js_region_guard_pack(u32::MAX, 1, key_bits("ta"), 0, 0, 0, 0), + REGION_GUARD_WORD_EMPTY + ); + assert_eq!( + js_region_guard_pack(0, 1, key_bits("ta"), 0, 0, 0, 0), + REGION_GUARD_WORD_EMPTY + ); + assert_eq!( + js_region_guard_pack(shape, 0, 0, 0, 0, 0, 0), + REGION_GUARD_WORD_EMPTY + ); + assert_eq!( + js_region_guard_pack(shape, REGION_GUARD_MAX_KEYS + 1, key_bits("ta"), 0, 0, 0, 0), + REGION_GUARD_WORD_EMPTY + ); + } + + /// The empty word can never match a live receiver: its low half is not a + /// ShapeId. Pinned because the region's miss path depends on it. + #[test] + fn the_empty_word_is_not_a_shape_id() { + assert!(!is_shape_id(REGION_GUARD_WORD_EMPTY as u32)); + } +} diff --git a/crates/perry-runtime/src/shared_sab.rs b/crates/perry-runtime/src/shared_sab.rs index d6e7292a73..12f29cd701 100644 --- a/crates/perry-runtime/src/shared_sab.rs +++ b/crates/perry-runtime/src/shared_sab.rs @@ -26,6 +26,10 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; use crate::buffer::BufferHeader; +// `GC_FLAG_PINNED` is deliberately NOT imported: the #7645 custody gate reads a +// bare mention of the token as a pin creation, and this module only ever masks +// with it (in the header-survival test). Spelled in full at those two reads. +use crate::gc::{GcHeader, GC_FLAG_TENURED, GC_HEADER_SIZE, GC_TYPE_BUFFER}; /// Set of `BufferHeader` addresses that back a `SharedArrayBuffer`. static SHARED_SAB_REGISTRY: OnceLock>> = OnceLock::new(); @@ -45,11 +49,21 @@ fn registry() -> &'static Mutex> { SHARED_SAB_REGISTRY.get_or_init(|| Mutex::new(HashSet::new())) } -/// Header + data layout for a SAB of `size` data bytes. 8-byte alignment so the -/// data region (which begins immediately after the 8-byte `BufferHeader`) is -/// itself 8-aligned — required for `BigInt64Array` / `Float64` atomic slots. +/// Layout for a SAB of `size` data bytes: +/// `[GcHeader:8][BufferHeader:8][data:size]`, 8-byte aligned. +/// +/// #340/#341 / #10925: the leading `GcHeader` is what makes a SAB an honest +/// pointer. Before it, the JS value (the `BufferHeader` address) had no header, +/// and every `*(addr - 8)` type probe read whatever `alloc_zeroed` block sat in +/// front of it — the tail of another SAB's user-writable data — so writing a +/// SAB's own bytes could flip `Array.isArray` on another and crash a brand +/// check (#10925). With the header, `BufferHeader` and the data region keep +/// their exact offsets (the returned pointer still points at the `BufferHeader`, +/// so `buffer_data` == `buf + 8` is unchanged), and `buf - 8` is a real +/// `GC_TYPE_BUFFER` header. 8-byte alignment keeps the data region 8-aligned for +/// `BigInt64Array` / `Float64` atomic slots. fn sab_layout(size: u32) -> Layout { - let total = std::mem::size_of::() + size as usize; + let total = GC_HEADER_SIZE + std::mem::size_of::() + size as usize; Layout::from_size_align(total, 8).expect("shared SAB layout") } @@ -72,11 +86,38 @@ pub fn alloc_shared_sab(size: u32) -> *mut BufferHeader { if raw.is_null() { handle_alloc_error(layout); } - let buf = raw as *mut BufferHeader; - // SAFETY: `buf` points at a fresh `BufferHeader`-sized-and-aligned block. + // The `GcHeader` sits at `raw`; the JS-visible value is the `BufferHeader` + // one header down, so `buf - GC_HEADER_SIZE` reads back this header. + let buf = unsafe { raw.add(GC_HEADER_SIZE) } as *mut BufferHeader; + let total = layout.size(); + // SAFETY: `raw` owns `total` zeroed, 8-aligned bytes; the header and the + // BufferHeader both fit within the first `GC_HEADER_SIZE + 8` of them. unsafe { + let header = raw as *mut GcHeader; + (*header).obj_type = GC_TYPE_BUFFER; + // PINNED + TENURED and NOT `GC_FLAG_ARENA`: this block is a raw, + // process-global `alloc_zeroed`, not an arena or a gc_malloc cell. The + // collector recognises a SAB by process-global registry membership + // (`is_shared_sab`), never by this header, and — proven by the + // header-write audit in the PR — no collector path (mark, scavenge, + // sweep, remembered-set) reaches an object outside its own thread's + // arena/tracked set, so this header is only ever READ by the collector, + // never written. It carries the honest kind for the mutator-side + // `*(addr - 8)` probes (`Array.isArray`, the collection-thunk brand, + // `JSON.stringify`), which is what #10925 needed. + (*header).gc_flags = GC_FLAG_TENURED; + (*header)._reserved = 0; + // Total block size, for honesty; a non-arena object is never block-walked. + (*header).size = total.min(u32::MAX as usize) as u32; (*buf).length = size; (*buf).capacity = size; + // #7645 custody: the PIN goes through `gc::pin`, not a raw flag write. + // `pin_object_non_young` is the right variant and its safety contract + // is met by construction — this block is a process-global + // `alloc_zeroed` with no `GC_FLAG_ARENA`, so it is malloc space and can + // never be Eden/FromSurvivor, and the latch must stay disarmed for it. + // `pin_object_non_young_call_sites_are_never_young` carries the case. + crate::gc::pin_object_non_young(header); } // Latch BEFORE the insert, not after. `buffer::is_registered_buffer` and // `buffer::is_shared_array_buffer` both report a SAB backing as a buffer @@ -128,6 +169,101 @@ pub(crate) fn snapshot_shared_sabs() -> Option> { registry().lock().ok().map(|r| r.clone()) } +#[cfg(test)] +mod header_survival_tests { + use super::*; + + /// #10925, the precondition for putting a `GcHeader` in front of + /// process-global memory: **no collector may WRITE it.** Two threads' + /// collectors setting a mark or forwarding bit on one header would be a + /// data race that shows up as rare corruption rather than a clean failure. + /// + /// The argument is the source audit (plan L15.7): every mark, scavenge, + /// sweep and remembered-set write gates on THIS thread's arena or + /// malloc-tracked membership — a set a process-global SAB is in on no + /// thread — and the moving paths classify by arena range before they read + /// a header at all. This test is the empirical backstop for that argument, + /// not a proof of it: it snapshots the header word, drives several minor + /// and major collections on this thread AND on two others while all three + /// hold the SAB, and requires the word to come back unchanged. + /// + /// It can fail: point `alloc_shared_sab` at the arena, or drop the + /// membership gate in front of any mark write, and the mark bit lands in + /// this word. + #[test] + fn no_collector_writes_a_shared_sab_header() { + let buf = alloc_shared_sab(64); + let header_addr = (buf as usize) - GC_HEADER_SIZE; + // Read as one 64-bit word: obj_type, gc_flags, _reserved and size + // together, so a write to ANY of them is caught. + let snapshot = unsafe { std::ptr::read_volatile(header_addr as *const u64) }; + + // The header must actually say what the fix intends, or "unchanged" + // would be vacuous. + // The canonical read predicate, not a bare cast: this is an ordinary + // header READ and `try_read_gc_header` expresses it exactly, the same + // way `object::tombstone_tests` reads a keys array's flags. + // `try_read_gc_header` takes the OBJECT address and reads the header at + // `addr - GC_HEADER_SIZE`; `header_addr` is already that subtraction, + // so passing it reads a header's-worth of bytes too far back. + let header = unsafe { crate::value::addr_class::try_read_gc_header(buf as usize) } + .expect("the shared SAB block carries a GcHeader"); + assert_eq!(header.obj_type, GC_TYPE_BUFFER); + // Masking reads, per the #7645 custody gate: a creation of the flag + // may only live in `gc/pin.rs`. Both bits set, and nothing else. + assert_ne!( + header.gc_flags & crate::gc::GC_FLAG_PINNED, + 0, + "the SAB header is pinned" + ); + assert_ne!( + header.gc_flags & GC_FLAG_TENURED, + 0, + "the SAB header is tenured" + ); + assert_eq!( + header.gc_flags & !(crate::gc::GC_FLAG_PINNED | GC_FLAG_TENURED), + 0, + "no other flag is set on a SAB header" + ); + + fn churn() { + for _ in 0..8 { + for _ in 0..2000 { + let o = crate::object::js_object_alloc(0, 0); + std::hint::black_box(o); + } + crate::gc::js_gc_collect(); + } + } + + let workers: Vec<_> = (0..2) + .map(|_| { + let addr = buf as usize; + std::thread::spawn(move || { + // Touch the shared bytes the way an Atomics user would, + // so the SAB is live across this thread's collections. + let data = crate::buffer::buffer_data(addr as *const BufferHeader); + for i in 0..64u8 { + unsafe { std::ptr::write_volatile((data as *mut u8).add(i as usize), i) }; + } + churn(); + }) + }) + .collect(); + churn(); + for w in workers { + w.join().expect("a collecting thread panicked"); + } + + let after = unsafe { std::ptr::read_volatile(header_addr as *const u64) }; + assert_eq!( + after, snapshot, + "a collector wrote the process-global SAB header: {snapshot:#018x} -> {after:#018x}" + ); + } +} + /// Test-only: pretend `addr` is a process-global SAB backing. /// /// A real backing has no `GcHeader`, so the GC's dead-buffer scan can only diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 008226b455..19a1bc73f4 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -61,6 +61,11 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ // machine pipeline for bounded ISel/regalloc. That changes object bytes, // so both the build and object caches must distinguish its settings. "PERRY_LL_FAST_EMIT_MAX_INSTRS", + // #10884 step 4b kill switch: `PERRY_REGION_READS=0` makes the region + // slice(s) decline, so every guarded run lowers as individual reads instead + // of one shape compare plus a slot load. Different emitted code, so an + // object built with regions must not be served to a build without them. + "PERRY_REGION_READS", // #10777: gates computing numeric-by-construction provenance AFTER the // `Ptr` receiver proofs it depends on. On, an accumulator written // `h = h + o.a` is admitted and the `+` routes to INLINE_FADD; off, the @@ -222,6 +227,12 @@ const BUILD_CACHE_ENV_EXCLUSIONS: &[&str] = &[ "PERRY_LLVM_DIFF_DIR", "PERRY_REPSEL_DEBUG", "PERRY_STATEPOINT_REPORT", + // #10884 step 4b census: `PERRY_REGION_DIAG=1` runs `statement_run_census` + // over the HIR and prints the counts from `ModuleDiag::drop`. The census + // result is read in exactly one place -- that `eprintln!` -- and nothing in + // lowering consults it, so the emitted object is byte-identical with the + // report on and off. + "PERRY_REGION_DIAG", // Writes malformed dialect IR for diagnostics without changing emitted code. // `opt_report`'s own module doc states the contract this exclusion rests // on: "Observational only. Nothing in this module is read by codegen … diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 3cf446b889..21c7dc013f 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -1416,6 +1416,14 @@ fn compute_object_cache_key_with_env( env_var("PERRY_L14_NBC_ORDER").as_deref().unwrap_or(""), ); + // #10884 step 4b — the region kill switch. Same reasoning as the build + // cache above, and the same trap #10929 fell into: keying ONE of the two + // caches leaves the other serving objects compiled the other way. + h.field( + "env_region_reads", + env_var("PERRY_REGION_READS").as_deref().unwrap_or(""), + ); + h.finish() } diff --git a/crates/perry/tests/sab_header_read.rs b/crates/perry/tests/sab_header_read.rs new file mode 100644 index 0000000000..3bcd1a8b56 --- /dev/null +++ b/crates/perry/tests/sab_header_read.rs @@ -0,0 +1,152 @@ +//! #10925 -- a `SharedArrayBuffer` must not have its kind decided by the bytes +//! that happen to sit in front of it. +//! +//! A SAB was handed to JS as the address of a header-less `alloc_zeroed` +//! block, and several paths read `addr - 8` as a `GcHeader` for it. The bytes +//! there are, in the allocator layout observed on Linux x86_64, the tail of +//! the PREVIOUS SAB's data -- user-writable through an ordinary typed-array +//! view. So writing a byte into one SAB's own memory changed `Array.isArray` +//! on another, and made `Map.prototype.get.call` on it dereference fabricated +//! pointers (SIGSEGV). A type confusion driven by user bytes. +//! +//! MUST-FAIL: committed BEFORE the fix. On the unfixed runtime the first test +//! prints `true` and the second segfaults (the harness reports the signal); +//! the expected strings are node 26.8.1's. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed (a signal here is #10925's segfault)\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// Wrong value, no crash: kind byte 1 (`GC_TYPE_ARRAY`) planted in the tail of +/// the first SAB made the second answer `Array.isArray(b) === true`. +#[test] +fn a_byte_written_into_one_sab_does_not_change_another_sabs_kind() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const sabs: any[] = []; +for (let i = 0; i < 8; i++) sabs.push(new SharedArrayBuffer(24)); +for (const s of sabs) new Uint8Array(s)[16] = 1; +console.log("isArray", sabs.map((s) => Array.isArray(s)).join(",")); +console.log("brand", Object.prototype.toString.call(sabs[3])); +console.log("json", JSON.stringify(sabs[3])); +"#, + ); + assert_eq!( + stdout, + "isArray false,false,false,false,false,false,false,false\n\ + brand [object SharedArrayBuffer]\n\ + json {}\n" + ); +} + +/// The segfault: kind byte 7 (`GC_TYPE_ERROR`) routed a collection thunk's +/// incompatible-receiver message into `js_error_get_name` on fabricated +/// pointers. node throws a `TypeError` for every one of these receivers. +#[test] +fn a_collection_brand_check_on_a_sab_throws_instead_of_crashing() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const sabs: any[] = []; +for (let i = 0; i < 8; i++) sabs.push(new SharedArrayBuffer(24)); +for (const s of sabs) { const u = new Uint8Array(s); u[16] = 7; u[20] = 64; } +let threw = 0; +for (const s of sabs) { + try { Map.prototype.get.call(s, 1); } catch (e: any) { if (e instanceof TypeError) threw++; } +} +console.log("threw", threw); +console.log("keys", sabs.map((s) => Object.keys(s).length).join(",")); +"#, + ); + // Deliberately NOT asserting `String(sab)`: it returns the buffer bytes + // (not `[object SharedArrayBuffer]`) for a plain `new ArrayBuffer(n)` too, + // so that divergence is not a header read and would keep this test red + // after the fix for a reason it does not name. Tracked separately. + assert_eq!( + stdout, + "threw 8\n\ + keys 0,0,0,0,0,0,0,0\n" + ); +} + +/// The sharing semantics the fix must NOT regress: two views over one SAB see +/// each other's writes, and so does a worker the SAB is handed to -- both by +/// closure capture and as a module-level binding (the escape hatch in +/// `closure_analysis.rs` that reads a top-level SAB in place from a worker). +#[test] +fn sab_bytes_are_shared_across_views_and_threads() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +import { spawn } from "perry/thread"; +const top = new SharedArrayBuffer(16); +const topView = new Int32Array(top); +topView[0] = 7; +async function main() { + const local = new SharedArrayBuffer(16); + const a = new Int32Array(local); + const b = new Uint8Array(local); + a[0] = 0x01020304; + console.log("views", b[0], b[3]); + const fromCapture = await spawn(() => { + const v = new Int32Array(local); + Atomics.add(v, 1, 5); + return Atomics.load(v, 0); + }); + console.log("capture", fromCapture, Atomics.load(a, 1)); + const fromTop = await spawn(() => { + const v = new Int32Array(top); + Atomics.store(v, 1, 99); + return Atomics.load(v, 0); + }); + console.log("module-level", fromTop, Atomics.load(topView, 1)); +} +main(); +"#, + ); + assert_eq!( + stdout, + "views 4 1\n\ + capture 16909060 5\n\ + module-level 7 99\n" + ); +} diff --git a/crates/perry/tests/sab_header_survives_gc.rs b/crates/perry/tests/sab_header_survives_gc.rs new file mode 100644 index 0000000000..8827ffa3e7 --- /dev/null +++ b/crates/perry/tests/sab_header_survives_gc.rs @@ -0,0 +1,110 @@ +//! #10925 smoke test (NOT a proof): the SAB's `GcHeader` must survive heavy +//! multi-thread collection. +//! +//! The source audit (PR body / plan L15.7) shows no collector path WRITES a +//! SAB header — every mark/move/sweep gates on this-thread arena or +//! malloc-tracked membership, which a process-global SAB is in on no thread. +//! This backs that empirically: the main thread and two workers each allocate +//! enough to force several minor and major collections while all three hold +//! the same SAB and run `Atomics` traffic on it. If any collector wrote the +//! header (a mark bit, a stale forward), the bytes would move and a later +//! typed-array read over the SAB would see corruption or the program would +//! crash. A clean, node-matching run across many collections is the signal. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn a_sab_header_survives_heavy_multithread_collection() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +import { spawn } from "perry/thread"; +const sab = new SharedArrayBuffer(64); +const cell = new Int32Array(sab); +cell[0] = 0; +function churn(rounds: number): void { + for (let r = 0; r < rounds; r++) { + let junk: any[] = []; + for (let i = 0; i < 20000; i++) junk.push({ a: i, b: [i, i + 1], c: "s" + i }); + junk = []; + Atomics.add(cell, 0, 1); + } +} +async function main() { + const w1 = spawn(() => { + const v = new Int32Array(sab); + for (let r = 0; r < 40; r++) { + let j: any[] = []; + for (let i = 0; i < 20000; i++) j.push({ x: i, y: "" + i }); + j = []; + Atomics.add(v, 1, 1); + } + return Atomics.load(v, 1); + }); + const w2 = spawn(() => { + const v = new Int32Array(sab); + for (let r = 0; r < 40; r++) { + let j: any[] = []; + for (let i = 0; i < 20000; i++) j.push([i, i, i]); + j = []; + Atomics.add(v, 2, 1); + } + return Atomics.load(v, 2); + }); + churn(40); + const a = await w1; + const b = await w2; + // Every worker's and the main thread's Atomics counters landed in the one + // shared buffer, and the buffer is still a SharedArrayBuffer afterwards. + console.log("main", Atomics.load(cell, 0)); + console.log("w1", a, "w2", b); + console.log("shared-w1", Atomics.load(cell, 1), "shared-w2", Atomics.load(cell, 2)); + console.log("brand", Object.prototype.toString.call(sab)); + console.log("isArray", Array.isArray(sab)); + console.log("len", sab.byteLength); +} +main(); +"#, + ) + .unwrap(); + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("compile"); + assert!( + compile.status.success(), + "compile failed\n{}", + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run"); + assert!( + run.status.success(), + "a signal here would be a collector writing the shared SAB header\nstatus {:?}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "main 40\n\ + w1 40 w2 40\n\ + shared-w1 40 shared-w2 40\n\ + brand [object SharedArrayBuffer]\n\ + isArray false\n\ + len 64\n" + ); +} diff --git a/run_parity_tests.sh b/run_parity_tests.sh index afb81c9035..62e5bfa4d2 100755 --- a/run_parity_tests.sh +++ b/run_parity_tests.sh @@ -540,6 +540,15 @@ for raw in sys.stdin: # A crash under the instrument is still caught: abnormal exits are # detected from the exit status, before either comparison runs. sed -E '/^\[gc-schedule\]/d' | \ + # #10868 step 2.5 stage 1 appends an `[object-dictionary]` counter row to + # that SAME exit summary (`gc/schedule.rs` prints it beside the + # `[gc-schedule]` lines, so it appears under exactly the same + # `parity-env: PERRY_GC_SCHEDULE_SEED=…` fixtures). It is a different + # prefix, so the rule above does not cover it and every such fixture + # diffed as an output mismatch — `test_gap_dynamic_import_alias_binding` + # was the first to show it. Same reasoning, same treatment: instrument + # noise, not program output. + sed -E '/^\[object-dictionary\]/d' | \ # Strip Node v22+ MODULE_TYPELESS_PACKAGE_JSON warnings (4 lines # printed to stderr when running .ts files without "type": # "module" in package.json — pure environmental noise that diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index 5999b574ff..0a25141eb4 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -179,3 +179,5 @@ crates/perry-runtime/src/json/stringify_record_output.rs | as *const crate::gc:: crates/perry-runtime/src/array/storage.rs | let header = (arr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; | #10060 logical array storage primitive: its unsafe contract requires a live, forwarding-resolved GC_TYPE_ARRAY supplied by the allocator, collector, or a validated array receiver. It never accepts a NaN-box payload or registry handle; the preceding allocation header is part of that ownership proof. No allocation or safepoint occurs during the read. crates/perry-runtime/src/box/scope_release.rs | (closure as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader | #10613 test-only: inside a `#[test]` in this file's `mod tests`, which sets GC_FLAG_MARKED on a closure the test itself just allocated and then restores it. `try_read_gc_header` reads a header and cannot hand back the MUTABLE pointer the flag write needs, and the address is owned by the test rather than derived from untrusted input. Never compiled into a shipped binary. crates/perry-runtime/src/object/prototype_chain.rs | let header = (obj_ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; | #10614 residual-prototype owner marking: the address is validated through the canonical predicate immediately above — `let Some(header) = try_read_gc_header(obj_ptr) else { return }`, plus an obj_type check — and this cast only re-derives a WRITABLE pointer to the header that predicate already proved present, so it can set GC_RESIDUAL_PROTO_OWNER in `_reserved`. No allocation or safepoint occurs between the validation and the write. +crates/perry-runtime/src/shared_sab.rs | let header = raw as *mut GcHeader; | #10925 SAB header INITIALISATION, not classification: `raw` is the block this function just `alloc_zeroed`'d microseconds earlier and still solely owns, and the cast exists to WRITE obj_type/gc_flags into it. Every addr_class predicate is a read-side check on an address of unknown provenance; none can express constructing a header on memory the caller allocated. The block is deliberately not an arena or gc_malloc cell (process-global, never freed), so `try_read_gc_header` is not applicable to it either. +crates/perry-runtime/src/object/keys_front_offset_tests.rs | let keys_gc = (keys as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; | #10939 test-only: inside `#[cfg(test)]` in this file, stamping GC_FLAG_SHAPE_SHARED on a keys array the test itself just built, to send the next append down the clone-before-push branch under test. `try_read_gc_header` hands back a SHARED reference and cannot express the flag write. Same discipline as the box/release_tests.rs and box/scope_release.rs entries above; never compiled into a shipped binary. diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index 4a9ee37fb0..dabee26279 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -23,8 +23,6 @@ # Regenerate: python3 scripts/addr_class_inventory.py --write-baseline handle-floor | crates/perry-ext-events/src/lib.rs | 3 -handle-floor | crates/perry-ext-exponential-backoff/src/lib.rs | 1 -handle-floor | crates/perry-ext-fastify/src/server.rs | 1 handle-floor | crates/perry-ext-http/src/agent.rs | 3 handle-floor | crates/perry-ext-http/src/lib.rs | 2 handle-floor | crates/perry-runtime/src/array/alloc.rs | 2 @@ -117,7 +115,7 @@ handle-floor | crates/perry-runtime/src/object/field_get_set/enumeration.rs | 3 handle-floor | crates/perry-runtime/src/object/field_get_set/field_ops.rs | 3 handle-floor | crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs | 3 handle-floor | crates/perry-runtime/src/object/field_get_set/has_property.rs | 2 -handle-floor | crates/perry-runtime/src/object/field_get_set/ic_miss.rs | 4 +handle-floor | crates/perry-runtime/src/object/field_get_set/ic_miss.rs | 3 handle-floor | crates/perry-runtime/src/object/field_set_by_name.rs | 1 handle-floor | crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs | 2 handle-floor | crates/perry-runtime/src/object/field_set_by_name/tail.rs | 6 @@ -143,7 +141,7 @@ handle-floor | crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs handle-floor | crates/perry-runtime/src/object/object_ops/has_own.rs | 4 handle-floor | crates/perry-runtime/src/object/object_ops/keys_array.rs | 6 handle-floor | crates/perry-runtime/src/object/object_ops/prototype.rs | 1 -handle-floor | crates/perry-runtime/src/object/object_ops_frozen.rs | 7 +handle-floor | crates/perry-runtime/src/object/object_ops_frozen.rs | 4 handle-floor | crates/perry-runtime/src/object/polymorphic_index.rs | 2 handle-floor | crates/perry-runtime/src/object/property_key.rs | 1 handle-floor | crates/perry-runtime/src/object/prototype_chain.rs | 2 @@ -204,9 +202,7 @@ handle-floor | crates/perry-stdlib/src/crypto/util.rs | 4 handle-floor | crates/perry-stdlib/src/crypto/x509.rs | 3 handle-floor | crates/perry-stdlib/src/domain.rs | 1 handle-floor | crates/perry-stdlib/src/events.rs | 1 -handle-floor | crates/perry-stdlib/src/exponential_backoff.rs | 1 handle-floor | crates/perry-stdlib/src/fetch/dispatch.rs | 4 -handle-floor | crates/perry-stdlib/src/jsonwebtoken.rs | 1 handle-floor | crates/perry-stdlib/src/querystring.rs | 5 handle-floor | crates/perry-stdlib/src/readline/mod.rs | 1 handle-floor | crates/perry-stdlib/src/sqlite/options.rs | 3 @@ -236,12 +232,11 @@ lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/entries_shape lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/enumeration.rs | 2 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/field_ops.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs | 1 -lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/ic_miss.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs | 2 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/array_error.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/fetch_globals.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/iterator_prototypes.rs | 1 -lone-valid-obj-ptr | crates/perry-runtime/src/object/native_call_method.rs | 3 +lone-valid-obj-ptr | crates/perry-runtime/src/object/native_call_method.rs | 2 lone-valid-obj-ptr | crates/perry-runtime/src/object/native_call_method/common_methods.rs | 2 lone-valid-obj-ptr | crates/perry-runtime/src/object/native_call_method/proto_dispatch.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/native_this_alias.rs | 1 diff --git a/scripts/global_sink_asserted_baseline.txt b/scripts/global_sink_asserted_baseline.txt new file mode 100644 index 0000000000..3e86811f44 --- /dev/null +++ b/scripts/global_sink_asserted_baseline.txt @@ -0,0 +1,66 @@ +# #10944: bare process-global `static`s that TEST code reads. +# Each is a flake waiting for a scheduling change. Fix one by moving it +# into `per_test_global!` and deleting its line here. This list may only +# shrink -- `--update` refuses to add. +crates/perry-runtime/src/alloc_census.rs::FILTER +crates/perry-runtime/src/arena/quarantine.rs::ON +crates/perry-runtime/src/array/subclass_elements.rs::ON +crates/perry-runtime/src/buffer/header.rs::EXTERNAL_UINT8ARRAYS_NONEMPTY +crates/perry-runtime/src/bun_compat/plugin.rs::LOADER_CALLS +crates/perry-runtime/src/bun_compat/plugin.rs::SETUP_CALLS +crates/perry-runtime/src/dgram_reactor.rs::LIVE +crates/perry-runtime/src/eh.rs::ON +crates/perry-runtime/src/eh_walker.rs::ON +crates/perry-runtime/src/eh_windows.rs::ON +crates/perry-runtime/src/error_stack_frames.rs::ON +crates/perry-runtime/src/error_stack_frames.rs::STATIC_SYMBOL_SPAWN_ATTEMPTS +crates/perry-runtime/src/event_pump.rs::NOTIFIED +crates/perry-runtime/src/fs/dir_glob_watch/watch_fsevents.rs::API +crates/perry-runtime/src/fs/mod.rs::COUNTER +crates/perry-runtime/src/gc/layout_tables.rs::N +crates/perry-runtime/src/gc/poll_arm.rs::ARM_EVENTS +crates/perry-runtime/src/gc/poll_arm.rs::FRESH +crates/perry-runtime/src/gc/poll_arm.rs::PERRY_GC_POLL_ARMED +crates/perry-runtime/src/gc/roots/runtime_handles/tests.rs::DROPPED +crates/perry-runtime/src/gc/roots/stack_maps.rs::ON +crates/perry-runtime/src/hot_diag/receiver_repr.rs::EVENTS +crates/perry-runtime/src/intl/segments_view.rs::ON +crates/perry-runtime/src/lib.rs::ACTIVE_OUTER_PUMPS +crates/perry-runtime/src/lib.rs::TICK_BEGIN_CALLS +crates/perry-runtime/src/native_handle.rs::FINALIZER_CALLS +crates/perry-runtime/src/native_handle.rs::NULL_FINALIZER_CALLS +crates/perry-runtime/src/node_api_host/v10_tests.rs::ASYNC_THROWN +crates/perry-runtime/src/node_api_host/v10_tests.rs::EXTERNAL_FINALIZED +crates/perry-runtime/src/node_api_host/v10_tests.rs::THROWING_TSFN_CALLS +crates/perry-runtime/src/object/arguments.rs::ARGUMENTS_OBJECTS_EVER_USED +crates/perry-runtime/src/object/class_meta_registry.rs::GENERIC_ORIGIN_LATCH +crates/perry-runtime/src/object/class_meta_registry.rs::PARENT_DENSE_INCOMPLETE +crates/perry-runtime/src/object/delete_rest.rs::ON +crates/perry-runtime/src/object/field_get_set/enumeration.rs::ON +crates/perry-runtime/src/object/field_get_set/ic_miss.rs::ON +crates/perry-runtime/src/object/inherited_read_cache.rs::ON +crates/perry-runtime/src/object/map_set_subclass.rs::ITERATOR_PROTOCOL_TOUCHED +crates/perry-runtime/src/object/native_module_registry.rs::NM_DISPATCH_REGISTRY +crates/perry-runtime/src/object/prop_plan.rs::ON +crates/perry-runtime/src/object/shape_mint_census.rs::MEMO_HITS +crates/perry-runtime/src/object/shape_mint_census.rs::TC_HITS +crates/perry-runtime/src/object/shape_mint_census.rs::TC_INSERTS +crates/perry-runtime/src/object/shape_mint_census.rs::TC_MISS_COLLIDE +crates/perry-runtime/src/object/spill.rs::ON +crates/perry-runtime/src/promise/microtasks/empty.rs::CALLED +crates/perry-runtime/src/promise/native_async.rs::CLEANUP_FINALIZER_CALLS +crates/perry-runtime/src/promise/then_probe.rs::ON +crates/perry-runtime/src/regex/site_test.rs::NESTED_WRAPPER_CALLS +crates/perry-runtime/src/registry_latch.rs::LATCH +crates/perry-runtime/src/symbol.rs::PERRY_SYMBOL_PROPERTY_IC_EPOCH +crates/perry-runtime/src/symbol.rs::SYMBOL_EVER_REGISTERED +crates/perry-runtime/src/timer/tests_inline.rs::RAN +crates/perry-runtime/src/timer/tests_inline.rs::SAW_HAS_REF +crates/perry-runtime/src/timer/tests_inline.rs::SAW_KNOWN +crates/perry-runtime/src/tls_hot.rs::OBSERVED +crates/perry-runtime/src/tls_hot/provider_tests.rs::DROPPED +crates/perry-runtime/src/tls_hot/provider_tests.rs::INITIALIZED +crates/perry-runtime/src/tls_os_pool.rs::KEY +crates/perry-runtime/src/tui/input.rs::EXIT_FLAG +crates/perry-runtime/src/tui/input.rs::INPUT_HANDLER +crates/perry-runtime/src/tui/input.rs::PENDING_BYTES diff --git a/scripts/global_sink_isolation.py b/scripts/global_sink_isolation.py index 32e3ae2e56..47cd1dd787 100644 --- a/scripts/global_sink_isolation.py +++ b/scripts/global_sink_isolation.py @@ -532,13 +532,235 @@ def self_test() -> int: return 1 if failures else 0 +# --------------------------------------------------------------------------- +# #10944: the SECOND class — a process-global counter a TEST ASSERTS ON. +# --------------------------------------------------------------------------- +# +# The rule above covers tables the GC guards CLEAR. It does not cover the much +# larger population of process-global counters that tests read directly, and +# that population is why `cargo test -p perry-runtime` cannot attribute a +# regression today: +# +# --test-threads=1 4215 passed; 0 failed +# parallel (x6) 4198-4205 passed; 10-17 failed, a DIFFERENT set each run +# +# ZERO genuine failures. Every failure the suite produces is one test's +# assertion disturbed by another test's increment, and it always presents the +# same way -- off by exactly one: +# +# assertion `left == right` failed: ... reuse the prior negative verdict +# left: 2, right: 1 +# +# The premise was written down in `json_tape/cached_read.rs`: +# +# // The runtime suite is serial. This witness holds no managed values. +# static ROOTED_READS: AtomicU32 = AtomicU32::new(0); +# +# It is not serial. libtest runs tests in one process across many threads. +# +# WHY A RATCHET AND NOT A SWEEP +# ----------------------------- +# The population is not enumerable by inspection -- six parallel runs after +# three modules were converted still produced 17 distinct failures, including +# names no earlier run had shown. Converting every one at a time means editing +# modules owned by several lanes at once. So this records today's set and +# fails only on ADDITIONS, exactly like `raw_handle_debt.py` and +# `unrooted_local_shape.py`: existing entries get converted by whoever owns +# each file, opportunistically, and no new instance can arrive quietly. +# +# `per_test_global!` is the fix for an entry, and its own module docs are the +# justification: "a new sink cannot be added quietly, and a new *reader* never +# has to remember anything." #7665, #7671, #7672 and #7975 are the first four +# instances of this class; #10944 is the fifth, which is the argument for a +# gate rather than a fifth patch. +# +# OUT OF SCOPE: the timing-shaped family (`child_process::reactor`, `pty`, +# `stdlib_pump`) fails under load on a shared box and has nothing to do with +# shared counters. It needs its own triage and must not be swept in here. + +ASSERTED_BASELINE = REPO_ROOT / "scripts" / "global_sink_asserted_baseline.txt" + +# Types whose whole purpose is cross-thread mutation. A `static` of one of +# these is shared state; a `const`, a plain integer or a `&str` table is not. +_SHARED_TY = re.compile( + r"\b(Atomic(?:Bool|I8|I16|I32|I64|Isize|U8|U16|U32|U64|Usize|Ptr)" + r"|Mutex|RwLock|OnceLock|OnceCell|ImageTable|RegistryLatch)\b" +) +# A declaration the macros already make safe. +_SAFE_BLOCK = re.compile(r"\b(thread_local|per_test_global|perry_thread_local)\s*!") +# An `assert*!(...)` invocation, body included (non-greedy to the first `);` +# at the end of a line, which is how this codebase formats them). +_ASSERT_CALL = re.compile(r"\bassert(?:_eq|_ne)?!\s*\(.*?\)\s*;", re.S) +_STATIC = re.compile(r"^\s*(?:pub(?:\([^)]*\))?\s+)?static\s+([A-Z][A-Z0-9_]*)\s*:\s*(.+?)\s*=") + + +def _test_region(text: str) -> str: + """The part of a file that is test code. + + Everything from the first `#[cfg(test)]` to EOF, which is where this + codebase puts its test modules, plus the whole file when it is a + `*_tests.rs`. Over-inclusive on purpose: a ratchet may record a few + entries no test actually asserts on, and the cost of that is one baseline + line, whereas a miss is a flake nobody can attribute. + """ + marker = text.find("#[cfg(test)]") + return text[marker:] if marker >= 0 else "" + + +def asserted_globals(sources) -> set[str]: + """`path::NAME` for every bare shared `static` a test reads. + + `sources` is `rust_sources()`'s `{path: text}` mapping, or a list of + `(name, text)` pairs in the self-test. + """ + items = sources.items() if isinstance(sources, dict) else list(sources) + items = [(str(p), t) for p, t in items] + bare: dict[str, list[tuple[str, str]]] = {} + for path, text in items: + depth = 0 + in_safe = False + found: list[tuple[str, str]] = [] + for line in text.splitlines(): + if not in_safe and _SAFE_BLOCK.search(line): + in_safe, depth = True, 0 + if in_safe: + depth += line.count("{") - line.count("}") + if depth <= 0 and "{" in line or (in_safe and depth <= 0): + if depth <= 0: + in_safe = False + continue + m = _STATIC.match(line) + if m and _SHARED_TY.search(m.group(2)): + found.append((m.group(1), m.group(2))) + if found: + bare[path] = found + + hits: set[str] = set() + tests_by_path = {p: (t if p.endswith("_tests.rs") else _test_region(t)) for p, t in items} + # Only an ASSERTION on the static is the hazard. A test that merely + # mentions one -- arming a feature flag, reading a census counter it does + # not check -- cannot be broken by a sibling's increment, and flagging + # those made the first draft of this list 481 entries of mostly noise. + # The failure this gate exists for always looks the same: a test asserts a + # global count and a sibling makes it off by one. + asserted_text = "\n".join( + m.group(0) + for text in tests_by_path.values() + for m in _ASSERT_CALL.finditer(text) + ) + for path, decls in bare.items(): + for name, _ty in decls: + if re.search(r"\b%s\b" % re.escape(name), asserted_text): + # repo-relative, so the baseline is stable across checkouts + rel = path.split("/crates/", 1) + key = ("crates/" + rel[1]) if len(rel) == 2 else path + hits.add("%s::%s" % (key, name)) + return hits + + +def _load_asserted_baseline() -> set[str] | None: + if not ASSERTED_BASELINE.exists(): + return None + return { + ln.strip() + for ln in ASSERTED_BASELINE.read_text(encoding="utf-8").splitlines() + if ln.strip() and not ln.startswith("#") + } + + +def check_asserted(update: bool = False) -> int: + sources = rust_sources() + head = asserted_globals(sources) + base = _load_asserted_baseline() + + if update: + if base is not None: + added = head - base + if added: + print( + "refusing to raise the baseline; convert these to " + "per_test_global! instead:\n " + "\n ".join(sorted(added)), + file=sys.stderr, + ) + return 1 + header = ( + "# #10944: bare process-global `static`s that TEST code reads.\n" + "# Each is a flake waiting for a scheduling change. Fix one by moving it\n" + "# into `per_test_global!` and deleting its line here. This list may only\n" + "# shrink -- `--update` refuses to add.\n" + ) + ASSERTED_BASELINE.write_text(header + "\n".join(sorted(head)) + "\n", encoding="utf-8") + removed = len(base - head) if base else 0 + print("asserted-global baseline: %d entries (%d removed)" % (len(head), removed)) + return 0 + + if base is None: + print("no asserted-global baseline; run --update-asserted. current=%d" % len(head)) + return 1 + added = sorted(head - base) + print("asserted process-global statics: %d (baseline %d)" % (len(head), len(base))) + if added: + for entry in added: + print("NEW ASSERTED GLOBAL: %s" % entry, file=sys.stderr) + print( + "\n%d bare process-global `static`(s) newly readable from test code. " + "libtest runs tests in one process on many threads, so a sibling's " + "increment breaks another test's assertion by exactly one and the " + "failing SET moves between runs -- see #10944, where the suite was " + "4215/0 single-threaded and 10-17 failures in parallel. Declare it " + "with `per_test_global!` (per-thread in a test build, the plain " + "`static` byte for byte outside one)." % len(added), + file=sys.stderr, + ) + return 1 + return 0 + + +def asserted_no_raise_vs(ref: str) -> int: + """Reject a diff that ADDS an entry and baselines it in the same commit.""" + import subprocess + + try: + base_text = subprocess.run( + ["git", "show", "%s:scripts/global_sink_asserted_baseline.txt" % ref], + capture_output=True, text=True, cwd=REPO_ROOT, check=False, + ).stdout + except OSError as exc: + print("cannot resolve %s: %s" % (ref, exc), file=sys.stderr) + return 1 + if not base_text.strip(): + print("merge base recorded no asserted-global baseline; nothing to compare") + return 0 + base = {ln.strip() for ln in base_text.splitlines() if ln.strip() and not ln.startswith("#")} + head = _load_asserted_baseline() or set() + added = sorted(head - base) + if added: + for entry in added: + print("BASELINE RAISED: %s" % entry, file=sys.stderr) + print( + "\nthe baseline gained %d entr(y/ies) relative to %s. The ratchet only " + "goes down: convert them with `per_test_global!` rather than recording " + "them." % (len(added), ref), + file=sys.stderr, + ) + return 1 + print("asserted-global baseline vs %s: %d -> %d, no additions" % (ref, len(base), len(head))) + return 0 + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--self-test", action="store_true") + parser.add_argument("--update-asserted", action="store_true") + parser.add_argument("--asserted-no-raise-vs", metavar="REF") args = parser.parse_args() if args.self_test: - return self_test() + return self_test() or asserted_self_test() + if args.update_asserted: + return check_asserted(update=True) + if args.asserted_no_raise_vs: + return asserted_no_raise_vs(args.asserted_no_raise_vs) try: violations = audit( @@ -561,6 +783,56 @@ def main() -> int: file=sys.stderr, ) return 1 + # #10944's ratchet runs unconditionally alongside the clear-list rule. + return check_asserted() + + +def asserted_self_test() -> int: + """A gate that cannot fail is documentation. + + Four fixtures: the hazard must be reported, and each of the three ways a + static is NOT the hazard must not be. + """ + hazard = [( + "b.rs", + "static HITS: AtomicU64 = AtomicU64::new(0);\n" + "#[cfg(test)]\nmod tests {\n" + " #[test]\n fn t() { assert_eq!(HITS.load(Relaxed), 1); }\n}\n", + )] + if asserted_globals(hazard) != {"b.rs::HITS"}: + print("self-test FAILED: a bare asserted static was NOT reported", file=sys.stderr) + return 1 + + safe_macro = [( + "a.rs", + "per_test_global! {\n static HITS: AtomicU64 = AtomicU64::new(0);\n}\n" + "#[cfg(test)]\nmod tests {\n" + " #[test]\n fn t() { assert_eq!(HITS.load(Relaxed), 1); }\n}\n", + )] + if asserted_globals(safe_macro): + print("self-test FAILED: a per_test_global! static was reported", file=sys.stderr) + return 1 + + mentioned_not_asserted = [( + "c.rs", + "static HITS: AtomicU64 = AtomicU64::new(0);\n" + "#[cfg(test)]\nmod tests {\n" + " #[test]\n fn t() { HITS.store(1, Relaxed); }\n}\n", + )] + if asserted_globals(mentioned_not_asserted): + print("self-test FAILED: a static no test ASSERTS on was reported", file=sys.stderr) + return 1 + + production_only = [( + "d.rs", + "static HITS: AtomicU64 = AtomicU64::new(0);\n" + "fn p() { assert_eq!(HITS.load(Relaxed), 1); }\n", + )] + if asserted_globals(production_only): + print("self-test FAILED: a non-test assertion was reported", file=sys.stderr) + return 1 + + print("asserted-global self-test: reports the hazard and none of the three near-misses") return 0 diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index 0f821ab3b5..3c5f112c97 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -12,6 +12,7 @@ "crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple)": 1, "crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, "crates/perry-codegen/src/expr/proxy_reflect.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 3, + "crates/perry-codegen/src/expr/region_read_run.rs|let header = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/new.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/new_alloc.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, "crates/perry-codegen/src/lower_call/property_get/imported_object.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, @@ -62,7 +63,7 @@ "crates/perry-runtime/src/object/test_root_accessors.rs|keys_array|access|let inline = unsafe { (*st.object_hot.shape_inline_cache.get())[slot].keys_array as usize };": 1 }, "summary": { - "codegen_object_header_size_sites": 42, + "codegen_object_header_size_sites": 43, "raw_member_files": 12, "raw_member_sites": { "keys_array": 35 diff --git a/test-files/test_parity_dictionary_mode_order.ts b/test-files/test_parity_dictionary_mode_order.ts new file mode 100644 index 0000000000..02ff797f2b --- /dev/null +++ b/test-files/test_parity_dictionary_mode_order.ts @@ -0,0 +1,174 @@ +// Dictionary mode (#10868 step 2.5 stage 1) — the ORDER contract. +// +// A dictionary-mode object stops pointing at a layout the shape table owns and +// carries its own ordered key list in its `ObjectMeta`. Key order is +// observable in JS, so if the private list reproduces the order differently +// from the shared one — or if a consumer of the shape's key list was never +// branched — the result is a silent wrong answer in every program, not a slow +// one. These rows pin the order across every way an object can reach a +// layout, exactly as `test_parity_shape_identity_order.ts` does for an +// ordinary object. +// +// RUN IT TWICE. This file is byte-identical to node with the latch off (the +// default) AND with `PERRY_OBJECT_DICTIONARY_MIN_KEYS=0` set at COMPILE time, +// which forces every receiver that publishes a key list into dictionary mode. +// The second run is the point: it puts the whole file through the mode, so a +// consumer of the shape's key list that nobody branched shows up here as a +// diff rather than as a plausible-looking pass. +// +// PROVEN ABLE TO FAIL — each view was reddened by a sabotage applied to the +// runtime and reverted, because a row that cannot redden is documentation. +// The four views reach the key list by THREE different paths, which is why +// one sabotage is not enough. The sabotages are the dictionary-mode twins of +// the three in #10919, and each names the enforcement point it removes: +// +// view path sabotage that reddens it +// ---- ---- ------------------------ +// keys= js_object_keys -> object_keys_array make `object/mod.rs`'s +// forin= (same function; the two move together) dictionary branch return the +// shape's null keys instead of +// `dictionary::keys_array` — +// every row loses every key +// own= js_object_get_own_property_names, reverse `clone_key_list`'s copy +// a DIFFERENT walk of the same array in `object/dictionary.rs`: the +// latch is where the private list +// is built, so a wrong order there +// is invisible until an object +// latches +// json= object_keys_array read DIRECTLY by the skip `publish_keys`' store when +// json/stringify_* serialisers, bypassing the array reallocates: appends +// both enumeration functions above past the latch's slack vanish +// +// The row that matters most is `grownWide`: it grows past the inline region so +// its values live in spill, which is the case where "the names moved but the +// values did not" has to hold or the printed values are silently wrong. + +function show(label, o) { + const keys = Object.keys(o); + const forin = []; + for (const k in o) forin.push(k); + const own = Reflect.ownKeys(o).map((k) => (typeof k === "symbol" ? String(k) : k)); + console.log(label + " keys=" + JSON.stringify(keys)); + console.log(label + " forin=" + JSON.stringify(forin)); + console.log(label + " own=" + JSON.stringify(own)); + console.log(label + " json=" + JSON.stringify(o)); +} + +// --- 1. born vs grown, same key set, same order ----------------------------- +const born3 = { a: 1, b: 2, c: 3 }; +const grown3 = {}; +grown3.a = 1; +grown3.b = 2; +grown3.c = 3; +show("born3", born3); +show("grown3", grown3); + +// --- 2. same SET, different ORDER. These must NOT be merged ----------------- +const ab = {}; +ab.a = 1; +ab.b = 2; +const ba = {}; +ba.b = 2; +ba.a = 1; +show("ab", ab); +show("ba", ba); + +// --- 3. tombstone: delete then re-add moves the key to the END -------------- +const tomb = {}; +tomb.a = 1; +tomb.b = 2; +tomb.c = 3; +delete tomb.a; +tomb.a = 9; +show("tomb", tomb); +const direct = {}; +direct.b = 2; +direct.c = 3; +direct.a = 9; +show("direct", direct); + +// --- 4. delete in the middle, no re-add ------------------------------------- +const hole = {}; +hole.a = 1; +hole.b = 2; +hole.c = 3; +hole.d = 4; +delete hole.b; +show("hole", hole); + +// --- 5. WIDE: grown past the inline region, so the values live in spill ----- +// This is the row dictionary mode is built for: a key list unique to one +// object, grown by name. The names move to the meta record; the values must +// not move at all. +const grownWide = {}; +for (let i = 0; i < 24; i++) grownWide["w" + i] = i * 3; +show("grownWide", grownWide); +const bornWide = { w0: 0, w1: 3, w2: 6, w3: 9, w4: 12, w5: 15, w6: 18, w7: 21 }; +show("bornWide", bornWide); + +// --- 6. integer-like keys sort FIRST, ascending, then strings --------------- +const mixed = {}; +mixed.z = 1; +mixed["2"] = 2; +mixed.a = 3; +mixed["10"] = 4; +mixed["1"] = 5; +show("mixed", mixed); + +// --- 7. a non-enumerable key is in ownKeys, not in keys --------------------- +const dp = {}; +dp.a = 1; +Object.defineProperty(dp, "hidden", { value: 2, enumerable: false, configurable: true }); +dp.b = 3; +show("dp", dp); + +// --- 8. an accessor keeps its insertion position ---------------------------- +const acc = {}; +acc.a = 1; +Object.defineProperty(acc, "g", { get() { return 7; }, enumerable: true, configurable: true }); +acc.b = 3; +show("acc", acc); +console.log("acc g=" + acc.g); + +// --- 9. symbols come after strings in ownKeys ------------------------------- +const sym = {}; +sym.a = 1; +sym[Symbol.for("s")] = 2; +sym.b = 3; +show("sym", sym); + +// --- 10. a null-prototype object -------------------------------------------- +const np = Object.create(null); +np.a = 1; +np.b = 2; +show("np", np); + +// --- 11. an own field must still shadow a prototype method ------------------ +// The own-field shadowing scan reads the shape's key list; on a dictionary +// object that list is empty, so an unbranched scan lets the vtable method win. +const shadow = {}; +shadow.a = 1; +shadow.toString = function () { return "own-toString"; }; +console.log("shadow=" + String(shadow)); +console.log("shadow keys=" + JSON.stringify(Object.keys(shadow))); + +// --- 12. an own property must not be answered from the prototype chain ------ +const protoOwner = { p: "from-proto" }; +const child = Object.create(protoOwner); +for (let i = 0; i < 12; i++) child["c" + i] = i; +child.p = "own"; +console.log("child p=" + child.p); +console.log("child hasOwn p=" + Object.prototype.hasOwnProperty.call(child, "p")); +console.log("child in p=" + ("p" in child)); +show("child", child); + +// --- 13. read-back after the whole lot -------------------------------------- +let sum = 0; +for (const k in grownWide) sum += grownWide[k]; +console.log("grownWide sum=" + sum); +console.log("grownWide w23=" + grownWide.w23); +console.log("grownWide delete=" + delete grownWide.w5); +console.log("grownWide after delete=" + JSON.stringify(Object.keys(grownWide))); +grownWide.w5 = 999; +console.log("grownWide readd=" + JSON.stringify(Object.keys(grownWide))); +console.log("grownWide w5=" + grownWide.w5);