diff --git a/CLAUDE.md b/CLAUDE.md index 020d0b5406..89807f8b15 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.1584 +**Current Version:** 0.5.1585 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index d6105a5a80..e5cf6e645d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "fc61f41aef38c94e922057977bcb33bf185ab42242188719991ecfdc0fa1fe6b" [[package]] name = "perry" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1584" +version = "0.5.1585" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1584" +version = "0.5.1585" [[package]] name = "perry-parser" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6477,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1584" +version = "0.5.1585" [[package]] name = "perry-ui-test" @@ -6594,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1584" +version = "0.5.1585" [[package]] name = "perry-ui-tvos" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1584" +version = "0.5.1585" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 8667d9ebc2..a9b3a5b729 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1584" +version = "0.5.1585" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10306-module-path-canonicalize-memo.md b/changelog.d/10306-module-path-canonicalize-memo.md new file mode 100644 index 0000000000..f7ea35f74c --- /dev/null +++ b/changelog.d/10306-module-path-canonicalize-memo.md @@ -0,0 +1,17 @@ +Module path canonicalization is memoized per directory. Registering a module +canonicalizes its absolute path, and `std::fs::canonicalize` is a full +realpath: one `readlink` for every path component, every time. Sibling modules +share every ancestor, so the same prefixes were re-walked once per module. + +Running `opencode --version` issued 86,745 `readlink` calls over only 9,467 +distinct paths — 98.7% of every syscall the process made and 0.32s of system +time. The tree root alone was resolved 7,501 times and `node_modules/.bun` +5,580 times. + +Directories are now resolved once and reused, so each additional module in a +directory costs one `readlink` for its own basename instead of one per path +component: a 400-module fixture drops from 4,010 `readlink` calls to 405. A +path containing `.` or `..` components, or a basename that really is a symlink, +still goes through `std::fs::canonicalize`, so resolution semantics are +unchanged. This is wall-clock, not instruction count — it moves `instructions:u` +by 0.04%. diff --git a/changelog.d/10375-new-globalthis-shadowed-binding.md b/changelog.d/10375-new-globalthis-shadowed-binding.md new file mode 100644 index 0000000000..d1c9494326 --- /dev/null +++ b/changelog.d/10375-new-globalthis-shadowed-binding.md @@ -0,0 +1,7 @@ +- **`new globalThis.X(...)` constructs the global even when a module binding shadows `X` (#10359).** `globalThis.X` names the global object's property, never a module binding, but every arm that lowered the qualified construct *by name* resolved it against the module's bindings. With `import { Event } from "./ev"` in scope, `new globalThis.Event("ping")` built the imported class (`e instanceof Event === true`, `e.type === undefined`), while the aliased `const E = globalThis.Event; new E("ping")` was correct. That defeats `globalThis.X`'s only purpose, escaping a local shadow; OpenCode's graph exports `Event`, `File`, `Request`, `Error`, `WebSocket`, `FormData` and `Storage`. Four by-name paths now back off when an import, class (at any depth), function, local or class alias shares the name, and construct the global property's value instead (`NewDynamic` over `globalThis.X`): + - the #6726 re-dispatch through the bare-identifier arm (`crates/perry-hir/src/lower/expr_new.rs`) ignored the shadow only for the dedicated intrinsic nodes (`SetNew`, `ErrorNew`, `UrlNew`, …). Names with none (`Event`, `Request`, `Headers`, `MessageChannel`, the three-argument typed-array form) reached the by-name tail (`New { class_name }` / `FuncRef` / `LocalGet`, plus the proxy-local and dynamic-function-subclass arms); + - `lower_new_member_native`'s `globalThis` fetch-constructor and `MessageChannel`/`BroadcastChannel` arms (`expr_new/member.rs`); + - `lower_new_non_ident`'s global-object fetch arm, reached through a `globalThis` alias (`const g = globalThis; new g.Headers()`); + - codegen's `try_static_class_name` (`crates/perry-codegen/src/expr/v8_interop.rs`) folded a `globalThis.X` callee onto a same-named module class, class alias or import. `class Widget {}` plus `globalThis.Widget = class {…}` built the module class, and with no such global `new globalThis.Gadget()` quietly built `class Gadget` instead of throwing a `TypeError`. Now `NewDynamic` (`expr/new_dynamic.rs`) builds the declined callee through the builtin table (`lower_global_intrinsic_new` → `lower_builtin_new`, skipping module classes), the construct the unshadowed form reaches. Only a name no builtin arm owns reads the property at runtime. Without that step, streams came back method-less and `WebSocket` had no `readyState`. + + Unshadowed names keep their by-name intrinsic construct, and a shadowed name with a dedicated intrinsic node (`new globalThis.Map()` under `import { Map }`) keeps that node. Tests: `lower::tests::global_this_new_shadowed` (the shadowed test fails against the pre-fix lowering) and `test-files/test_gap_new_globalthis_shadowed_10359.ts` (byte-identical to Node 26.5.1. Before the fix it diverged from line 1 and crashed at `mc.port1.close()`). diff --git a/changelog.d/10378-hit-path-instruction-wave2.md b/changelog.d/10378-hit-path-instruction-wave2.md new file mode 100644 index 0000000000..3de764a7b4 --- /dev/null +++ b/changelog.d/10378-hit-path-instruction-wave2.md @@ -0,0 +1,54 @@ +Second wave of the executed-instruction campaign (after #10295), aimed at the +three areas that audit deferred: parameter guards, per-element array work, and +key/string lookups. Across a 97-probe set called through a dynamic namespace +lookup, the summed per-call cost falls 20,308 -> 16,996 (-16.3%) with no probe +regressing beyond the noise floor. + +A constant-key `in` now caches a presence answer on the receiver's ShapeId: +955 -> 30 instructions. Only positive answers about an OWN key are cached, +because that is the only claim no prototype mutation can falsify -- a negative +would be a statement about the whole chain, and there is no epoch to key one +on. Every way of losing the key either moves the ShapeId or raises +OBJ_FLAG_STABLE_TOMBSTONES, which the guard rejects. + +Parameter guards stop walking descriptors nothing consumes. A clone consumes +one fact per parameter -- the declared type -- and never the descriptor's +field nodes, so an all-number class parameter is proved nominally from the +class id plus the typed-layout-intact bit, and the rule that let a loop in the +body license an unbounded per-element walk is gone. A 1,600-element `Pt[]` +parameter costs 1,146,528 instructions per call before and 21,553 after; a +`string[]` of the same length 213,233 -> 88,468; a class parameter with eight +number fields 3,194 -> 1,306. One non-`number` field on the chain puts the +whole chain back on the walk (control: 1,828 -> 1,844), because the intact bit +is a raw-f64 claim and says nothing about what a pointer slot holds. + +Rest bundles are built the way array literals are -- one inline bump +allocation and N stores instead of `js_array_alloc` plus a per-element +`js_array_push_f64` that re-classified the receiver every time: `f(1, 2, 3)` +909 -> 85, `f(o, o, o)` 1,655 -> 486, with bundles wider than 16 left on the +old path. `map` resolves its result header once per element rather than three +times, keeping the full protocol (canonicalize, retire the numeric claim, +layout note, remembered-set edge): `a.map(v => v + 1)` over 16 elements +6,068 -> 4,028. The packed loop stops re-deriving its element base per element +and its counter read shades no GC root, restricted to offset 0 because +`arr[i +/- c]` can leave the array and reach the prototype chain. + +Smaller runtime paths: an ASCII string index answers from the short-string +value instead of a four-call chain ending in a thread-local table (172 -> 123); +`[[HasProperty]]` resolves the recorded prototype only where it is read; +the concat chain formats number parts in place instead of building an +intermediate heap string; `instanceof`'s `util.inherits` escape hatch becomes +a process-wide latch instead of two registry probes per miss (miss 1,199 -> +1,081, hit unchanged); and a subclass `pop` flushes store plans only when it +actually retires a proof. + +One change carries no measured win and its commit message says so: skipping +the re-registration of an unchanged class parent edge removes a process-global +prop_plan epoch bump and a CLASS_REGISTRY write lock from the outlined +allocation entry, but every allocation loop that could be built takes the +inline allocator instead, which never calls register_class. + +Three spec divergences found while measuring are filed, not fixed: #10364 +(`instanceof` against a Proxy right-hand side segfaults), #10365 (four +divergences from the spec's prototype walk), #10366 (`in` does not reach +`Function.prototype`). All reproduce on unmodified main. diff --git a/changelog.d/10381-gc-relocation-address-keyed-records.md b/changelog.d/10381-gc-relocation-address-keyed-records.md new file mode 100644 index 0000000000..ae309e305e --- /dev/null +++ b/changelog.d/10381-gc-relocation-address-keyed-records.md @@ -0,0 +1,28 @@ +### Performance + +### Performance + +- **A moving collection no longer re-derives an object's layout header (#10362).** `layout_transfer` + runs for every evacuated object on every copying minor, both old-generation evacuations and + `js_array_grow`. All four callers copy the source header's `_reserved` into the destination + first, so the layout state, `GC_LAYOUT_ALL_POINTERS`, the raw-f64 / holes flags, the + element-shape bit and `GC_OBJ_TYPED_LAYOUT_INTACT` have already arrived — yet the funnel rewrote + those bits anyway, classified both headers, evaluated #7510's flag-and-filter gate twice, and + for every intact object re-resolved the intact bit through a ShapeId-keyed `SHAPE_LAYOUTS` + probe whose answer a relocation cannot change. Measured by single-stepping the #10362 + retained-graph fixture: 160 instructions per moved array and 245 per moved object, 518M in all + (4.2% of the run), none of which reached a side-table record. + That contract is now stated and asserted, and the funnel moves only what a header cannot carry: + the element-shape record (#7480), the residual static-prototype registry (#9304) and the + per-object `TYPED_LAYOUTS` / `LAYOUT_SLOT_MASKS` entries (#7510) — each behind the bit or latch + that governs it, with the record moves in a `#[cold]` path entered on 0.05% of relocations. + The one behavioural change is that the lazy intact downgrade is gone: the bit is a fact of the + object and of tables a move does not touch, the state it cleared is legal and handled + (`shape_install_shared` leaves still-INTACT siblings to fall back, #8115 clears at the first + contradicting store, the trace falls back to scanning every slot), and an unmoved sibling keeps + its bit today. A new test builds a poisoned-shape intact receiver, moves it through a real + copying minor, and checks the bit, every query answer and the survival and rewrite of its child; + it fails on the parent commit and under a funnel that skips the per-object record move. + instructions:u, min of 5: gc3 12.276G -> 11.881G (-3.22%), retained-set variants -2.50% to + -4.88%, old->young churn -2.13%, allocation-only unchanged. 2,560,042 relocations on both arms. + diff --git a/changelog.d/10384-windows-build-breaks.md b/changelog.d/10384-windows-build-breaks.md new file mode 100644 index 0000000000..1614f9ffc6 --- /dev/null +++ b/changelog.d/10384-windows-build-breaks.md @@ -0,0 +1,32 @@ +Unbreak both Windows CI legs on `main`. + +`windows-arm64-build` failed with `LNK1120: 7 unresolved externals` — the whole +`js_lru_cache_*` ABI — when linking **the `perry` compiler itself**. +`perry-runtime/src/lru_subclass.rs` declares that ABI `extern "C"` and leaves it +to whichever cache provider the program links; `perry` links neither provider, so +its link carries seven undefined references. Every other target hides this +because its linker dead-strips before it reports (the same command succeeds on +macOS while the rlib still shows all seven as `U`), whereas `link.exe` resolves +symbols before `/OPT:REF`. + +A Cargo feature cannot express "this link has no provider": the Windows job +builds `-p perry -p perry-runtime-static -p perry-stdlib-static` in one +invocation, so perry-stdlib unifies `perry-runtime/stdlib` onto the copy of +perry-runtime that `perry` links, and anything gated on `stdlib` — including the +existing `stdlib_stubs` mechanism — is compiled out in exactly the failing +configuration. Fixed with `/ALTERNATENAME` directives in `.drectve` behind +`cfg(all(windows, target_env = "msvc"))` plus no-op fallbacks reporting through +`perry_stub_warn`; `link.exe` substitutes an alternate only for a symbol still +undefined after all inputs are read, so a real provider always wins. + +`windows-build` failed with `error[E0425]: cannot find function reorder_child in +module widgets`, in `perry-ui-windows-winui`: it `#[path]`-includes +perry-ui-windows' `ffi/mod.rs`, so `widgets::` resolves against winui's own +module, which never gained `reorder_child`. Added in the module's established +shape — delegate to the Win32 implementation when Fluent is inactive, otherwise +reorder the node's children under `with_node_mut`. + +Neither Windows job runs in the PR tier (`ci_plan.py`: sweep and full only), so +the fix was validated by emitting the COFF object for +`x86_64-pc-windows-msvc` and confirming the `.drectve` contents and symbol +classes directly; no Windows link was performed. diff --git a/changelog.d/10388-copying-minor-weak-holder-fact.md b/changelog.d/10388-copying-minor-weak-holder-fact.md new file mode 100644 index 0000000000..274e7e343a --- /dev/null +++ b/changelog.d/10388-copying-minor-weak-holder-fact.md @@ -0,0 +1,38 @@ +Read the copying minor's weak-holder question once per traced object instead of +once per slot. `visit_slot_with_parent` called `weakref::is_weak_target_trace_slot` +for every slot of every object, an out-of-line call that re-reads the parent's +`obj_type` and `class_id` and then rejects on class for every ordinary object. +The per-object form (`weakref::is_weak_holder_header`) already existed: #10182 +gave it to the full mark, and the copying minor never got it. + +**The fact is read lazily, on the first slot that needs it, not eagerly per +object** — and that is a rule for this collector, not an implementation detail. +Reading it eagerly, once per traced object, regressed every fixture: + + gc3 +1.59% oldyoung +5.09% w20000 +1.44% + w1000 +0.88% alloc +0.87% w5000 +0.86% (instructions:u, min of 5) + +with peak RSS +3% to +8% alongside. A great many traced objects — strings, +pointer-free arrays — have no slot to visit at all, and an eager hoist makes +every one of them pay for an answer nobody then asks for. Any future per-object +hoist on this path has to be lazy for the same reason. + +Measured on six GC fixtures, instructions:u min-of-5: gc3 -0.99%, w5000 -1.61%, +w20000 -1.37%, oldyoung -1.10%, w1000 -0.81%, alloc flat. Peak RSS within 0.1% +on all six and max pause better or flat on all six. On a control that isolates +the slot term (60k records whose fields all point at one shared object, against +the same records holding doubles, so the mutator difference cancels between +arms), the pointer-slot-attributable instruction count falls 4.4% at 2 slots per +object, 5.9% at 8 and 6.3% at 16 — 25.5 instructions per pointer-slot visit, +which cross-checks against the 6.7 cycles/slot of self time the profile +attributed to `is_weak_target_trace_slot`. + +The companion hoist for `barrier_parent_needs_remembering`'s generation clause is +deliberately NOT included. It measured as the half that makes a +one-pointer-slot object pay (oldyoung +0.198% with it, -1.10% without), and no +sabotage of it could be made to fail: sticky dirty-page coverage carries an +old-to-young edge independently of the remembered-set re-insertion, so forgetting +the fact changes nothing observable. It needs its own witness first. + +`gc/copying.rs` loses the slot visit to a new `gc/copying_parent_facts.rs`, which +the 2000-line file lint required and which leaves `copying.rs` smaller than before. diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 99f55d65e7..592d956d69 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -3202,7 +3202,6 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> f.id, &module_prefix, &f.params, - &f.body, &demoted, &guard_blocked, &cross_module.type_aliases, diff --git a/crates/perry-codegen/src/codegen/param_guard.rs b/crates/perry-codegen/src/codegen/param_guard.rs index c6d97a2c3c..1567fddc97 100644 --- a/crates/perry-codegen/src/codegen/param_guard.rs +++ b/crates/perry-codegen/src/codegen/param_guard.rs @@ -44,6 +44,10 @@ enum GuardNode { class_id: Option, fields: Vec, }, + /// A class proved by identity + the per-object typed-layout-intact bit, + /// with no field walk. Only for a chain whose every field is declared + /// `number` — see `OP_CLASS_NOMINAL` in the runtime validator. + ClassNominal(u32), Union(Vec), RecursiveRef(u32), Map { @@ -116,7 +120,7 @@ impl<'a> GuardGraphBuilder<'a> { /// Cycle-guarded like every other chain walk in this crate: same-named /// classes pulled across modules into one name-keyed table can form a /// parent cycle (`type_analysis_class_fields.rs` carries the same note). - fn class_chain_fields(&mut self, name: &str) -> Option> { + fn class_chain_fields(&mut self, name: &str) -> Option<(Vec, bool)> { let mut chain: Vec<&perry_hir::Class> = Vec::new(); let mut seen: HashSet = HashSet::new(); let mut current = Some(name.to_string()); @@ -165,7 +169,16 @@ impl<'a> GuardGraphBuilder<'a> { Some((field, ty, false)) }) .collect::>>()?; - self.build_fields(fields) + // A chain whose every field is a raw-f64 candidate needs no walk: the + // intact bit states the same value fact for all of them at once. An + // EMPTY chain is deliberately excluded — it has no value fact to carry, + // so requiring the intact bit there could only reject receivers the + // by-name walk accepts, buying nothing. + let nominal = !fields.is_empty() + && fields + .iter() + .all(|(_, ty, _)| crate::typed_shape::type_is_raw_f64_candidate(ty)); + Some((self.build_fields(fields)?, nominal)) } fn build_named(&mut self, name: &str) -> Option { @@ -217,15 +230,33 @@ impl<'a> GuardGraphBuilder<'a> { // stale claim came from the doc comment on // `ObjectHeader::keys_array`, corrected alongside this. // - // Identity ALONE was measured and rejected: with `fields` empty the - // emitted clone comes out structurally identical to the `$generic` - // sibling it routes around — same line count, same call multiset, - // `js_typed_feedback_class_field_get_guard` already present in both - // — so a class-annotated receiver reaches the class-field guard - // path with no parameter evidence at all. It bought nothing and - // cost one guard call per invocation: -51% on `tree`, -30% on - // `tree_wide`. The field VALUE facts are the whole payload, which - // is why they are not optional here. + // Identity ALONE was measured and rejected on `tree`/`tree_wide`: + // the clone came out structurally identical to the `$generic` + // sibling it routed around, so the guard was pure cost — -51% and + // -30%. That verdict is REAL BUT LOCAL, and the conclusion once + // drawn from it here ("the field VALUE facts are the whole + // payload") was too broad. Two corrections: + // + // 1. Codegen never reads these field nodes. The clone is compiled + // with `SpecParamGuard::proof`, which is `param.ty` — a NAME — + // and looks the class's fields up from `ctx.classes`, which it + // has from the annotation either way. Forcing `fields` empty + // and recompiling leaves all 24 emitted specialized and + // generic clone bodies across a 16-function probe set + // unchanged. The + // descriptor is the runtime ENFORCEMENT of the proof, not the + // proof. `tree` is identical to its `$generic` because its + // fields are reference-typed and both bodies route through + // `js_typed_feedback_class_field_get_guard` — a property of + // that class shape, not of identity-only descriptors. + // 2. That regression cannot recur regardless: wave 1's + // `spec_clone_consumes_no_proof` (`codegen/function.rs`) now + // detects a clone identical to its generic sibling and emits a + // plain forwarder, dropping the guard. + // + // What the walk still buys is the VALUE half of the proof, and + // only for fields the intact bit cannot speak for — see the + // `nominal` branch below. // // Cost is bounded by #8094's existing rule rather than a new one: // a field-bearing descriptor claims heap CONTENTS, so a @@ -233,10 +264,18 @@ impl<'a> GuardGraphBuilder<'a> { // that contains a call. A recursive class (`Tree.left: Tree`) // therefore cannot be guarded in the recursive walker that would // make its validation O(nodes x depth). - let fields = self.class_chain_fields(name)?; - GuardNode::Object { - class_id: Some(class_id), - fields, + let (fields, nominal) = self.class_chain_fields(name)?; + if nominal { + // Every declared field is `number`, so (class chain reaches C, + // typed-layout-intact) implies each one holds a plain double — + // the whole payload of the walk this replaces. Measured at + // ~326 instructions per field walked. + GuardNode::ClassNominal(class_id) + } else { + GuardNode::Object { + class_id: Some(class_id), + fields, + } } } else { self.building_named.remove(name); @@ -548,6 +587,10 @@ fn encode_node(node: &GuardNode) -> Option> { put_u32(&mut out, field.ty); } } + GuardNode::ClassNominal(class_id) => { + out.push(17); + put_u32(&mut out, *class_id); + } GuardNode::Union(variants) => { out.push(12); put_u32(&mut out, variants.len().try_into().ok()?); @@ -677,54 +720,10 @@ fn descriptor_for_type( .map(|(descriptor, _)| descriptor) } -/// A loop makes the body's own work potentially unbounded, so a collection or -/// recursive graph walk can still be amortizable. With no loop, validating an -/// unbounded input to enter a bounded body cannot win as the input grows. -/// Nested closure bodies are not part of the enclosing function's work. -fn body_contains_loop(stmts: &[perry_hir::Stmt]) -> bool { - use perry_hir::Stmt; - stmts.iter().any(|stmt| match stmt { - Stmt::While { .. } | Stmt::DoWhile { .. } | Stmt::For { .. } => true, - Stmt::If { - then_branch, - else_branch, - .. - } => { - body_contains_loop(then_branch) - || else_branch.as_deref().is_some_and(body_contains_loop) - } - Stmt::Try { - body, - catch, - finally, - } => { - body_contains_loop(body) - || catch - .as_ref() - .is_some_and(|catch| body_contains_loop(&catch.body)) - || finally.as_deref().is_some_and(body_contains_loop) - } - Stmt::Switch { cases, .. } => cases.iter().any(|case| body_contains_loop(&case.body)), - Stmt::Labeled { body, .. } => body_contains_loop(std::slice::from_ref(body.as_ref())), - Stmt::Expr(_) - | Stmt::Throw(_) - | Stmt::Return(_) - | Stmt::Let { .. } - | Stmt::Break - | Stmt::Continue - | Stmt::LabeledBreak(_) - | Stmt::LabeledContinue(_) - | Stmt::PreallocateBoxes(_) - | Stmt::PreallocateTdzBoxes(_) - | Stmt::ReleaseBoxes(_) => false, - }) -} - pub(crate) fn declaration_guards( function_id: u32, module_prefix: &str, params: &[perry_hir::Param], - body: &[perry_hir::Stmt], demoted_params: &[bool], // (#8094) Guard-only ineligibility, kept SEPARATE from `demoted_params` // because that mask also drives raw representation selection: a reference @@ -736,7 +735,6 @@ pub(crate) fn declaration_guards( classes: &HashMap, class_ids: &HashMap, ) -> Vec> { - let body_can_amortize_unbounded_walk = body_contains_loop(body); params .iter() .zip(demoted_params.iter()) @@ -758,10 +756,26 @@ pub(crate) fn declaration_guards( // graph to read one discriminant and one field. The validator was // 9.8-12% of those programs and the clone it licensed was worth // only 0.1-0.2%. Do not emit a guard whose work grows with the - // input when the guarded body itself is statically bounded. A - // loop leaves the decision unchanged: array reducers and similar - // consumers can amortize validation over their own traversal. - if !walk_is_bounded && !body_can_amortize_unbounded_walk { + // input. + // + // A loop in the body used to lift this, on the theory that array + // reducers amortize validation over their own traversal. Measured, + // they do not. The walk is a SECOND full pass over the same array, + // and the clone's saving per element is smaller than the walk's + // cost per element, so the guarded arm loses at every length — + // instructions per call against the same body taking an unproven + // parameter, both arms re-run in one window: + // + // Pt[], 16 elements 11,635 vs 9,360 +24.3% + // Pt[], 1600 elements 789,536 vs 550,881 +43.3% + // string[], 16 elements 10,210 vs 9,818 +4.0% + // string[], 1600 elements 603,351 vs 529,337 +14.0% + // + // Refusing is the win: the fallback is the generic body. Note the + // penalty GROWS with length, which is the opposite of what + // amortization would predict, and is why a longer array cannot be + // the case that rescues the rule. + if !walk_is_bounded { return None; } Some(SpecParamGuard { @@ -1361,11 +1375,7 @@ mod tests { assert_eq!(scalar_descriptor_rep(b"PGT1"), None); } - fn declaration_guard_for( - ty: Type, - body: &[perry_hir::Stmt], - aliases: &HashMap, - ) -> Option { + fn declaration_guard_for(ty: Type, aliases: &HashMap) -> Option { let params = [perry_hir::Param { id: 1, name: "value".to_string(), @@ -1379,7 +1389,6 @@ mod tests { 1, "walk_bound_test", ¶ms, - body, &[false], &[false], aliases, @@ -1401,21 +1410,21 @@ mod tests { let flat = object_alias("Flat", &[("value", Type::Number)]); let flat_aliases = HashMap::from([flat]); assert!( - declaration_guard_for(Type::Named("Flat".to_string()), &[], &flat_aliases).is_some(), + declaration_guard_for(Type::Named("Flat".to_string()), &flat_aliases).is_some(), "a fixed field walk remains eligible" ); let array = Type::Array(Box::new(Type::Number)); - assert!(declaration_guard_for(array.clone(), &[], &HashMap::new()).is_none()); + assert!(declaration_guard_for(array.clone(), &HashMap::new()).is_none()); - let loop_body = [perry_hir::Stmt::While { - condition: perry_hir::Expr::Bool(false), - body: Vec::new(), - }]; - assert!( - declaration_guard_for(array, &loop_body, &HashMap::new()).is_some(), - "a loop consumer keeps the existing structural specialization" - ); + // The refusal is now unconditional. A loop in the body used to lift + // it, on the theory that array reducers amortize validation over their + // own traversal; measurement contradicts that (the guarded arm loses + // 4-43%, by MORE the longer the array), so the body is no longer an + // input to this decision at all — `declaration_guards` does not take + // one. That makes the old behavior unexpressible rather than merely + // untested. + let _ = &array; let recursive_aliases = HashMap::from([object_alias( "Link", @@ -1425,8 +1434,7 @@ mod tests { )], )]); assert!( - declaration_guard_for(Type::Named("Link".to_string()), &[], &recursive_aliases) - .is_none(), + declaration_guard_for(Type::Named("Link".to_string()), &recursive_aliases).is_none(), "a recursive value walk is runtime-sized too" ); } @@ -1708,6 +1716,134 @@ mod tests { ); } + /// A chain whose every field is declared `number` needs no walk: the + /// per-object typed-layout-intact bit states "this slot holds a plain + /// double" for all of them at once, which is exactly what walking them by + /// name would establish. Measured at ~326 instructions per field walked. + #[test] + fn an_all_number_class_is_proved_nominally_without_a_field_walk() { + let descriptor = class_descriptor( + "Vec3", + &[class( + 21, + "Vec3", + None, + vec![ + ("x", Type::Number), + ("y", Type::Number), + ("z", Type::Number), + ], + )], + ) + .expect("a plain numeric class is guardable"); + let nominal = descriptor + .windows(5) + .find(|window| window[0] == 17) + .unwrap_or_else(|| panic!("an OP_CLASS_NOMINAL node: {descriptor:?}")); + assert_eq!( + u32::from_le_bytes(nominal[1..5].try_into().unwrap()), + 21, + "the class id is the identity half of the proof: {descriptor:?}" + ); + assert!( + !descriptor.windows(1).any(|window| window[0] == 11), + "a nominal class must not also emit the OP_OBJECT walk it \ + replaces: {descriptor:?}" + ); + assert!( + !descriptor.windows(1).any(|window| window[0] == b'x'), + "no field name should be serialized at all: {descriptor:?}" + ); + } + + /// The intact bit is a raw-f64 claim. It says a `string` field's slot is in + /// the POINTER mask, which is not "it holds a string" — and a clone that + /// inlines `s.length` trusts exactly that. One non-numeric field therefore + /// puts the whole chain back on the by-name walk. + #[test] + fn one_non_numeric_field_keeps_the_whole_chain_on_the_by_name_walk() { + for (label, ty) in [ + ("string", Type::String), + ("boolean", Type::Boolean), + ("class-typed", Type::Named("Vec3".to_string())), + ] { + let descriptor = class_descriptor( + "Mixed", + &[ + class(22, "Vec3", None, vec![("x", Type::Number)]), + class( + 23, + "Mixed", + None, + vec![("n", Type::Number), ("other", ty.clone())], + ), + ], + ) + .unwrap_or_else(|| panic!("{label}: descriptor")); + assert!( + descriptor.windows(1).any(|window| window[0] == 11), + "{label}: a non-numeric field must keep OP_OBJECT: {descriptor:?}" + ); + assert!( + !descriptor.windows(5).any(|window| window[0] == 17 + && window.len() == 5 + && u32::from_le_bytes(window[1..5].try_into().unwrap()) == 23), + "{label}: must not claim the chain nominally: {descriptor:?}" + ); + } + } + + /// Inherited fields are part of the instance, so the numeric verdict is a + /// property of the whole chain — a numeric leaf under a string parent is + /// NOT nominal. + #[test] + fn the_nominal_verdict_is_taken_over_the_whole_inheritance_chain() { + let all_numeric = class_descriptor( + "NumLeaf", + &[ + class(24, "NumBase", None, vec![("b", Type::Number)]), + class(25, "NumLeaf", Some("NumBase"), vec![("l", Type::Number)]), + ], + ) + .expect("descriptor"); + assert!( + all_numeric.windows(1).any(|window| window[0] == 17), + "an all-numeric chain is nominal: {all_numeric:?}" + ); + let string_parent = class_descriptor( + "StrLeaf", + &[ + class(26, "StrBase", None, vec![("b", Type::String)]), + class(27, "StrLeaf", Some("StrBase"), vec![("l", Type::Number)]), + ], + ) + .expect("descriptor"); + assert!( + string_parent.windows(1).any(|window| window[0] == 11), + "a string field ANYWHERE on the chain keeps the walk: \ + {string_parent:?}" + ); + } + + /// A fieldless class has no value fact to carry, so requiring the intact + /// bit could only reject receivers the by-name walk accepts. Excluded + /// deliberately — this asserts the exclusion rather than leaving it to + /// chance. + #[test] + fn a_fieldless_class_keeps_its_plain_identity_node() { + let descriptor = class_descriptor("Marker", &[class(28, "Marker", None, Vec::new())]) + .expect("descriptor"); + assert!( + descriptor.windows(1).any(|window| window[0] == 11), + "a fieldless class stays on OP_OBJECT: {descriptor:?}" + ); + assert!( + !descriptor.windows(1).any(|window| window[0] == 17), + "and must not demand an intact bit it has no fields to justify: \ + {descriptor:?}" + ); + } + /// Inherited fields belong to the instance, so a proof that names only the /// leaf's own fields would license a parent field's declared type without /// having validated it. diff --git a/crates/perry-codegen/src/expr/array_literal.rs b/crates/perry-codegen/src/expr/array_literal.rs index 75035b330c..e76458d916 100644 --- a/crates/perry-codegen/src/expr/array_literal.rs +++ b/crates/perry-codegen/src/expr/array_literal.rs @@ -103,275 +103,312 @@ pub(crate) fn lower_array_literal(ctx: &mut FnCtx<'_>, elements: &[Expr]) -> Res } let element_refs: Vec<&Expr> = elements.iter().collect(); rooting::with_operands_rooted(ctx, &element_refs, |ctx, vals| { - // #5391: oversized modules outline array-literal construction. The inline - // bump-alloc + N×(store + layout-note + barrier) sequence makes minified - // data-table builders huge (single 18MB functions are impractical to - // optimize). Instead spill the already-evaluated element values to - // a per-literal stack buffer and build the array in ONE runtime call. The - // buffer is hoisted to the entry block (fixed size per site; bounded total - // stack) and consumed immediately by the call, so no GC-visible window. - if crate::codegen::full_outline_ic_enabled() { - let buf = ctx.func.alloca_entry_array(DOUBLE, n); - for (i, v) in vals.iter().enumerate() { - let slot = ctx.block().gep(DOUBLE, &buf, &[(I64, &i.to_string())]); - ctx.block().store(DOUBLE, v, &slot); - } - let n_str = n.to_string(); - let arr = ctx - .block() - .call(I64, "js_array_from_values", &[(PTR, &buf), (I32, &n_str)]); - return Ok(nanbox_pointer_inline(ctx.block(), &arr)); - } + let arr = emit_array_from_lowered_values( + ctx, + vals, + &canonical_raw_f64, + &layout_notes_needed, + all_numeric_elements, + )?; + Ok(nanbox_pointer_inline(ctx.block(), &arr)) + }) +} - // Inline bump-allocator path for small literals. Size threshold matches - // `MAX_SCALAR_ARRAY_LEN` in collectors.rs so every candidate the escape - // pass rejects can still benefit from the inline alloc. - const INLINE_MAX_ELEMENTS: usize = 16; - if n <= INLINE_MAX_ELEMENTS { - // Layout constants — must match `ArrayHeader` in array.rs and - // `GcHeader` in gc.rs. Duplicated here because codegen emits raw - // byte offsets; the runtime declarations are authoritative. - const GC_HEADER_SIZE: u64 = 8; - const ARRAY_HEADER_SIZE: u64 = 8; - const ELEMENT_SIZE: u64 = 8; - const GC_TYPE_ARRAY: u64 = 1; - const GC_FLAG_ARENA: u64 = 0x02; - // PR #1146: pointer-free hint for slot-layout tracking. The - // element-store loop below only suppresses per-slot notes for - // values whose non-pointer bits are proven by expression shape. - const GC_LAYOUT_POINTER_FREE: u64 = 0x4000; - - let total_size = GC_HEADER_SIZE + ARRAY_HEADER_SIZE + (n as u64) * ELEMENT_SIZE; - let total_size_str = total_size.to_string(); - - // Load state + compute bump check. `total_size` is always a - // multiple of 8, every prior alloc rounds offset to 8, and blocks - // start 8-aligned, so no align-up step is needed. - let state_ptr = load_inline_arena_state(ctx); - let blk = ctx.block(); - let offset_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "8")]); - let offset_val = blk.load(I64, &offset_field_ptr); - let aligned_off = offset_val.clone(); - let new_offset = blk.add(I64, &aligned_off, &total_size_str); - let size_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "16")]); - let size_val = blk.load(I64, &size_field_ptr); - let fits = blk.icmp_ule(I64, &new_offset, &size_val); - - let fast_idx = ctx.new_block("arrlit.fast"); - let slow_idx = ctx.new_block("arrlit.slow"); - let merge_idx = ctx.new_block("arrlit.merge"); - let fast_label = ctx.block_label(fast_idx); - let slow_label = ctx.block_label(slow_idx); - let merge_label = ctx.block_label(merge_idx); - - ctx.block().cond_br(&fits, &fast_label, &slow_label); - - // Fast path: commit the bump, compute `data + offset`. - ctx.current_block = fast_idx; - let blk = ctx.block(); - // GC_STORE_AUDIT(INIT): arena bump offset is allocator metadata, not a JS heap edge. - blk.store(I64, &new_offset, &offset_field_ptr); - let data_ptr = blk.load(PTR, &state_ptr); - let raw_fast = blk.gep(I8, &data_ptr, &[(I64, &aligned_off)]); - let fast_pred_label = blk.label.clone(); - blk.br(&merge_label); - - // Slow path: call the runtime slow-alloc (same one used by the - // inline `new` path). Returns a fresh raw pointer (inclusive of - // GcHeader space). - ctx.current_block = slow_idx; - let raw_slow = ctx.block().call( - PTR, - "js_inline_arena_slow_alloc", - &[(PTR, &state_ptr), (I64, &total_size_str), (I64, "8")], - ); - let slow_pred_label = ctx.block().label.clone(); - ctx.block().br(&merge_label); - - // Merge: phi the raw pointer and write everything. - ctx.current_block = merge_idx; - let blk = ctx.block(); - let raw = blk.phi( - PTR, - &[(&raw_fast, &fast_pred_label), (&raw_slow, &slow_pred_label)], - ); +/// Element count up to which an array is built inline (bump allocation plus N +/// stores) rather than through `js_array_alloc`. Matches `MAX_SCALAR_ARRAY_LEN` +/// in collectors.rs so every candidate the escape pass rejects still benefits. +pub(crate) const INLINE_ARRAY_MAX_ELEMENTS: usize = 16; - // Packed GcHeader (bits 0..7 obj_type, 8..15 gc_flags, 16..31 - // _reserved, 32..63 size). PR #1146 packs the layout-tag in the - // reserved bits so the GC sees the array as pointer-free until - // the element-store loop overrides per-slot via - // `js_gc_note_slot_layout` below. - let gc_packed: u64 = GC_TYPE_ARRAY - | (GC_FLAG_ARENA << 8) - | (GC_LAYOUT_POINTER_FREE << 16) - | (total_size << 32); - // A literal whose elements are statically numbers is usually all - // plain doubles at runtime. Then the array is born exactly as - // `js_array_mark_numeric_f64_layout` would leave it — pointer-free - // with the dense raw-f64 flag — so decide that with one signed - // compare per element and skip every per-slot note and the - // marking walk. Any NaN-boxed element (an int32 box, or a value - // whose annotation lied) takes the unchanged noted path. - let all_plain_numbers = if all_numeric_elements { - let mut all_plain: Option = None; - for (i, v) in vals.iter().enumerate() { - if canonical_raw_f64[i] { - continue; - } - let bits = blk.bitcast_double_to_i64(v); - // 0x7FF9 << 48: the lowest NaN-box tag. - let plain = blk.icmp_slt(I64, &bits, "9221401712017801216"); - all_plain = Some(match all_plain { - None => plain, - Some(acc) => blk.and(I1, &acc, &plain), - }); - } - Some(all_plain.unwrap_or_else(|| "true".to_string())) - } else { - None - }; - let header_word = match &all_plain_numbers { - Some(all_plain) => { - // GC_ARRAY_RAW_F64_LAYOUT (0x80) in `_reserved`. - let flagged = gc_packed | (0x80u64 << 16); - blk.select( - I1, - all_plain, - I64, - &flagged.to_string(), - &gc_packed.to_string(), - ) +/// Build an array from element values the caller has already lowered (and +/// rooted), returning the raw `i64` user pointer. +/// +/// Split out of [`lower_array_literal`] so the rest/`arguments` bundle at a +/// call site builds its array the same way a literal does — one inline bump +/// allocation and N stores — instead of `js_array_alloc` plus one +/// `js_array_push_f64` per element, where every push re-classifies the +/// receiver, re-notes the slot layout and re-checks the barrier (#7154's +/// accumulator shape keeps the rooting, only the construction changes). +/// +/// `canonical_raw_f64[i]` says element `i` is a plain double by construction, +/// `layout_notes_needed[i]` that it may carry a heap pointer, and +/// `all_numeric_elements` that every element is statically a number. The +/// caller owns rooting: the slow arm of the bump allocator collects, so every +/// pointer value must already live in a root the group re-reads. +pub(crate) fn emit_array_from_lowered_values( + ctx: &mut FnCtx<'_>, + vals: &[String], + canonical_raw_f64: &[bool], + layout_notes_needed: &[bool], + all_numeric_elements: bool, +) -> Result { + let n = vals.len(); + // #5391: oversized modules outline array-literal construction. The inline + // bump-alloc + N×(store + layout-note + barrier) sequence makes minified + // data-table builders huge (single 18MB functions are impractical to + // optimize). Instead spill the already-evaluated element values to + // a per-literal stack buffer and build the array in ONE runtime call. The + // buffer is hoisted to the entry block (fixed size per site; bounded total + // stack) and consumed immediately by the call, so no GC-visible window. + if crate::codegen::full_outline_ic_enabled() { + let buf = ctx.func.alloca_entry_array(DOUBLE, n); + for (i, v) in vals.iter().enumerate() { + let slot = ctx.block().gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + ctx.block().store(DOUBLE, v, &slot); + } + let n_str = n.to_string(); + let arr = ctx + .block() + .call(I64, "js_array_from_values", &[(PTR, &buf), (I32, &n_str)]); + return Ok(arr); + } + + // Inline bump-allocator path for small literals. Size threshold matches + // `MAX_SCALAR_ARRAY_LEN` in collectors.rs so every candidate the escape + // pass rejects can still benefit from the inline alloc. + if n <= INLINE_ARRAY_MAX_ELEMENTS { + // Layout constants — must match `ArrayHeader` in array.rs and + // `GcHeader` in gc.rs. Duplicated here because codegen emits raw + // byte offsets; the runtime declarations are authoritative. + const GC_HEADER_SIZE: u64 = 8; + const ARRAY_HEADER_SIZE: u64 = 8; + const ELEMENT_SIZE: u64 = 8; + const GC_TYPE_ARRAY: u64 = 1; + const GC_FLAG_ARENA: u64 = 0x02; + // PR #1146: pointer-free hint for slot-layout tracking. The + // element-store loop below only suppresses per-slot notes for + // values whose non-pointer bits are proven by expression shape. + const GC_LAYOUT_POINTER_FREE: u64 = 0x4000; + + let total_size = GC_HEADER_SIZE + ARRAY_HEADER_SIZE + (n as u64) * ELEMENT_SIZE; + let total_size_str = total_size.to_string(); + + // Load state + compute bump check. `total_size` is always a + // multiple of 8, every prior alloc rounds offset to 8, and blocks + // start 8-aligned, so no align-up step is needed. + let state_ptr = load_inline_arena_state(ctx); + let blk = ctx.block(); + let offset_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "8")]); + let offset_val = blk.load(I64, &offset_field_ptr); + let aligned_off = offset_val.clone(); + let new_offset = blk.add(I64, &aligned_off, &total_size_str); + let size_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "16")]); + let size_val = blk.load(I64, &size_field_ptr); + let fits = blk.icmp_ule(I64, &new_offset, &size_val); + + let fast_idx = ctx.new_block("arrlit.fast"); + let slow_idx = ctx.new_block("arrlit.slow"); + let merge_idx = ctx.new_block("arrlit.merge"); + let fast_label = ctx.block_label(fast_idx); + let slow_label = ctx.block_label(slow_idx); + let merge_label = ctx.block_label(merge_idx); + + ctx.block().cond_br(&fits, &fast_label, &slow_label); + + // Fast path: commit the bump, compute `data + offset`. + ctx.current_block = fast_idx; + let blk = ctx.block(); + // GC_STORE_AUDIT(INIT): arena bump offset is allocator metadata, not a JS heap edge. + blk.store(I64, &new_offset, &offset_field_ptr); + let data_ptr = blk.load(PTR, &state_ptr); + let raw_fast = blk.gep(I8, &data_ptr, &[(I64, &aligned_off)]); + let fast_pred_label = blk.label.clone(); + blk.br(&merge_label); + + // Slow path: call the runtime slow-alloc (same one used by the + // inline `new` path). Returns a fresh raw pointer (inclusive of + // GcHeader space). + ctx.current_block = slow_idx; + let raw_slow = ctx.block().call( + PTR, + "js_inline_arena_slow_alloc", + &[(PTR, &state_ptr), (I64, &total_size_str), (I64, "8")], + ); + let slow_pred_label = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + // Merge: phi the raw pointer and write everything. + ctx.current_block = merge_idx; + let blk = ctx.block(); + let raw = blk.phi( + PTR, + &[(&raw_fast, &fast_pred_label), (&raw_slow, &slow_pred_label)], + ); + + // Packed GcHeader (bits 0..7 obj_type, 8..15 gc_flags, 16..31 + // _reserved, 32..63 size). PR #1146 packs the layout-tag in the + // reserved bits so the GC sees the array as pointer-free until + // the element-store loop overrides per-slot via + // `js_gc_note_slot_layout` below. + let gc_packed: u64 = GC_TYPE_ARRAY + | (GC_FLAG_ARENA << 8) + | (GC_LAYOUT_POINTER_FREE << 16) + | (total_size << 32); + // A literal whose elements are statically numbers is usually all + // plain doubles at runtime. Then the array is born exactly as + // `js_array_mark_numeric_f64_layout` would leave it — pointer-free + // with the dense raw-f64 flag — so decide that with one signed + // compare per element and skip every per-slot note and the + // marking walk. Any NaN-boxed element (an int32 box, or a value + // whose annotation lied) takes the unchanged noted path. + let all_plain_numbers = if all_numeric_elements { + let mut all_plain: Option = None; + for (i, v) in vals.iter().enumerate() { + if canonical_raw_f64[i] { + continue; } - None => gc_packed.to_string(), - }; - // GC_STORE_AUDIT(INIT): freshly allocated array header starts pointer-free until slot notes below. - blk.store(I64, &header_word, &raw); - - // Packed ArrayHeader at raw+8 (length low 32 / capacity high 32). - let arr_header_addr = blk.gep(I8, &raw, &[(I64, "8")]); - let arr_header_packed = (n as u64) | ((n as u64) << 32); - // GC_STORE_AUDIT(INIT): freshly allocated ArrayHeader length/capacity, no child pointer. - blk.store(I64, &arr_header_packed.to_string(), &arr_header_addr); - - // User pointer = raw + GC_HEADER_SIZE. Computed before the - // element loop so the per-slot layout notes target the correct - // user-visible address. - let user_ptr = blk.gep(I8, &raw, &[(I64, "8")]); - let user_ptr_as_i64 = blk.ptrtoint(&user_ptr, I64); - - if let Some(all_plain) = all_plain_numbers { - let plain_idx = ctx.new_block("arrlit.plain_numbers"); - let noted_idx = ctx.new_block("arrlit.noted"); - let done_idx = ctx.new_block("arrlit.done"); - let plain_label = ctx.block_label(plain_idx); - let noted_label = ctx.block_label(noted_idx); - let done_label = ctx.block_label(done_idx); - ctx.block().cond_br(&all_plain, &plain_label, ¬ed_label); - - ctx.current_block = plain_idx; - { - let blk = ctx.block(); - for (i, v) in vals.iter().enumerate() { - let offset = (16 + i * 8).to_string(); - let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); - // GC_STORE_AUDIT(POINTER_FREE): every element was just - // tested to be a plain double; the header already says - // pointer-free raw-f64. - blk.store(DOUBLE, v, &elem_ptr); - } - blk.br(&done_label); + let bits = blk.bitcast_double_to_i64(v); + // 0x7FF9 << 48: the lowest NaN-box tag. + let plain = blk.icmp_slt(I64, &bits, "9221401712017801216"); + all_plain = Some(match all_plain { + None => plain, + Some(acc) => blk.and(I1, &acc, &plain), + }); + } + Some(all_plain.unwrap_or_else(|| "true".to_string())) + } else { + None + }; + let header_word = match &all_plain_numbers { + Some(all_plain) => { + // GC_ARRAY_RAW_F64_LAYOUT (0x80) in `_reserved`. + let flagged = gc_packed | (0x80u64 << 16); + blk.select( + I1, + all_plain, + I64, + &flagged.to_string(), + &gc_packed.to_string(), + ) + } + None => gc_packed.to_string(), + }; + // GC_STORE_AUDIT(INIT): freshly allocated array header starts pointer-free until slot notes below. + blk.store(I64, &header_word, &raw); + + // Packed ArrayHeader at raw+8 (length low 32 / capacity high 32). + let arr_header_addr = blk.gep(I8, &raw, &[(I64, "8")]); + let arr_header_packed = (n as u64) | ((n as u64) << 32); + // GC_STORE_AUDIT(INIT): freshly allocated ArrayHeader length/capacity, no child pointer. + blk.store(I64, &arr_header_packed.to_string(), &arr_header_addr); + + // User pointer = raw + GC_HEADER_SIZE. Computed before the + // element loop so the per-slot layout notes target the correct + // user-visible address. + let user_ptr = blk.gep(I8, &raw, &[(I64, "8")]); + let user_ptr_as_i64 = blk.ptrtoint(&user_ptr, I64); + + if let Some(all_plain) = all_plain_numbers { + let plain_idx = ctx.new_block("arrlit.plain_numbers"); + let noted_idx = ctx.new_block("arrlit.noted"); + let done_idx = ctx.new_block("arrlit.done"); + let plain_label = ctx.block_label(plain_idx); + let noted_label = ctx.block_label(noted_idx); + let done_label = ctx.block_label(done_idx); + ctx.block().cond_br(&all_plain, &plain_label, ¬ed_label); + + ctx.current_block = plain_idx; + { + let blk = ctx.block(); + for (i, v) in vals.iter().enumerate() { + let offset = (16 + i * 8).to_string(); + let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); + // GC_STORE_AUDIT(POINTER_FREE): every element was just + // tested to be a plain double; the header already says + // pointer-free raw-f64. + blk.store(DOUBLE, v, &elem_ptr); } + blk.br(&done_label); + } - ctx.current_block = noted_idx; - { - let blk = ctx.block(); - for (i, v) in vals.iter().enumerate() { - let offset = (16 + i * 8).to_string(); - let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); - let slot_index = i.to_string(); - emit_jsvalue_slot_store_on_block( - blk, - &elem_ptr, - v, - &user_ptr_as_i64, - &slot_index, - layout_notes_needed[i], - &user_ptr_as_i64, - "0", - false, - ); - } - blk.call( - I32, - "js_array_mark_numeric_f64_layout", - &[(I64, &user_ptr_as_i64)], + ctx.current_block = noted_idx; + { + let blk = ctx.block(); + for (i, v) in vals.iter().enumerate() { + let offset = (16 + i * 8).to_string(); + let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); + let slot_index = i.to_string(); + emit_jsvalue_slot_store_on_block( + blk, + &elem_ptr, + v, + &user_ptr_as_i64, + &slot_index, + layout_notes_needed[i], + &user_ptr_as_i64, + "0", + false, ); - blk.br(&done_label); } - ctx.current_block = done_idx; - return Ok(nanbox_pointer_inline(ctx.block(), &user_ptr_as_i64)); - } - - // Elements at raw+16 + i*8. - let blk = ctx.block(); - for (i, v) in vals.iter().enumerate() { - let offset = (16 + i * 8).to_string(); - let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); - let slot_index = i.to_string(); - emit_jsvalue_slot_store_on_block( - blk, - &elem_ptr, - v, - &user_ptr_as_i64, - &slot_index, - layout_notes_needed[i], - &user_ptr_as_i64, - "0", - false, + blk.call( + I32, + "js_array_mark_numeric_f64_layout", + &[(I64, &user_ptr_as_i64)], ); + blk.br(&done_label); } - - return Ok(nanbox_pointer_inline(ctx.block(), &user_ptr_as_i64)); + ctx.current_block = done_idx; + return Ok(user_ptr_as_i64); } - // Fallback for N > INLINE_MAX_ELEMENTS: keep the extern call + N inline - // stores. Thin-LTO already inlines this call into user IR, so the cost - // is ~1 inlined arena bump plus some LLVM churn around the arg pack. - let cap_str = n.to_string(); - let arr = ctx - .block() - .call(I64, "js_array_alloc_literal", &[(I32, &cap_str)]); - - let arr_ptr = ctx.block().inttoptr(I64, &arr); + // Elements at raw+16 + i*8. + let blk = ctx.block(); for (i, v) in vals.iter().enumerate() { - let offset = (8 + i * 8).to_string(); - let elem_ptr = ctx.block().gep_inbounds(I8, &arr_ptr, &[(I64, &offset)]); - let elem_addr = if layout_notes_needed[i] { - ctx.block().ptrtoint(&elem_ptr, I64) - } else { - "0".to_string() - }; + let offset = (16 + i * 8).to_string(); + let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); let slot_index = i.to_string(); emit_jsvalue_slot_store_on_block( - ctx.block(), + blk, &elem_ptr, v, - &arr, + &user_ptr_as_i64, &slot_index, layout_notes_needed[i], - &arr, - &elem_addr, - layout_notes_needed[i], + &user_ptr_as_i64, + "0", + false, ); } - if all_numeric_elements { - ctx.block() - .call(I32, "js_array_mark_numeric_f64_layout", &[(I64, &arr)]); - } + return Ok(user_ptr_as_i64); + } - Ok(nanbox_pointer_inline(ctx.block(), &arr)) - }) + // Fallback for N > INLINE_MAX_ELEMENTS: keep the extern call + N inline + // stores. Thin-LTO already inlines this call into user IR, so the cost + // is ~1 inlined arena bump plus some LLVM churn around the arg pack. + let cap_str = n.to_string(); + let arr = ctx + .block() + .call(I64, "js_array_alloc_literal", &[(I32, &cap_str)]); + + let arr_ptr = ctx.block().inttoptr(I64, &arr); + for (i, v) in vals.iter().enumerate() { + let offset = (8 + i * 8).to_string(); + let elem_ptr = ctx.block().gep_inbounds(I8, &arr_ptr, &[(I64, &offset)]); + let elem_addr = if layout_notes_needed[i] { + ctx.block().ptrtoint(&elem_ptr, I64) + } else { + "0".to_string() + }; + let slot_index = i.to_string(); + emit_jsvalue_slot_store_on_block( + ctx.block(), + &elem_ptr, + v, + &arr, + &slot_index, + layout_notes_needed[i], + &arr, + &elem_addr, + layout_notes_needed[i], + ); + } + + if all_numeric_elements { + ctx.block() + .call(I32, "js_array_mark_numeric_f64_layout", &[(I64, &arr)]); + } + + Ok(arr) } /// #8583 follow-up gate. Default ON; `PERRY_CONST_ARRAY_DESCRIPTOR=0/off/false` diff --git a/crates/perry-codegen/src/expr/in_presence_ic.rs b/crates/perry-codegen/src/expr/in_presence_ic.rs new file mode 100644 index 0000000000..0e2beb523f --- /dev/null +++ b/crates/perry-codegen/src/expr/in_presence_ic.rs @@ -0,0 +1,130 @@ +//! Presence inline cache for `"k" in o` with a constant key. +//! +//! `in` was the last common operator lowering with no cache slot at all: every +//! `"k" in o` called `js_in_operator`, which re-derived the receiver's keys +//! array from its ShapeId (a shape-slab probe) and re-scanned it — ~950 +//! instructions for a hit on a plain object, against ~15 for a property read +//! of the same key through the property PIC. +//! +//! The answer is a property of the shape, not of the object, so the site +//! caches one ShapeId and the guard below answers `true` when the receiver +//! still carries it. See `perry-runtime`'s `has_property_ic` module for what +//! the cached claim means, why only positives are cached (a negative is a +//! claim about the whole prototype chain, and there is no chain epoch to key +//! one on), and why every way of losing the key moves the receiver off this +//! guard. +//! +//! The inline path can only produce `true`. Anything the guard cannot settle — +//! a primitive receiver (which must still throw), a non-object heap value, a +//! descriptor-bearing or tombstoned object, a different shape, an unprimed +//! site — takes the same `js_in_operator` semantics through the priming entry. + +use crate::nanbox::{POINTER_MASK_I64, POINTER_TAG_TOP16_I64, TAG_TRUE_I64}; +use crate::types::{DOUBLE, I1, I16, I32, I64, I8, PTR}; + +use super::FnCtx; + +/// Runtime `GC_TYPE_OBJECT`. +const GC_TYPE_OBJECT: &str = "2"; +/// Runtime `GC_FLAG_FORWARDED` (0x80). +const GC_FLAG_FORWARDED: &str = "128"; +/// `OBJ_FLAG_STABLE_TOMBSTONES | OBJ_FLAG_HAS_DESCRIPTORS` (0x400 | 0x800). +/// +/// Both are read from the `_reserved` half-word the other inline guards +/// already load. A tombstoned receiver keeps its ShapeId across a `delete` +/// (#9064), so rejecting the bit is what makes the cached positive safe; a +/// descriptor-bearing one answers `in` from the accessor side table, which the +/// shape does not describe. +const IN_PIC_BLOCKING_FLAGS: &str = "3072"; + +/// Emit the presence guard for one `"k" in o` site, returning the NaN-boxed +/// result. `key_box` is a constant string: the cache records a ShapeId only, +/// so the key it stands for must be fixed at this site. +pub(crate) fn lower_in_presence_ic(ctx: &mut FnCtx<'_>, obj_box: &str, key_box: &str) -> String { + let site_id = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let cache_name = super::inline_cache_global_name(ctx, site_id); + ctx.ic_globals.push(cache_name.clone()); + + let guard_idx = ctx.new_block("in.pic.guard"); + let hit_idx = ctx.new_block("in.pic.hit"); + let miss_idx = ctx.new_block("in.pic.miss"); + let merge_idx = ctx.new_block("in.pic.merge"); + let guard_label = ctx.block_label(guard_idx); + let hit_label = ctx.block_label(hit_idx); + let miss_label = ctx.block_label(miss_idx); + let merge_label = ctx.block_label(merge_idx); + + // #9708: the cache sits behind a pointer slot that stays null until the + // site's first prime. The guard block reads word 0 through the loaded + // pointer, so the non-null test joins the receiver predicate here rather + // than costing a branch of its own. + let ic_slot = super::emit_inline_cache_slot(ctx, &cache_name); + let cache_ref = ic_slot.cache.clone(); + let cache_slot_ref = ic_slot.slot_ref.clone(); + + // Branch before the first header load: a primitive, a forged non-pointer + // bit pattern and a handle-band id must never be dereferenced here. They + // all take the miss, where `js_in_operator`'s own classification decides + // between an answer and the TypeError ECMA-262 13.10.1 step 5 requires. + let obj_bits = ctx.block().bitcast_double_to_i64(obj_box); + let obj_raw = ctx.block().and(I64, &obj_bits, POINTER_MASK_I64); + let obj_tag = ctx.block().lshr(I64, &obj_bits, "48"); + let is_pointer = ctx.block().icmp_eq(I64, &obj_tag, POINTER_TAG_TOP16_I64); + let above_handles = ctx.block().icmp_ugt(I64, &obj_raw, "1048575"); + let eligible = ctx.block().and(I1, &is_pointer, &above_handles); + let eligible = ctx.block().and(I1, &eligible, &ic_slot.present); + ctx.block().cond_br(&eligible, &guard_label, &miss_label); + + ctx.current_block = guard_idx; + let gc_type_addr = ctx.block().sub(I64, &obj_raw, "8"); + let gc_type_ptr = ctx.block().inttoptr(I64, &gc_type_addr); + let gc_type = ctx.block().load(I8, &gc_type_ptr); + let is_object = ctx.block().icmp_eq(I8, &gc_type, GC_TYPE_OBJECT); + let gc_flags_addr = ctx.block().sub(I64, &obj_raw, "7"); + let gc_flags_ptr = ctx.block().inttoptr(I64, &gc_flags_addr); + let gc_flags = ctx.block().load(I8, &gc_flags_ptr); + let forwarded = ctx.block().and(I8, &gc_flags, GC_FLAG_FORWARDED); + let not_forwarded = ctx.block().icmp_eq(I8, &forwarded, "0"); + let reserved_addr = ctx.block().sub(I64, &obj_raw, "6"); + let reserved_ptr = ctx.block().inttoptr(I64, &reserved_addr); + let reserved = ctx.block().load(I16, &reserved_ptr); + let blocked = ctx.block().and(I16, &reserved, IN_PIC_BLOCKING_FLAGS); + let ordinary = ctx.block().icmp_eq(I16, &blocked, "0"); + // ObjectHeader offset 4: the runtime ShapeId once the object is stamped, + // else its `parent_class_id`. ShapeIds occupy 0x8000_0000..0xC000_0000, + // disjoint from every class id, and are never reused — so an armed word + // can only be matched by an object carrying that exact shape, and a stale + // one can only miss. + let shape_addr = ctx.block().add(I64, &obj_raw, "4"); + let shape_ptr = ctx.block().inttoptr(I64, &shape_addr); + let shape_id = ctx.block().load(I32, &shape_ptr); + let shape_word = ctx.block().zext(I32, &shape_id, I64); + let cached_shape_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "0")]); + let cached_shape = ctx.block().load(I64, &cached_shape_ptr); + // An unarmed cache reads 0, which no stamped shape word can equal, so the + // "is this site armed?" question needs no test of its own. + let shape_matches = ctx.block().icmp_eq(I64, &shape_word, &cached_shape); + let present = ctx.block().and(I1, &is_object, ¬_forwarded); + let present = ctx.block().and(I1, &present, &ordinary); + let present = ctx.block().and(I1, &present, &shape_matches); + ctx.block().cond_br(&present, &hit_label, &miss_label); + + ctx.current_block = hit_idx; + let hit_value = ctx.block().bitcast_i64_to_double(TAG_TRUE_I64); + let hit_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = miss_idx; + let miss_value = ctx.block().call( + DOUBLE, + "js_in_operator_presence_ic", + &[(DOUBLE, obj_box), (DOUBLE, key_box), (PTR, &cache_slot_ref)], + ); + let miss_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + ctx.block() + .phi(DOUBLE, &[(&hit_value, &hit_end), (&miss_value, &miss_end)]) +} diff --git a/crates/perry-codegen/src/expr/index_get/guarded_array.rs b/crates/perry-codegen/src/expr/index_get/guarded_array.rs index b86355026e..968e04af03 100644 --- a/crates/perry-codegen/src/expr/index_get/guarded_array.rs +++ b/crates/perry-codegen/src/expr/index_get/guarded_array.rs @@ -714,10 +714,19 @@ pub(super) fn lower_packed_f64_loop_index_get( .cond_br(&in_bounds, &cont_label, &fact.store_side_exit_label); ctx.current_block = cont_idx; } + // #9379 proved this clone has no safepoint: the matcher admits no call, + // closure or await, its reads and writes are bare `double` load/store on + // existing slots, and the back-edge poll is suppressed for exactly that + // reason. So the receiver cannot move and its header words cannot change + // for the clone's whole dynamic extent — take the pre-masked handle from + // the hoisted receiver cache instead of re-laundering the rooted slot and + // re-masking it per element. The cache is a plain `i64` alloca nothing in + // the clone stores to, so the element-base chain hanging off it + // (`size` at `-4`, `capacity` at `+4`, the shifts and the subtract) becomes + // loop-invariant to LICM, which the laundered reload deliberately blocked. let value = { + let arr_handle = super::super::receiver_descriptor_handle_i64(ctx, Some(arr_id), arr_box); let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(arr_box); - let arr_handle = blk.and(I64, &arr_bits, POINTER_MASK_I64); let idx_i64 = blk.zext(I32, idx_i32, I64); let byte_offset = blk.shl(I64, &idx_i64, "3"); let elements_addr = blk.array_elements_addr(&arr_handle); diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index f35a5f0e43..d5777e3d26 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -1212,7 +1212,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // first enforces ECMA-262 13.10.1 step 5: a non-Object right operand // (`"x" in 5`, `... in null`, `... in Symbol()`, …) throws a TypeError. Expr::In { property, object } => { + // A literal key gets a presence inline cache (`in_presence_ic`). + // The cache records a ShapeId and nothing else, so the key it + // stands for has to be fixed at the site: a dynamic key keeps the + // bare call. `full_outline_ic_enabled` is the size mode that asks + // every IC to stay out of the emitted function. + let constant_key = matches!(&**property, Expr::String(_)) + && !crate::codegen::full_outline_ic_enabled(); rooting::with_operands_rooted(ctx, &[property, object], |ctx, vals| { + if constant_key { + return Ok(super::in_presence_ic::lower_in_presence_ic( + ctx, &vals[1], &vals[0], + )); + } Ok(ctx.block().call( DOUBLE, "js_in_operator", diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index de64342d1c..778f93e792 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -47,6 +47,7 @@ mod class_method_arguments_object_tests; mod conforming_layout_note_tests; mod helpers; mod i32_fast_path; +mod in_presence_ic; mod index; mod nanbox_inline; mod native_memory; @@ -64,7 +65,9 @@ mod v8_interop; mod write_barrier; pub(crate) use crate::native_value::{materialize_js_value, materialize_js_value_without_record}; -pub(crate) use array_literal::lower_array_literal; +pub(crate) use array_literal::{ + emit_array_from_lowered_values, lower_array_literal, INLINE_ARRAY_MAX_ELEMENTS, +}; pub(crate) use buffer_access::{ access_facts_for_spec, can_lower_buffer_access_without_calls, can_lower_integer_typed_array_store_value, emit_buffer_access_pointer, @@ -3027,6 +3030,8 @@ mod index_set_packed_loop; mod index_set_typed_array; mod instance_misc1; mod member_update; +#[cfg(test)] +mod packed_loop_shadow_barrier_tests; mod typed_array_rmw; pub(crate) use instance_misc1::builtin_parent_reserved_class_id; pub(crate) mod class_field_inline_guard; diff --git a/crates/perry-codegen/src/expr/new_dynamic.rs b/crates/perry-codegen/src/expr/new_dynamic.rs index 39b221c8f2..5636b8f1c4 100644 --- a/crates/perry-codegen/src/expr/new_dynamic.rs +++ b/crates/perry-codegen/src/expr/new_dynamic.rs @@ -235,6 +235,23 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return crate::lower_call::lower_new_member_captured(ctx, name.as_ref(), args); } + // #10359: a global-object callee gets here only when the fold above + // declined it because a module binding shares the name. Build the + // intrinsic the unshadowed fold reaches, never the binding. A name no + // builtin arm owns falls through and reads the property at runtime. + if let Expr::PropertyGet { + object, property, .. + } = callee.as_ref() + { + if super::v8_interop::is_global_object_expr(object) { + if let Some(value) = + crate::lower_call::lower_global_intrinsic_new(ctx, property, args)? + { + return Ok(value); + } + } + } + // date-fns `constructFrom(date, value)`: // return new date.constructor(value); // The callee is `PropertyGet { LocalGet(date), "constructor" }` diff --git a/crates/perry-codegen/src/expr/packed_loop_shadow_barrier_tests.rs b/crates/perry-codegen/src/expr/packed_loop_shadow_barrier_tests.rs new file mode 100644 index 0000000000..007ce41a6a --- /dev/null +++ b/crates/perry-codegen/src/expr/packed_loop_shadow_barrier_tests.rs @@ -0,0 +1,246 @@ +//! The packed-numeric clone's counter read is a proven Number, so binding it to +//! a `const` must not shade a GC root — and `arr[i ± c]` must still shade one. +//! +//! `expr_is_known_non_pointer_shadow_value` suppresses +//! `emit_persistent_shadow_root_barrier` for a value that cannot be a heap +//! reference. Inside an active packed-numeric loop fact the entry guard has +//! proved a dense raw-f64 plain Array, the clone has no safepoint and no growth +//! (#9379), and the fast condition bounds the counter by the length read at loop +//! entry — so `arr[i]` reads a raw numeric word. +//! +//! `arr[i + 1]` has none of that: the index can leave the array, and an +//! out-of-bounds element read consults the prototype chain, where +//! `Array.prototype[7] = {}` is a genuine heap reference that must stay rooted. +//! The suppression is therefore restricted to offset 0, and this file is the +//! assertion that the restriction is real. +//! +//! Both reads live in the SAME fast clone, so neither direction can pass +//! vacuously: if the offset read were wrongly admitted the barrier count in the +//! fast body would be 0, and if the counter read were wrongly refused it would +//! be 2. Every test also asserts the clone was entered at all — a barrier count +//! taken over blocks that were never emitted is CLAUDE.md hazard 4. + +use perry_hir::types::Type; +use perry_hir::{BinaryOp, CompareOp, Expr, Function, Module as HirModule, Param, Stmt, UpdateOp}; + +const ARR: u32 = 0; +const SUM: u32 = 1; +const IDX: u32 = 2; +const BOUND_V: u32 = 3; +const BOUND_W: u32 = 4; + +/// The root-shading barrier's inline arming test, emitted once per shaded store. +const SHADING_TEST: &str = "@PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT"; + +fn compile(name: &str, body: Vec) -> String { + let mut hir = HirModule::new(name); + hir.functions.push(Function { + id: 0, + name: "build".to_string(), + type_params: Vec::new(), + params: vec![Param { + id: ARR, + name: "a".to_string(), + ty: Type::Array(Box::new(Type::Number)), + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Number, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let opts = crate::CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + String::from_utf8(crate::compile_module(&hir, opts).expect("test module compiles")) + .expect("LLVM IR is UTF-8") +} + +/// The first emitted block whose label starts with `prefix`, up to the next +/// top-level label. Panics when the block is absent, so an assertion can never +/// be taken over a clone that was not emitted. +fn block(ir: &str, prefix: &str) -> String { + let start = ir + .find(&format!("\n{prefix}")) + .unwrap_or_else(|| panic!("no block labelled {prefix}* was emitted:\n{ir}")); + let rest = &ir[start + 1..]; + let body_start = rest.find(":\n").expect("a block label ends in a colon") + 2; + let mut end = rest.len(); + let mut at = body_start; + for line in rest[body_start..].split_inclusive('\n') { + let trimmed = line.trim_end(); + if trimmed.ends_with(':') && !trimmed.starts_with(' ') && !trimmed.is_empty() { + end = at; + break; + } + at += line.len(); + } + rest[..end].to_string() +} + +/// The clone's fast body — where a counter read's binding store lands. +fn fast_body(ir: &str) -> String { + block(ir, "for.packed_f64_fast.body") +} + +/// Where an `arr[i ± c]` binding store lands instead: the offset read carries an +/// inline bounds check that side-exits to the slow preheader, and its store sits +/// past that check rather than in the body block. +fn offset_read_block(ir: &str) -> String { + block(ir, "packed_f64_loop.foreign.inbounds") +} + +/// `let s = 0; for (let i = 0; i < a.length; i++) { } return s;` +fn packed_loop_with(bindings: Vec) -> Vec { + vec![ + Stmt::Let { + id: SUM, + name: "s".into(), + ty: Type::Number, + init: Some(Expr::Number(0.0)), + mutable: true, + }, + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: IDX, + name: "i".into(), + ty: Type::Number, + init: Some(Expr::Integer(0)), + mutable: true, + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(IDX)), + right: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(ARR)), + property: "length".to_string(), + byte_offset: 0, + }), + }), + update: Some(Expr::Update { + id: IDX, + op: UpdateOp::Increment, + prefix: false, + }), + body: bindings, + }, + Stmt::Return(Some(Expr::LocalGet(SUM))), + ] +} + +/// `Type::Any`, not `Type::Number`: a number-annotated local is never given a +/// shadow slot, so a barrier could not be emitted for it under any predicate +/// and both arms of the comparison would read 0. The `for…of` desugaring this +/// models erases the element type, which is what earns the slot in the first +/// place — and what makes the suppression worth anything. +fn bind(id: u32, name: &str, index: Expr) -> Stmt { + Stmt::Let { + id, + name: name.into(), + ty: Type::Any, + init: Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ARR)), + index: Box::new(index), + }), + mutable: false, + } +} + +fn accumulate(id: u32) -> Stmt { + Stmt::Expr(Expr::LocalSet( + SUM, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(SUM)), + right: Box::new(Expr::LocalGet(id)), + }), + )) +} + +fn counter_read() -> Expr { + Expr::LocalGet(IDX) +} + +fn offset_read() -> Expr { + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(IDX)), + right: Box::new(Expr::Integer(1)), + } +} + +/// `const v = a[i]` alone: the clone's fast body shades nothing. +#[test] +fn a_packed_loop_counter_read_binding_shades_no_root() { + let ir = compile( + "packed_counter_read", + packed_loop_with(vec![ + bind(BOUND_V, "v", counter_read()), + accumulate(BOUND_V), + ]), + ); + let body = fast_body(&ir); + assert_eq!( + body.matches(SHADING_TEST).count(), + 0, + "a[i] under a packed-numeric fact is a proven Number; binding it must shade no root:\n{body}" + ); +} + +/// `const w = a[i + 1]` alone: still shaded, because the read can leave the +/// array and reach a prototype index holding a heap reference. +#[test] +fn a_packed_loop_offset_read_binding_still_shades_its_root() { + let ir = compile( + "packed_offset_read", + packed_loop_with(vec![bind(BOUND_W, "w", offset_read()), accumulate(BOUND_W)]), + ); + let guarded = offset_read_block(&ir); + assert_eq!( + guarded.matches(SHADING_TEST).count(), + 1, + "a[i + 1] can read past the array into the prototype chain; its binding must stay \ + shaded:\n{guarded}" + ); +} + +/// Both in one clone, which is what makes neither direction vacuous: the +/// counter read must contribute nothing to the fast body and the offset read +/// must still contribute exactly one to its own guarded block. A non-zero body +/// count would mean the counter read stopped being recognised and the +/// optimisation is dead; a zero guarded count would mean the offset read was +/// wrongly admitted and a prototype-held object could go unshaded. +#[test] +fn only_the_offset_read_shades_when_both_live_in_one_clone() { + let ir = compile( + "packed_counter_and_offset", + packed_loop_with(vec![ + bind(BOUND_V, "v", counter_read()), + accumulate(BOUND_V), + bind(BOUND_W, "w", offset_read()), + accumulate(BOUND_W), + ]), + ); + assert_eq!( + fast_body(&ir).matches(SHADING_TEST).count(), + 0, + "the counter read's binding must shade nothing:\n{}", + fast_body(&ir) + ); + assert_eq!( + offset_read_block(&ir).matches(SHADING_TEST).count(), + 1, + "the offset read's binding must still be shaded:\n{}", + offset_read_block(&ir) + ); +} diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index 2383e3890d..9eb78a6aaa 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -104,6 +104,9 @@ pub(crate) fn expr_is_known_non_pointer_shadow_value(ctx: &FnCtx<'_>, expr: &Exp object.as_ref(), Expr::LocalGet(arr_id) if super::masked_window_fact_for_index(ctx, *arr_id, index).is_some() + ) || matches!( + object.as_ref(), + Expr::LocalGet(arr_id) if packed_loop_counter_read_is_numeric(ctx, *arr_id, index) ) || super::is_proven_u32_view_read(ctx, expr) } // #6996: a typed-array / Buffer element read is a number (or @@ -157,6 +160,31 @@ pub(crate) fn expr_is_known_non_pointer_shadow_value(ctx: &FnCtx<'_>, expr: &Exp } } +/// `arr[i]` at the counter of an ACTIVE packed-numeric loop fact: the same +/// class of proof the masked-window arm above rests on. The entry guard proved +/// `arr` is a plain dense raw-f64 (or packed i32/u32) Array, the fast clone it +/// scopes has no safepoint and no growth (#9379), and the fast condition bounds +/// the counter by the length read at loop entry — so the slot this reads is a +/// raw numeric word and the value is a Number, never a heap reference. The fact +/// is dematerialized before the slow clone is lowered, so this never leaks past +/// the clone it was proved for. +/// +/// Restricted to offset 0 on purpose. `arr[i ± c]` is in bounds only under a +/// range-validated fact, and an out-of-bounds element read consults the +/// prototype chain — where `Array.prototype[7] = {}` yields a genuine heap +/// pointer that must stay rooted. The counter read cannot leave the array. +fn packed_loop_counter_read_is_numeric(ctx: &FnCtx<'_>, arr_id: u32, index: &Expr) -> bool { + let Some((idx_id, offset)) = super::packed_f64_loop_index_parts(index) else { + return false; + }; + if offset != 0 { + return false; + } + ctx.receiver_descriptors + .packed_f64_loop_facts() + .any(|fact| fact.array_local_id == arr_id && fact.index_local_id == idx_id) +} + pub(crate) fn emit_shadow_slot_clear(ctx: &mut FnCtx<'_>, slot_idx: u32) { if ctx.persistent_shadow_slots.contains(&slot_idx) { return; diff --git a/crates/perry-codegen/src/expr/v8_interop.rs b/crates/perry-codegen/src/expr/v8_interop.rs index fca116e6d4..5e6fae9455 100644 --- a/crates/perry-codegen/src/expr/v8_interop.rs +++ b/crates/perry-codegen/src/expr/v8_interop.rs @@ -278,7 +278,7 @@ pub(crate) fn emit_v8_member_method_call( /// collision-free `(namespace, member)` registry key; the rest of the /// lower_new path resolves that key through the usual `ctx.classes` /// lookup. -fn is_global_object_expr(expr: &Expr) -> bool { +pub(crate) fn is_global_object_expr(expr: &Expr) -> bool { match expr { Expr::GlobalGet(_) => true, Expr::PropertyGet { @@ -308,6 +308,18 @@ pub(crate) fn try_static_class_name<'a>(callee: &'a Expr, ctx: &FnCtx<'_>) -> Op object, property, .. } => { if is_global_object_expr(object.as_ref()) { + // #10359: `lower_new` resolves the name against the module's + // classes, class aliases and imports before (or instead of) + // the builtin, but a module binding is never a property of + // the global object. With `import { Event } from "./ev"` in + // scope, folding `new globalThis.Event()` built the imported + // class; read the property and construct it at runtime. + if ctx.classes.contains_key(property) + || ctx.local_class_aliases.contains_key(property) + || ctx.import_function_prefixes.contains_key(property) + { + return None; + } return Some(Cow::Borrowed(property.as_str())); } // Namespace import: `import * as ns from 'm'; new ns.Foo()`. diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 236b982c67..d976f3e675 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -1843,6 +1843,23 @@ pub(super) fn lower_builtin_new<'a>( } } +/// #10359: construct the global intrinsic `class_name` through the builtin +/// table alone, for a `new globalThis.()` whose name a module class, +/// class alias or import shadows. `lower_new` resolves those bindings first, +/// so it would construct the binding. `None` means no builtin arm owns the name +/// and no argument was lowered; the caller then constructs the global +/// property's runtime value. +pub(crate) fn lower_global_intrinsic_new( + ctx: &mut FnCtx<'_>, + class_name: &str, + args: &[Expr], +) -> Result> { + let mut group = rooting::open_rooted_group(args.len() + 1); + let result = lower_builtin_new(ctx, class_name, args, &mut group); + group.release(ctx); + result +} + /// Map a typed-array constructor name to its runtime `KIND_*` integer (mirrors /// `perry_runtime::typedarray::KIND_*`). Used by the `#4103` view-constructor /// arm to tell `js_typed_array_view` which element type to build. diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 25ac5d680a..deee0dc456 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -153,6 +153,7 @@ pub(crate) use native::lower_native_method_call; // Re-export pub(crate) `new.rs` items consumed outside this module // (codegen.rs / expr.rs / stmt.rs) so `crate::lower_call::lower_new` // etc. keep resolving after the split. +pub(crate) use builtin::lower_global_intrinsic_new; pub(crate) use field_init::{ apply_field_initializers_recursive, defer_dynamic_derived_fields, FieldInitMode, }; @@ -342,7 +343,44 @@ pub(crate) fn lower_rest_call_args_rooted<'a>( // exactly as the push loop is for its elements. let mut accs: Vec = Vec::with_capacity(bundles.len()); for bundle in bundles { - let cap = (args.len().saturating_sub(bundle.from) as u32).to_string(); + let count = args.len().saturating_sub(bundle.from); + // Build it the way an array literal of the same width is built: ONE + // inline bump allocation and N stores. `js_array_alloc` + one + // `js_array_push_f64` per element re-classified the receiver, + // re-noted the slot layout and re-checked the barrier on every push — + // 1,586 instructions for `f(a, b, c)` into a three-element rest. + // Rooting is unchanged: every element is re-read from the group's + // slots first (the allocator's slow arm collects), and the finished + // array is adopted into the same scope, so the next bundle's + // allocation cannot sweep it. + if count > 0 && count <= crate::expr::INLINE_ARRAY_MAX_ELEMENTS { + let rest_args = &args[bundle.from..]; + let canonical_raw_f64: Vec = rest_args + .iter() + .map(|e| crate::type_analysis::expr_produces_canonical_raw_f64(ctx, e)) + .collect(); + let layout_notes_needed: Vec = rest_args + .iter() + .map(|e| !crate::expr::expr_produces_non_pointer_bits_by_construction(ctx, e)) + .collect(); + let all_numeric = rest_args + .iter() + .all(|e| crate::type_analysis::is_numeric_expr(ctx, e)); + let mut vals: Vec = Vec::with_capacity(count); + for i in bundle.from..group.len() { + vals.push(group.reread(ctx, i)?); + } + let arr = crate::expr::emit_array_from_lowered_values( + ctx, + &vals, + &canonical_raw_f64, + &layout_notes_needed, + all_numeric, + )?; + accs.push(group.adopt_array(ctx, &arr)); + continue; + } + let cap = (count as u32).to_string(); let acc = group.begin_array(ctx, &cap); for i in bundle.from..group.len() { // Re-read per element: the previous push allocated, so the register diff --git a/crates/perry-codegen/src/lower_string_concat.rs b/crates/perry-codegen/src/lower_string_concat.rs index e1d6cca0e0..4f0fa92e10 100644 --- a/crates/perry-codegen/src/lower_string_concat.rs +++ b/crates/perry-codegen/src/lower_string_concat.rs @@ -757,9 +757,49 @@ pub(crate) fn flatten_string_add_chain<'a>( /// One per-function buffer is shared across all chain call sites — fine /// because each chain call writes its parts and immediately calls into /// the runtime helper before any other call site can clobber the slots. +/// Drop a `StringCoerce` wrapper the chain helper's own formatting makes +/// redundant. +/// +/// The template desugaring wraps every substitution in `StringCoerce` so it is +/// toString-first rather than `+`'s valueOf-first (#6078). But +/// `js_string_concat_chain` formats each part itself, and for two kinds of part +/// its formatting IS `ToString`: a value already proven a string (the coercion +/// is the identity) and a number that cannot be a heap pointer — the helper +/// runs `format_number_into`, which `stack_number_formatting_matches_js_format_f64` +/// pins to `js_format_f64`, i.e. `Number::toString` including the ryu-js +/// tie-break and the exponent thresholds (#3987). +/// +/// For those, the wrapper only mints an intermediate heap string for the helper +/// to copy and immediately drop: `` `${s}:${n}` `` spent 338 instructions per +/// call in `js_number_to_string` -> `js_string_from_bytes_with_capacity` -> +/// `string_storage_alloc` doing exactly that. +/// +/// The non-pointer proof is what keeps an object out: `String(obj)` and the +/// helper's slow path can disagree on a value with both `valueOf` and +/// `toString`, so an object-valued part — including one a lying annotation +/// claims is a number — keeps its wrapper. +fn chain_part_without_redundant_coerce<'a>(ctx: &FnCtx<'_>, part: &'a Expr) -> &'a Expr { + let Expr::StringCoerce(inner) = part else { + return part; + }; + let is_string = crate::type_analysis::string_value_is_runtime_guaranteed(ctx, inner); + let is_plain_number = crate::type_analysis::is_numeric_expr(ctx, inner) + && crate::expr::expr_produces_non_pointer_bits_by_construction(ctx, inner); + if is_string || is_plain_number { + inner + } else { + part + } +} + pub(crate) fn lower_string_concat_chain(ctx: &mut FnCtx<'_>, parts: &[&Expr]) -> Result { debug_assert!(parts.len() >= 2); debug_assert!(parts.len() <= CONCAT_CHAIN_MAX_PARTS); + let parts: Vec<&Expr> = parts + .iter() + .map(|part| chain_part_without_redundant_coerce(ctx, part)) + .collect(); + let parts = parts.as_slice(); // Lower each part first (in source order); side effects must fire // left-to-right per JS spec. #6951: that ordering is exactly what makes diff --git a/crates/perry-codegen/src/rooting/mod.rs b/crates/perry-codegen/src/rooting/mod.rs index e791b8215b..0ee73530e1 100644 --- a/crates/perry-codegen/src/rooting/mod.rs +++ b/crates/perry-codegen/src/rooting/mod.rs @@ -982,6 +982,15 @@ impl<'a> RootedGroup<'a> { AccArray(self.accs.len() - 1) } + /// Root an array this group did not allocate — the inline-constructed + /// rest bundle — so one release still drops operands and arrays together. + pub(crate) fn adopt_array(&mut self, ctx: &mut FnCtx<'_>, arr: &str) -> AccArray { + let slot = temp_root::rooted_array_adopt(ctx, arr); + self.note_slot(Some(slot.clone())); + self.accs.push(slot); + AccArray(self.accs.len() - 1) + } + /// Push one element, re-reading the array from its slot and publishing the /// possibly-reallocated pointer back into it. pub(crate) fn push_array(&mut self, ctx: &mut FnCtx<'_>, acc: AccArray, value: &str) { diff --git a/crates/perry-codegen/src/rooting/temp_root.rs b/crates/perry-codegen/src/rooting/temp_root.rs index ffee46dd9f..9b51a27078 100644 --- a/crates/perry-codegen/src/rooting/temp_root.rs +++ b/crates/perry-codegen/src/rooting/temp_root.rs @@ -285,6 +285,15 @@ pub(in crate::rooting) fn rooted_array_begin(ctx: &mut FnCtx<'_>, cap: &str) -> temp_root_push_i64(ctx, &arr) } +/// Root an array the caller has already built, in the same slot shape +/// [`rooted_array_begin`] produces. The inline construction path builds the +/// whole array before anything else can collect; what follows (another +/// bundle's allocation, the consuming call) still can, so it must be rooted +/// exactly like an accumulator. +pub(in crate::rooting) fn rooted_array_adopt(ctx: &mut FnCtx<'_>, arr: &str) -> String { + temp_root_push_i64(ctx, arr) +} + /// Read the accumulator back out of its temp-root slot. Does NOT truncate: /// callers truncate after the consuming call, so the array is still rooted /// while the consumer runs (formatting an argument list allocates). diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index be02718575..a73cd55079 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -455,6 +455,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_map_from_iterable", I64, &[DOUBLE]); module.declare_function("js_object_has_property", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_in_operator", DOUBLE, &[DOUBLE, DOUBLE]); + module.declare_function("js_in_operator_presence_ic", DOUBLE, &[DOUBLE, DOUBLE, PTR]); module.declare_function( "js_private_brand_check", DOUBLE, diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 89658745c3..8eeac99c36 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -24,8 +24,9 @@ mod member; mod non_ident; pub(crate) use helpers::{ - callee_is_generic_construct_shape, is_depd_wrapfunction_shape, is_fetch_constructor_name, - is_global_object_expr, is_url_encoding_constructor_name, is_worker_messaging_constructor_name, + callee_is_generic_construct_shape, global_name_has_user_binding, global_property_new_dynamic, + is_depd_wrapfunction_shape, is_fetch_constructor_name, is_global_object_expr, + is_url_encoding_constructor_name, is_worker_messaging_constructor_name, is_worker_threads_module_name, lower_new_spread_args, lower_optional_args, lower_text_decoder_new, lower_url_encoding_constructor, lower_worker_messaging_new, lower_worker_new, nonconstructable_builtin_throw_expr, peel_new_callee, @@ -211,6 +212,11 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // shadows the bare name, so the re-dispatch sets // `global_intrinsic_new_once` to tell the recursive call to ignore that // shadowing (consumed at the top of `lower_new`, above). + // - #10359: ignoring the shadow only helps the arms that build a dedicated + // HIR node (`SetNew`, `ErrorNew`, …). A name with no such arm (`Event`, + // `Request`, `MessageChannel`, a multi-argument typed array) reaches the + // by-name tail, which a same-named user binding still captured; the + // recursive call builds `global_property_new_dynamic` there instead. if let ast::Expr::Member(member) = callee_expr { if let (ast::Expr::Ident(obj_ident), ast::MemberProp::Ident(prop_ident)) = (peel_new_callee(member.obj.as_ref()), &member.prop) @@ -248,7 +254,8 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // must use the same kind-aware path (`new GeneratorFunction()`, // `new AsyncFunction(...)`, and async generators) instead of the // generic object-construction fallback. - if ctx.local_decl_scope_depth(ident.sym.as_ref()) == Some(0) { + if !force_global_intrinsic && ctx.local_decl_scope_depth(ident.sym.as_ref()) == Some(0) + { if let Some(super::fn_ctor_env::FnCtorShape::DynCtor(kind)) = ctx.fn_ctor_env.entries.get(ident.sym.as_str()).cloned() { @@ -287,7 +294,11 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R == Some(ident.sym.as_str()) && ctx.current_class.is_some() && !nearest_local_is_inside_class_binding; - let mut class_name = if is_current_class_self { + // #10359: a re-dispatched `globalThis.` is the global's own + // name — never a collision-renamed or enclosing user class key. + let mut class_name = if force_global_intrinsic { + source_class_name.to_string() + } else if is_current_class_self { ctx.current_class.clone().unwrap() } else { ctx.resolve_class_name(source_class_name) @@ -383,6 +394,12 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R || ctx.lookup_func(&class_name).is_some() || ctx.lookup_imported_func(&class_name).is_some() || ctx.forward_class_names.contains(source_class_name)); + // #10359: the re-dispatched counterpart. Here the shadowing binding + // must NOT win, so every arm that would construct by name backs + // off and the tail builds `global_property_new_dynamic`. Snapshotted + // with the flags above, for the same scope-stack reason. + let global_intrinsic_shadowed = + force_global_intrinsic && global_name_has_user_binding(ctx, source_class_name); if matches!( ctx.lookup_native_module(&class_name), Some(("url", Some("Url"))) @@ -448,7 +465,10 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // runtime globals delegate to the registered worker_threads // factories when the stdlib is present, so ports stay fully // functional in graphs that have it. - if is_worker_messaging_constructor_name(&class_name) && !shadowed_by_user_binding { + if is_worker_messaging_constructor_name(&class_name) + && !shadowed_by_user_binding + && !global_intrinsic_shadowed + { return Ok(Expr::New { class_name: class_name.to_string(), args: lower_optional_args(ctx, new_expr.args.as_deref())?, @@ -717,8 +737,11 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // the same kind-aware fold as a direct dynamic-function-constructor // call. The trivial explicit constructor supplies no arguments; // the implicit constructor forwards the new-site arguments. - if let Some((kind, forward_args)) = - ctx.dynamic_function_subclasses.get(&class_name).copied() + if let Some((kind, forward_args)) = ctx + .dynamic_function_subclasses + .get(&class_name) + .copied() + .filter(|_| !global_intrinsic_shadowed) { let empty_args: &[ast::ExprOrSpread] = &[]; let args_slice = if forward_args { @@ -1074,7 +1097,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R arg_present, }); } - if ctx.is_proxy_local(&class_name) { + if !global_intrinsic_shadowed && ctx.is_proxy_local(&class_name) { let args = new_expr .args .as_ref() @@ -1388,6 +1411,20 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R } } + // #10359: no dedicated intrinsic arm matched a re-dispatched + // `new globalThis.()` whose name a user binding shares. Every + // arm below resolves by name (`Expr::New { class_name }`, `FuncRef`, + // `LocalGet`), so it would construct that binding — construct the + // global property's value instead. + if global_intrinsic_shadowed { + let args = lower_optional_args(ctx, new_expr.args.as_deref())?; + return Ok(global_property_new_dynamic( + source_class_name, + args, + new_byte_offset, + )); + } + let mut args = new_expr .args .as_ref() diff --git a/crates/perry-hir/src/lower/expr_new/helpers.rs b/crates/perry-hir/src/lower/expr_new/helpers.rs index f09e5e454d..e465935861 100644 --- a/crates/perry-hir/src/lower/expr_new/helpers.rs +++ b/crates/perry-hir/src/lower/expr_new/helpers.rs @@ -375,3 +375,44 @@ pub(crate) fn is_global_object_expr(ctx: &LoweringContext, expr: &Expr) -> bool _ => false, } } + +/// #10359: does a binding in this module share a global constructor's name? +/// +/// A `globalThis.` member callee names the global object's property, +/// never a module binding — a class declaration, function, import, or local +/// does not create a property on the global object. But the construct arms +/// that lower such a callee by NAME (`Expr::New { class_name }`, and the +/// bare-identifier arm's `FuncRef` / `LocalGet` reroutes) are resolved against +/// the module's bindings, so a same-named binding captures them: with +/// `import { Event } from "./ev"` in scope, `new globalThis.Event("ping")` +/// built the imported class. Those arms consult this and, when it holds, build +/// [`global_property_new_dynamic`] instead. +/// +/// Covers every table a by-name construct resolves through: locals, functions, +/// imports and classes in scope (`shadows_unqualified_global`), `let`/`const` +/// class aliases, a sibling class declared later in the body, and a class +/// declared at any depth (codegen's class table is module-wide). +pub(crate) fn global_name_has_user_binding(ctx: &LoweringContext, name: &str) -> bool { + ctx.shadows_unqualified_global(name) + || ctx.resolve_class_alias(name).is_some() + || ctx.forward_class_names.contains(name) + || ctx.class_decl_names_any_depth.contains(name) +} + +/// #10359: `new globalThis.(args)` constructing the global, not a +/// same-named binding. Codegen's `try_static_class_name` declines to fold this +/// callee onto a module class, class alias or import of that name, and builds +/// the intrinsic through its builtin table (`lower_global_intrinsic_new`) — +/// the construct the unshadowed form reaches. A name the table does not own +/// reads the property and constructs its runtime value. +pub(crate) fn global_property_new_dynamic(name: &str, args: Vec, byte_offset: u32) -> Expr { + Expr::NewDynamic { + callee: Box::new(Expr::PropertyGet { + byte_offset: 0, + object: Box::new(Expr::GlobalGet(0)), + property: name.to_string(), + }), + args, + byte_offset, + } +} diff --git a/crates/perry-hir/src/lower/expr_new/member.rs b/crates/perry-hir/src/lower/expr_new/member.rs index d05b85eda4..08afc10181 100644 --- a/crates/perry-hir/src/lower/expr_new/member.rs +++ b/crates/perry-hir/src/lower/expr_new/member.rs @@ -65,7 +65,11 @@ pub(crate) fn lower_new_member_native( // that never import `node:worker_threads`. The runtime global // delegates to the full worker_threads factory whenever the // stdlib has registered it, so no behavior is lost. - if is_worker_messaging_constructor_name(class_name) { + // #10359: `Expr::New` resolves by name, so a same-named user + // binding would capture it — fall through to the re-dispatch. + if is_worker_messaging_constructor_name(class_name) + && !(obj_name == "globalThis" && global_name_has_user_binding(ctx, class_name)) + { return Ok(Some(Expr::New { class_name: class_name.to_string(), args: lower_optional_args(ctx, new_expr.args.as_deref())?, @@ -83,6 +87,7 @@ pub(crate) fn lower_new_member_native( if obj_name == "globalThis" && ctx.lookup_local("globalThis").is_none() && is_fetch_constructor_name(prop_ident.sym.as_ref()) + && !global_name_has_user_binding(ctx, prop_ident.sym.as_ref()) { ctx.uses_fetch = true; return Ok(Some(Expr::New { diff --git a/crates/perry-hir/src/lower/expr_new/non_ident.rs b/crates/perry-hir/src/lower/expr_new/non_ident.rs index 16305e3cfb..01604f126a 100644 --- a/crates/perry-hir/src/lower/expr_new/non_ident.rs +++ b/crates/perry-hir/src/lower/expr_new/non_ident.rs @@ -258,6 +258,11 @@ pub(crate) fn lower_new_non_ident( if is_fetch_constructor_name(property) { ctx.uses_fetch = true; } + // #10359: a same-named user binding would capture the by-name + // `Expr::New`; construct the global property's value instead. + if global_name_has_user_binding(ctx, property) { + return Ok(global_property_new_dynamic(property, args, new_byte_offset)); + } return Ok(Expr::New { class_name: property.clone(), args, diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 15eba5b2b6..b14aee3240 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1985,6 +1985,8 @@ fn hoisted_class_constructs_sibling_declared_inside_a_later_closure() { mod ambient_declare; mod unresolved_new_global; +mod global_this_new_shadowed; + mod capture_stash; mod mixin_parent_chain; mod native_module_sync; diff --git a/crates/perry-hir/src/lower/tests/global_this_new_shadowed.rs b/crates/perry-hir/src/lower/tests/global_this_new_shadowed.rs new file mode 100644 index 0000000000..7f7a653339 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/global_this_new_shadowed.rs @@ -0,0 +1,88 @@ +//! #10359: `new globalThis.(…)` constructs the global property even when +//! a module binding shares the name. Split from `tests.rs` for the 2000-line +//! cap. + +fn lowered_function_debug(source: &str, name: &str) -> String { + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let function = hir + .functions + .iter() + .find(|function| function.name == name) + .unwrap_or_else(|| panic!("{name} is lowered")); + format!("{function:?}") +} + +fn global_property_construct(name: &str) -> String { + format!( + r#"NewDynamic {{ callee: PropertyGet {{ object: GlobalGet(0), property: "{name}", byte_offset: 0 }}"# + ) +} + +/// The issue's shape: an import, a class, a function and a local each shadow a +/// global constructor with no dedicated intrinsic HIR node. Each used to lower +/// to a by-name construct (`New { class_name }` / `FuncRef` / `LocalGet`) that +/// bound to the shadowing binding; each must construct the global property, +/// exactly like the aliased `const E = globalThis.Event; new E()` form. +#[test] +fn shadowed_global_constructor_reads_the_global_property() { + let source = r#" + import { Event } from "./ev"; + class Headers { tag = 1 } + function Request(this: any) { this.tag = 2; } + export function viaImport(): any { return new globalThis.Event("ping"); } + export function viaClass(): any { return new globalThis.Headers({ a: "1" }); } + export function viaFunction(): any { return new globalThis.Request("http://x.test/"); } + export function viaLocal(): any { + const MessageChannel = function () {}; + return new globalThis.MessageChannel(); + } + export function viaGlobalAlias(): any { + const g = globalThis; + return new g.Headers(); + } + "#; + for (function, name) in [ + ("viaImport", "Event"), + ("viaClass", "Headers"), + ("viaFunction", "Request"), + ("viaLocal", "MessageChannel"), + ("viaGlobalAlias", "Headers"), + ] { + let debug = lowered_function_debug(source, function); + assert!( + debug.contains(&global_property_construct(name)), + "{function}: `new globalThis.{name}()` must construct the global property:\n{debug}" + ); + assert!( + !debug.contains(&format!(r#"New {{ class_name: "{name}""#)), + "{function}: a by-name construct binds to the shadowing `{name}`:\n{debug}" + ); + } +} + +/// Guards the other side: an unshadowed name keeps its by-name intrinsic +/// construct, and a shadowed name WITH a dedicated intrinsic node (#6726's +/// `class Set {}` case) keeps that node rather than going dynamic. +#[test] +fn unshadowed_and_dedicated_intrinsics_keep_their_lowering() { + let source = r#" + import { Map } from "./m"; + export function unshadowed(): any { return new globalThis.Event("ping"); } + export function dedicated(): any { return new globalThis.Map([[1, 2]]); } + "#; + let unshadowed = lowered_function_debug(source, "unshadowed"); + assert!( + unshadowed.contains(r#"New { class_name: "Event""#), + "an unshadowed global keeps the by-name intrinsic construct:\n{unshadowed}" + ); + let dedicated = lowered_function_debug(source, "dedicated"); + assert!( + dedicated.contains("MapNewFromArray"), + "a shadowed global with a dedicated intrinsic node keeps it:\n{dedicated}" + ); + assert!( + !dedicated.contains(&global_property_construct("Map")), + "the dedicated intrinsic node must not be replaced by a dynamic construct:\n{dedicated}" + ); +} diff --git a/crates/perry-runtime/src/array/element_shape.rs b/crates/perry-runtime/src/array/element_shape.rs index 4da435ce39..76358d587e 100644 --- a/crates/perry-runtime/src/array/element_shape.rs +++ b/crates/perry-runtime/src/array/element_shape.rs @@ -32,7 +32,7 @@ //! | fast proof | `_reserved` bit 7 | `_reserved` bit 11 | //! | rides a move | yes (`_reserved` is copied) | yes (same word) | //! | self-heals by rescan | `ensure_array_numeric_raw_f64` | [`ensure_element_shape`] | -//! | move fixup | `transfer_array_numeric_layout` | [`transfer_element_shape`] | +//! | move fixup | none — the bit IS the record | [`transfer_element_shape`] | //! | clear funnel | `clear_array_numeric_layout` | [`clear_element_shape`] | //! //! The one thing 4a does not need is a *payload*: "raw f64" is the whole @@ -443,16 +443,6 @@ pub(crate) unsafe fn clear_element_shape(arr: *const ArrayHeader) { bump_epoch(); } -/// Address-keyed sibling of [`clear_element_shape`], for the `layout_*` -/// family and other callers that hold a `usize`. -#[inline] -pub(crate) fn clear_element_shape_ptr(user_ptr: usize) { - if user_ptr == 0 { - return; - } - unsafe { clear_element_shape(user_ptr as *const ArrayHeader) } -} - /// Forget everything about an address, bit included. Used when an allocation /// dies and its address may be recycled (`layout_clear_for_ptr`). pub(crate) fn forget_element_shape(user_ptr: usize) { diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index c388728b2a..e9cb8f72bf 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -1334,25 +1334,6 @@ pub(crate) fn clear_array_numeric_layout_ptr(user_ptr: usize) { } } -#[inline] -pub(crate) fn transfer_array_numeric_layout(old_user: usize, new_user: usize) { - if old_user == 0 || new_user == 0 || old_user == new_user { - return; - } - unsafe { - if array_has_raw_f64_layout_flag(old_user as *const ArrayHeader) { - set_array_raw_f64_layout_flag(new_user as *const ArrayHeader); - } else if array_has_raw_f64_holes_flag(old_user as *const ArrayHeader) { - // #6011: relocation copies slot bits verbatim, so the verified - // raw-f64-or-holes invariant carries over to the new backing. - clear_array_raw_f64_layout_flag(new_user as *const ArrayHeader); - set_array_raw_f64_holes_flag(new_user as *const ArrayHeader); - } else { - clear_array_raw_f64_layout_flag(new_user as *const ArrayHeader); - } - } -} - #[inline] pub(crate) unsafe fn array_numeric_layout(arr: *const ArrayHeader) -> Option { let arr = clean_arr_ptr(arr); diff --git a/crates/perry-runtime/src/array/header_gc_slots.rs b/crates/perry-runtime/src/array/header_gc_slots.rs index e64fd97bc6..0188d8c5fe 100644 --- a/crates/perry-runtime/src/array/header_gc_slots.rs +++ b/crates/perry-runtime/src/array/header_gc_slots.rs @@ -178,6 +178,73 @@ pub(crate) unsafe fn store_array_slot_resolved( value_bits } +/// Store one element into a plain array the caller is BULK-FILLING, from a +/// header resolved once. +/// +/// Same protocol as [`note_array_slot_layout_only`] — canonicalize under a +/// raw-f64 layout, write, keep the numeric-layout flags honest, note the slot +/// layout unless that note is provably a no-op, and keep the born-old +/// remembered-set edge — but a filler that has just re-derived the live head +/// from its own root already owns the ownership/forwarding proof each of those +/// steps otherwise repeats: `clean_arr_ptr` inside the canonicalization, a +/// second `addr_class::try_read_gc_header` inside the elision check, a third +/// flag read inside the numeric note. `a.map(v => v + 1)` paid all three per +/// element. +/// +/// Anything this cannot prove from that one header — an unrecognized or +/// forwarded head — falls back to the fully re-classifying helper, so the +/// conservative path stays the default rather than the exception. +/// +/// # Safety +/// +/// `arr` must be a live, forwarding-resolved `GC_TYPE_ARRAY` head re-derived +/// below the last collection point, with `index` inside its allocation. +#[inline] +pub(crate) unsafe fn fill_resolved_array_slot( + arr: *mut ArrayHeader, + index: usize, + value_bits: u64, +) { + let Some(header) = super::header::array_gc_header(arr) else { + note_array_slot_layout_only(arr, index, value_bits); + return; + }; + if (*header).gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { + note_array_slot_layout_only(arr, index, value_bits); + return; + } + let flags = (*header)._reserved; + let raw_layout = crate::gc::GC_ARRAY_RAW_F64_LAYOUT | crate::gc::GC_ARRAY_RAW_F64_HOLES; + let number = super::header::value_bits_to_number(value_bits); + let value_bits = match number { + Some(n) if flags & raw_layout != 0 => n.to_bits(), + _ => value_bits, + }; + // GC_STORE_AUDIT(INIT): bulk fill of a caller-owned array; the layout note + // and born-old barrier below cover this slot exactly as the layout-only + // helper does. + std::ptr::write(array_elements_ptr(arr).add(index), value_bits); + if number.is_none() { + // A non-number retires the dense raw-f64 claim, exactly as + // `note_array_numeric_index_write` does. + super::header::clear_array_numeric_layout(arr); + } + let scalar = !crate::gc::layout_pointer_bearing_bits(value_bits); + let note_elidable = flags & SCALAR_NOTE_ELIDABLE_MASK == crate::gc::GC_LAYOUT_POINTER_FREE; + if !(scalar && note_elidable) { + crate::gc::layout_note_slot(arr as usize, index, value_bits); + } + // Born-old arrays still need the old->young edge; a scalar child skips by + // shape before the old-gen classification runs (see the note in + // `note_array_slot_layout_only`). + if !crate::gc::barrier_scalar_child_skips(value_bits) + && crate::arena::pointer_in_old_gen(arr as usize) + { + let slot = array_elements_ptr(arr).add(index) as usize; + crate::gc::runtime_write_barrier_slot(arr as usize, slot, value_bits); + } +} + #[inline] pub(crate) unsafe fn note_array_slot_layout_only( arr: *mut ArrayHeader, diff --git a/crates/perry-runtime/src/array/iter_methods.rs b/crates/perry-runtime/src/array/iter_methods.rs index f15edd4130..c8fa7668bb 100644 --- a/crates/perry-runtime/src/array/iter_methods.rs +++ b/crates/perry-runtime/src/array/iter_methods.rs @@ -406,7 +406,10 @@ pub extern "C" fn js_array_map( ptr::write(result_elements.add(i), mapped); let mapped_bits = mapped.to_bits(); if length <= 64 { - note_array_slot_layout_only(result, i, mapped_bits); + // The head was just re-derived from `result_rooted`, so the + // per-element helpers' repeated ownership/forwarding proofs + // are redundant: resolve the header once. + super::header_gc_slots::fill_resolved_array_slot(result, i, mapped_bits); } else { note_array_slot(result, i, mapped_bits); } diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 2af3af130c..efdfb88608 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -101,8 +101,8 @@ pub use self::concat_reverse::{ js_array_fill_range, js_array_reverse, js_array_reverse_value, }; pub(crate) use self::element_shape::{ - clear_element_shape_ptr, forget_element_shape, invalidate_all_element_shapes, - note_element_store, prune_dead_element_shape_owners, transfer_element_shape, + forget_element_shape, invalidate_all_element_shapes, note_element_store, + prune_dead_element_shape_owners, transfer_element_shape, }; pub use self::element_shape::{ js_array_element_shape_check, js_array_element_shape_class, js_array_element_shape_epoch, @@ -291,8 +291,8 @@ pub(crate) use self::header::{ note_array_slot_resolved_flags, rebuild_array_layout, rebuild_array_layout_exact, reclassify_array_numeric_layout_from_slots, refresh_array_numeric_layout, replay_array_growth_write_barriers, set_array_numeric_layout, store_array_slot, - store_array_slot_resolved, transfer_array_numeric_layout, typed_array_receiver, - value_bits_to_number, NumericArrayLayout, MIN_ARRAY_CAPACITY, + store_array_slot_resolved, typed_array_receiver, value_bits_to_number, NumericArrayLayout, + MIN_ARRAY_CAPACITY, }; pub(crate) use self::named_props::{ array_has_named_properties_resolved, array_has_sparse_index_properties_resolved, diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index a17db4f826..414d7011c8 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -656,14 +656,19 @@ pub(crate) unsafe fn array_subclass_named_prefix_token_matches_class( /// spill path calls it against the owner because its physical store is noted /// on the child Array buffer instead. #[inline] -pub(crate) unsafe fn clear_packed_subclass_numeric_proof(obj: *mut ObjectHeader) { +/// Returns whether this call actually RETIRED a proof. A receiver that never +/// carried one — or whose proof an earlier call already retired — is left +/// untouched, and the `false` answer is what lets a caller skip an +/// invalidation it would otherwise pay on every operation (see +/// `array_subclass_fast_pop_validated`). +pub(crate) unsafe fn clear_packed_subclass_numeric_proof(obj: *mut ObjectHeader) -> bool { let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { - return; + return false; }; if header.obj_type != crate::gc::GC_TYPE_OBJECT || header._reserved & crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF == 0 { - return; + return false; } let header = std::ptr::from_ref(header).cast_mut(); // Retire the authority first. A missing/moving meta then merely leaves an @@ -673,6 +678,7 @@ pub(crate) unsafe fn clear_packed_subclass_numeric_proof(obj: *mut ObjectHeader) if !meta.is_null() { (*meta).flags &= !PACKED_NUMERIC_META_MASK; } + true } /// Owner-side invalidation for an object-owned spill write. The common @@ -1459,8 +1465,20 @@ fn array_subclass_fast_pop_validated(receiver: ValidatedObjectReceiver) -> Optio number.is_finite() && *number >= 0.0 && *number <= i32::MAX as f64 && number.fract() == 0.0 }); let obj = obj as *mut ObjectHeader; - unsafe { clear_packed_subclass_numeric_proof(obj) }; - crate::object::prop_plan::prop_plan_epoch_bump(); + // Only a proof this call actually retired can invalidate a cached verdict. + // A pop loop retires one on its FIRST iteration and nothing afterwards, + // while the bump it used to pay unconditionally discarded every cached + // store plan in the program — per `pop()`. + // + // The shape-version install below needs no bump of its own: the sibling + // push path (`array_subclass_fast_push_one_validated`) performs the same + // `install_cache_carried_object_shape_version` and has never bumped. A + // per-object shape version is not an input to the store-plan verdict, + // which is keyed on (class_id, interned key) and invalidated by vtable + // mutation, descriptor/prototype changes and GC — see `object::prop_plan`. + if unsafe { clear_packed_subclass_numeric_proof(obj) } { + crate::object::prop_plan::prop_plan_epoch_bump(); + } let installed = unsafe { crate::object::shapes::install_cache_carried_object_shape_version( obj, diff --git a/crates/perry-runtime/src/array/subclass_tests.rs b/crates/perry-runtime/src/array/subclass_tests.rs index b540f8ff4c..668592716f 100644 --- a/crates/perry-runtime/src/array/subclass_tests.rs +++ b/crates/perry-runtime/src/array/subclass_tests.rs @@ -1300,3 +1300,57 @@ fn dense_array_subclass_guard_rejects_other_object_brands() { 17.0 ); } + +/// Retiring a packed-numeric proof is idempotent, and only the call that +/// ACTUALLY retires one reports `true`. +/// +/// `array_subclass_fast_pop_validated` flushes the process-global store-plan +/// cache when this returns `true`. It used to flush unconditionally, so a pop +/// loop discarded every cached store plan in the program on every iteration +/// while retiring a proof only on the first. +#[test] +fn retiring_a_packed_numeric_proof_reports_only_the_call_that_did_it() { + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); + let class_id = 0x0074_8694; + crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); + let obj = js_object_alloc(class_id, 2); + assert!(!obj.is_null()); + let receiver = crate::value::js_nanbox_pointer(obj as i64); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(receiver); + crate::node_stream::js_array_subclass_init(receiver_h.get_nanbox_f64(), 0.0); + for (index, value) in [11.0, 22.0, 33.0].into_iter().enumerate() { + let live_raw = receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF; + crate::object::js_object_set_index_polymorphic(live_raw as i64, index as f64, value); + } + let mut facts = [0u64; 7]; + assert_eq!( + js_packed_arraylike_loop_guard(receiver_h.get_nanbox_f64(), 3.0, 1, facts.as_mut_ptr()), + 2, + "test premise: the numeric range establishes a proof to retire" + ); + + let live = + || (receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader; + let epoch_before = crate::object::prop_plan::prop_plan_semantic_epoch(); + assert!( + unsafe { super::subclass::clear_packed_subclass_numeric_proof(live()) }, + "the first retire must report that it retired the proof" + ); + let epoch_after_retire = crate::object::prop_plan::prop_plan_semantic_epoch(); + + for _ in 0..4 { + assert!( + !unsafe { super::subclass::clear_packed_subclass_numeric_proof(live()) }, + "a receiver with no proof left must report that it retired nothing" + ); + } + assert_eq!( + crate::object::prop_plan::prop_plan_semantic_epoch(), + epoch_after_retire, + "retiring nothing must not move the epoch — the whole point of the \ + conditional flush in array_subclass_fast_pop_validated" + ); + let _ = epoch_before; +} diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 0bafd9362b..52442f4f6f 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1,3 +1,4 @@ +use super::copying_parent_facts::weak_holder_fact; use super::copying_phase::{ finalize_dead_copied_minor_from_space_side_allocations, CopyingMinorPhase as Phase, CopyingMinorPhaseDiag as PhaseDiag, @@ -116,12 +117,15 @@ impl CopyingNurseryPreflight { } pub(super) unsafe fn scan_object_fields(&mut self, header: *mut GcHeader) { + let mut weak_holder: Option = None; visit_gc_rewrite_slots(header, |slot| unsafe { // Weak-only reachability imposes no copy constraint: the // collector never evacuates through a weak edge (a weak-only // young target dies in place and tombstones), so a pinned // target behind one must not force the fallback path. - if crate::weakref::is_weak_target_trace_slot(header, slot.slot) { + if *weak_holder.get_or_insert_with(|| weak_holder_fact(header)) + && crate::weakref::is_weak_target_trace_slot(header, slot.slot) + { return; } slot.record_layout_read(); @@ -650,55 +654,6 @@ impl CopyingNurseryCollector { new_user as usize } - pub(super) unsafe fn visit_slot_with_parent( - &mut self, - slot: *mut u64, - parent_header: *mut GcHeader, - external: bool, - ) { - if slot.is_null() { - return; - } - // Weak target edge (WeakRef referent / weak entry key / finreg - // record target): never evacuate through it — the mark/barrier - // paths skip these (`is_weak_target_trace_slot`), and copying - // through them strengthened the reference, so WeakMap entries - // never tombstoned and FinalizationRegistry never fired while - // copied-minor was the operative cycle. Repair an already-moved - // target's address now and queue the slot so `repair_weak_slots` - // fixes targets evacuated after this visit; the registry pass then - // tombstones dead ones. - // No remembered-set entry either — the write barrier skips weak - // slots the same way. - if !parent_header.is_null() - && crate::weakref::is_weak_target_trace_slot(parent_header, slot) - { - if let Some(new_bits) = self.rewrite_value_bits(*slot) { - *slot = new_bits; - } - self.weak_slots.push(slot); - return; - } - let bits = *slot; - if let Some(new_bits) = self.visit_value_bits(bits) { - *slot = new_bits; - } - if !parent_header.is_null() && !self.skip_remembering { - let parent_user = (parent_header as *mut u8).add(GC_HEADER_SIZE) as usize; - if barrier_parent_needs_remembering(parent_user, external) { - if let Some((child_addr, _, _)) = self.ptrs.decode_bits(*slot) { - // Keep old→malloc pages dirty alongside old→nursery: - // the malloc child is spared by this cycle's mark - // (mark_addr handles CopyingPointerKind::Malloc) but - // the NEXT minor's malloc sweep needs the edge again. - if crate::gc::barrier::remembered_child_needs_tracking(child_addr) { - self.sticky.remember_slot(parent_header, slot, external); - } - } - } - } - } - pub(super) unsafe fn drain(&mut self) { let mut i = 0usize; while i < self.worklist.len() { @@ -743,10 +698,16 @@ impl CopyingNurseryCollector { pub(super) unsafe fn scan_object_fields(&mut self, header: *mut GcHeader) { let mut changed = false; + // LAZY, not eager. Reading the fact once per traced OBJECT regressed + // all six fixtures (+0.88 % to +5.09 % instructions): a great many + // traced objects — strings, pointer-free arrays — have no slot to + // visit at all, and paid for an answer nobody then asked for. + let mut weak_holder: Option = None; visit_gc_rewrite_slots(header, |slot| unsafe { slot.record_layout_read(); let before = *slot.slot; - self.visit_slot_with_parent(slot.slot, header, slot.external()); + let weak = *weak_holder.get_or_insert_with(|| weak_holder_fact(header)); + self.visit_slot_with_weak_fact(slot.slot, header, weak, slot.external()); changed |= *slot.slot != before; }); if changed { diff --git a/crates/perry-runtime/src/gc/copying_parent_facts.rs b/crates/perry-runtime/src/gc/copying_parent_facts.rs new file mode 100644 index 0000000000..2cecd82327 --- /dev/null +++ b/crates/perry-runtime/src/gc/copying_parent_facts.rs @@ -0,0 +1,122 @@ +//! The per-parent weak-holder fact the copying minor's slot visit reads, and +//! the slot visit itself. Split out of `gc/copying.rs` for the 2000-line lint. + +use super::*; + +/// Is this parent one of the weak-holder classes whose weak slots the copying +/// minor must not evacuate through? +/// +/// The question is a property of the PARENT's class, but the collector asked +/// the per-SLOT question (`weakref::is_weak_target_trace_slot`) for every slot +/// of every object — an out-of-line call that re-reads `obj_type` and +/// `class_id` and then rejects on class. #10182 gave the full mark the +/// per-object read (`gc/trace.rs`); the copying minor never got it. +/// +/// Read LAZILY by the callers: once per object is right only for objects that +/// actually have a slot to visit. See `scan_object_fields`. +/// +/// Null-safe: `is_weak_holder_header` answers false for a null header, which +/// is the same answer the per-slot question gave. +#[inline] +pub(super) unsafe fn weak_holder_fact(header: *mut GcHeader) -> bool { + #[cfg(test)] + if copy_hoist_sabotage::forgetting_weak() { + return false; + } + crate::weakref::is_weak_holder_header(header) +} + +/// Test-only sabotage for [`weak_holder_fact`]: forgetting the per-object fact +/// must change what the collector does, or the hoist is documentation +/// (CLAUDE.md, a gate that cannot fail). Its witness is +/// `gc::tests::copy_slot_hoists`. +#[cfg(test)] +pub(crate) mod copy_hoist_sabotage { + use std::cell::Cell; + + thread_local! { + static FORGET_WEAK: Cell = const { Cell::new(false) }; + } + + #[inline] + pub(crate) fn forgetting_weak() -> bool { + FORGET_WEAK.with(Cell::get) + } + + pub(crate) struct WeakGuard(bool); + + impl WeakGuard { + pub(crate) fn arm() -> Self { + Self(FORGET_WEAK.with(|s| s.replace(true))) + } + } + + impl Drop for WeakGuard { + fn drop(&mut self) { + FORGET_WEAK.with(|s| s.set(self.0)); + } + } +} + +impl CopyingNurseryCollector { + pub(super) unsafe fn visit_slot_with_parent( + &mut self, + slot: *mut u64, + parent_header: *mut GcHeader, + external: bool, + ) { + let weak_holder = weak_holder_fact(parent_header); + self.visit_slot_with_weak_fact(slot, parent_header, weak_holder, external); + } + + /// [`visit_slot_with_parent`](Self::visit_slot_with_parent) with the + /// parent's weak-holder fact supplied by the caller, so a whole object's + /// slots pay for it once. See [`weak_holder_fact`]. + pub(super) unsafe fn visit_slot_with_weak_fact( + &mut self, + slot: *mut u64, + parent_header: *mut GcHeader, + weak_holder: bool, + external: bool, + ) { + if slot.is_null() { + return; + } + // Weak target edge (WeakRef referent / weak entry key / finreg + // record target): never evacuate through it — the mark/barrier + // paths skip these (`is_weak_target_trace_slot`), and copying + // through them strengthened the reference, so WeakMap entries + // never tombstoned and FinalizationRegistry never fired while + // copied-minor was the operative cycle. Repair an already-moved + // target's address now and queue the slot so `repair_weak_slots` + // fixes targets evacuated after this visit; the registry pass then + // tombstones dead ones. + // No remembered-set entry either — the write barrier skips weak + // slots the same way. + if weak_holder && crate::weakref::is_weak_target_trace_slot(parent_header, slot) { + if let Some(new_bits) = self.rewrite_value_bits(*slot) { + *slot = new_bits; + } + self.weak_slots.push(slot); + return; + } + let bits = *slot; + if let Some(new_bits) = self.visit_value_bits(bits) { + *slot = new_bits; + } + if !parent_header.is_null() && !self.skip_remembering { + let parent_user = (parent_header as *mut u8).add(GC_HEADER_SIZE) as usize; + if barrier_parent_needs_remembering(parent_user, external) { + if let Some((child_addr, _, _)) = self.ptrs.decode_bits(*slot) { + // Keep old→malloc pages dirty alongside old→nursery: + // the malloc child is spared by this cycle's mark + // (mark_addr handles CopyingPointerKind::Malloc) but + // the NEXT minor's malloc sweep needs the edge again. + if crate::gc::barrier::remembered_child_needs_tracking(child_addr) { + self.sticky.remember_slot(parent_header, slot, external); + } + } + } + } + } +} diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 72a5a7c0d2..cb5e79282a 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -1,6 +1,7 @@ -//! Per-object pointer-slot states, store maintenance, copying-GC transfer and -//! child-slot enumeration. Mask storage is in `layout/slot_mask.rs`; typed -//! descriptor installation is in `layout/typed_shape.rs`. +//! Per-object pointer-slot states, store maintenance and child-slot +//! enumeration. Mask storage is in `layout/slot_mask.rs`; typed descriptor +//! installation is in `layout/typed_shape.rs`; the relocation funnel every +//! moving-GC and growth path calls is in `layout/transfer.rs`. use super::hot_tls::{hot_layout_slot_masks, hot_shape_layouts}; use super::layout_tables::{ @@ -82,8 +83,9 @@ pub(crate) const GC_LAYOUT_ALL_POINTERS: u16 = 0x2000; // "slot K is raw-f64" from this single bit — no cross-crate guard call, no // thread-local hashmap probe — for any field K the class declares as a raw-f64 // candidate. The bit travels with `_reserved` across copying/evacuating GC (the -// collector copies the whole reserved word), and `layout_transfer` re-syncs it -// defensively after moving the descriptor. +// collector copies the whole reserved word), which is all a relocation owes it: +// `layout/transfer.rs` re-sets it only for an object whose per-object +// descriptor moved, and never re-derives it (#10362). pub const GC_OBJ_TYPED_LAYOUT_INTACT: u16 = 0x1000; #[inline] @@ -111,9 +113,11 @@ pub(super) fn clear_typed_layout_intact_for_user(user_ptr: usize) { } mod slot_mask; +mod transfer; mod typed_shape; pub(in crate::gc) use slot_mask::LayoutSlotMask; +pub(crate) use transfer::layout_transfer; pub use typed_shape::{ js_gc_declare_typed_shape_layout, js_gc_init_typed_shape_layout, js_gc_typed_shape_id_for_keys, }; @@ -1254,91 +1258,6 @@ pub(crate) unsafe fn layout_rebuild_exact_from_slots( layout_rebuild_from_slots_with_policy(user_ptr, slots, slot_count, true); } -pub(crate) unsafe fn layout_transfer(old_user: *mut u8, new_user: *mut u8) { - if old_user.is_null() || new_user.is_null() || old_user == new_user { - return; - } - let Some(old_header) = layout_header_for_user(old_user as usize) else { - return; - }; - let Some(new_header) = layout_header_for_user(new_user as usize) else { - return; - }; - let state = (*old_header)._reserved & GC_LAYOUT_STATE_MASK; - let all_pointers = (*old_header)._reserved & GC_LAYOUT_ALL_POINTERS != 0; - set_layout_state(new_header, state); - if all_pointers { - (*new_header)._reserved |= GC_LAYOUT_ALL_POINTERS; - } - if (*old_header).obj_type == GC_TYPE_ARRAY && (*new_header).obj_type == GC_TYPE_ARRAY { - crate::array::transfer_array_numeric_layout(old_user as usize, new_user as usize); - // #7480: the element-shape bit rides `_reserved` for free, but its - // record is address-keyed and has to follow the move — same split, - // and same call site, as `TYPED_LAYOUTS` below. - crate::array::transfer_element_shape(old_user as usize, new_user as usize); - // #9304: real arrays keep explicit [[Prototype]] values in the - // residual address-keyed registry. Array growth and moving GC both - // replace the owner allocation through this transfer hook. - crate::object::prototype_chain::object_static_prototype_owner_moved( - old_user as usize, - new_user as usize, - ); - } else { - crate::array::clear_array_numeric_layout_ptr(new_user as usize); - crate::array::clear_element_shape_ptr(new_user as usize); - } - // Read the source object's intact bit BEFORE the transfer clears it — it is - // the per-object half of the shape-keyed resolution below. `_reserved` is - // untouched by `set_forwarding_address` (which writes gc_flags and the first - // payload word), so it is still authoritative here even though the - // evacuation callers forward the original before calling us. - let old_intact = (*old_header)._reserved & GC_OBJ_TYPED_LAYOUT_INTACT != 0; - // #7510: with both per-object maps provably empty there is nothing to - // move, and every relocated object would otherwise pay two `RefCell` - // round-trips plus two hashes during evacuation. The shape-keyed half - // below is unaffected — it needs no move at all. - let new_has_typed = transfer_per_object_descriptor(old_user as usize, new_user as usize); - // #6964: the canonical descriptor may live in EITHER map, exactly as the - // query helpers resolve it (#6957/#6963). The per-object `TYPED_LAYOUTS` - // entry is keyed by ADDRESS, so it has to be moved (above). The shape-keyed - // `SHAPE_LAYOUTS` entry (#6893/#8289) is keyed by immutable runtime - // ShapeId, which the relocated copy carries verbatim — it needs no move, - // but it only describes THIS object while the object is still INTACT. - // - // Probing only `TYPED_LAYOUTS` missed for every object #6893 actually moved - // (i.e. every class instance: it carries a keys_array and therefore has NO - // per-object entry), so `new_has_typed` was false and the relocated copy had - // a still-valid intact bit CLEARED — permanently deopting its typed guards. - // Latent until an evacuating minor became reachable (#6950); the fourth - // caller, array growth in `array/push_pop.rs`, is `GC_TYPE_ARRAY`, which is - // not `GcLayoutSlotKind::ObjectFields` and so never had a shape-keyed - // descriptor to lose. - // - // Read the shape through `new_user`: the evacuation callers install the - // forwarding pointer over the ORIGINAL's first payload word, which for an - // ObjectFields object overlaps the header fields this lookup reads. - // - // Mirrors #6963's split: the per-object half stays ungated (so a forged or - // stale intact bit cannot manufacture a descriptor), the shared half is - // gated on the source object's intact bit (so an object that diverged from - // its shape does not silently re-adopt the shape's stale descriptor by - // moving). - let new_has_shape_typed = !new_has_typed - && old_intact - && with_shape_shared_descriptor(new_user as usize, |_| ()).is_some(); - // Keep the intact bit in lock-step with the moved descriptor. Copying GC - // normally propagates `_reserved` (so the bit already rode along), but - // re-sync defensively for callers that allocate the destination fresh - // (e.g. array growth) so a stale/missing bit can never desync from the map. - if new_has_typed || new_has_shape_typed { - header_set_typed_layout_intact(new_header); - } else { - header_clear_typed_layout_intact(new_header); - } - header_clear_typed_layout_intact(old_header); - transfer_per_object_slot_mask(old_user as usize, new_user as usize); -} - pub(super) fn layout_visit_pointer_slots( user_ptr: usize, slot_count: usize, diff --git a/crates/perry-runtime/src/gc/layout/transfer.rs b/crates/perry-runtime/src/gc/layout/transfer.rs new file mode 100644 index 0000000000..b99cce0cf2 --- /dev/null +++ b/crates/perry-runtime/src/gc/layout/transfer.rs @@ -0,0 +1,171 @@ +//! The relocation funnel: what follows an object when its storage moves. +//! +//! Split out of `gc/layout.rs` (#10362), which sits on the repo's 2000-line +//! cap, and narrowed to the contract its callers have always satisfied. +//! +//! # The contract +//! +//! Four paths replace an object's storage: the copying nursery's `move_young`, +//! the two old-generation evacuations in `gc/oldgen.rs`, and `js_array_grow`. +//! **Every one of them copies the source header's `_reserved` into the +//! destination before calling** — the copying minor through +//! `reserved_with_copied_survival_age`, which rewrites only the age bits. +//! +//! So every layout fact the header carries has already arrived at the +//! destination by construction: the layout state, `GC_LAYOUT_ALL_POINTERS`, +//! the raw-f64 / holes flags, `GC_ARRAY_ELEMENT_SHAPE` and +//! `GC_OBJ_TYPED_LAYOUT_INTACT`. What cannot ride a header is a record keyed +//! by the object's ADDRESS, and that is all this funnel moves: +//! +//! * the element-shape proof record (#7480), gated by the header bit that is +//! authoritative for it; +//! * the residual static-prototype owner registry (#9304), gated by its +//! process-global latch (#7733/#7737); +//! * the per-object `TYPED_LAYOUTS` and `LAYOUT_SLOT_MASKS` entries, gated by +//! #7510's emptiness flag and address filter. +//! +//! Until #10362 the funnel re-derived the header half too — rewriting bits +//! that were already equal, and re-resolving the intact bit through a +//! ShapeId-keyed `SHAPE_LAYOUTS` probe — once per relocated object. Measured +//! on #10362's retained-graph workload that was 160 instructions per moved +//! array and 245 per moved object, 518M instructions (4.2% of the run), of +//! which zero reached a side-table record: both per-object maps held one key. +//! The gates below answer the same questions from the header word and two +//! flags the caller has already brought into cache. +//! +//! # Why the intact bit is not re-derived +//! +//! `GC_OBJ_TYPED_LAYOUT_INTACT` asks whether a canonical typed descriptor is +//! reachable for this object. Its inputs are the receiver's stamped ShapeId +//! (copied verbatim with the payload), `SHAPE_LAYOUTS`, the process-global +//! registered typed-shape registry (#8405) and the per-object map — and a +//! relocation changes none of them. Re-asking at move time could therefore +//! only apply a LAZY downgrade, and only to the objects that happen to move. +//! +//! The state that downgrade cleared — intact while no descriptor is reachable +//! — is legal and handled. `shape_install_shared` poisons a shape's shared +//! entry to `None` and deliberately leaves "any still-INTACT siblings" to fall +//! back; #8115 clears the bit at the first contradicting store; the trace path +//! resolves no mask, sets `GC_LAYOUT_UNKNOWN` and scans every slot; the query +//! helpers answer "no descriptor". An unmoved sibling in exactly that state +//! keeps its bit today, so an argument that needed the move to clear it would +//! already be broken for every object that does not move. +//! `gc/tests/layout_trace/typed_shape.rs` pins the pair across a real copying +//! minor: the moved object and its unmoved peer must answer identically, and +//! the child behind the poisoned shape must survive the cycle. + +use super::*; +use crate::gc::layout_tables::per_object_layouts_may_hold_either; + +/// Move the address-keyed layout records of a relocated object. +/// +/// # Safety +/// +/// `old_user` and `new_user` are user pointers of live allocations, and the +/// caller has already made the destination header a copy of the source's (see +/// the module docs). The precondition is asserted in test and debug builds. +#[inline] +pub(crate) unsafe fn layout_transfer(old_user: *mut u8, new_user: *mut u8) { + if old_user.is_null() || new_user.is_null() || old_user == new_user { + return; + } + // Kinds with no layout metadata at all (strings, meta records, RegExps) + // leave before anything else, exactly as before #10362. The destination + // carries the same `obj_type`, so one classification answers for both. + let Some(old_header) = layout_header_for_user(old_user as usize) else { + return; + }; + assert_relocation_copied_the_header(old_header, new_user); + + let reserved = (*old_header)._reserved; + let is_array = (*old_header).obj_type == GC_TYPE_ARRAY; + // Three gates, all answered from words already in registers or in the one + // hot thread-local slot #7510 keeps them in. Each is the same question the + // record mover behind it asks first, hoisted so the common case — no + // record anywhere near either address — never leaves this function. + let per_object = per_object_layouts_may_hold_either(old_user as usize, new_user as usize); + let element_shape = is_array && reserved & GC_ARRAY_ELEMENT_SHAPE != 0; + let static_prototype = + is_array && crate::object::prototype_chain::object_static_prototypes_maybe_nonempty(); + if per_object || element_shape || static_prototype { + transfer_address_keyed_records( + old_user as usize, + new_user as usize, + header_from_user_ptr(new_user as *const u8), + is_array, + ); + } + + // The source is a dead evacuation original or a growth forwarding stub the + // moment we return. Drop its claim to a descriptor rather than leave the + // bit readable at an address whose records now belong to the destination. + header_clear_typed_layout_intact(old_header); +} + +/// The record moves themselves. Cold: on a workload holding no per-object +/// layout record, no element-shape proof and no re-prototyped array — the +/// steady state of every monomorphic program — it is never reached. +#[cold] +#[inline(never)] +unsafe fn transfer_address_keyed_records( + old_user: usize, + new_user: usize, + new_header: *mut GcHeader, + is_array: bool, +) { + if is_array { + // #7480: the proof record is keyed by the array's address while the + // header bit is what a read consults. `transfer_element_shape` decides + // from both headers and fails closed — it clears the destination bit + // when no record follows the move. + crate::array::transfer_element_shape(old_user, new_user); + // #9304: a real array keeps an explicit [[Prototype]] in the residual + // address-keyed registry; moving GC and growth both replace the owner + // allocation through this hook. + crate::object::prototype_chain::object_static_prototype_owner_moved(old_user, new_user); + } + // #7510's two per-object maps. Both re-test the gate above for their own + // address pair, so calling them when only a sibling gate fired costs one + // predictable branch each. + // + // Re-setting the intact bit for a moved per-object descriptor is parity + // with the pre-#10362 funnel rather than a fact the copy lost: a source + // whose descriptor existed while its own bit was clear had the bit SET by + // the move. Keeping that leaves the lazy downgrade (module docs) as the + // single behavioural difference of #10362. + if transfer_per_object_descriptor(old_user, new_user) { + header_set_typed_layout_intact(new_header); + } + transfer_per_object_slot_mask(old_user, new_user); +} + +/// The funnel's precondition: the destination header is the source's copy. +/// +/// Checked in test and debug builds — including `cargo test --release`, which +/// is how the GC suites run — so a future relocation path that allocates a +/// destination without copying `_reserved` fails loudly here instead of +/// silently losing a layout state, an `ALL_POINTERS` bit or an element-shape +/// proof at the first collection. +#[inline] +unsafe fn assert_relocation_copied_the_header(old_header: *mut GcHeader, new_user: *mut u8) { + #[cfg(any(test, debug_assertions))] + { + let new_header = header_from_user_ptr(new_user as *const u8); + assert_eq!( + (*new_header).obj_type, + (*old_header).obj_type, + "layout_transfer: a relocation must not change the object type" + ); + assert_eq!( + (*new_header)._reserved & !GC_COPY_SURVIVAL_AGE_MASK, + (*old_header)._reserved & !GC_COPY_SURVIVAL_AGE_MASK, + "layout_transfer: the caller must copy `_reserved` into the destination before \ + relocating (only the copied-survival age may differ) — every header-carried \ + layout fact rides that copy" + ); + } + #[cfg(not(any(test, debug_assertions)))] + { + let _ = (old_header, new_user); + } +} diff --git a/crates/perry-runtime/src/gc/layout_tables.rs b/crates/perry-runtime/src/gc/layout_tables.rs index 76a9f298b0..dc006c52c8 100644 --- a/crates/perry-runtime/src/gc/layout_tables.rs +++ b/crates/perry-runtime/src/gc/layout_tables.rs @@ -1037,6 +1037,17 @@ pub(in crate::gc) fn per_object_layouts_maybe_nonempty() -> bool { hot_per_object_layout_hint().nonempty.get() } +/// Can EITHER address carry a per-object record? The relocation funnel +/// (`gc/layout/transfer.rs`) asks once for both maps and both ends of a move, +/// where the two `transfer_*` entry points below each resolve the hot slot +/// again for their own pair (#10362). Same answer, one thread-local +/// resolution: the flag and the filter live in the same slot. +#[inline] +pub(in crate::gc) fn per_object_layouts_may_hold_either(old_user: usize, new_user: usize) -> bool { + let hint = hot_per_object_layout_hint(); + hint.nonempty.get() && (hint_may_hold(hint, old_user) || hint_may_hold(hint, new_user)) +} + /// Arm the flag. Called by anything that inserts into either map — including /// the one insert site that holds its own `borrow_mut` and so cannot go /// through the wrappers below. diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 2926edf988..f897b34fb4 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -164,6 +164,7 @@ pub(crate) mod prefetch; mod copying; mod copying_first_cycle; +mod copying_parent_facts; mod copying_phase; mod copying_pointer_set; mod diag_sites; diff --git a/crates/perry-runtime/src/gc/tests/copy_slot_hoists.rs b/crates/perry-runtime/src/gc/tests/copy_slot_hoists.rs new file mode 100644 index 0000000000..d0f1fbd172 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copy_slot_hoists.rs @@ -0,0 +1,70 @@ +//! The copying minor reads two facts once per traced object that it used to +//! re-derive for every slot of that object: whether the parent is a weak +//! holder, and whether the parent is in old-gen. +//! +//! Both are pinned by a COLLECTION and its observable outcome, not by reading +//! the hoisted value back — and each has a sabotaged twin that forgets the +//! fact, so the hoist is shown to be load-bearing rather than merely present. + +use super::super::*; +use super::support::*; +use crate::gc::copying_parent_facts::copy_hoist_sabotage; + +/// A young target reachable ONLY through a rooted `WeakRef`'s weak slot. +/// A copying minor must not evacuate through that slot, so the target dies +/// and the reference reads `undefined`. +fn weak_target_cleared_by_minor(sabotaged: bool) -> bool { + std::thread::spawn(move || { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _scan = ConservativeScanDisabledGuard::new(); + reset_global_roots(); + let _roots = ShadowAndGlobalRootResetGuard; + + // An OBJECT: a string is not "CanBeHeldWeakly", so `js_weakref_new` + // would reject it before the collector is ever involved. + let target = unsafe { alloc_nursery_test_object(0).0 } as usize; + assert!( + crate::arena::pointer_in_nursery(target), + "premise: the weak target must be young, or the minor cannot collect it" + ); + let holder = crate::weakref::js_weakref_new(f64::from_bits(ptr_bits(target))); + let mut root = ptr_bits(holder as usize); + js_gc_register_global_root(&mut root as *mut u64 as i64); + assert!( + unsafe { + crate::weakref::is_weak_holder_header( + header_from_user_ptr(holder as *const u8) as *mut GcHeader + ) + }, + "premise: a WeakRef is a weak holder" + ); + + { + let _sabotage = sabotaged.then(copy_hoist_sabotage::WeakGuard::arm); + let _ = gc_collect_minor(); + } + crate::weakref::js_weakref_deref(f64::from_bits(root)).to_bits() + == crate::value::TAG_UNDEFINED + }) + .join() + .expect("copy-hoist weak test thread must not panic") +} + +#[test] +fn a_copying_minor_skips_a_weak_holders_weak_slot_through_the_per_object_fact() { + assert!( + weak_target_cleared_by_minor(false), + "a target reachable only through the WeakRef's weak slot must not be \ + evacuated through, so it dies in the nursery" + ); +} + +#[test] +fn sabotaged_weak_holder_fact_evacuates_through_the_weak_slot() { + assert!( + !weak_target_cleared_by_minor(true), + "with the per-object weak-holder fact forgotten the weak slot is \ + treated as strong and the target survives the minor" + ); +} diff --git a/crates/perry-runtime/src/gc/tests/layout_trace/array_layout.rs b/crates/perry-runtime/src/gc/tests/layout_trace/array_layout.rs index 1d6009797f..9ea1587006 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace/array_layout.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace/array_layout.rs @@ -66,6 +66,7 @@ fn test_layout_mask_overflow_fields_and_array_grow_transfer() { let moved = crate::array::js_array_alloc_with_length(4); unsafe { + model_relocation_header_copy(grown as usize, moved as usize); layout_transfer(grown as *mut u8, moved as *mut u8); } assert_eq!(test_layout_pointer_slot_count(moved as usize, 4), Some(1)); diff --git a/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs b/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs index c63e2e193b..10fb7b0040 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs @@ -352,6 +352,184 @@ fn test_shape_keyed_typed_layout_survives_copying_minor() { assert!(layout_slot_is_raw_f64_typed(after, 0)); } +/// #10362: a relocation carries `GC_OBJ_TYPED_LAYOUT_INTACT` in the `_reserved` +/// copy and no longer re-derives it. This pins the exact state that the old +/// re-derivation used to clear — an INTACT receiver whose shape's SHARED +/// descriptor has since been poisoned to `None` — across a real copying minor, +/// together with the address-keyed half the funnel must still move. +/// +/// The two receivers are one fixture because they are one cycle: +/// +/// * `poisoned` installs the shared descriptor first and keeps its bit. An +/// unmoved sibling in this state keeps its bit too (`shape_install_shared` +/// leaves "any still-INTACT siblings" to fall back), so clearing it on the +/// copy alone was never a correctness rule. What the collector owes the +/// object is that its pointer field survive: with no descriptor resolvable +/// the trace falls back to scanning every slot. +/// * `per_object` is the receiver whose different layout POISONED the shape, +/// so its canonical descriptor is a per-object record — the address-keyed +/// half `transfer_address_keyed_records` still has to move. Its pointer +/// mask must answer the same after the move; a funnel that skips the record +/// move fails exactly that assertion. +#[test] +fn test_poisoned_shape_intact_and_per_object_record_survive_a_copying_minor() { + // Two rooted receivers, so two shadow slots: a slot index outside the + // pushed frame is bounds-checked into a silent no-op (#7184), which would + // leave the second receiver unrooted and every verdict below vacuous. + let _guard = CopyingNurseryTestGuard::new(2); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + let packed = b"x\0y\0"; + let keys = crate::object::js_build_class_keys_array( + 0x1036_20, + 2, + packed.as_ptr(), + packed.len() as u32, + ); + + // Slot 0 raw-f64, slot 1 a declared pointer: the shape's first descriptor. + let poisoned = crate::object::js_object_alloc_class_inline_keys(0x1036_20, 0, 2, keys); + let poisoned_child = crate::string::js_string_from_bytes(b"poisoned-child".as_ptr(), 14); + crate::object::js_object_set_field(poisoned, 0, crate::value::JSValue::number(1.5)); + crate::object::js_object_set_field( + poisoned, + 1, + crate::value::JSValue::string_ptr(poisoned_child), + ); + let raw_mask = [0b01u64]; + let pointer_mask = [0b10u64]; + js_gc_init_typed_shape_layout( + poisoned as u64, + 2, + raw_mask.as_ptr(), + raw_mask.len() as u32, + pointer_mask.as_ptr(), + pointer_mask.len() as u32, + ); + assert!(layout_typed_intact_for_user(poisoned as usize)); + assert!(layout_typed_raw_f64_slot_for_user(poisoned as usize, 0)); + + // Same keys, DIFFERENT layout (slot 0 carries no raw-f64 proof): the + // install poisons the shared entry and falls back to a per-object record. + let per_object = crate::object::js_object_alloc_class_inline_keys(0x1036_20, 0, 2, keys); + let per_object_child = crate::string::js_string_from_bytes(b"per-object-child".as_ptr(), 16); + crate::object::js_object_set_field(per_object, 0, crate::value::JSValue::number(2.5)); + crate::object::js_object_set_field( + per_object, + 1, + crate::value::JSValue::string_ptr(per_object_child), + ); + js_gc_init_typed_shape_layout( + per_object as u64, + 2, + std::ptr::null(), + 0, + pointer_mask.as_ptr(), + pointer_mask.len() as u32, + ); + + // The fixture must START in the state under test, or every verdict below + // is vacuous: the shared descriptor is gone for the first receiver while + // its intact bit stands, and the second receiver answers from a record. + assert!( + layout_typed_intact_for_user(poisoned as usize), + "the poisoning install must not clear a sibling's intact bit" + ); + assert!( + !layout_typed_raw_f64_slot_for_user(poisoned as usize, 0), + "fixture precondition: the shared descriptor must be poisoned, so no \ + descriptor is resolvable for the first receiver" + ); + assert_eq!( + test_layout_pointer_slot_count(poisoned as usize, 2), + None, + "fixture precondition: an unresolvable descriptor means the conservative scan" + ); + assert_eq!( + test_layout_pointer_slot_count(per_object as usize, 2), + Some(1), + "fixture precondition: the second receiver's layout is a per-object record" + ); + + js_shadow_slot_set(0, ptr_bits(poisoned as usize)); + js_shadow_slot_set(1, ptr_bits(per_object as usize)); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + + let poisoned_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + let per_object_after = (js_shadow_slot_get(1) & POINTER_MASK) as usize; + assert_ne!( + poisoned_after, poisoned as usize, + "the minor must actually relocate the first receiver — an inert arm proves nothing" + ); + assert_ne!( + per_object_after, per_object as usize, + "the minor must actually relocate the second receiver" + ); + + // The bit rides `_reserved`. Before #10362 the funnel re-probed + // `SHAPE_LAYOUTS` here, found the poisoned `None`, and cleared it on the + // copy — a downgrade no unmoved sibling ever received. + assert!( + layout_typed_intact_for_user(poisoned_after), + "the relocated receiver keeps the intact bit its `_reserved` copy carried" + ); + assert!( + !layout_typed_raw_f64_slot_for_user(poisoned_after, 0), + "and still resolves no descriptor, exactly as before the move" + ); + assert_eq!( + test_layout_pointer_slot_count(poisoned_after, 2), + None, + "so the trace still falls back to scanning every slot" + ); + + // What the collector owes it: the field behind the unresolvable descriptor + // is marked and rewritten. + let moved_child = + crate::object::js_object_get_field(poisoned_after as *const crate::object::ObjectHeader, 1); + assert!(moved_child.is_string()); + let moved_child_ptr = moved_child.as_string_ptr(); + assert_ne!( + moved_child_ptr as usize, poisoned_child as usize, + "the child moved too, so the slot proves the rewrite, not just the mark" + ); + unsafe { + assert_string_bytes(moved_child_ptr, b"poisoned-child"); + } + + // The address-keyed half: the per-object record followed the move. + assert_eq!( + test_layout_pointer_slot_count(per_object_after, 2), + Some(1), + "the per-object layout record must be keyed by the post-move address" + ); + assert!(layout_typed_intact_for_user(per_object_after)); + let moved_per_object_child = crate::object::js_object_get_field( + per_object_after as *const crate::object::ObjectHeader, + 1, + ); + assert!(moved_per_object_child.is_string()); + unsafe { + assert_string_bytes(moved_per_object_child.as_string_ptr(), b"per-object-child"); + } +} + +/// #10362: the relocation contract is asserted in test and debug builds, so a +/// future move path that allocates a destination without copying `_reserved` +/// fails here instead of silently losing a layout state, an `ALL_POINTERS` bit +/// or an element-shape proof at the first collection. +#[test] +#[should_panic(expected = "the caller must copy `_reserved`")] +fn test_layout_transfer_requires_the_relocation_header_copy() { + let src = crate::array::js_array_alloc_pointer_elements(2); + let dst = crate::array::js_array_alloc(2); + unsafe { + layout_transfer(src as *mut u8, dst as *mut u8); + } +} + #[test] fn test_typed_shape_raw_numeric_slots_accept_pointer_like_f64_bits() { clear_marks(); @@ -488,6 +666,7 @@ fn test_typed_shape_descriptor_transfers_on_object_move() { ); unsafe { + model_relocation_header_copy(src as usize, dst as usize); layout_transfer(src as *mut u8, dst as *mut u8); } @@ -511,6 +690,9 @@ fn test_all_pointer_layout_transfers_on_array_move() { let src = crate::array::js_array_alloc_pointer_elements(2); let dst = crate::array::js_array_alloc(2); unsafe { + // `GC_LAYOUT_ALL_POINTERS` rides `_reserved`, so since #10362 the + // header copy is what carries it and the funnel must leave it alone. + model_relocation_header_copy(src as usize, dst as usize); layout_transfer(src as *mut u8, dst as *mut u8); } diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 706289090f..1ffdcb9893 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -17,6 +17,7 @@ mod census_block_windows; mod census_whole_block; mod concat_site; mod contract; +mod copy_slot_hoists; mod copying; mod copying_side_tables; mod cycle_state; diff --git a/crates/perry-runtime/src/gc/tests/support.rs b/crates/perry-runtime/src/gc/tests/support.rs index ec6e4409ce..bfd3a3ddd1 100644 --- a/crates/perry-runtime/src/gc/tests/support.rs +++ b/crates/perry-runtime/src/gc/tests/support.rs @@ -24,6 +24,17 @@ impl Drop for ShadowAndGlobalRootResetGuard { } } +/// Model what every relocation caller does before `layout_transfer`: make the +/// destination header a copy of the source's, which is the funnel's contract +/// (#10362, `gc/layout/transfer.rs`). A test that calls the funnel directly +/// has to do it for the same reason `move_young` and `js_array_grow` do — +/// every header-carried layout fact rides this word. +pub(super) unsafe fn model_relocation_header_copy(src_user: usize, dst_user: usize) { + let src = header_from_user_ptr(src_user as *const u8); + let dst = header_from_user_ptr(dst_user as *const u8); + (*dst)._reserved = (*src)._reserved; +} + pub(super) unsafe fn test_heap_child_slots_for_user(user_ptr: *mut u8) -> Vec { let header = header_from_user_ptr(user_ptr as *const u8); gc_child_slots(header).collect() diff --git a/crates/perry-runtime/src/lru_subclass.rs b/crates/perry-runtime/src/lru_subclass.rs index 58cb3934f7..7454627d7e 100644 --- a/crates/perry-runtime/src/lru_subclass.rs +++ b/crates/perry-runtime/src/lru_subclass.rs @@ -210,3 +210,128 @@ pub extern "C" fn js_lru_cache_subclass_init(this: f64, opts: f64) -> f64 { install_methods_on_existing_object(obj, this, &methods, &[]); this } + +/// Link-time default for the `js_lru_cache_*` ABI on MSVC, and nowhere else. +/// +/// The extern block above is satisfied by whichever cache provider the PROGRAM +/// links. A Rust binary that links perry-runtime without one still carries the +/// references, and two of them are built in CI on every Windows leg: the +/// `perry` compiler itself (`cargo build -p perry …`) and this crate's own +/// `--lib` test harness (`cargo test --lib -p perry-runtime`). Neither wants an +/// LRU cache; neither links a provider. +/// +/// On every other target that is harmless, because the linker dead-strips +/// before it reports: `ld64 -dead_strip` / `ld --gc-sections` drop the thunks +/// above out of a binary that never calls them, and the references go with +/// them. Verified on macOS — the linked `target/perry-dev/perry` contains no +/// `js_lru_cache_subclass_init` symbol at all, and the build succeeds while the +/// rlib it links still shows all seven as `U`. `link.exe` resolves symbols +/// BEFORE `/OPT:REF`, so the same inputs are 7 × LNK2019 there. +/// +/// A Cargo feature cannot express "this link has no provider". The Windows job +/// builds `-p perry -p perry-runtime-static -p perry-stdlib-static` in ONE +/// invocation, so perry-stdlib's `perry-runtime/stdlib` feature is unified onto +/// the copy of perry-runtime that the `perry` binary links — even though +/// perry-stdlib is not in that binary's link. Anything gated on `stdlib` +/// (`crate::stdlib_stubs`, an `external-*-symbols` flag) is therefore compiled +/// out in exactly the configuration that fails. +/// +/// `/ALTERNATENAME` is MSVC's spelling of a weak default: link.exe substitutes +/// the alternate only for a symbol still undefined after every input has been +/// read. A program that does link `perry_stdlib.lib` or the ext archive binds +/// the real implementation and never reaches these — so this cannot shadow a +/// provider the way an unconditional definition would. They live in this module +/// so that they share a codegen unit with the thunks whose references they +/// answer. +/// +/// `js_lru_cache_new` answering 0 is already the "no cache" path: the +/// subclass-init returns `this` without installing any method, so a `.get()` on +/// it throws `is not a function` at the call site — the same failure this +/// module deliberately chooses for `forEach`/`dispose`/`fetch`. +#[cfg(all(windows, target_env = "msvc"))] +mod msvc_absent_provider { + use crate::stub_diag::perry_stub_warn; + + const REASON: &str = + "no lru-cache provider (perry-ext-lru-cache / perry-stdlib bundled-lru-cache) \ + is linked into this binary"; + + /// Emit one `/ALTERNATENAME:=` linker directive. + macro_rules! alternatename { + ($stat:ident, $bytes:literal) => { + #[used] + #[link_section = ".drectve"] + static $stat: [u8; $bytes.len()] = *$bytes; + }; + } + + alternatename!( + D_NEW, + b" /ALTERNATENAME:js_lru_cache_new=perry_lru_cache_absent_new" + ); + alternatename!( + D_GET, + b" /ALTERNATENAME:js_lru_cache_get=perry_lru_cache_absent_get" + ); + alternatename!( + D_SET, + b" /ALTERNATENAME:js_lru_cache_set=perry_lru_cache_absent_set" + ); + alternatename!( + D_HAS, + b" /ALTERNATENAME:js_lru_cache_has=perry_lru_cache_absent_has" + ); + alternatename!( + D_DELETE, + b" /ALTERNATENAME:js_lru_cache_delete=perry_lru_cache_absent_delete" + ); + alternatename!( + D_CLEAR, + b" /ALTERNATENAME:js_lru_cache_clear=perry_lru_cache_absent_clear" + ); + alternatename!( + D_PEEK, + b" /ALTERNATENAME:js_lru_cache_peek=perry_lru_cache_absent_peek" + ); + + #[no_mangle] + pub extern "C" fn perry_lru_cache_absent_new(_options: f64) -> i64 { + perry_stub_warn("js_lru_cache_new", REASON, None); + 0 + } + + #[no_mangle] + pub extern "C" fn perry_lru_cache_absent_get(_handle: i64, _key: f64) -> f64 { + perry_stub_warn("js_lru_cache_get", REASON, None); + super::undefined_value() + } + + #[no_mangle] + pub extern "C" fn perry_lru_cache_absent_set(handle: i64, _key: f64, _value: f64) -> i64 { + perry_stub_warn("js_lru_cache_set", REASON, None); + handle + } + + #[no_mangle] + pub extern "C" fn perry_lru_cache_absent_has(_handle: i64, _key: f64) -> f64 { + perry_stub_warn("js_lru_cache_has", REASON, None); + super::bool_value(false) + } + + #[no_mangle] + pub extern "C" fn perry_lru_cache_absent_delete(_handle: i64, _key: f64) -> f64 { + perry_stub_warn("js_lru_cache_delete", REASON, None); + super::bool_value(false) + } + + #[no_mangle] + pub extern "C" fn perry_lru_cache_absent_clear(_handle: i64) { + perry_stub_warn("js_lru_cache_clear", REASON, None); + } + + #[no_mangle] + pub extern "C" fn perry_lru_cache_absent_peek(_handle: i64, _key: f64) -> f64 { + perry_stub_warn("js_lru_cache_peek", REASON, None); + super::undefined_value() + } +} diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index f0be892bae..6b215be875 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -838,10 +838,96 @@ crate::perry_thread_local! { static PENDING_REQUIRE_PARENT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } +crate::perry_thread_local! { + /// Memo for [`canonicalize_module_path`]. Module registration canonicalizes + /// one absolute path per module, and `std::fs::canonicalize` is a full + /// realpath: a `readlink` for EVERY component, every time. + /// + /// The components repeat massively. Compiling OpenCode 1.18.30 and running + /// `--version` issued 86,745 `readlink` calls over only 9,467 distinct + /// paths — 98% of every syscall the process made and 0.32s of system time. + /// `/root/.../oc` alone was resolved 7,501 times and + /// `node_modules/.bun` 5,580 times, because each of ~7,500 module paths + /// re-walked the same prefixes from the root down. + /// + /// Node caches realpath during module resolution for the same reason. The + /// memo is per path STRING, so a path that resolves once keeps its answer + /// for the life of the process; module paths are registered during startup + /// and are not expected to change underneath a running program. + static CANONICAL_MODULE_PATHS: std::cell::RefCell< + std::collections::HashMap, + > = std::cell::RefCell::new(std::collections::HashMap::new()); + /// Memo of canonicalized DIRECTORIES, which is what sibling modules share. + static CANONICAL_MODULE_DIRS: std::cell::RefCell< + std::collections::HashMap, + > = std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Canonicalize the DIRECTORY `dir`, memoized per directory. +/// +/// This is where the redundancy lives: sibling modules share every ancestor, +/// so resolving each module path independently re-walks the same prefixes +/// thousands of times. Resolving a directory once makes each additional module +/// in it cost one `readlink` for its own basename instead of one per component. +fn canonical_dir(dir: &std::path::Path) -> std::path::PathBuf { + let key = dir.to_string_lossy().into_owned(); + if let Some(hit) = CANONICAL_MODULE_DIRS.with(|memo| memo.borrow().get(&key).cloned()) { + return hit; + } + // Resolve the parent first (memoized), then this one component, so a deep + // tree costs one lookup per NEW directory rather than a full walk each time. + let resolved = match (dir.parent(), dir.file_name()) { + (Some(parent), Some(name)) if parent != dir => { + let base = canonical_dir(parent); + let joined = base.join(name); + match std::fs::read_link(&joined) { + // Not a symlink (the common case): the parent is already + // canonical, so the join is canonical too — no deeper walk. + Err(_) => joined, + // A symlink: hand it to the real resolver rather than + // re-implementing chain and relative-target semantics. + Ok(_) => std::fs::canonicalize(&joined).unwrap_or(joined), + } + } + _ => std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()), + }; + CANONICAL_MODULE_DIRS.with(|memo| { + memo.borrow_mut().insert(key, resolved.clone()); + }); + resolved +} + fn canonicalize_module_path(path: &str) -> String { - std::fs::canonicalize(path) - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|_| path.to_string()) + if let Some(hit) = CANONICAL_MODULE_PATHS.with(|memo| memo.borrow().get(path).cloned()) { + return hit; + } + let candidate = std::path::Path::new(path); + // Only take the fast route for an absolute, already-normalized path: `..` + // and `.` change what a prefix means, and `canonicalize` resolves those. + let normal = candidate.is_absolute() + && !candidate.components().any(|c| { + matches!( + c, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }); + let resolved = match (normal, candidate.parent(), candidate.file_name()) { + (true, Some(parent), Some(name)) => { + let base = canonical_dir(parent); + let joined = base.join(name); + match std::fs::read_link(&joined) { + Err(_) => joined, + Ok(_) => std::fs::canonicalize(&joined).unwrap_or(joined), + } + } + _ => std::fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf()), + } + .to_string_lossy() + .into_owned(); + CANONICAL_MODULE_PATHS.with(|memo| { + memo.borrow_mut().insert(path.to_string(), resolved.clone()); + }); + resolved } /// Codegen FFI: record that `__init` (address `init_addr`) initializes diff --git a/crates/perry-runtime/src/object/class_meta_registry.rs b/crates/perry-runtime/src/object/class_meta_registry.rs index 267055f050..eb81c7ccb2 100644 --- a/crates/perry-runtime/src/object/class_meta_registry.rs +++ b/crates/perry-runtime/src/object/class_meta_registry.rs @@ -437,4 +437,55 @@ mod dense_parent_tests { assert_eq!(fetch_parent_kind(A), None); } } + + /// Re-registering an edge that is already published must be a no-op. + /// + /// Every allocation of an inheriting class calls `register_class` + /// (`object_alloc_class_inline_keys_impl`), so a bump here is a bump per + /// `new`, and `prop_plan_epoch_bump`'s own contract says its callers are + /// "rare, cold paths by construction" — an epoch bump throws away every + /// cached store plan in the program. + #[test] + fn re_registering_the_same_edge_flushes_nothing() { + const CHILD: u32 = 60_020; + const PARENT: u32 = 60_021; + crate::object::class_registry::register_class(CHILD, PARENT); + + let epoch_after_first = crate::object::prop_plan::prop_plan_semantic_epoch(); + for _ in 0..8 { + crate::object::class_registry::register_class(CHILD, PARENT); + } + assert_eq!( + crate::object::prop_plan::prop_plan_semantic_epoch(), + epoch_after_first, + "re-registering an unchanged edge must not invalidate cached store plans" + ); + assert_eq!(get_parent_class_id(CHILD), Some(PARENT)); + } + + /// The other direction, which is what keeps the skip honest: a CHANGED + /// parent is a different chain, so it must publish and flush. + #[test] + fn re_parenting_still_publishes_and_flushes() { + const CHILD: u32 = 60_030; + const FIRST: u32 = 60_031; + const SECOND: u32 = 60_032; + crate::object::class_registry::register_class(CHILD, FIRST); + let before = crate::object::prop_plan::prop_plan_semantic_epoch(); + + crate::object::class_registry::register_class(CHILD, SECOND); + + assert_ne!( + crate::object::prop_plan::prop_plan_semantic_epoch(), + before, + "a re-parent changes what the chain intercepts and must flush plans" + ); + assert_eq!(get_parent_class_id(CHILD), Some(SECOND)); + let map = CLASS_REGISTRY.read().unwrap(); + assert_eq!( + map.as_ref().and_then(|m| m.get(&CHILD).copied()), + Some(SECOND), + "the authoritative map must carry the new edge too" + ); + } } diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index 188b092518..e193539c0f 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -5,6 +5,31 @@ use std::sync::atomic::Ordering; /// Register a class with its parent class ID in the global registry pub(crate) fn register_class(class_id: u32, parent_class_id: u32) { + // Re-registering an edge that is ALREADY registered with this same parent + // changes nothing: the chain a reader walks is identical, so there is no + // cached store plan to flush and no entry to publish. + // + // Every allocation of an inheriting class arrives here — + // `object_alloc_class_inline_keys_impl` calls `register_class` whenever + // `parent_class_id != 0`, and codegen ALSO emits one + // `js_register_class_parent` per inheriting class in the init prelude, so + // by the time user code allocates, the edge is always already there. The + // work being skipped is a process-global `prop_plan` epoch bump (which + // invalidates every cached store plan in the program) plus a write lock on + // `CLASS_REGISTRY` and a map insert, per `new`. Measured on `new Sub()` + // where `Sub extends Base`: 993 -> 901 instructions per allocation, and + // 1,197 -> 1,105 for a two-level chain. The epoch bump's own cost is not in + // those numbers: it is paid by every store site whose cached plan it threw + // away, which a microbenchmark that allocates and nothing else cannot see. + // + // The read is the same dense indexed load every parent-chain walk uses; an + // in-window child answers without touching the map at all. A genuinely new + // or CHANGED edge falls through to the full publication below, so + // re-parenting still flushes. + if crate::object::class_meta_registry::get_parent_class_id(class_id) == Some(parent_class_id) { + return; + } + // Parent linking changes what a class chain can intercept — flush cached // store plans (`object::prop_plan`). crate::object::prop_plan::prop_plan_epoch_bump(); diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 9ff187b390..f6bbc8b8a7 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -213,6 +213,7 @@ mod get_field_by_name_async; mod get_field_by_name_probe_tests; mod get_field_by_name_tail; mod has_property; +mod has_property_ic; mod ic_miss; #[cfg(test)] #[path = "field_get_set/ic_miss_array_length_tests.rs"] @@ -291,6 +292,7 @@ pub(crate) use has_property::{ wide_key_index_lookup, wide_key_index_note_hit, WIDE_KEY_INDEX_MIN_KEYS, }; pub use has_property::{js_in_operator, js_object_has_property}; +pub use has_property_ic::js_in_operator_presence_ic; pub(crate) use ic_miss::{ bind_primitive_proto_method_static, cannot_be_private_member_name, current_private_lexical_brand_value, is_array_method_value_name, diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 0a0be3f051..47455bf4dd 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -1126,8 +1126,12 @@ unsafe fn ordinary_has_property( // fallback below must be skipped — the recorded chain (walked above) is now // authoritative, so a key that was deleted/replaced off the prototype must // not be resurrected from the original class vtable. - let has_recorded_prototype = - super::super::prototype_chain::object_static_prototype(obj_ptr as usize).is_some(); + // Asked at the class-vtable fallback below, which is the ONLY consumer and + // is reached only after the whole chain walk has missed. Resolving it here + // spent a shape/registry probe on every call, including the common one that + // finds an own key on the first hop and returns from the loop. Nothing in + // the walk can change the recorded prototype (it runs no user code), so + // asking later is the same answer. let mut cur = obj_ptr; let mut last_valid = obj_ptr; let mut guard = 0u32; @@ -1289,7 +1293,7 @@ unsafe fn ordinary_has_property( // `keys_array`, so the own-key + recorded-prototype walk above misses them. // Check the class chain so `'method' in instance` is `true` (e.g. NestJS's // app Proxy gating on `'listen' in receiver`). - if !has_recorded_prototype { + if super::super::prototype_chain::object_static_prototype(obj_ptr as usize).is_none() { if let Some(name) = key_name { let class_id = unsafe { (*obj_ptr).class_id }; if class_id != 0 diff --git a/crates/perry-runtime/src/object/field_get_set/has_property_ic.rs b/crates/perry-runtime/src/object/field_get_set/has_property_ic.rs new file mode 100644 index 0000000000..7451702d33 --- /dev/null +++ b/crates/perry-runtime/src/object/field_get_set/has_property_ic.rs @@ -0,0 +1,194 @@ +//! Presence inline cache for `"k" in o` with a **constant** key. +//! +//! `in` has no cache slot today: every `"k" in o` is a bare `js_in_operator` +//! call that re-derives the receiver's keys array from its ShapeId (a shape +//! slab probe) and re-scans it, ~950 instructions for a hit on a plain object. +//! The answer it recomputes is a property of the *shape*, not of the object: +//! two objects with the same ShapeId have the same keys array, so "shape S has +//! own key K" is stable for as long as S is stamped on the receiver. +//! +//! So the site caches exactly that — one ShapeId — and the emitted guard +//! answers `true` when the receiver still carries it. Everything else calls +//! [`js_in_operator_presence_ic`], which computes the real answer and may arm +//! the site. +//! +//! # Why only positives, and why no prototype epoch is needed +//! +//! The cached claim is about an **own** key, so it does not mention the +//! prototype chain: `Object.setPrototypeOf`, a late `Proto.x = 1`, a +//! `delete Proto.x` — none of them can make an own key stop existing, so none +//! of them can invalidate a positive. A *negative* would be a claim about the +//! whole chain, and there is no prototype-chain epoch in this runtime to key +//! one on (`prototype_chain` records per-object replacements; nothing counts +//! chain mutations globally), so negatives are not cached at all — `"zz" in o` +//! keeps calling the runtime every time. +//! +//! # What invalidates a positive +//! +//! Only losing the key, and every way of losing it moves the receiver off the +//! cached ShapeId or off the guard: +//! +//! * A compacting `delete` rebuilds the keys array and publishes a new +//! ShapeId — the stamp at header offset 4 no longer matches. +//! * A tombstoning `delete` (#9064) deliberately KEEPS the ShapeId and leaves +//! `TAG_HOLE` in the slot, but it sets `OBJ_FLAG_STABLE_TOMBSTONES` on the +//! receiver in the same step; the emitted guard rejects that bit, so such a +//! receiver never takes the inline answer again. +//! * Adding a key is a transition to a different ShapeId, which can only turn +//! a hit into a miss (`true` stays `true` for the cached key anyway). +//! * A moved receiver reads its own header, so evacuation is invisible here; +//! a forwarded one is rejected by the guard's `GC_FLAG_FORWARDED` test. +//! +//! ShapeIds are allocated monotonically from a process-global counter and are +//! never reused (`shapes::SHAPE_ID_NEXT`), so a stale stamp can only miss. The +//! id range (`0x8000_0000..0xC000_0000`) is disjoint from every class id, so +//! the guard's compare against a header word that still holds +//! `parent_class_id` — an object that was never shape-stamped — cannot alias +//! an armed id either. +//! +//! The cache holds two integers and never a pointer, so it is not a GC root +//! (contrast the caches enumerated by `scripts/gc_runtime_root_holders.py`). + +use super::*; + +/// The words the emitted site reads. Word 0 is the only one the inline guard +/// touches; the arena hands out zeroed memory, and `shape == 0` is "unarmed". +#[repr(C)] +pub struct InPresenceCache { + /// Armed ShapeId, widened to the guard's compare width. 0 until the site + /// primes. + shape: u64, + /// Arming attempts spent at this site. Bounds the work a site that can + /// never arm (an inherited hit, a proxy receiver) or one that thrashes + /// between shapes pays on every call. + attempts: u64, +} + +/// Arming attempts a site gets before it stops trying. A monomorphic own-key +/// site spends exactly one; a site whose `true` comes from the prototype chain +/// spends this many and then costs one load and one compare per call forever. +const IN_PRESENCE_ATTEMPT_BUDGET: u64 = 8; + +/// `"k" in o` for a site that owns a presence-cache slot. +/// +/// Answers exactly what [`js_in_operator`] answers — including its TypeError +/// on a non-object right operand — and then, for a `true` that came from an +/// own string key on an ordinary receiver, records the receiver's ShapeId so +/// the site's inline guard can answer the next one without a call. +/// +/// # Safety +/// `slot` is the address of the site's `@perry_ic_N` global (or null): a live, +/// pointer-sized location holding null or a cache from the IC arena. +#[no_mangle] +pub unsafe extern "C" fn js_in_operator_presence_ic( + obj: f64, + key: f64, + slot: *mut *mut InPresenceCache, +) -> f64 { + const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; + let answer = js_in_operator(obj, key); + if answer.to_bits() != TAG_TRUE { + // Absent, or a `false` from a trap: nothing positive to record, and a + // negative is not cacheable (see the module header). + return answer; + } + let cache = crate::object::pic_slot_resolve(slot); + if cache.is_null() { + return answer; + } + if (*cache).attempts >= IN_PRESENCE_ATTEMPT_BUDGET { + return answer; + } + (*cache).attempts += 1; + if let Some(shape) = armable_own_key_shape(obj, key) { + (*cache).shape = u64::from(shape); + } + answer +} + +/// The receiver's ShapeId, when a site may answer `true` for `key` from it +/// alone: an ordinary, shape-stamped, descriptor-free, tombstone-free heap +/// object whose keys array holds `key` as an own entry. +/// +/// Every rejection here is a receiver whose `in` answer is decided by +/// something the ShapeId does not capture — a proxy trap, a native-module +/// dispatch table, a RegExp expando, an accessor side table, an internal +/// runtime key, or a hole left by a tombstoning delete. +unsafe fn armable_own_key_shape(obj: f64, key: f64) -> Option { + let obj_val = JSValue::from_bits(obj.to_bits()); + let key_val = JSValue::from_bits(key.to_bits()); + if !obj_val.is_pointer() || !key_val.is_any_string() { + return None; + } + let addr = (obj_val.bits() & crate::value::POINTER_MASK) as usize; + let header = crate::value::addr_class::try_read_gc_header(addr)?; + if header.obj_type != crate::gc::GC_TYPE_OBJECT + || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + || header._reserved + & (crate::gc::OBJ_FLAG_HAS_DESCRIPTORS | crate::gc::OBJ_FLAG_STABLE_TOMBSTONES) + != 0 + { + return None; + } + // RegExp cells are OBJECT-typed but answer `in` through the exotic expando + // registry, exactly as `js_object_has_property`'s own fast path documents. + if super::super::exotic_expando::exotic_expando_kind(addr).is_some() { + return None; + } + let obj_ptr = addr as *const ObjectHeader; + // Native-module namespaces (console, fs, …) expose VIRTUAL keys that never + // live in `keys_array`, and their answer is the vtable's, not the shape's. + if (*obj_ptr).class_id == NATIVE_MODULE_CLASS_ID { + return None; + } + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let key_bytes = crate::string::js_string_key_bytes(key_val, &mut sso)?; + // A compiler-private storage key is invisible to [[HasProperty]] even + // though it sits in `keys_array`. + if super::is_internal_runtime_key_bytes(key_bytes) { + return None; + } + let shape = super::super::shapes::object_shape_stamp(obj_ptr); + if shape == 0 { + return None; + } + let keys = crate::object::object_keys_array(obj_ptr); + match crate::value::addr_class::try_read_gc_header(keys as usize) { + Some(h) if h.obj_type == crate::gc::GC_TYPE_ARRAY => {} + _ => return None, + } + let key_count = crate::array::js_array_length(keys); + super::super::keys_lookup::keys_find_slot_by_bytes(keys, key_count, key_bytes)?; + Some(shape) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The emitted guard compares a zero-extended `i32` header word against + /// word 0, so an armed id must be unrepresentable as a class id — else an + /// object that was never shape-stamped could alias one. + #[test] + fn armed_ids_cannot_alias_a_class_id() { + assert!(!super::super::super::shapes::is_shape_id(0)); + assert!(!super::super::super::shapes::is_shape_id(1)); + assert!(!super::super::super::shapes::is_shape_id(0x7FFF_FFFF)); + assert!(!super::super::super::shapes::is_shape_id(0xFFFF_0000)); + assert!(super::super::super::shapes::is_shape_id(0x8000_0000)); + } + + /// Word 0 is what the emitted guard loads; word 1 must not move under it. + #[test] + fn cache_layout_matches_the_emitted_guard() { + assert_eq!(std::mem::size_of::(), 16); + assert_eq!(std::mem::offset_of!(InPresenceCache, shape), 0); + assert_eq!(std::mem::offset_of!(InPresenceCache, attempts), 8); + } + + /// A site that can never arm must stop paying for the attempt. + #[test] + fn the_attempt_budget_is_small_and_nonzero() { + assert!(IN_PRESENCE_ATTEMPT_BUDGET > 0 && IN_PRESENCE_ATTEMPT_BUDGET <= 16); + } +} diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index a688ed8f8e..6030dd9601 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -1122,19 +1122,33 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { // prototype chain contains BaseClass.prototype. Only pay // for the spec prototype walk when the candidate class's // declaration prototype has a user-selected parent. - let candidate_proto = super::class_registry::class_decl_prototype_object(cur); - let target_proto = super::class_registry::class_decl_prototype_object(class_id); - if !candidate_proto.is_null() - && !target_proto.is_null() - && super::prototype_chain::object_has_user_prototype_override( - candidate_proto as usize, - ) - && ordinary_has_instance_prototype_walk( - value, - super::class_constructor_ref_value(class_id), - ) - { - return true_val; + // The two `class_decl_prototype_object` probes are class + // registry reads (TLS + RwLock + map, ~130 instructions + // each) and they ran EAGERLY on every call that got this + // far — which is every MISS, the path this whole ladder + // exists to answer `false` on. They exist only to ask a + // question whose answer is `false` for every receiver in a + // process that never re-points an object's prototype, and + // the latch answers that for the whole process in one + // load. Set, never cleared, and published before the flag + // it guards, so it can only ever be conservatively true. + if super::prototype_chain::any_user_prototype_override() { + let candidate_proto = + super::class_registry::class_decl_prototype_object(cur); + let target_proto = + super::class_registry::class_decl_prototype_object(class_id); + if !candidate_proto.is_null() + && !target_proto.is_null() + && super::prototype_chain::object_has_user_prototype_override( + candidate_proto as usize, + ) + && ordinary_has_instance_prototype_walk( + value, + super::class_constructor_ref_value(class_id), + ) + { + return true_val; + } } } } diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index d5469546d4..c61ab25901 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -132,6 +132,34 @@ pub(crate) fn test_resolution_stack_enter_and_forget(owner: usize) -> bool { /// an object — the overwhelmingly common case. static OBJECT_PROTOTYPES_NONEMPTY: AtomicBool = AtomicBool::new(false); +/// Latched true by the first `OBJECT_META_FLAG_USER_PROTO_OVERRIDE` a receiver +/// is ever given — i.e. the first `Object.setPrototypeOf` / `util.inherits` +/// that re-points a live object's `[[Prototype]]` away from its class default. +/// +/// The flag lives on the receiver's meta record, so asking "does this object +/// have one?" costs two dependent loads — but only after the caller has +/// already found the object. `instanceof`'s `util.inherits` escape hatch has +/// to look up TWO class declaration prototypes through the class registry +/// before it can ask, and that pair of registry probes was the single largest +/// cost of a `o instanceof C` MISS (~130 instructions each, on a path whose +/// whole budget was 669). This latch answers for the entire process in one +/// relaxed-acquire load. +/// +/// Conservative by construction: it is set, never cleared, and it is stored +/// BEFORE the flag it guards (same discipline as [`OBJECT_PROTOTYPES_NONEMPTY`] +/// above), so any reader that could observe the flag already observes the +/// latch. A false positive costs a probe pair; a false negative is impossible. +static USER_PROTO_OVERRIDE_EVER: AtomicBool = AtomicBool::new(false); + +/// Has any object in this process ever been given a user `[[Prototype]]` +/// override? A `false` proves `object_has_user_prototype_override` would +/// answer `false` for every receiver, so a caller may skip whatever work it +/// would need to do to ask. +#[inline] +pub(crate) fn any_user_prototype_override() -> bool { + USER_PROTO_OVERRIDE_EVER.load(Ordering::Acquire) +} + fn get_object_prototypes() -> &'static Mutex> { OBJECT_PROTOTYPES.get_or_init(|| Mutex::new(HashMap::new())) } @@ -258,6 +286,9 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: (*meta).flags |= crate::object::OBJECT_META_FLAG_PROTO_DIVERGED; } if user_override { + // Latch BEFORE the flag: a reader that observes the flag must + // already observe the latch (see `USER_PROTO_OVERRIDE_EVER`). + USER_PROTO_OVERRIDE_EVER.store(true, Ordering::Release); (*meta).flags |= crate::object::OBJECT_META_FLAG_USER_PROTO_OVERRIDE; } if link_kind == PrototypeLinkKind::ClassEvaluation { @@ -469,6 +500,17 @@ pub(crate) fn prune_dead_object_prototype_owners(is_dead_owner: &dyn Fn(usize) - } } +/// Can the residual owner registry hold an entry at all? +/// +/// The latch is stored (`Release`) before the first insert, so `false` proves +/// the registry empty — the same proof [`object_static_prototype_owner_moved`] +/// makes on entry, exposed so the relocation funnel +/// (`gc/layout/transfer.rs`) can decide without the call (#10362). +#[inline] +pub(crate) fn object_static_prototypes_maybe_nonempty() -> bool { + OBJECT_PROTOTYPES_NONEMPTY.load(Ordering::Acquire) +} + /// Migrate the residual side-table entry when an owner's allocation address /// changes, either through moving GC or an `ArrayHeader` growth replacement. /// Mirrors `closure_dynamic_props_owner_moved`. diff --git a/crates/perry-runtime/src/param_type_guard.rs b/crates/perry-runtime/src/param_type_guard.rs index 260b124cb6..1fe0df8457 100644 --- a/crates/perry-runtime/src/param_type_guard.rs +++ b/crates/perry-runtime/src/param_type_guard.rs @@ -72,6 +72,21 @@ const OP_STRING_LITERAL: u8 = 13; const OP_RECURSIVE_REF: u8 = 14; const OP_MAP: u8 = 15; const OP_SET: u8 = 16; +/// A class parameter proved NOMINALLY: exact class identity plus the +/// per-object typed-layout-intact bit, with no field-by-name walk. +/// +/// Emitted only when every field on the class's inheritance chain is declared +/// `number`, i.e. every one is a raw-f64 candidate. For those fields the pair +/// (class chain reaches C, intact bit set) already implies the value fact the +/// walk would establish — "slot K holds a plain double" — so walking them by +/// name re-derives what the header already states. Measured at ~326 +/// instructions per field walked, so a 3-field class pays ~1_000 per call for +/// a fact two loads can settle. +/// +/// A class with any non-`number` field keeps `OP_OBJECT`: the intact bit says +/// a string field's slot is in the pointer mask, which is NOT "it holds a +/// string", and a clone that inlines `s.length` trusts exactly that. +const OP_CLASS_NOMINAL: u8 = 17; fn read_u16(bytes: &[u8], offset: usize) -> Option { Some(u16::from_le_bytes( @@ -640,6 +655,31 @@ impl GuardState<'_> { } valid && cursor == node.len() } + OP_CLASS_NOMINAL => { + let Some(class_id) = read_u32(node, 1) else { + return false; + }; + if node.len() != 5 || class_id == 0 { + return false; + } + let Some((object, address, _)) = self.plain_object(value) else { + return false; + }; + if !crate::object::class_chain_reaches((*object).class_id, class_id) { + return false; + } + // The value half. Without it this node would claim only + // identity, and `(p as any).x = "s"` on a real instance keeps + // the class id while retiring the raw-f64 layout. + // + // Read straight off the header rather than through a helper in + // `gc/layout.rs`: that file sits one line under the 2000-line + // cap, and `plain_object` has already proved this address + // carries a readable Gc header. Fails closed if it does not. + crate::value::addr_class::try_read_gc_header(address).is_some_and(|header| { + header._reserved & crate::gc::GC_OBJ_TYPED_LAYOUT_INTACT != 0 + }) + } OP_UNION => { let Some(count) = read_u32(node, 1).map(|value| value as usize) else { return false; @@ -806,6 +846,59 @@ mod tests { ) } + fn class_nominal_node(class_id: u32) -> Vec { + let mut body = vec![OP_CLASS_NOMINAL]; + body.extend_from_slice(&class_id.to_le_bytes()); + body + } + + /// The nominal node trades the field walk for two header facts, so both + /// have to be load-bearing. A plain object carries class id 0 and reaches + /// no class, which is also the `Object.create(C.prototype)` and + /// same-shaped-literal case: the walk would have ACCEPTED a literal whose + /// fields all happen to be numbers, and the nominal node must not. + #[test] + fn a_nominal_class_node_rejects_everything_that_is_not_that_class() { + let node = class_nominal_node(4242); + let (_, literal) = + plain_object(&[(b"x", JSValue::number(1.0)), (b"y", JSValue::number(2.0))]); + assert_eq!( + guard(literal, &one_node(&node)), + 0, + "a same-shaped plain object reaches no class id" + ); + assert_eq!(guard(JSValue::number(1.0), &one_node(&node)), 0); + assert_eq!(guard(JSValue::undefined(), &one_node(&node)), 0); + assert_eq!(guard(JSValue::null(), &one_node(&node)), 0); + assert_eq!(guard(JSValue::bool(true), &one_node(&node)), 0); + } + + /// Class id 0 means "structural" for `OP_OBJECT`, where it is a legal + /// wildcard. A nominal node has nothing BUT identity, so a 0 there would + /// be a node that accepts every object with an intact layout. Fail closed. + #[test] + fn a_nominal_node_without_a_class_id_fails_closed() { + let (_, literal) = plain_object(&[(b"x", JSValue::number(1.0))]); + assert_eq!(guard(literal, &one_node(&class_nominal_node(0))), 0); + } + + /// Truncated or over-long bodies must not read past the node. + #[test] + fn a_malformed_nominal_node_fails_closed() { + let (_, literal) = plain_object(&[(b"x", JSValue::number(1.0))]); + for body in [vec![OP_CLASS_NOMINAL], vec![OP_CLASS_NOMINAL, 1, 0], { + let mut long = class_nominal_node(7); + long.push(0); + long + }] { + assert_eq!( + guard(literal, &one_node(&body)), + 0, + "malformed nominal node accepted: {body:?}" + ); + } + } + fn object_node(class_id: u32, fields: &[(bool, &[u8], u32)]) -> Vec { let mut body = vec![OP_OBJECT]; body.extend_from_slice(&class_id.to_le_bytes()); diff --git a/crates/perry-runtime/src/string/char_ops.rs b/crates/perry-runtime/src/string/char_ops.rs index 9fdf19e7c3..c807f477a4 100644 --- a/crates/perry-runtime/src/string/char_ops.rs +++ b/crates/perry-runtime/src/string/char_ops.rs @@ -125,6 +125,13 @@ pub extern "C" fn js_string_index_get_boxed(value: f64, key: f64) -> f64 { const UNDEFINED: f64 = f64::from_bits(crate::value::TAG_UNDEFINED); let jsval = crate::value::JSValue::from_bits(value.to_bits()); if jsval.is_short_string() { + // Reading one character out of an ASCII short string needs neither the + // heap nor a handle scope: the bytes are in the value. Without this, + // every `s[i]` on a short string allocated a `StringHeader` for the + // receiver just to index it. + if let Some(character) = short_string_index_get(jsval, key) { + return character; + } let scope = crate::gc::RuntimeHandleScope::new(); let key = scope.root_nanbox_f64(key); let hdr = crate::string::js_string_materialize_to_heap(value); @@ -220,37 +227,79 @@ pub extern "C" fn js_string_index_get(s: *const StringHeader, key: f64) -> f64 { } } let len = unsafe { (*s).utf16_len } as u64; - let jsval = crate::value::JSValue::from_bits(key.to_bits()); - let idx: u64 = if jsval.is_int32() { - let i = jsval.as_int32(); - if i < 0 { - return UNDEFINED; - } - i as u64 - } else if jsval.is_number() { - // Real double: only a finite, non-negative integer is an array index. - if !key.is_finite() || key < 0.0 || key.fract() != 0.0 { - return UNDEFINED; - } - key as u64 // saturating; an out-of-range magnitude fails the bound below - } else if jsval.is_any_string() { - match crate::builtins::jsvalue_string_content(key).and_then(|k| canonical_string_index(&k)) - { - Some(i) => i, - None => return UNDEFINED, - } - } else { - return UNDEFINED; + let idx = match canonical_index_of(key) { + Some(idx) => idx, + None => return UNDEFINED, }; if idx >= len { return UNDEFINED; } + // An ASCII receiver's character is one byte, which is exactly a + // short-string value: pack it here instead of routing through + // `js_string_char_at` -> `ascii_char_string`, whose canonical table costs a + // thread-local lookup and hands back a heap `StringHeader` that the caller + // immediately NaN-boxes. Same value either way — a short string and a heap + // string with the same bytes compare equal everywhere (`is_any_string` + + // `string_bytes` decode both) — and two equal characters now share one bit + // pattern rather than one pointer. + if is_ascii_string(s) { + let byte = unsafe { *string_data(s).add(idx as usize) }; + debug_assert!(byte < 0x80, "utf16_len == byte_len proves one-byte units"); + return f64::from_bits(crate::value::JSValue::short_string_unchecked(&[byte]).bits()); + } let ptr = js_string_char_at(s, idx as i32); crate::value::js_nanbox_string(ptr as i64) } +/// The index `s[key]` names, per `CanonicalNumericIndexString`, or `None` when +/// the key is not an index at all (`s.length`, `s["01"]`, a symbol, …) and the +/// caller must continue to the ordinary property lookup. +fn canonical_index_of(key: f64) -> Option { + let jsval = crate::value::JSValue::from_bits(key.to_bits()); + if jsval.is_int32() { + let i = jsval.as_int32(); + return if i < 0 { None } else { Some(i as u64) }; + } + if jsval.is_number() { + // Real double: only a finite, non-negative integer is an array index. + if !key.is_finite() || key < 0.0 || key.fract() != 0.0 { + return None; + } + // Saturating; an out-of-range magnitude fails the caller's bound check. + return Some(key as u64); + } + if jsval.is_any_string() { + return crate::builtins::jsvalue_string_content(key) + .and_then(|k| canonical_string_index(&k)); + } + None +} + +/// `s[i]` on a short-string receiver of pure ASCII: the answer is one of its +/// own packed bytes, so neither the receiver nor the result needs to reach the +/// heap. `None` falls through to the general path, which owns every other case +/// (non-index keys, out-of-range indices, non-ASCII payloads). +fn short_string_index_get(jsval: crate::value::JSValue, key: f64) -> Option { + let mut buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let len = jsval.short_string_to_buf(&mut buf); + let bytes = &buf[..len]; + if !bytes.is_ascii() { + // A multi-byte payload makes the byte index and the UTF-16 index + // disagree; the heap path resolves those. + return None; + } + let idx = canonical_index_of(key)?; + if idx >= len as u64 { + return None; + } + let byte = bytes[idx as usize]; + Some(f64::from_bits( + crate::value::JSValue::short_string_unchecked(&[byte]).bits(), + )) +} + /// Parse a property-key string into a canonical array index per /// `CanonicalNumericIndexString`: the string must equal the exact `ToString` of /// the resulting non-negative integer, so `"0"`→0 and `"12"`→12 are canonical diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 4b7b5773e7..ef136ddf02 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -2548,6 +2548,14 @@ pub extern "C" fn js_typed_feedback_numeric_array_push_guard( value: f64, ) -> i32 { let raw_addr = normalize_raw_object_addr(receiver.to_bits()); + // #5094's gate, which this guard never got. With recording off (the + // default) `guard_observe` hands back `contract_valid` untouched, so the + // push index lookup, the `classify_array` walk and the observation are all + // dead work on every `a.push(v)`. Every sibling array guard already gates + // here; this one was the last hot one that did not. + if !typed_feedback_enabled() { + return numeric_array_push_guard(raw_addr as *const ArrayHeader, value) as i32; + } let push_index = match gc_header_for_user_addr(raw_addr) { Some(header) if unsafe { (*header).obj_type == crate::gc::GC_TYPE_ARRAY } => unsafe { (*(raw_addr as *const ArrayHeader)).length @@ -2793,6 +2801,18 @@ pub extern "C" fn js_typed_feedback_object_set_unboxed_f64_field( key: *const crate::StringHeader, value: f64, ) { + // #5094's gate. `object_shape` resolves the receiver's shape and `key_hash` + // hashes the key purely to fill an `Observation` that `guard_observe` + // discards while recording is off. + if !typed_feedback_enabled() { + if object_key_matches_field(obj, key, field_index) && is_plain_number_bits(value.to_bits()) + { + crate::object::js_object_set_field(obj, field_index, crate::JSValue::number(value)); + } else { + crate::object::js_object_set_field_by_name(obj, key, value); + } + return; + } let object_addr = normalize_raw_object_addr(obj as u64); let (shape_addr, class_id, gc_type) = object_shape(object_addr); let observation = Observation { @@ -2826,6 +2846,12 @@ pub extern "C" fn js_typed_feedback_object_set_unboxed_f64_field( #[no_mangle] pub extern "C" fn js_typed_feedback_observe_helper_return(site_id: u64, value: f64) -> f64 { + // #5094's gate. The contract is unconditionally valid here, so with + // recording off this wrapper is the identity function and `helper_return_facts` + // (which resolves a shape for a pointer payload) is pure dead work. + if !typed_feedback_enabled() { + return value; + } let bits = value.to_bits(); let (shape_addr, class_id, heap_type, aux, value_kind) = helper_return_facts(bits); let observation = Observation { diff --git a/crates/perry-ui-windows-winui/src/widgets.rs b/crates/perry-ui-windows-winui/src/widgets.rs index a12669f6ed..965345f584 100644 --- a/crates/perry-ui-windows-winui/src/widgets.rs +++ b/crates/perry-ui-windows-winui/src/widgets.rs @@ -610,6 +610,35 @@ pub fn add_child_at(parent: i64, child: i64, index: i64) { }); } +/// Move an existing child without changing its native window or layout +/// metadata. The Win32 backend owns the widget list whenever Fluent +/// rendering is off, so this mirrors `perry_ui_windows::widgets::reorder_child` +/// exactly — including its out-of-range / no-op guards. Needed here because +/// `ffi/widget_layout_extras.rs` is `#[path]`-shared with perry-ui-windows and +/// resolves `widgets::` against THIS module. +pub fn reorder_child(parent: i64, from_index: i64, to_index: i64) { + // Win32 rejects a non-positive parent outright, and so must this: the + // Fluent arm reaches its node through `handle.saturating_sub(1)`, which + // would turn handle 0 into node 0 and reorder the wrong subtree. + if parent <= 0 { + return; + } + if !is_fluent() { + perry_ui_windows::widgets::reorder_child(parent, from_index, to_index); + return; + } + with_node_mut(parent, |node| { + let from = from_index as usize; + let to = to_index as usize; + let len = node.common.children.len(); + if from >= len || to >= len || from == to { + return; + } + let child = node.common.children.remove(from); + node.common.children.insert(to, child); + }); +} + pub fn remove_child(parent: i64, child: i64) { if !is_fluent() { perry_ui_windows::widgets::remove_child(parent, child); diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 749d68732e..c2c7b7d504 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -5,7 +5,7 @@ "", "An entry that matches no such holder FAILS the gate. That is deliberate: it is what", "makes a fix delete its own entry, and it is why 'covered_elsewhere' is a verdict rather", - "than a suppression — if the scanner that covers it is ever deleted, the holder stays", + "than a suppression \u2014 if the scanner that covers it is ever deleted, the holder stays", "uncovered, the entry stays matched, and nothing tells you. Read the named scanner if you", "touch it.", "", @@ -50,7 +50,7 @@ "file": "crates/perry-ext-http/src/lib.rs", "name": "HTTP_PENDING_EVENTS", "verdict": "not_a_gc_pointer", - "why": "Client-side pending-event queue. Every variant carries a perry-ffi registry Handle, strings, Bytes, or an errno i64 (rule S fired on TransportError.errno) — no NaN-boxed value. The closures the drain fires live in ClientRequestHandle (response_callback/end_callback/pending_write_callbacks/listeners), which scan_http_roots visits." + "why": "Client-side pending-event queue. Every variant carries a perry-ffi registry Handle, strings, Bytes, or an errno i64 (rule S fired on TransportError.errno) \u2014 no NaN-boxed value. The closures the drain fires live in ClientRequestHandle (response_callback/end_callback/pending_write_callbacks/listeners), which scan_http_roots visits." }, { "file": "crates/perry-ext-http/src/server/https_server.rs", @@ -92,13 +92,13 @@ "file": "crates/perry-ext-net/src/bun_tcp.rs", "name": "TOKENS", "verdict": "not_a_gc_pointer", - "why": "write_tokens(): HashMap. Each i64 is the same handle-band id ABORTS holds — the key used to look the socket up in crate::statics::sockets(), never a heap address. Entries are removed on write completion (bun_tcp.rs) and retained-out when a handle closes, so no id outlives its facade." + "why": "write_tokens(): HashMap. Each i64 is the same handle-band id ABORTS holds \u2014 the key used to look the socket up in crate::statics::sockets(), never a heap address. Entries are removed on write completion (bun_tcp.rs) and retained-out when a handle closes, so no id outlives its facade." }, { "file": "crates/perry-ext-net/src/lib.rs", "name": "P", "verdict": "not_a_gc_pointer", - "why": "pending_events(): Vec; every variant carries socket/server ids, Bytes, String, bool, or DropInfo (SocketAddrs) — no closures or NaN-boxed values. Listener closures live in listeners(), visited by scan_net_roots." + "why": "pending_events(): Vec; every variant carries socket/server ids, Bytes, String, bool, or DropInfo (SocketAddrs) \u2014 no closures or NaN-boxed values. Listener closures live in listeners(), visited by scan_net_roots." }, { "file": "crates/perry-ext-net/src/server_state.rs", @@ -140,7 +140,7 @@ "file": "crates/perry-runtime/src/arena/alloc_sample.rs", "name": "UNTIL", "verdict": "not_a_gc_pointer", - "why": "#9794 allocation-site sampling: bytes remaining until the next sample. A `Cell` countdown, decremented per allocation and reset on fire — a quantity, never an address." + "why": "#9794 allocation-site sampling: bytes remaining until the next sample. A `Cell` countdown, decremented per allocation and reset on fire \u2014 a quantity, never an address." }, { "file": "crates/perry-runtime/src/arena/from_space.rs", @@ -194,7 +194,7 @@ "file": "crates/perry-runtime/src/box.rs", "name": "BOX_YOUNG_ROOTS", "verdict": "covered_elsewhere", - "why": "#9976: the minor remembered set for box roots — a YoungLog of box addresses whose payload may matter to a minor. Every address in it is also in the box REGISTRY, which the module's own doc calls the authoritative full/major root set and which `scan_box_roots_mut` walks. The log is an accelerator over that set, not an independent holder: an address dropped from it is still reached through the registry.", + "why": "#9976: the minor remembered set for box roots \u2014 a YoungLog of box addresses whose payload may matter to a minor. Every address in it is also in the box REGISTRY, which the module's own doc calls the authoritative full/major root set and which `scan_box_roots_mut` walks. The log is an accelerator over that set, not an independent holder: an address dropped from it is still reached through the registry.", "scanner": "box::scan_box_roots_mut (crates/perry-runtime/src/box.rs), registered by reg_scanner! in crates/perry-runtime/src/gc/mod.rs" }, { @@ -235,7 +235,7 @@ "file": "crates/perry-runtime/src/closure/alloc.rs", "name": "CAPTURED_MISS_STREAK", "verdict": "not_a_gc_pointer", - "why": "Keyed by the closure's func_ptr — a CODE address, which the collector neither moves nor traces; the value is a miss-streak count." + "why": "Keyed by the closure's func_ptr \u2014 a CODE address, which the collector neither moves nor traces; the value is a miss-streak count." }, { "file": "crates/perry-runtime/src/closure/alloc.rs", @@ -259,7 +259,7 @@ "file": "crates/perry-runtime/src/closure/registry.rs", "name": "BODY_RECORD_LOOKUPS", "verdict": "test_only", - "why": "#10127's call/apply rest-dispatch cache counts how often a dispatch resolves through the body-record lookup instead of the cache, so the tests can assert the cache is actually taken rather than that nothing threw. It is a `Cell` count inside a `#[cfg(test)]` `std::thread_local!` block — absent from production builds, and it never stores an address. Its sibling RESOLVE_STRATEGY_SLOW_CALLS in the same block is pinned on the frontier as debt; this one takes a researched verdict instead, which is the stronger record." + "why": "#10127's call/apply rest-dispatch cache counts how often a dispatch resolves through the body-record lookup instead of the cache, so the tests can assert the cache is actually taken rather than that nothing threw. It is a `Cell` count inside a `#[cfg(test)]` `std::thread_local!` block \u2014 absent from production builds, and it never stores an address. Its sibling RESOLVE_STRATEGY_SLOW_CALLS in the same block is pinned on the frontier as debt; this one takes a researched verdict instead, which is the stronger record." }, { "file": "crates/perry-runtime/src/closure/registry.rs", @@ -271,7 +271,7 @@ "file": "crates/perry-runtime/src/closure/registry.rs", "name": "TRUSTED_TARGETS", "verdict": "not_a_gc_pointer", - "why": "#9707: dense side array of `TrustedTargets`, two `Option` per eligible arrow. TrustedDirectTarget{func_ptr:*const u8, capture_count:u32, boxed_capture_mask:u64} is a static code pointer plus plain integers — the same payload the deleted CLOSURE_ARROW_FUNCTION_REGISTRY / CLOSURE_VERSIONED_LOOP_REGISTRY held under this verdict. Nothing for the collector." + "why": "#9707: dense side array of `TrustedTargets`, two `Option` per eligible arrow. TrustedDirectTarget{func_ptr:*const u8, capture_count:u32, boxed_capture_mask:u64} is a static code pointer plus plain integers \u2014 the same payload the deleted CLOSURE_ARROW_FUNCTION_REGISTRY / CLOSURE_VERSIONED_LOOP_REGISTRY held under this verdict. Nothing for the collector." }, { "file": "crates/perry-runtime/src/fs/deferred.rs", @@ -283,7 +283,7 @@ "file": "crates/perry-runtime/src/fs/dir_glob_watch/watch_backend.rs", "name": "QUEUE", "verdict": "not_a_gc_pointer", - "why": "#9613: per-JS-thread Arc of fs.watch RawEvents — PathBufs, EventClass tags and WatchError strings from the OS backends. Pure Rust data; no NaN-boxed values or heap object pointers ever enter the queue (events are converted to JS values only in the pump drain, under a fresh handle scope)." + "why": "#9613: per-JS-thread Arc of fs.watch RawEvents \u2014 PathBufs, EventClass tags and WatchError strings from the OS backends. Pure Rust data; no NaN-boxed values or heap object pointers ever enter the queue (events are converted to JS values only in the pump drain, under a fresh handle scope)." }, { "file": "crates/perry-runtime/src/fs/dir_glob_watch/watch_backend.rs", @@ -313,7 +313,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize — INSIDE the window — the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes — in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -330,7 +330,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "5c151725460ffb92a55a6bee781123ef5159263b4ce5958d16570f78216e0d67", "crates/perry-runtime/src/gc/cycle.rs": "b035dcb44df029358cbab0afaa526e8e506765f5178034663257e18ceefaf9df", - "crates/perry-runtime/src/gc/mod.rs": "9fedd2790f48154aaeceefb4805d3fbaa2fdf3c407529b326425fde86c2bf9a5", + "crates/perry-runtime/src/gc/mod.rs": "d401b22ffd6b7423bc4709153e888f79b88ac1c33aa776edee04bc7d3f5aca84", "crates/perry-runtime/src/gc/policy.rs": "895c6f4bd1a6e491adf348ecfa89985b03e354fcee7cf73826bb590f9ace9163", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } @@ -394,13 +394,13 @@ "file": "crates/perry-runtime/src/gc/layout_tables.rs", "name": "LAYOUT_RESIDUE_HISTOGRAM_ENTRIES", "verdict": "not_a_gc_pointer", - "why": "#9976: how many entries the surviving per-object layout-mask residue histogram holds — a `Cell` COUNT for the diagnostic, never an address or a NaN-boxed value." + "why": "#9976: how many entries the surviving per-object layout-mask residue histogram holds \u2014 a `Cell` COUNT for the diagnostic, never an address or a NaN-boxed value." }, { "file": "crates/perry-runtime/src/gc/oldgen_defrag.rs", "name": "LAST_IDLE_PREDICTED_RELEASE", "verdict": "not_a_gc_pointer", - "why": "#9772: releasable block BYTES the last idle selection promised — a size, not an address. A `Cell` compared against what the collection actually released." + "why": "#9772: releasable block BYTES the last idle selection promised \u2014 a size, not an address. A `Cell` compared against what the collection actually released." }, { "file": "crates/perry-runtime/src/gc/policy.rs", @@ -430,7 +430,7 @@ "file": "crates/perry-runtime/src/gc/policy.rs", "name": "GC_YOUNG_LEAF_BORN_OLD", "verdict": "not_a_gc_pointer", - "why": "#10169: a `Cell` scheduling flag — set when a document-sized JSON result was born old under young pressure, consumed by the next `gc_budgeted_due_trigger` decision. Holds no address of any kind." + "why": "#10169: a `Cell` scheduling flag \u2014 set when a document-sized JSON result was born old under young pressure, consumed by the next `gc_budgeted_due_trigger` decision. Holds no address of any kind." }, { "file": "crates/perry-runtime/src/gc/promoted_cohort.rs", @@ -502,7 +502,7 @@ "file": "crates/perry-runtime/src/gc/trace.rs", "name": "FORWARDED_STUB_MEMBERSHIP_RECOVERIES", "verdict": "not_a_gc_pointer", - "why": "#9717: monotonic count of array-growth forwarding stubs a budgeted full cycle admitted through `classifier_valid_object_start`, reported as `forwarded_stub_recoveries=` on the PERRY_GC_DIAG `[gc-incremental]` line. A `Cell` holding a tally, never an address — the stubs it counts are reached through the worklist, not retained here. Nothing for the collector." + "why": "#9717: monotonic count of array-growth forwarding stubs a budgeted full cycle admitted through `classifier_valid_object_start`, reported as `forwarded_stub_recoveries=` on the PERRY_GC_DIAG `[gc-incremental]` line. A `Cell` holding a tally, never an address \u2014 the stubs it counts are reached through the worklist, not retained here. Nothing for the collector." }, { "file": "crates/perry-runtime/src/gc/trace/adopt_census.rs", @@ -562,49 +562,49 @@ "file": "crates/perry-runtime/src/hot_diag.rs", "name": "ENUM_DIAG", "verdict": "not_a_gc_pointer", - "why": "#9823 for-in diagnostics (`PERRY_ENUM_DIAG`), off unless armed. Two `Instant`s and a set of u64 counters (`for_in_calls`, `for_in_primitive`, `for_in_levels`, `for_in_key_arrays`, …). No field stores an address." + "why": "#9823 for-in diagnostics (`PERRY_ENUM_DIAG`), off unless armed. Two `Instant`s and a set of u64 counters (`for_in_calls`, `for_in_primitive`, `for_in_levels`, `for_in_key_arrays`, \u2026). No field stores an address." }, { "file": "crates/perry-runtime/src/hot_diag.rs", "name": "IC_DIAG", "verdict": "not_a_gc_pointer", - "why": "Inline-cache miss diagnostics (`PERRY_IC_DIAG`). `IcDiag` is two `Instant`s, three counters, and `sites: HashMap` whose KEY is a PIC cache-slot address — malloc'd arena storage from `field_get_set/ic_slot.rs`, never GC heap — and whose value is a `String` plus counters. Nothing here is a managed pointer." + "why": "Inline-cache miss diagnostics (`PERRY_IC_DIAG`). `IcDiag` is two `Instant`s, three counters, and `sites: HashMap` whose KEY is a PIC cache-slot address \u2014 malloc'd arena storage from `field_get_set/ic_slot.rs`, never GC heap \u2014 and whose value is a `String` plus counters. Nothing here is a managed pointer." }, { "file": "crates/perry-runtime/src/intl/segments_view.rs", "name": "DECLINE_EMPTY", "verdict": "not_a_gc_pointer", - "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode — how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." }, { "file": "crates/perry-runtime/src/intl/segments_view.rs", "name": "DECLINE_NOT_GRAPHEME", "verdict": "not_a_gc_pointer", - "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode — how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." }, { "file": "crates/perry-runtime/src/intl/segments_view.rs", "name": "DECLINE_NOT_SEGMENTER", "verdict": "not_a_gc_pointer", - "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode — how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." }, { "file": "crates/perry-runtime/src/intl/segments_view.rs", "name": "DECLINE_NOT_UTF8", "verdict": "not_a_gc_pointer", - "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode — how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." }, { "file": "crates/perry-runtime/src/intl/segments_view.rs", "name": "DECLINE_SEGMENT_PATCHED", "verdict": "not_a_gc_pointer", - "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode — how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." }, { "file": "crates/perry-runtime/src/intl/segments_view.rs", "name": "MATERIALISE_SEGMENT", "verdict": "not_a_gc_pointer", - "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode — how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." + "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode \u2014 how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." }, { "file": "crates/perry-runtime/src/json/mod.rs", @@ -666,8 +666,8 @@ "count": 1, "classification": "not_a_gc_pointer", "verdict": "not_a_gc_pointer", - "why": "Per-Map compaction log for the epoch-based for-of/iterator cursor rebase: keyed by MapHeader ADDRESS (identity only, never dereferenced), values are VecDeque<{epoch: u32, removed: Prefix(u32) | Indices(Vec)}> — raw entry indices, no heap references, so no collector edge originates here. Correctness across evacuation is maintained by map_header_moved_for_gc, which re-keys the entry when a header moves; js_map_alloc drops any stale entry for a reused address; prune_dead_map_compaction_log_owners is registered in gc/dead_owner.rs (table MAP_COMPACTION_LOG) so a dead Map's history is dropped.", - "reason": "Per-Map compaction log for the epoch-based for-of/iterator cursor rebase: keyed by MapHeader ADDRESS (identity only, never dereferenced), values are VecDeque<{epoch: u32, removed: Prefix(u32) | Indices(Vec)}> — raw entry indices, no heap references, so no collector edge originates here. Correctness across evacuation is maintained by map_header_moved_for_gc, which re-keys the entry when a header moves; js_map_alloc drops any stale entry for a reused address; prune_dead_map_compaction_log_owners is registered in gc/dead_owner.rs (table MAP_COMPACTION_LOG) so a dead Map's history is dropped." + "why": "Per-Map compaction log for the epoch-based for-of/iterator cursor rebase: keyed by MapHeader ADDRESS (identity only, never dereferenced), values are VecDeque<{epoch: u32, removed: Prefix(u32) | Indices(Vec)}> \u2014 raw entry indices, no heap references, so no collector edge originates here. Correctness across evacuation is maintained by map_header_moved_for_gc, which re-keys the entry when a header moves; js_map_alloc drops any stale entry for a reused address; prune_dead_map_compaction_log_owners is registered in gc/dead_owner.rs (table MAP_COMPACTION_LOG) so a dead Map's history is dropped.", + "reason": "Per-Map compaction log for the epoch-based for-of/iterator cursor rebase: keyed by MapHeader ADDRESS (identity only, never dereferenced), values are VecDeque<{epoch: u32, removed: Prefix(u32) | Indices(Vec)}> \u2014 raw entry indices, no heap references, so no collector edge originates here. Correctness across evacuation is maintained by map_header_moved_for_gc, which re-keys the entry when a header moves; js_map_alloc drops any stale entry for a reused address; prune_dead_map_compaction_log_owners is registered in gc/dead_owner.rs (table MAP_COMPACTION_LOG) so a dead Map's history is dropped." }, { "file": "crates/perry-runtime/src/map.rs", @@ -675,8 +675,20 @@ "count": 1, "classification": "not_a_gc_pointer", "verdict": "not_a_gc_pointer", - "why": "Holds Set/Map header ADDRESSES as identity keys for in-flight forEach walks (#9082); the values are only ever compared (`contains`, `pop` equality) and truncated, never dereferenced, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc / map_header_moved_for_gc, which rewrite every matching entry when a header moves — see the SET_FOREACH_STACK/MAP_FOREACH_STACK rewrite loops there.", - "reason": "Holds Set/Map header ADDRESSES as identity keys for in-flight forEach walks (#9082); the values are only ever compared (`contains`, `pop` equality) and truncated, never dereferenced, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc / map_header_moved_for_gc, which rewrite every matching entry when a header moves — see the SET_FOREACH_STACK/MAP_FOREACH_STACK rewrite loops there." + "why": "Holds Set/Map header ADDRESSES as identity keys for in-flight forEach walks (#9082); the values are only ever compared (`contains`, `pop` equality) and truncated, never dereferenced, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc / map_header_moved_for_gc, which rewrite every matching entry when a header moves \u2014 see the SET_FOREACH_STACK/MAP_FOREACH_STACK rewrite loops there.", + "reason": "Holds Set/Map header ADDRESSES as identity keys for in-flight forEach walks (#9082); the values are only ever compared (`contains`, `pop` equality) and truncated, never dereferenced, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc / map_header_moved_for_gc, which rewrite every matching entry when a header moves \u2014 see the SET_FOREACH_STACK/MAP_FOREACH_STACK rewrite loops there." + }, + { + "file": "crates/perry-runtime/src/module_require.rs", + "name": "CANONICAL_MODULE_DIRS", + "verdict": "not_a_gc_pointer", + "why": "Memo of canonicalized module DIRECTORIES (module_require.rs:861). Key is an owned Rust `String`, value an owned `std::path::PathBuf`; both are plain filesystem text with no JSValue, NaN-boxed word, or arena pointer, so it holds no GC root." + }, + { + "file": "crates/perry-runtime/src/module_require.rs", + "name": "CANONICAL_MODULE_PATHS", + "verdict": "not_a_gc_pointer", + "why": "Memo of canonicalized module path STRINGS (module_require.rs:857). Key and value are both owned Rust `String`; the map never stores a JSValue, a NaN-boxed word, or a raw pointer into the arena, so there is nothing for a scanner to mark or rewrite. Populated during module resolution from `Path`/`OsStr` text only." }, { "file": "crates/perry-runtime/src/net.rs", @@ -739,14 +751,14 @@ "file": "crates/perry-runtime/src/node_submodules/diagnostics_tail.rs", "name": "DIAG_STORE_SCOPES", "verdict": "not_a_gc_pointer", - "why": "DiagStoreScopeState.handles are store KEYS produced by store_handle() (diagnostics_tail.rs:72), which admits only an INT32-tagged value, a POINTER_TAG value inside the handle band (raw < 0x10000), or a finite float — a real heap pointer returns None, so one cannot be stored here by construction. The store CONTEXT objects live in DIAG_CHANNELS[*].stores, which scan_node_submodule_singleton_roots_mut visits." + "why": "DiagStoreScopeState.handles are store KEYS produced by store_handle() (diagnostics_tail.rs:72), which admits only an INT32-tagged value, a POINTER_TAG value inside the handle band (raw < 0x10000), or a finite float \u2014 a real heap pointer returns None, so one cannot be stored here by construction. The store CONTEXT objects live in DIAG_CHANNELS[*].stores, which scan_node_submodule_singleton_roots_mut visits." }, { "file": "crates/perry-runtime/src/node_vm.rs", "name": "VM_INTRINSIC_GLOBAL", "verdict": "covered_elsewhere", "scanner": "gc::roots::visit_global_root_slots, reached by js_gc_register_global_root (gc/roots.rs:325 pushes the slot into GLOBAL_ROOTS; gc/roots.rs:1433 hands it to the mutable-root walk)", - "why": "Caches the shared VM intrinsic realm's globalThis as NaN-boxed bits. The single site that writes the cell — fresh_intrinsic_global, node_vm.rs:1080 — calls js_gc_register_global_root(slot.as_ptr()) in the same `with` closure, immediately after the store and with no allocation in between; the early return on a non-zero cell means that store happens at most once per thread, so there is no path that populates the cell without registering it. GLOBAL_ROOTS is thread_local, exactly like the cell, and visit_mutable_root_slots feeds it to BOTH the marker and the post-evacuation rewrite (gc/tests/copying.rs:1272, test_copying_minor_rewrites_shadow_and_global_roots), so the cached pointer is marked and forwarded rather than left stale." + "why": "Caches the shared VM intrinsic realm's globalThis as NaN-boxed bits. The single site that writes the cell \u2014 fresh_intrinsic_global, node_vm.rs:1080 \u2014 calls js_gc_register_global_root(slot.as_ptr()) in the same `with` closure, immediately after the store and with no allocation in between; the early return on a non-zero cell means that store happens at most once per thread, so there is no path that populates the cell without registering it. GLOBAL_ROOTS is thread_local, exactly like the cell, and visit_mutable_root_slots feeds it to BOTH the marker and the post-evacuation rewrite (gc/tests/copying.rs:1272, test_copying_minor_rewrites_shadow_and_global_roots), so the cached pointer is marked and forwarded rather than left stale." }, { "file": "crates/perry-runtime/src/object/class_registry/state.rs", @@ -759,7 +771,7 @@ "name": "CLASS_OBJECT_VALUES", "verdict": "covered_elsewhere", "scanner": "object::scan_class_side_table_roots_mut and its budgeted step twin (class_registry/gc_roots.rs:138 and :256)", - "why": "The class side tables are declared in state.rs and scanned from gc_roots.rs. Both twins visit it — #7239 diffed all eight budgeted (FULL, STEP) pairs and found no drift." + "why": "The class side tables are declared in state.rs and scanned from gc_roots.rs. Both twins visit it \u2014 #7239 diffed all eight budgeted (FULL, STEP) pairs and found no drift." }, { "file": "crates/perry-runtime/src/object/class_registry/state.rs", @@ -772,7 +784,7 @@ "file": "crates/perry-runtime/src/object/class_registry/state.rs", "name": "CLASS_STATIC_PROTOTYPE_NULLED", "verdict": "not_a_gc_pointer", - "why": "Set of class ids whose constructor [[Prototype]] was explicitly set to null, so Object.getPrototypeOf answers null rather than the default Function.prototype. Stores u32 class ids only — no heap address, nothing to trace or forward." + "why": "Set of class ids whose constructor [[Prototype]] was explicitly set to null, so Object.getPrototypeOf answers null rather than the default Function.prototype. Stores u32 class ids only \u2014 no heap address, nothing to trace or forward." }, { "file": "crates/perry-runtime/src/object/class_registry/state.rs", @@ -784,7 +796,7 @@ "file": "crates/perry-runtime/src/object/descriptor_state.rs", "name": "TEST_SUPPRESS_DESCRIPTOR_YOUNG_NOTE", "verdict": "not_a_gc_pointer", - "why": "#9976: the descriptor-table twin of TEST_SUPPRESS_CLOSURE_YOUNG_NOTE — a `Cell` test seam, not a pointer." + "why": "#9976: the descriptor-table twin of TEST_SUPPRESS_CLOSURE_YOUNG_NOTE \u2014 a `Cell` test seam, not a pointer." }, { "file": "crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs", @@ -802,14 +814,14 @@ "file": "crates/perry-runtime/src/object/global_this/fetch_globals.rs", "name": "THREAD_GLOBAL_THIS", "verdict": "covered_elsewhere", - "scanner": "gc::roots GLOBAL_ROOTS — the cell's address is registered with js_gc_register_global_root (fetch_globals.rs, js_get_global_this) and marked+rewritten as a mutable global root", + "scanner": "gc::roots GLOBAL_ROOTS \u2014 the cell's address is registered with js_gc_register_global_root (fetch_globals.rs, js_get_global_this) and marked+rewritten as a mutable global root", "why": "A raw-pointer cache slot registered as a global root at first population; the registration is a call, not a scanner body, so the walk cannot see it." }, { "file": "crates/perry-runtime/src/object/global_this/fetch_globals.rs", "name": "THREAD_MODULE_TOP_THIS", "verdict": "covered_elsewhere", - "scanner": "gc::roots GLOBAL_ROOTS — the cell's address is registered with js_gc_register_global_root (fetch_globals.rs, js_module_top_this)", + "scanner": "gc::roots GLOBAL_ROOTS \u2014 the cell's address is registered with js_gc_register_global_root (fetch_globals.rs, js_module_top_this)", "why": "Same shape as THREAD_GLOBAL_THIS: a NaN-boxed cache slot registered as a mutable global root at first population." }, { @@ -822,7 +834,7 @@ "file": "crates/perry-runtime/src/object/mod.rs", "name": "TRANSITION_CACHE_YOUNG", "verdict": "not_a_gc_pointer", - "why": "#9754 remembered set: `YoungLog` = two `Vec` (live + recycled spare) holding transition-cache SLOT INDICES whose `key_ptr`/`next_keys` a minor may act on. Indices, not addresses — a `u32` cannot hold a 48-bit pointer. The pointer-bearing entries they index are visited by `scan_transition_cache_roots_mut`, which is registered." + "why": "#9754 remembered set: `YoungLog` = two `Vec` (live + recycled spare) holding transition-cache SLOT INDICES whose `key_ptr`/`next_keys` a minor may act on. Indices, not addresses \u2014 a `u32` cannot hold a 48-bit pointer. The pointer-bearing entries they index are visited by `scan_transition_cache_roots_mut`, which is registered." }, { "file": "crates/perry-runtime/src/object/native_get.rs", @@ -840,7 +852,7 @@ "file": "crates/perry-runtime/src/object/read_stub.rs", "name": "READ_STUB", "verdict": "not_a_gc_pointer", - "why": "Megamorphic property-read stub cache, the read twin of WRITE_STUB: 2-way ways of (shape_token, key_bits, slot) plain u64s. The token is a shape id and the slot an index; key_bits are content-derived by construction, because read_stub_key_bits returns short_ascii_sso_bits(key) — the key's characters packed inline — and yields None for any key that would otherwise be stored under a pointer. No way holds a heap address, so nothing here keeps an object alive, and a stale entry cannot hit: receiver_shape_token returns None for a receiver with no live shape, and the token identifies the exact key set and order, so a shape change yields a different token." + "why": "Megamorphic property-read stub cache, the read twin of WRITE_STUB: 2-way ways of (shape_token, key_bits, slot) plain u64s. The token is a shape id and the slot an index; key_bits are content-derived by construction, because read_stub_key_bits returns short_ascii_sso_bits(key) \u2014 the key's characters packed inline \u2014 and yields None for any key that would otherwise be stored under a pointer. No way holds a heap address, so nothing here keeps an object alive, and a stale entry cannot hit: receiver_shape_token returns None for a receiver with no live shape, and the token identifies the exact key set and order, so a shape change yields a different token." }, { "file": "crates/perry-runtime/src/object/regex_proto_thunks.rs", @@ -852,13 +864,13 @@ "file": "crates/perry-runtime/src/object/shapes.rs", "name": "SHAPE_YOUNG_LOG_SUPPRESSED", "verdict": "not_a_gc_pointer", - "why": "#9976: the shape-table twin of the same test seam — a `Cell` that forces the full shape walk instead of the young-log path. A flag, not a pointer." + "why": "#9976: the shape-table twin of the same test seam \u2014 a `Cell` that forces the full shape walk instead of the young-log path. A flag, not a pointer." }, { "file": "crates/perry-runtime/src/object/shapes_store.rs", "name": "ID_LIST_OP_STATS", "verdict": "not_a_gc_pointer", - "why": "#9881: the IdList operation tally that measures the swap-remove win — `removals`, `elems_moved`, `positions_scanned`, three plain `u64` counts in a `Cell`. It holds NUMBERS, never an address or a NaN-boxed value: written only by the `IdList` remove/scan paths incrementing them and read only by the diagnostic that reports the memmove volume, so there is no slot for the collector to mark or rewrite." + "why": "#9881: the IdList operation tally that measures the swap-remove win \u2014 `removals`, `elems_moved`, `positions_scanned`, three plain `u64` counts in a `Cell`. It holds NUMBERS, never an address or a NaN-boxed value: written only by the `IdList` remove/scan paths incrementing them and read only by the diagnostic that reports the memmove volume, so there is no slot for the collector to mark or rewrite." }, { "file": "crates/perry-runtime/src/os/os_process_emitter.rs", @@ -897,7 +909,7 @@ "name": "PROCESS_FINALIZATION_BEFORE_EXIT_LISTENER", "verdict": "covered_elsewhere", "scanner": "process::scan_process_finalization_roots_mut (process/finalization.rs:171; visits the cell at :184-189 via visit_raw_const_ptr_slot; reg_scanner! at gc/mod.rs:1008)", - "why": "Declared in process.rs, scanned from the process/finalization.rs submodule — same file split as the MODULE_LOADER_* siblings." + "why": "Declared in process.rs, scanned from the process/finalization.rs submodule \u2014 same file split as the MODULE_LOADER_* siblings." }, { "file": "crates/perry-runtime/src/promise/microtasks.rs", @@ -1027,8 +1039,8 @@ "count": 1, "classification": "not_a_gc_pointer", "verdict": "not_a_gc_pointer", - "why": "Per-Set compaction log for the epoch-based for-of/iterator cursor rebase: keyed by SetHeader ADDRESS (identity only, never dereferenced), values are VecDeque<{epoch: u32, removed: Prefix(u32) | Indices(Vec)}> — raw element indices, no heap references, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc, which re-keys the entry when a header moves; js_set_alloc drops any stale entry for a reused address; prune_dead_set_compaction_log_owners is registered in gc/dead_owner.rs (table SET_COMPACTION_LOG).", - "reason": "Per-Set compaction log for the epoch-based for-of/iterator cursor rebase: keyed by SetHeader ADDRESS (identity only, never dereferenced), values are VecDeque<{epoch: u32, removed: Prefix(u32) | Indices(Vec)}> — raw element indices, no heap references, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc, which re-keys the entry when a header moves; js_set_alloc drops any stale entry for a reused address; prune_dead_set_compaction_log_owners is registered in gc/dead_owner.rs (table SET_COMPACTION_LOG)." + "why": "Per-Set compaction log for the epoch-based for-of/iterator cursor rebase: keyed by SetHeader ADDRESS (identity only, never dereferenced), values are VecDeque<{epoch: u32, removed: Prefix(u32) | Indices(Vec)}> \u2014 raw element indices, no heap references, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc, which re-keys the entry when a header moves; js_set_alloc drops any stale entry for a reused address; prune_dead_set_compaction_log_owners is registered in gc/dead_owner.rs (table SET_COMPACTION_LOG).", + "reason": "Per-Set compaction log for the epoch-based for-of/iterator cursor rebase: keyed by SetHeader ADDRESS (identity only, never dereferenced), values are VecDeque<{epoch: u32, removed: Prefix(u32) | Indices(Vec)}> \u2014 raw element indices, no heap references, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc, which re-keys the entry when a header moves; js_set_alloc drops any stale entry for a reused address; prune_dead_set_compaction_log_owners is registered in gc/dead_owner.rs (table SET_COMPACTION_LOG)." }, { "file": "crates/perry-runtime/src/set.rs", @@ -1036,14 +1048,14 @@ "count": 1, "classification": "not_a_gc_pointer", "verdict": "not_a_gc_pointer", - "why": "Holds Set/Map header ADDRESSES as identity keys for in-flight forEach walks (#9082); the values are only ever compared (`contains`, `pop` equality) and truncated, never dereferenced, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc / map_header_moved_for_gc, which rewrite every matching entry when a header moves — see the SET_FOREACH_STACK/MAP_FOREACH_STACK rewrite loops there.", - "reason": "Holds Set/Map header ADDRESSES as identity keys for in-flight forEach walks (#9082); the values are only ever compared (`contains`, `pop` equality) and truncated, never dereferenced, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc / map_header_moved_for_gc, which rewrite every matching entry when a header moves — see the SET_FOREACH_STACK/MAP_FOREACH_STACK rewrite loops there." + "why": "Holds Set/Map header ADDRESSES as identity keys for in-flight forEach walks (#9082); the values are only ever compared (`contains`, `pop` equality) and truncated, never dereferenced, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc / map_header_moved_for_gc, which rewrite every matching entry when a header moves \u2014 see the SET_FOREACH_STACK/MAP_FOREACH_STACK rewrite loops there.", + "reason": "Holds Set/Map header ADDRESSES as identity keys for in-flight forEach walks (#9082); the values are only ever compared (`contains`, `pop` equality) and truncated, never dereferenced, so no collector edge originates here. Correctness across evacuation is maintained by set_header_moved_for_gc / map_header_moved_for_gc, which rewrite every matching entry when a header moves \u2014 see the SET_FOREACH_STACK/MAP_FOREACH_STACK rewrite loops there." }, { "file": "crates/perry-runtime/src/set.rs", "name": "SET_INDEX", "verdict": "not_a_gc_pointer", - "why": "Derived cache of the Set's own elements array, which is the canonical scanned storage (GcRewriteDescriptorKind::Set element-slot walk). Not root-scanner-shaped: the outer address key is rekeyed on relocation by GcMoveHookKind::SetSideTables -> set_header_moved_for_gc (set.rs:352), the JSValueKey inner keys are rebuilt post-rewrite by GcRewriteHookKind::SetIndex -> rebuild_set_index_for_gc (set.rs:315, fired from gc/copying.rs:669/798, gc/barrier/mod.rs:195, gc/verify.rs:114), and finalize_set_side_allocation_for_gc (set.rs:374) prunes dead owners — the same three-hook design as MAP_INDEX." + "why": "Derived cache of the Set's own elements array, which is the canonical scanned storage (GcRewriteDescriptorKind::Set element-slot walk). Not root-scanner-shaped: the outer address key is rekeyed on relocation by GcMoveHookKind::SetSideTables -> set_header_moved_for_gc (set.rs:352), the JSValueKey inner keys are rebuilt post-rewrite by GcRewriteHookKind::SetIndex -> rebuild_set_index_for_gc (set.rs:315, fired from gc/copying.rs:669/798, gc/barrier/mod.rs:195, gc/verify.rs:114), and finalize_set_side_allocation_for_gc (set.rs:374) prunes dead owners \u2014 the same three-hook design as MAP_INDEX." }, { "file": "crates/perry-runtime/src/string/char_ops/utf16_index.rs", @@ -1055,7 +1067,7 @@ "file": "crates/perry-runtime/src/string/concat.rs", "name": "CONCAT_MEMO_TAGS", "verdict": "not_a_gc_pointer", - "why": "#9391 admission doorkeeper: one hash tag byte per CONCAT_MEMO slot, so a concat result must be observed twice before it earns a rooted entry. A [u8; 512] of plain bytes derived from a splitmix64-finalized hash — never an address, so the collector never sees a pointer here. The admitted strings live in CONCAT_MEMO, which scan_concat_memo_roots_mut visits." + "why": "#9391 admission doorkeeper: one hash tag byte per CONCAT_MEMO slot, so a concat result must be observed twice before it earns a rooted entry. A [u8; 512] of plain bytes derived from a splitmix64-finalized hash \u2014 never an address, so the collector never sees a pointer here. The admitted strings live in CONCAT_MEMO, which scan_concat_memo_roots_mut visits." }, { "file": "crates/perry-runtime/src/string/format.rs", @@ -1073,7 +1085,7 @@ "file": "crates/perry-runtime/src/symbol/properties.rs", "name": "CACHED", "verdict": "not_a_gc_pointer", - "why": "Memoizes the sym_key of the Symbol.for('NextInternalRequestMeta') REGISTERED symbol. Registered / well-known symbols are Box::leak'd (symbol.rs's SYMBOL_REGISTRY / WELL_KNOWN_SYMBOLS), so they live outside the GC arena and their addresses are stable for the process. A FRESH Symbol() would not be — see #7246." + "why": "Memoizes the sym_key of the Symbol.for('NextInternalRequestMeta') REGISTERED symbol. Registered / well-known symbols are Box::leak'd (symbol.rs's SYMBOL_REGISTRY / WELL_KNOWN_SYMBOLS), so they live outside the GC arena and their addresses are stable for the process. A FRESH Symbol() would not be \u2014 see #7246." }, { "file": "crates/perry-runtime/src/text.rs", @@ -2270,7 +2282,7 @@ "file": "crates/perry-ui-windows-winui/src/app.rs", "name": "APPS", "verdict": "not_a_gc_pointer", - "why": "AppState holds a Rust-owned title String, the two f64 window dimensions, an i64 WIDGET handle (root: a 1-based index into widgets::NODES, not an address), two Option<(f64, f64)> size constraints and a PresenterKind enum — no NaN-boxed JavaScript value, so rule S fired on the f64/i64 fields rather than on a heap pointer. This module's real callback roots (ON_ACTIVATE / ON_TERMINATE / PENDING_TIMERS, each a raw closure pointer unboxed by js_nanbox_get_pointer) are visited by scan_winui_app_gc_roots." + "why": "AppState holds a Rust-owned title String, the two f64 window dimensions, an i64 WIDGET handle (root: a 1-based index into widgets::NODES, not an address), two Option<(f64, f64)> size constraints and a PresenterKind enum \u2014 no NaN-boxed JavaScript value, so rule S fired on the f64/i64 fields rather than on a heap pointer. This module's real callback roots (ON_ACTIVATE / ON_TERMINATE / PENDING_TIMERS, each a raw closure pointer unboxed by js_nanbox_get_pointer) are visited by scan_winui_app_gc_roots." }, { "file": "crates/perry-ui-windows/src/app.rs", @@ -2507,7 +2519,7 @@ "why": "Window-root registry maps numeric window handles to numeric root-widget handles; neither value is a JavaScript heap pointer." } ], - "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core raw/Perry TLS declarations (see the census docstring, “The identity-pinned frontier”). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", + "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core raw/Perry TLS declarations (see the census docstring, \u201cThe identity-pinned frontier\u201d). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", "frontier": [ { "file": "crates/perry-runtime/src/array/element_shape.rs", diff --git a/test-files/_helpers/new_globalthis_shadowed_10359.ts b/test-files/_helpers/new_globalthis_shadowed_10359.ts new file mode 100644 index 0000000000..3479c1d8c5 --- /dev/null +++ b/test-files/_helpers/new_globalthis_shadowed_10359.ts @@ -0,0 +1,21 @@ +// Helper for test_gap_new_globalthis_shadowed_10359.ts — user classes that +// deliberately share their names with global constructors, so importing them +// shadows the bare names in the test module. +export class Event { + readonly tag = "user-Event"; +} +export class Request { + readonly tag = "user-Request"; +} +export class MessageChannel { + readonly tag = "user-MessageChannel"; +} +export class Map { + readonly tag = "user-Map"; +} +export class Int32Array { + readonly tag = "user-Int32Array"; +} +export class ReadableStream { + readonly tag = "user-ReadableStream"; +} diff --git a/test-files/test_gap_array_subclass_pop_plan_cache.ts b/test-files/test_gap_array_subclass_pop_plan_cache.ts new file mode 100644 index 0000000000..e57ae97816 --- /dev/null +++ b/test-files/test_gap_array_subclass_pop_plan_cache.ts @@ -0,0 +1,88 @@ +// Popping from an Array subclass retires that receiver's packed-numeric proof +// on the first pop and nothing afterwards. The flush of the process-global +// store-plan cache is now conditional on actually retiring one, so this pins +// that stores through those plans still see every property change. + +class NumList extends Array { + tag: string; + constructor(tag: string) { + super(); + this.tag = tag; + } +} + +class Holder { + a: number; + b: number; + c: number; + constructor() { + this.a = 0; + this.b = 0; + this.c = 0; + } +} + +const list = new NumList("l1"); +for (let i = 0; i < 64; i++) list.push(i); +const h = new Holder(); + +// Interleave pops with stores through the same plans. +let acc = 0; +for (let i = 0; i < 32; i++) { + const v = list.pop() as number; + h.a = v; + h.b = v * 2; + h.c = h.a + h.b; + acc += h.c; +} +console.log("interleaved", acc, list.length, h.a, h.b, h.c, list.tag); + +// A property added to the prototype mid-loop MUST be seen by later stores: +// this is what the flush protects, so it must still work. +const proto = Object.getPrototypeOf(h) as any; +let setterSeen = 0; +Object.defineProperty(proto, "d", { + set(v: number) { + setterSeen += v; + }, + get() { + return setterSeen; + }, + configurable: true, +}); +for (let i = 0; i < 8; i++) { + list.pop(); + (h as any).d = i; +} +console.log("setter-after-pop", setterSeen, (h as any).d, list.length); + +// Freezing after pops is honored too (non-strict: silent no-op). +const h2 = new Holder(); +h2.a = 5; +list.pop(); +Object.freeze(h2); +try { + h2.a = 99; +} catch (e) { + console.log("threw", (e as Error).constructor.name); +} +console.log("frozen", h2.a, Object.isFrozen(h2)); + +// Mixed element kinds retire the numeric proof; pops must still be correct. +const mixed = new NumList("mixed"); +mixed.push(1); +mixed.push(2); +(mixed as any).push("three"); +mixed.push(4); +const popped: unknown[] = []; +for (let i = 0; i < 4; i++) popped.push(mixed.pop()); +console.log("mixed", JSON.stringify(popped), mixed.length, mixed.tag); + +// Subclass identity survives the whole sequence. +console.log( + "identity", + list instanceof NumList, + list instanceof Array, + Array.isArray(list), + list.length, +); diff --git a/test-files/test_gap_in_operator_presence.ts b/test-files/test_gap_in_operator_presence.ts new file mode 100644 index 0000000000..1f013ca180 --- /dev/null +++ b/test-files/test_gap_in_operator_presence.ts @@ -0,0 +1,107 @@ +// `in` presence across the receivers whose keys do not live in an ordinary +// keys array, and across the mutations that must change the answer: the walk +// resolves a recorded prototype only at its class-vtable fallback, so every +// one of these has to keep answering what Node answers. + +class Base { + baseField: number; + constructor() { + this.baseField = 1; + } + baseMethod(): number { + return 1; + } +} +class Derived extends Base { + ownField: string; + constructor() { + super(); + this.ownField = "x"; + } + derivedMethod(): number { + return 2; + } +} + +const d = new Derived(); +console.log( + "class", + "ownField" in d, + "baseField" in d, + "derivedMethod" in d, + "baseMethod" in d, + "toString" in d, + "nope" in d, +); + +// A plain object: own, inherited, absent, and index-like keys. +const plain: any = { a: 1, b: undefined }; +console.log("plain", "a" in plain, "b" in plain, "c" in plain, "toString" in plain, "0" in plain); + +// delete must flip presence, and re-adding must flip it back. +console.log("delete", "a" in plain, delete plain.a, "a" in plain, ((plain.a = 9), "a" in plain)); + +// A wide object crosses the keys-index threshold. +const wide: any = {}; +for (let i = 0; i < 40; i++) wide["k" + i] = i; +console.log("wide", "k0" in wide, "k39" in wide, "k40" in wide, delete wide.k39, "k39" in wide); + +// Object.setPrototypeOf records a prototype: presence must follow the new +// chain, and stop following the old one. +const protoA: any = { onA: 1 }; +const protoB: any = { onB: 2 }; +const movable: any = Object.create(protoA); +console.log("proto-a", "onA" in movable, "onB" in movable); +Object.setPrototypeOf(movable, protoB); +console.log("proto-b", "onA" in movable, "onB" in movable); +Object.setPrototypeOf(movable, null); +console.log("proto-null", "onB" in movable, "toString" in movable); + +// A prototype gaining or losing a key after the first lookup. +const parent: any = {}; +const child: any = Object.create(parent); +console.log("late-proto", "later" in child, ((parent.later = 1), "later" in child)); +console.log("late-delete", (delete parent.later, "later" in child)); + +// Accessors, non-enumerable and symbol keys. +const withAccessor: any = {}; +Object.defineProperty(withAccessor, "acc", { get: () => 1, configurable: true }); +Object.defineProperty(withAccessor, "hidden", { value: 2, enumerable: false }); +const sym = Symbol("s"); +withAccessor[sym] = 3; +console.log("descriptors", "acc" in withAccessor, "hidden" in withAccessor, sym in withAccessor); + +// Arrays: indices, length, holes, and inherited members. +const arr: any = [1, 2, 3]; +arr[7] = 8; +console.log("array", 0 in arr, 2 in arr, 5 in arr, 7 in arr, "length" in arr, "map" in arr); + +// A Proxy answers through its has trap. +const proxied: any = new Proxy({ real: 1 }, { has: (t, k) => k === "virtual" || k in t }); +console.log("proxy", "virtual" in proxied, "real" in proxied, "other" in proxied); + +// Built-in receivers whose members are not ordinary keys. +console.log("builtins", "size" in new Map(), "has" in new Set(), "byteLength" in new ArrayBuffer(8)); +// NOTE: `"call" in function f(){}` is a separate, pre-existing gap — Perry +// answers false where Node answers true, on this commit's parent as well — so +// it is deliberately not asserted here; inherited Function.prototype members +// are not this fixture's subject. +console.log("fn", "length" in function g(a: number) {}, "name" in function h() {}, "x" in { x: 1 }); + +// process.env is backed by the OS, not a keys array. +(process.env as any).PERRY_IN_PROBE = "1"; +console.log("env", "PERRY_IN_PROBE" in process.env, "PERRY_ABSENT_XYZ" in process.env); +console.log("env-proto", "toString" in process.env); + +// A native-module namespace exposes virtual keys. +import * as pathMod from "node:path"; +console.log("module", "join" in pathMod, "definitelyNot" in (pathMod as any)); + +// Numeric and coercing keys go through ToPropertyKey. +const numeric: any = { 307: "a", "1.5": "b" }; +console.log("coerce", 307 in numeric, "307" in numeric, 1.5 in numeric, "1.5" in numeric); + +// The same key, asked in a hot loop, must not drift. +let hits = 0; +for (let i = 0; i < 2000; i++) if ("ownField" in d) hits++; +console.log("hot", hits); diff --git a/test-files/test_gap_in_operator_presence_cache.ts b/test-files/test_gap_in_operator_presence_cache.ts new file mode 100644 index 0000000000..3b60def830 --- /dev/null +++ b/test-files/test_gap_in_operator_presence_cache.ts @@ -0,0 +1,185 @@ +// A warm `"k" in o` site caches "this shape has own key k". Every way of +// losing the key — a compacting delete, a tombstoning delete, a re-add, a +// shape change — must be visible at the very next evaluation of the SAME +// site, and a prototype mutation must be visible even though the cached claim +// is only about own keys. Each helper below is one call site, deliberately +// reused so the invalidation is proven against a cache that is already warm. + +function hasA(o: any): boolean { + return "a" in o; +} +function hasP(o: any): boolean { + return "p" in o; +} +function hasToString(o: any): boolean { + return "toString" in o; +} + +// ---- warm, then delete through the same site ------------------------------- +const warm: any = { a: 1, b: 2 }; +let seen = 0; +for (let i = 0; i < 2000; i++) if (hasA(warm)) seen++; +console.log("warm", seen, hasA(warm)); +delete warm.a; +console.log("deleted", hasA(warm), "a" in warm, warm.a); +warm.a = 9; +console.log("re-added", hasA(warm), warm.a); + +// ---- delete and re-add repeatedly through the warm site --------------------- +const churn: any = { a: 1, b: 2, c: 3 }; +let churnTrue = 0; +let churnFalse = 0; +for (let i = 0; i < 500; i++) { + if (hasA(churn)) churnTrue++; + delete churn.a; + if (hasA(churn)) churnFalse++; + churn.a = i; +} +console.log("churn", churnTrue, churnFalse, hasA(churn)); + +// ---- a warm site, then the prototype moves under the receiver --------------- +const protoA = { p: 1 }; +const protoB = { q: 2 }; +const moving: any = Object.create(protoA); +moving.own = 1; +let protoSeen = 0; +for (let i = 0; i < 2000; i++) if (hasP(moving)) protoSeen++; +console.log("proto-warm", protoSeen, hasP(moving)); +Object.setPrototypeOf(moving, protoB); +console.log("proto-swapped", hasP(moving), "q" in moving); +Object.setPrototypeOf(moving, null); +console.log("proto-null", hasP(moving), hasToString(moving), "own" in moving); +Object.setPrototypeOf(moving, protoA); +console.log("proto-restored", hasP(moving)); + +// ---- the key appears and disappears ON the prototype after a warm hit ------- +const lateProto: any = {}; +const lateChild: any = Object.create(lateProto); +lateChild.own = 1; +let lateSeen = 0; +for (let i = 0; i < 2000; i++) if (hasP(lateChild)) lateSeen++; +console.log("late-before", lateSeen, hasP(lateChild)); +lateProto.p = 7; +console.log("late-added", hasP(lateChild)); +delete lateProto.p; +console.log("late-removed", hasP(lateChild)); + +// ---- an own key that shadows, then is deleted so the proto shows through ---- +const shadowProto: any = { a: "proto" }; +const shadow: any = Object.create(shadowProto); +shadow.a = "own"; +let shadowSeen = 0; +for (let i = 0; i < 2000; i++) if (hasA(shadow)) shadowSeen++; +console.log("shadow-warm", shadowSeen, shadow.a); +delete shadow.a; +console.log("shadow-deleted", hasA(shadow), shadow.a); +delete shadowProto.a; +console.log("shadow-proto-deleted", hasA(shadow), shadow.a); + +// ---- one site, many shapes ------------------------------------------------- +const shapes: any[] = [ + { a: 1 }, + { a: 1, b: 2 }, + { b: 2 }, + { x: 0, y: 0, a: 3 }, + Object.create({ a: "inherited" }), + { get a() { + return 1; + } }, + Object.freeze({ a: 1 }), + Object.seal({ a: 1, z: 2 }), +]; +let polyCount = 0; +for (let i = 0; i < 2000; i++) if (hasA(shapes[i % shapes.length])) polyCount++; +console.log("poly", polyCount, shapes.map((s) => hasA(s)).join(",")); + +// ---- descriptors and non-enumerables --------------------------------------- +const desc: any = {}; +Object.defineProperty(desc, "a", { value: 1, enumerable: false, configurable: true }); +let descSeen = 0; +for (let i = 0; i < 1000; i++) if (hasA(desc)) descSeen++; +console.log("descriptor", descSeen, hasA(desc)); +delete desc.a; +console.log("descriptor-deleted", hasA(desc)); + +const accessor: any = {}; +Object.defineProperty(accessor, "a", { get: () => 1, configurable: true }); +let accSeen = 0; +for (let i = 0; i < 1000; i++) if (hasA(accessor)) accSeen++; +console.log("accessor", accSeen, hasA(accessor)); +delete accessor.a; +console.log("accessor-deleted", hasA(accessor)); + +// ---- a class instance, and a method that is not an own key ----------------- +class Point { + a = 1; + b = 2; + moveIt(): number { + return this.a; + } +} +const pt: any = new Point(); +let ptSeen = 0; +for (let i = 0; i < 2000; i++) if (hasA(pt)) ptSeen++; +console.log("class", ptSeen, hasA(pt), "moveIt" in pt, "nope" in pt); +delete pt.a; +console.log("class-deleted", hasA(pt), "b" in pt); + +// ---- a wide object, across the keys-index threshold ------------------------- +const wide: any = {}; +for (let i = 0; i < 40; i++) wide["k" + i] = i; +wide.a = "wide"; +let wideSeen = 0; +for (let i = 0; i < 2000; i++) if (hasA(wide)) wideSeen++; +console.log("wide", wideSeen, hasA(wide), "k39" in wide, "k40" in wide); +delete wide.a; +console.log("wide-deleted", hasA(wide), "k39" in wide); + +// ---- non-object receivers through the same warm site ----------------------- +const proxy: any = new Proxy({ a: 1 }, { + has(t, k) { + return k === "a" ? false : k in t; + }, +}); +let proxySeen = 0; +for (let i = 0; i < 1000; i++) if (hasA(proxy)) proxySeen++; +console.log("proxy", proxySeen, hasA(proxy), "b" in proxy); + +const arr: any = [1, 2, 3]; +(arr as any).a = 1; +console.log("array", hasA(arr), "0" in arr, "3" in arr, "length" in arr); +console.log("builtins", hasA(new Map()), hasA(new Set()), hasA(/re/), "lastIndex" in /re/); +console.log("wrapper", hasA(new String("xy")), "0" in new String("xy")); + +// ---- a primitive right operand still throws -------------------------------- +let threw = ""; +try { + hasA(5 as any); +} catch (e) { + threw = (e as Error).constructor.name; +} +console.log("primitive", threw); +let threwNull = ""; +try { + hasA(null as any); +} catch (e) { + threwNull = (e as Error).constructor.name; +} +console.log("null", threwNull); + +// ---- hot loop where the delete happens INSIDE the loop ---------------------- +const inLoop: any = { a: 1, b: 2 }; +let hits = 0; +for (let i = 0; i < 1000; i++) { + if (hasA(inLoop)) hits++; + if (i === 500) delete inLoop.a; +} +console.log("in-loop", hits, hasA(inLoop)); + +// ---- and one where the object itself is replaced each iteration ------------- +let fresh = 0; +for (let i = 0; i < 1000; i++) { + const o: any = i % 2 === 0 ? { a: i } : { b: i }; + if (hasA(o)) fresh++; +} +console.log("fresh", fresh); diff --git a/test-files/test_gap_instanceof_miss_ladder.ts b/test-files/test_gap_instanceof_miss_ladder.ts new file mode 100644 index 0000000000..275f86fe60 --- /dev/null +++ b/test-files/test_gap_instanceof_miss_ladder.ts @@ -0,0 +1,173 @@ +// `instanceof` answers a miss by falling through a ladder of built-in probes. +// Two of those steps are class-registry reads that exist only for the +// `util.inherits` / `Object.setPrototypeOf` case, and they are now behind a +// process-wide latch. The latch is set the first time any object is given a +// user [[Prototype]] override, so the case that must be proven is a program +// that runs `instanceof` HOT first and re-points a prototype afterwards: the +// answer has to change at the next evaluation of the same site. + +import { inherits } from "node:util"; + +class A { + x = 1; +} +class B { + y = 2; +} +class C1 extends A { + z = 3; +} +class C2 extends C1 { + w = 4; +} +class C3 extends C2 { + v = 5; +} + +function isA(o: any): boolean { + return o instanceof A; +} +function isB(o: any): boolean { + return o instanceof B; +} + +const a: any = new A(); +const c3: any = new C3(); + +// ---- warm the miss and the hit through one site each ------------------------ +let hits = 0; +let misses = 0; +for (let i = 0; i < 2000; i++) { + if (isA(a)) hits++; + if (isB(a)) misses++; +} +console.log("warm", hits, misses); +console.log("chain", isA(c3), isB(c3), c3 instanceof C1, c3 instanceof C2, c3 instanceof C3); + +// ---- the latch case: re-point a prototype AFTER the sites are hot ----------- +function Base(this: any) {} +(Base as any).prototype.hello = function () { + return "hi"; +}; +function Derived(this: any) {} +inherits(Derived as any, Base as any); +const d: any = new (Derived as any)(); +console.log("inherits", d instanceof (Derived as any), d instanceof (Base as any)); +console.log("inherits-method", typeof d.hello, d.hello()); + +const late: any = new A(); +console.log("late-before", isA(late), isB(late)); +Object.setPrototypeOf(late, B.prototype); +// Only the POSITIVE half is asserted. `isA(late)` after the swap is a +// separate, pre-existing divergence — Perry answers true where Node answers +// false, because the class-id chain walk matches on the instance's original +// class id before the recorded prototype is ever consulted, and it does so on +// this commit's parent as well. Acquiring the new brand is what this fixture +// is here to prove, and that part matches. +console.log("late-after-b", isB(late)); +Object.setPrototypeOf(late, A.prototype); +console.log("late-restored", isA(late), isB(late)); +// The hot sites must agree with a fresh evaluation. +let lateHits = 0; +for (let i = 0; i < 2000; i++) if (isA(late)) lateHits++; +console.log("late-rewarm", lateHits, late instanceof A, late instanceof B); + +// ---- Symbol.hasInstance on a user class, positive and negative -------------- +class Even { + static [Symbol.hasInstance](v: any): boolean { + return typeof v === "number" && v % 2 === 0; + } +} +console.log("hasInstance", 4 instanceof Even, 5 instanceof Even, ({} as any) instanceof Even); +let evenCount = 0; +for (let i = 0; i < 2000; i++) if (i instanceof Even) evenCount++; +console.log("hasInstance-hot", evenCount); + +// The defineProperty form (zod 4's shape). +class Tagged {} +Object.defineProperty(Tagged, Symbol.hasInstance, { + value: (v: any) => v !== null && typeof v === "object" && "tag" in v, +}); +console.log("hasInstance-defineProperty", { tag: 1 } instanceof Tagged, {} instanceof Tagged); + +// ---- a Proxy, including a getPrototypeOf trap ------------------------------ +const plainProxy: any = new Proxy(new A(), {}); +console.log("proxy-plain", plainProxy instanceof A, plainProxy instanceof B); +// A `getPrototypeOf` trap is NOT asserted: Perry unwraps the proxy to its +// target and walks the target's class chain, so it answers `true false` where +// Node answers `false true` — pre-existing on this commit's parent, and a +// different subsystem from this ladder. A Proxy on the RIGHT of `instanceof` +// is not exercised at all: it SIGSEGVs on the parent commit (see this +// fixture's companion note in the wave report), so a fixture that used one +// could never be green enough to detect a regression here. +const trapped: any = new Proxy(new A(), { + getPrototypeOf() { + return B.prototype; + }, +}); +console.log("proxy-trap-runs", typeof trapped, Object.getPrototypeOf(trapped) === B.prototype); +const callableProxy: any = new Proxy(A, {}); +console.log("proxy-construct", new callableProxy() instanceof A); + +// ---- a bound constructor --------------------------------------------------- +// Only the middle case is asserted. `a instanceof A.bind(null)` is `true` in +// Node (a bound function's [[HasInstance]] delegates to its target) and +// `false` in Perry, on this commit's parent too — a bound-function gap, not a +// ladder one. +const BoundA: any = A.bind(null); +console.log("bound-construct", new BoundA() instanceof A); + +// ---- built-ins, positive and miss ------------------------------------------ +const m = new Map(); +const e = new Error("x"); +const p = Promise.resolve(1); +const arr = [1, 2]; +console.log("map", m instanceof Map, e instanceof Map, a instanceof Map); +console.log("error", e instanceof Error, m instanceof Error, a instanceof Error); +console.log("promise", p instanceof Promise, m instanceof Promise); +console.log("array", arr instanceof Array, m instanceof Array, arr instanceof Object); +console.log("function", isA instanceof Function, A instanceof Function, a instanceof Function); +console.log("object", a instanceof Object, m instanceof Object, e instanceof Object); + +// ---- a subclass of a built-in ---------------------------------------------- +class MyMap extends Map {} +const mm: any = new MyMap(); +console.log("submap", mm instanceof MyMap, mm instanceof Map, m instanceof MyMap); +class MyErr extends Error {} +const me: any = new MyErr("y"); +console.log("suberr", me instanceof MyErr, me instanceof Error, e instanceof MyErr); + +// ---- the closest thing to cross-realm that is expressible here -------------- +// Two structurally identical classes are still distinct brands. +class Twin1 { + k = 1; +} +class Twin2 { + k = 1; +} +console.log("twins", new Twin1() instanceof Twin1, new Twin1() instanceof Twin2); +// An object whose prototype is a plain object literal is no class's instance. +const bare: any = Object.create({ k: 1 }); +console.log("bare", bare instanceof A, bare instanceof Object, bare instanceof Twin1); +// `Object.create(null) instanceof Object` is `false` in Node and `true` in +// Perry on this commit's parent — the null-prototype receiver reaches the +// Object arm anyway. Not asserted; recorded so the next reader knows it was +// looked at rather than missed. +console.log("nullproto-proto", Object.getPrototypeOf(Object.create(null))); + +// ---- primitives and non-objects on the left -------------------------------- +console.log("prims", 1 instanceof A, "s" instanceof A, null instanceof A, undefined instanceof A); + +// ---- a non-callable right operand still throws ------------------------------ +let threw = ""; +try { + console.log(({} as any) instanceof ({} as any)); +} catch (err) { + threw = (err as Error).constructor.name; +} +console.log("noncallable", threw); + +// ---- a hot miss loop, the shape the ladder is paid on ----------------------- +let missCount = 0; +for (let i = 0; i < 5000; i++) if (!isB(c3)) missCount++; +console.log("hot-miss", missCount); diff --git a/test-files/test_gap_new_globalthis_shadowed_10359.ts b/test-files/test_gap_new_globalthis_shadowed_10359.ts new file mode 100644 index 0000000000..6db91ef202 --- /dev/null +++ b/test-files/test_gap_new_globalthis_shadowed_10359.ts @@ -0,0 +1,104 @@ +// #10359 — `new globalThis.X(...)` must construct the GLOBAL `X` even when a +// module binding shadows the bare name. `globalThis.X` is the idiom for +// escaping exactly that shadow, but the qualified `new` lowered to a by-name +// construct that bound to the shadowing import/class/function/local — so it +// built the user class, while the aliased `const E = globalThis.Event; +// new E()` form was correct. +// @ts-nocheck +import { + Event, + Request, + MessageChannel, + Map, + Int32Array, + ReadableStream, +} from "./_helpers/new_globalthis_shadowed_10359.ts"; + +// The exact reproduction from the issue: an explicit import shadows `Event`. +const e: any = new globalThis.Event("ping"); +console.log("tag:", e.tag); +console.log("type:", e.type); +console.log("ctor-name:", e.constructor?.name); +console.log("is-user-class:", e instanceof Event); + +// The bare name is still legitimately the imported class, and the aliased +// form (always correct) stays correct. +console.log("bare:", (new Event() as any).tag); +const E = globalThis.Event; +console.log("aliased:", new E("pong").type); + +// Fetch constructors took a separate by-name arm. +const r: any = new globalThis.Request("http://example.test/a"); +console.log("request:", r.tag, r.url, r instanceof Request); + +// So did MessageChannel. +const mc: any = new globalThis.MessageChannel(); +console.log("channel:", mc.tag, typeof mc.port1, mc instanceof MessageChannel); +mc.port1.close(); +mc.port2.close(); + +// A stream's methods come from codegen's builtin table, not from the +// runtime value of `globalThis.ReadableStream`. +const rs: any = new globalThis.ReadableStream(); +console.log("stream:", rs.tag, typeof rs.getReader, rs instanceof ReadableStream); + +// A constructor with a dedicated intrinsic node keeps constructing it. +const m: any = new globalThis.Map([[1, 2]]); +console.log("map:", m.tag, m.get(1), m.size); + +// The multi-argument typed-array form falls past its dedicated node. +const ia: any = new globalThis.Int32Array(new ArrayBuffer(16), 4, 2); +console.log("int32:", ia.tag, ia.length, ia.byteOffset); + +// A module-scope class and a function declaration shadow just like an import. +class Headers { + tag = "user-Headers"; +} +const h: any = new globalThis.Headers({ a: "1" }); +console.log("headers:", h.tag, h.get("a"), h instanceof Headers); + +function URLSearchParams(this: any) { + this.tag = "user-URLSearchParams"; +} +const usp: any = new globalThis.URLSearchParams("a=1&b=2"); +console.log("usp:", usp.tag, usp.get("b")); + +// A function-local binding shadows too. +function local() { + const CustomEvent = function (this: any) { + this.tag = "local-CustomEvent"; + }; + const ce: any = new globalThis.CustomEvent("x", { detail: 7 }); + console.log("local:", ce.tag, ce.detail, ce.type); +} +local(); + +// The same through a `globalThis` alias. +const g = globalThis; +const h2: any = new g.Headers({ b: "2" }); +console.log("alias-headers:", h2.tag, h2.get("b"), h2 instanceof Headers); + +// A global the program installs itself, shadowed by a module class of the same +// name: codegen folded the `globalThis.Widget` callee back onto the class. +class Widget { + kind = "module-class"; +} +globalThis.Widget = class { + kind = "global-property"; +}; +console.log("widget:", new globalThis.Widget().kind, new Widget().kind); + +// …and with no such global, the qualified construct must throw rather than +// quietly build the module class. +class Gadget { + kind = "module-class"; +} +try { + const gadget: any = new globalThis.Gadget(); + console.log("gadget: constructed", gadget.kind); +} catch (err) { + console.log("gadget: threw", err instanceof TypeError); +} + +// Unshadowed forms are unaffected. +console.log("unshadowed:", new globalThis.CustomEvent("y", { detail: 9 }).detail); diff --git a/test-files/test_gap_nominal_class_param_guard.ts b/test-files/test_gap_nominal_class_param_guard.ts new file mode 100644 index 0000000000..9901c02eb6 --- /dev/null +++ b/test-files/test_gap_nominal_class_param_guard.ts @@ -0,0 +1,154 @@ +// A class-typed parameter whose chain is all `number` is proved NOMINALLY: +// exact class identity plus the per-object typed-layout-intact bit, with no +// field-by-name walk. This fixture pins the receivers that must NOT take that +// fast route, by construction — each one is built so the nominal check fails +// for a different reason, and each must still produce Node-identical output +// through the generic body. + +class Vec3 { + x: number; + y: number; + z: number; + constructor(x: number, y: number, z: number) { + this.x = x; + this.y = y; + this.z = z; + } +} + +// All-number chain -> nominal. The loop keeps the clone from being dropped as +// consuming no proof (a thin `p.x + p.y` body lowers identically either way). +function sumVec3(p: Vec3): number { + let s = 0; + for (let i = 0; i < 3; i++) s += p.x * p.y + p.z + i; + return s; +} + +// One string field -> the whole chain stays on the by-name walk. +class Tagged { + n: number; + tag: string; + constructor(n: number, tag: string) { + this.n = n; + this.tag = tag; + } +} +function describeTagged(t: Tagged): number { + let s = 0; + for (let i = 0; i < 3; i++) s += t.n + t.tag.length + i; + return s; +} + +// Inherited numeric chain -> still nominal. +class Vec4 extends Vec3 { + w: number; + constructor(x: number, y: number, z: number, w: number) { + super(x, y, z); + this.w = w; + } +} + +// A numeric leaf under a string parent -> NOT nominal. +class Named { + name: string; + constructor(name: string) { + this.name = name; + } +} +class NamedCount extends Named { + count: number; + constructor(name: string, count: number) { + super(name); + this.count = count; + } +} +function countOf(n: NamedCount): number { + let s = 0; + for (let i = 0; i < 3; i++) s += n.count + n.name.length + i; + return s; +} + +// Call through a dynamic route so every call reaches the public guarded entry +// rather than being resolved statically. +const api: any = { sumVec3, describeTagged, countOf }; +function call(name: string, arg: any): number { + return api[name](arg); +} + +const out: string[] = []; +function show(label: string, value: number): void { + out.push(label + "=" + String(value)); +} + +// --- accepted: a real instance ------------------------------------------- +show("plain", call("sumVec3", new Vec3(2, 3, 4))); +show("negzero", call("sumVec3", new Vec3(-0, 3, 4))); +show("frac", call("sumVec3", new Vec3(0.5, 0.25, 1.5))); +show("nan", call("sumVec3", new Vec3(NaN, 1, 1))); +show("inf", call("sumVec3", new Vec3(Infinity, 1, 1))); +show("big", call("sumVec3", new Vec3(1e308, 10, 1))); + +// --- rejected: a SUBCLASS instance --------------------------------------- +// Extra fields the proof never named; must still compute the base's view. +show("subclass", call("sumVec3", new Vec4(2, 3, 4, 5))); +const v4 = new Vec4(1, 2, 3, 4); +show("subclass_own", call("sumVec3", v4) + v4.w); + +// --- rejected: Object.create(C.prototype) -------------------------------- +// Right prototype, never ran the constructor, so no class-allocated layout. +const created: any = Object.create(Vec3.prototype); +created.x = 2; +created.y = 3; +created.z = 4; +show("object_create", call("sumVec3", created)); +show("object_create_proto", (Object.getPrototypeOf(created) === Vec3.prototype) ? 1 : 0); +const createdEmpty: any = Object.create(Vec3.prototype); +show("object_create_empty", call("sumVec3", createdEmpty)); + +// --- rejected: a SHAPE-BROKEN instance ----------------------------------- +// A real instance whose numeric slot is overwritten with a string retires the +// typed layout. JS still defines the arithmetic, so the answer must match. +const broken: any = new Vec3(2, 3, 4); +broken.x = "5"; +show("broken_string", call("sumVec3", broken)); +const broken2: any = new Vec3(2, 3, 4); +broken2.y = null; +show("broken_null", call("sumVec3", broken2)); +const broken3: any = new Vec3(2, 3, 4); +broken3.z = undefined; +show("broken_undef", call("sumVec3", broken3)); +const broken4: any = new Vec3(2, 3, 4); +broken4.extra = 9; +show("broken_added_field", call("sumVec3", broken4)); +const broken5: any = new Vec3(2, 3, 4); +delete broken5.y; +show("broken_deleted", call("sumVec3", broken5)); + +// --- rejected: a same-shaped PLAIN OBJECT -------------------------------- +// Identical keys and values, no class id at all. +show("plain_object", call("sumVec3", { x: 2, y: 3, z: 4 })); +show("plain_object_extra", call("sumVec3", { x: 2, y: 3, z: 4, w: 5 })); +show("plain_object_order", call("sumVec3", { z: 4, y: 3, x: 2 })); + +// --- rejected: frozen / accessor receivers ------------------------------- +const frozen: any = new Vec3(2, 3, 4); +Object.freeze(frozen); +show("frozen", call("sumVec3", frozen)); +const withGetter: any = new Vec3(2, 3, 4); +Object.defineProperty(withGetter, "x", { get: () => 7, configurable: true }); +show("accessor", call("sumVec3", withGetter)); + +// --- the non-nominal (by-name walk) path still works --------------------- +show("tagged", call("describeTagged", new Tagged(5, "abc"))); +show("tagged_broken", (() => { const t: any = new Tagged(5, "abc"); t.tag = 12345; return call("describeTagged", t); })()); +show("named_count", call("countOf", new NamedCount("ab", 7))); + +// --- a receiver reused after breaking, to catch a sticky verdict --------- +const reused: any = new Vec3(2, 3, 4); +show("reused_before", call("sumVec3", reused)); +reused.x = "5"; +show("reused_after", call("sumVec3", reused)); +reused.x = 2; +show("reused_restored", call("sumVec3", reused)); + +console.log(out.join("\n")); diff --git a/test-files/test_gap_numeric_push_guarded.ts b/test-files/test_gap_numeric_push_guarded.ts new file mode 100644 index 0000000000..bfb132ae60 --- /dev/null +++ b/test-files/test_gap_numeric_push_guarded.ts @@ -0,0 +1,121 @@ +// The numeric push guard's fast arm calls a post-guard entry that re-derives +// only the receiver's iteration policy. Every receiver shape the guard does +// not admit — frozen, sealed, non-extensible, descriptor-bearing, a prototype +// index setter, a subclass, a Proxy, a Buffer/TypedArray — must still reach +// the same result as before, and a length that is not writable must not move. + +function push(a: any, v: number): number { + a.push(v); + return a.length; +} + +// Plain dense numeric array: the admitted shape. +const plain: number[] = [1, 2, 3]; +console.log("plain", push(plain, 4), JSON.stringify(plain)); +for (let i = 0; i < 40; i++) push(plain, i); +console.log("grown", plain.length, plain[43], JSON.stringify(plain.slice(0, 5))); + +// Non-numeric values retire the dense claim through the same site. +const mixed: any[] = [1, 2]; +push(mixed, 3); +mixed.push("s"); +mixed.push({ o: 1 }); +mixed.push(null); +console.log("mixed", mixed.length, JSON.stringify(mixed), typeof mixed[3]); + +// Frozen / sealed / non-extensible receivers. +const frozen = Object.freeze([1, 2, 3]) as number[]; +try { + push(frozen, 4); +} catch (e: any) { + console.log("frozen-throw", e.constructor.name); +} +console.log("frozen", frozen.length, JSON.stringify(frozen)); + +const sealed = Object.seal([1, 2, 3]) as number[]; +try { + push(sealed, 4); +} catch (e: any) { + console.log("sealed-throw", e.constructor.name); +} +console.log("sealed", sealed.length, JSON.stringify(sealed)); + +// Non-extensible: only the resulting STATE is asserted. Perry does not throw +// here where node does (a pre-existing gap, identical before this change), and +// this fixture must not start failing on it. +const noExtend = Object.preventExtensions([1, 2, 3]) as number[]; +try { + push(noExtend, 4); +} catch { + /* node throws, perry does not; both must leave the array untouched */ +} +console.log("noextend", noExtend.length, JSON.stringify(noExtend)); + +// A non-writable length must refuse the push. +const fixedLen: number[] = [1, 2, 3]; +Object.defineProperty(fixedLen, "length", { writable: false }); +try { + push(fixedLen, 4); +} catch (e: any) { + console.log("fixedlen-throw", e.constructor.name); +} +console.log("fixedlen", fixedLen.length, JSON.stringify(fixedLen)); + +// An accessor defined on an index the push would write. +const accessor: any = [1, 2, 3]; +let seen: any = null; +Object.defineProperty(accessor, 3, { + set(v: any) { + seen = v; + }, + get() { + return "acc"; + }, + configurable: true, +}); +push(accessor, 99); +console.log("accessor", seen, accessor[3], accessor.length); + +// Sparse receiver. +const sparse: any[] = [1, , 3]; +push(sparse, 4); +console.log("sparse", sparse.length, 1 in sparse, JSON.stringify(sparse)); + +// Subclass and Proxy receivers. +class MyArr extends Array {} +const sub: any = MyArr.from([1, 2, 3]); +push(sub, 4); +console.log("subclass", sub.length, sub instanceof MyArr, JSON.stringify(Array.from(sub))); + +const proxied: any = new Proxy([1, 2, 3], { + set(t: any, k: any, v: any) { + t[k] = v; + return true; + }, +}); +push(proxied, 4); +console.log("proxy", proxied.length, JSON.stringify(Array.from(proxied))); + +// A mid-program Array.prototype index setter is observable on later pushes. +const beforeSetter: number[] = [1, 2]; +push(beforeSetter, 3); +let protoSaw: any = null; +Object.defineProperty(Array.prototype, 7, { + set(v: any) { + protoSaw = v; + }, + get() { + return "proto"; + }, + configurable: true, +}); +const afterSetter: number[] = [0, 1, 2, 3, 4, 5, 6]; +push(afterSetter, 42); +console.log("proto-setter", protoSaw, afterSetter[7], afterSetter.length); +delete (Array.prototype as any)[7]; + +// Buffer / typed-array receivers route to their own paths. +const u8: any = new Uint8Array([1, 2, 3]); +console.log("u8-push-typeof", typeof u8.push); +const buf: any = Buffer.from([1, 2, 3]); +console.log("buffer-len", buf.length); diff --git a/test-files/test_gap_packed_loop_cached_receiver.ts b/test-files/test_gap_packed_loop_cached_receiver.ts new file mode 100644 index 0000000000..054d88a34e --- /dev/null +++ b/test-files/test_gap_packed_loop_cached_receiver.ts @@ -0,0 +1,101 @@ +// The packed-f64 fast loop clone reads its element base from the hoisted +// receiver cache rather than re-laundering the rooted slot each iteration. +// That is only sound while the clone has no safepoint, so every shape that +// leaves the clone — a side exit, a hole, a foreign index, a length change, +// a non-numeric element — must still produce node's answer. + +function sumIndexed(a: number[]): number { + let s = 0; + for (let i = 0; i < a.length; i++) s += a[i]; + return s; +} + +function sumForOf(a: number[]): number { + let s = 0; + for (const v of a) s += v; + return s; +} + +function scaleInPlace(a: number[], k: number): number { + let s = 0; + for (let i = 0; i < a.length; i++) { + a[i] = a[i] * k; + s += a[i]; + } + return s; +} + +function sumWithForeignIndex(a: number[], j: number): number { + let s = 0; + for (let i = 0; i < a.length; i++) s += a[i] + a[j]; + return s; +} + +const dense: number[] = []; +for (let i = 0; i < 40; i++) dense.push(i * 1.5); +console.log("dense-indexed", sumIndexed(dense)); +console.log("dense-forof", sumForOf(dense)); +console.log("dense-scale", scaleInPlace(dense.slice(), 2)); +console.log("dense-foreign", sumWithForeignIndex(dense, 3)); + +// A single element makes the loop bound 1; an empty array makes it 0. +console.log("one", sumIndexed([7.5]), sumForOf([7.5])); +console.log("empty", sumIndexed([]), sumForOf([])); + +// Integer-valued members: the array is still raw-f64 but the values are +// exactly representable, which is a different canonicalization path. +const ints: number[] = [1, 2, 3, 4, 5]; +console.log("ints", sumIndexed(ints), sumForOf(ints), scaleInPlace(ints.slice(), 3)); + +// A hole must read as undefined, so the sum is NaN through both loops. +const holed: any[] = [1, 2, 3]; +holed[6] = 9; +console.log("holed-indexed", sumIndexed(holed as number[])); +console.log("holed-forof", sumForOf(holed as number[])); +console.log("holed-len", holed.length, 4 in holed); + +// A non-numeric element forces the side exit out of the fast clone. +const mixed: any[] = [1, 2, "3", 4]; +console.log("mixed-indexed", sumIndexed(mixed as number[])); +console.log("mixed-forof", sumForOf(mixed as number[])); + +// Growing the receiver DURING the loop: the bound is hoisted at entry, so the +// appended elements must not be visited, and the base must survive the +// reallocation the growth performs. +function sumWhileGrowing(a: number[]): number { + let s = 0; + for (let i = 0; i < a.length; i++) { + s += a[i]; + if (i === 0) for (let k = 0; k < 200; k++) a.push(k); + } + return s; +} +const grow: number[] = [1, 2, 3, 4, 5]; +console.log("growing", sumWhileGrowing(grow), grow.length); + +// Allocating in the body puts a real safepoint back in the loop, and the +// receiver may then move under it. +function sumAllocating(a: number[]): number { + let s = 0; + const keep: number[][] = []; + for (let i = 0; i < a.length; i++) { + keep.push([a[i], a[i] * 2]); + s += a[i]; + } + return s + keep.length; +} +console.log("allocating", sumAllocating(dense)); + +// Frozen and subclass receivers take their own paths. +const frozen = Object.freeze([1.5, 2.5, 3.5]) as number[]; +console.log("frozen", sumIndexed(frozen), sumForOf(frozen)); + +class MyArr extends Array {} +const sub: any = MyArr.from([1, 2, 3, 4]); +console.log("subclass", sumIndexed(sub), sumForOf(sub), sub.length); + +// A typed array is not a plain Array and must not enter the plain clone. +const ta = new Float64Array([1.5, 2.5, 3.5]); +let taSum = 0; +for (let i = 0; i < ta.length; i++) taSum += ta[i]; +console.log("typedarray", taSum); diff --git a/test-files/test_gap_packed_loop_proto_index_rooting.ts b/test-files/test_gap_packed_loop_proto_index_rooting.ts new file mode 100644 index 0000000000..5a90fe8ba5 --- /dev/null +++ b/test-files/test_gap_packed_loop_proto_index_rooting.ts @@ -0,0 +1,80 @@ +// `arr[i]` inside the packed-numeric fast clone is a proven Number, so its +// `const` binding shades no GC root. `arr[i ± c]` is NOT: the index can leave +// the array, and an out-of-bounds element read consults the prototype chain, +// where an installed index property is a genuine heap reference that must stay +// rooted. This fixture puts an object there, has an offset loop READ it, and +// retains it across collections — so a missed shading shows up as a dangling +// reference instead of as luck. + +const protoHeld: any = { tag: "proto-object", payload: [11, 22, 33] }; +Object.defineProperty(Array.prototype, 7, { + value: protoHeld, + writable: true, + enumerable: false, + configurable: true, +}); + +// Counter read: always in bounds, never reaches the prototype. +function sumCounter(a: number[]): number { + let s = 0; + for (let i = 0; i < a.length; i++) { + const v = a[i]; + s += v; + } + return s; +} + +// Offset read: a[i + 3] over a length-5 array touches 3, 4, 5, 6, 7 — the last +// of which is the prototype's object. +function collectOffset(a: number[], out: any[]): number { + let n = 0; + for (let i = 0; i < a.length; i++) { + const w = a[i + 3]; + out.push(w); + n++; + } + return n; +} + +const five: number[] = [1.5, 2.5, 3.5, 4.5, 5.5]; +console.log("counter", sumCounter(five)); + +const captured: any[] = []; +console.log("offset-count", collectOffset(five, captured)); +console.log("offset-kinds", captured.map((x) => typeof x).join(",")); +console.log("offset-values", JSON.stringify(captured.slice(0, 2))); +console.log("captured-is-proto", captured[4] === protoHeld); + +// Churn the nursery so the retained capture crosses collections. +let churn: any[] = []; +for (let round = 0; round < 60; round++) { + const block: any[] = []; + for (let k = 0; k < 200; k++) block.push({ k, s: "fill-" + k, arr: [k, k + 1] }); + churn.push(block); + if (churn.length > 4) churn = churn.slice(-2); + // Re-run both loops while the heap is moving. + sumCounter(five); + collectOffset(five, captured); +} + +// The prototype object captured before all that churn must still be intact. +const held = captured[4]; +console.log("held-tag", held.tag); +console.log("held-payload", JSON.stringify(held.payload)); +console.log("held-identity", held === protoHeld, protoHeld.tag); +console.log("captured-len", captured.length); +console.log("last-capture-ok", captured[captured.length - 1] === protoHeld); + +// The counter loop's answer is unchanged by the polluted prototype. +console.log("counter-again", sumCounter(five)); + +// And a length-8 array reads its OWN element 7, not the prototype's. +const eight: number[] = [1, 2, 3, 4, 5, 6, 7, 8]; +const ownSeven: any[] = []; +collectOffset(eight.slice(0, 5), ownSeven); +console.log("short-slice-sees-proto", ownSeven[4] === protoHeld); +console.log("full-eight-index7", eight[7]); +console.log("sum-eight", sumCounter(eight)); + +delete (Array.prototype as any)[7]; +console.log("after-delete", five[7], eight[7]); diff --git a/test-files/test_gap_rest_bundle_and_map_fill.ts b/test-files/test_gap_rest_bundle_and_map_fill.ts new file mode 100644 index 0000000000..75e7b33290 --- /dev/null +++ b/test-files/test_gap_rest_bundle_and_map_fill.ts @@ -0,0 +1,106 @@ +// Rest/`arguments` bundles are built the way an array literal is, and +// `Array.prototype.map`'s plain-array fill resolves the result header once. +// Both keep the element-kind, hole and barrier protocol of the paths they +// replace, so this pins what a caller can observe: identity, length, element +// values and kinds, holes, and what happens when a callback mutates or grows +// the array it is filling. + +function rest(...xs: any[]): any[] { + return xs; +} +function restAfterFixed(a: number, b: number, ...xs: any[]): string { + return `${a}|${b}|${xs.length}|${xs.join(",")}`; +} +function argsObject(): any { + // eslint-disable-next-line prefer-rest-params + return arguments; +} + +// Numbers, the shape the raw-f64 layout claims. +console.log("nums", JSON.stringify(rest(1, 2, 3)), rest(1, 2, 3).length); +console.log("empty", JSON.stringify(rest()), rest().length, Array.isArray(rest())); +console.log("fixed+rest", restAfterFixed(1, 2, 3, 4, 5)); +console.log("no-rest-args", restAfterFixed(1, 2)); + +// Mixed kinds must retire the numeric claim, not store raw bits. +const obj = { tag: "o" }; +const mixed = rest(1, "two", null, undefined, true, obj, 6.5, -0, NaN, Infinity); +console.log("mixed", mixed.length, typeof mixed[1], mixed[2], mixed[3], mixed[4]); +console.log("mixed-obj-identity", mixed[5] === obj, Object.is(mixed[7], -0), mixed[8] !== mixed[8]); +console.log("mixed-json", JSON.stringify(mixed)); + +// A rest array is an ordinary, extensible, mutable array. +const r = rest(1, 2, 3); +r.push(4); +r[6] = 7; +console.log("mutable", JSON.stringify(r), r.length, 5 in r, JSON.stringify(Object.keys(r))); + +// Past the inline width (16), the older construction still applies. +const wide = rest(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18); +console.log("wide", wide.length, wide[0], wide[17], JSON.stringify(wide.slice(15))); + +// `arguments` keeps its own identity and spread behaviour. +const a = argsObject(1, 2, 3); +console.log("arguments", a.length, a[0], a[2], JSON.stringify(Array.from(a))); + +// Rest of objects: every element must survive a collection that runs while the +// bundle is still being built by the next call's arguments. +function makeTag(i: number): any { + return { i, pad: new Array(8).fill(i) }; +} +const objs = rest(makeTag(0), makeTag(1), makeTag(2), makeTag(3)); +console.log("obj-rest", objs.length, objs.map((o: any) => o.i).join(",")); + +// ---- map ---------------------------------------------------------------- + +const nums = [1, 2, 3, 4]; +console.log("map-num", JSON.stringify(nums.map((v) => v * 2))); +console.log("map-to-string", JSON.stringify(nums.map((v) => `n${v}`))); +console.log("map-to-obj", JSON.stringify(nums.map((v) => ({ v })))); +console.log("map-mixed", JSON.stringify(nums.map((v) => (v % 2 === 0 ? v : String(v))))); +console.log("map-negzero", Object.is(nums.map(() => -0)[0], -0)); +console.log("map-nan", nums.map(() => NaN).every((x) => x !== x)); + +// Holes stay holes; the callback is not called for them. +const holey = [1, , 3]; +const mappedHoley = holey.map((v) => (v as number) * 10); +console.log("map-holes", JSON.stringify(mappedHoley), 1 in mappedHoley, mappedHoley.length); + +// A callback that mutates the source, grows it, or allocates heavily. +const src = [1, 2, 3]; +const mutated = src.map((v, i) => { + if (i === 0) { + src.push(99); + src[2] = 42; + } + return v; +}); +console.log("map-mutating", JSON.stringify(mutated), JSON.stringify(src)); + +const allocating = [1, 2, 3, 4, 5, 6].map((v) => { + const junk = new Array(64).fill({ v }); + return junk.length + v; +}); +console.log("map-allocating", JSON.stringify(allocating)); + +// Longer than the map fill's 64-element resolved-header branch. +const long = new Array(80).fill(0).map((_, i) => i); +const longMapped = long.map((v) => v + 0.5); +console.log("map-long", longMapped.length, longMapped[0], longMapped[79]); +const longObjs = long.map((v) => ({ v })); +console.log("map-long-obj", longObjs.length, longObjs[79].v, typeof longObjs[0]); + +// Species and subclass results take the unchanged [[Set]] path. Only the +// VALUES are asserted: `map` on a subclass receiver does not preserve the +// subclass in perry today (`subMapped instanceof MyArr` is false where node +// says true), a pre-existing gap this fixture must not start failing on. +class MyArr extends Array {} +const sub = MyArr.from([1, 2, 3]) as any; +const subMapped = sub.map((v: number) => v + 1); +console.log("map-species", JSON.stringify(Array.from(subMapped))); + +// Frozen source, and a result read back through every element kind. +const frozen = Object.freeze([1, 2, 3]); +console.log("map-frozen-src", JSON.stringify(frozen.map((v) => v + 1))); +const kinds = [0, "s", null, undefined, true, { o: 1 }, [1]].map((v) => typeof v); +console.log("map-kinds", JSON.stringify(kinds)); diff --git a/test-files/test_gap_string_index_character.ts b/test-files/test_gap_string_index_character.ts new file mode 100644 index 0000000000..cd3f99c445 --- /dev/null +++ b/test-files/test_gap_string_index_character.ts @@ -0,0 +1,65 @@ +// `s[i]` and the property lookups that share its entry point. The character a +// string index answers is a value, not a cell: a short-string receiver must +// give the same answer as the same text on the heap, and both must agree with +// Node for non-index keys, out-of-range indices, astral pairs and lone +// surrogates. + +function at(s: string, i: number): string { + return s[i]; +} +function atKey(s: string, k: any): any { + return (s as any)[k]; +} + +// Short (SSO-eligible) and long (heap) receivers with identical text. +const short = "abc"; +const long = ["abc", "defghijkl"].join(""); +console.log("short", at(short, 0), at(short, 1), at(short, 2)); +console.log("long", at(long, 0), at(long, 3), at(long, 11)); + +// The answer must compare equal and behave as a string wherever it flows. +const c = at(short, 1); +console.log("identity", c === "b", c == "b", typeof c, c.length, c.charCodeAt(0)); +console.log("concat", c + c, ("x" + c).length, [c, c].join("-")); +console.log("in-map", new Map([["b", 1]]).get(c), new Set(["b"]).has(c)); +console.log("as-key", { b: 7 }[c as "b"], JSON.stringify({ [c]: 1 })); + +// Out of range, negative, fractional, and non-index keys. +console.log("oob", at(short, 3), at(short, -1), at(short, 1.5), at(long, 99)); +console.log("keys", atKey(short, "0"), atKey(short, "01"), atKey(short, "1.0"), atKey(short, "")); +console.log("length", atKey(short, "length"), atKey(long, "length")); +console.log("proto", typeof atKey(short, "toUpperCase"), atKey(short, "nope")); + +// -0 and NaN keys. +console.log("weird", atKey(short, -0), atKey(short, NaN), atKey(short, Infinity)); + +// Non-ASCII: multi-byte code points make the byte index and the UTF-16 index +// disagree, and an astral character indexes as its two surrogate halves. +const accented = "héllo"; +const astral = "a😀b"; +console.log("accented", accented[0], accented[1], accented[2], accented.length); +console.log("astral", astral.length, astral[0], astral[3], astral[1] === "\uD83D", astral[2] === "\uDE00"); +console.log("astral-codes", astral.charCodeAt(1), astral.charCodeAt(2), astral.codePointAt(1)); + +// A lone surrogate survives a round trip through the index path. +const lone = "a\uD800b"; +console.log("lone", lone.length, lone.charCodeAt(1), lone[1] === "\uD800", (lone[1] + "").length); + +// Short receivers whose payload is multi-byte: the packed bytes are not the +// UTF-16 units, so the index must still count code units. +const shortMulti = "é1"; +console.log("short-multi", shortMulti.length, shortMulti[0], shortMulti[1], shortMulti[2]); + +// Every character of a mixed string, through both entry points. +let walked = ""; +for (let i = 0; i < astral.length; i++) walked += astral[i]; +console.log("walk", walked === astral, walked.length); + +// A String object (not a primitive) keeps object semantics. +const boxed: any = new String("xy"); +console.log("boxed", boxed[0], boxed[1], boxed[2], boxed.length, typeof boxed); + +// Index reads through a hot loop, the shape the inline path is built for. +let acc = 0; +for (let i = 0; i < 1000; i++) acc += long[i % long.length].charCodeAt(0); +console.log("hot", acc); diff --git a/test-files/test_gap_subclass_alloc_registration.ts b/test-files/test_gap_subclass_alloc_registration.ts new file mode 100644 index 0000000000..fb2ed75f43 --- /dev/null +++ b/test-files/test_gap_subclass_alloc_registration.ts @@ -0,0 +1,115 @@ +// Allocating an instance of an inheriting class re-registers its parent edge +// on every `new`. Skipping that when the edge is unchanged must not change what +// the chain answers: `instanceof` walks the registry, and so do method +// resolution, `super`, and the builtin-parent probes. + +class Shape { + kind: string; + constructor(kind: string) { + this.kind = kind; + } + describe(): string { + return "shape:" + this.kind; + } +} + +class Rect extends Shape { + w: number; + h: number; + constructor(w: number, h: number) { + super("rect"); + this.w = w; + this.h = h; + } + area(): number { + return this.w * this.h; + } +} + +class Square extends Rect { + constructor(s: number) { + super(s, s); + } + describe(): string { + return "square:" + super.describe(); + } +} + +// Many allocations: the second and later ones take the skip. +const squares: Square[] = []; +for (let i = 0; i < 200; i++) { + squares.push(new Square(i % 5)); +} +const s = squares[3]; +console.log("chain", s instanceof Square, s instanceof Rect, s instanceof Shape); +console.log("not", s instanceof Error, [] instanceof Shape); +console.log("methods", s.describe(), s.area(), s.kind, s.w, s.h); +console.log("count", squares.length, squares[199].area()); + +// A class expression built AFTER many allocations of the static chain: its +// edge is new, so it must publish normally. +const Dyn = class extends Rect { + constructor() { + super(2, 3); + } +}; +const d = new Dyn(); +console.log("dyn", d instanceof Rect, d instanceof Shape, d.area(), d.kind); + +// Two distinct children of one parent, interleaved with allocations. +class Circle extends Shape { + r: number; + constructor(r: number) { + super("circle"); + this.r = r; + } +} +for (let i = 0; i < 50; i++) { + new Rect(i, i); + new Circle(i); +} +const c = new Circle(7); +console.log("circle", c instanceof Circle, c instanceof Shape, c instanceof Rect, c.r, c.kind); + +// Deep chain, allocated repeatedly. +class A1 { + a = 1; +} +class B1 extends A1 { + b = 2; +} +class C1 extends B1 { + c = 3; +} +class D1 extends C1 { + d = 4; +} +let deepSum = 0; +for (let i = 0; i < 100; i++) { + const x = new D1(); + deepSum += x.a + x.b + x.c + x.d; +} +const deep = new D1(); +console.log("deep", deepSum, deep instanceof A1, deep instanceof B1, deep instanceof C1, deep instanceof D1); + +// Subclassing a builtin still resolves through the same registry. +class MyErr extends Error { + code: number; + constructor(code: number) { + super("boom " + code); + this.code = code; + } +} +for (let i = 0; i < 20; i++) { + new MyErr(i); +} +const e = new MyErr(9); +console.log("err", e instanceof MyErr, e instanceof Error, e.message, e.code); + +// Prototype identity and getPrototypeOf agree with the chain. +console.log( + "protos", + Object.getPrototypeOf(Square.prototype) === Rect.prototype, + Object.getPrototypeOf(Rect.prototype) === Shape.prototype, + Object.getPrototypeOf(s) === Square.prototype, +); diff --git a/test-files/test_gap_template_number_formatting.ts b/test-files/test_gap_template_number_formatting.ts new file mode 100644 index 0000000000..41004b9081 --- /dev/null +++ b/test-files/test_gap_template_number_formatting.ts @@ -0,0 +1,90 @@ +// Template substitutions are ToString, not `+`'s ToPrimitive: a substitution +// whose coercion the concat helper performs itself must still print exactly +// what String(x) prints, and a substitution whose coercion is observable must +// still run it. Covers the signs, zeroes, non-finites, integer/fraction split, +// the scientific-notation thresholds and the tie-break ryu-js owns (#3987), +// BigInt, and objects with valueOf/toString. + +function tpl(s: string, n: number): string { + return `${s}:${n}`; +} +function tplAny(a: any, b: any): string { + return `${a}|${b}`; +} + +const label = ["v", "al"].join(""); + +// Signs and zeroes: -0 prints as "0" in a template, unlike Object.is. +console.log(tpl(label, 0), tpl(label, -0), tpl(label, 1), tpl(label, -1)); +console.log("neg-zero", `${-0}`, `${0}`, Object.is(-0, -0 * 1)); + +// Non-finites. +console.log(tpl(label, NaN), tpl(label, Infinity), tpl(label, -Infinity)); + +// Integers, including the 2^53 boundary and negatives. +console.log(tpl(label, 42), tpl(label, -42), tpl(label, 1e15), tpl(label, 2 ** 53)); +console.log(tpl(label, Number.MAX_SAFE_INTEGER), tpl(label, -Number.MAX_SAFE_INTEGER)); + +// Fractions and the shortest-round-trip tie-break. +console.log(tpl(label, 0.1), tpl(label, 1 / 3), tpl(label, 0.5), tpl(label, 1.005)); +console.log(tpl(label, 5e-324), tpl(label, Number.MAX_VALUE), tpl(label, Number.MIN_VALUE)); + +// The scientific-notation thresholds: >= 1e21 and < 1e-6 switch form. +console.log(tpl(label, 1e20), tpl(label, 1e21), tpl(label, 1e-6), tpl(label, 1e-7)); +console.log(tpl(label, 123456789012345680000), tpl(label, 0.000001), tpl(label, 0.0000001)); + +// Every one of these must agree with String(x) and with `+`. +const values = [0, -0, 1, -1, NaN, Infinity, -Infinity, 0.1, 1e21, 1e-7, 2 ** 53, 5e-324]; +console.log("agree", values.every((v) => `${v}` === String(v) && `${v}` === "" + v)); + +// A number reached through `any` (the annotation cannot be trusted). +console.log(tplAny(1.5, -2.5), tplAny(0, NaN), tplAny(1e21, 1e-7)); + +// A lying annotation: the parameter says number, the value is not. +console.log("lie", tpl(label, "12" as any), tpl(label, true as any), tpl(label, null as any)); +console.log("lie-obj", tpl(label, { toString: () => "OBJ" } as any)); + +// ToString is toString-first, unlike `+` which is valueOf-first. +const both = { + valueOf() { + return 111; + }, + toString() { + return "STR"; + }, +}; +console.log("tostring-first", `${both}`, String(both), "" + both, tplAny(both, both)); + +// A substitution whose coercion has a side effect must run exactly once. +let calls = 0; +const counted = { + toString() { + calls++; + return "C"; + }, +}; +const once = `${counted}-${counted}`; +console.log("side-effect", once, calls); + +// Symbol.toPrimitive wins over both. +const prim = { + [Symbol.toPrimitive](hint: string) { + return "P:" + hint; + }, +}; +console.log("toPrimitive", `${prim}`, "" + (prim as any)); + +// BigInt substitutions. +console.log("bigint", `${10n}`, `${-10n}`, `${2n ** 64n}`, tplAny(1n, 2n)); + +// Strings, booleans, null/undefined and arrays keep their forms. +console.log("mixed", tplAny("s", true), tplAny(null, undefined), tplAny([1, 2], {})); + +// Longer chains and nesting. +const n1 = 1.25; +console.log(`a${n1}b${-n1}c${n1 * 4}d`, `${`${n1}`}`); + +// A hot loop, the shape the in-place formatting is for. +let acc = ""; +for (let i = 0; i < 200; i++) acc = `${i}:${i / 8}`; +console.log("hot", acc, acc.length);