diff --git a/CLAUDE.md b/CLAUDE.md index 79558c4d96..eaa41ce9e5 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.1364 +**Current Version:** 0.5.1365 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index ba2dc58148..c886d14b64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1364" +version = "0.5.1365" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1364" +version = "0.5.1365" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1364" +version = "0.5.1365" [[package]] name = "perry-ui-tvos" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1364" +version = "0.5.1365" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index e4ef02e9d3..9243c940b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1364" +version = "0.5.1365" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7633-json-materialiser-layout-deferred.md b/changelog.d/7633-json-materialiser-layout-deferred.md index 9b8fdb749a..94ac0ab32c 100644 --- a/changelog.d/7633-json-materialiser-layout-deferred.md +++ b/changelog.d/7633-json-materialiser-layout-deferred.md @@ -24,3 +24,15 @@ Pinned-host, interleaved, hash-identical: `json_pipeline` 200k `build_out` 451 → 422 MB; `layout_note_slot` / `layout_forget_object` vanish from the profile. Also splits `barrier.rs`'s slot-store helpers into `barrier_store.rs` for the 2000-line cap. + +Qualification added by #7643: this landed on the strength of its argument +(the materialiser owns each object end to end, finalize is reached on every +path, the pointer case is conservative) plus a clean gap suite — the GC +half of it had no regression test, and #7635 showed why that mattered by +forcing every parsed record to `POINTER_FREE` and getting byte-identical +correct output from every runtime instrument. The argument was right, but +the probe could not have shown it either way: `js_json_parse` is lazy for +1 KB–16 MB top-level arrays, so the records were materialised after the +last collection. #7643 adds the workload-free regression tests +(`gc/tests/copying/deferred_finalize_7635.rs`) and the verification note on +`GC_LAYOUT_POINTER_FREE`. diff --git a/changelog.d/7643-pointer-free-misdeclaration-detectable.md b/changelog.d/7643-pointer-free-misdeclaration-detectable.md new file mode 100644 index 0000000000..c01f7273b8 --- /dev/null +++ b/changelog.d/7643-pointer-free-misdeclaration-detectable.md @@ -0,0 +1,42 @@ +**test(gc): make the `POINTER_FREE` misdeclaration hazard detectable (#7635)** + +#7635 sabotaged #7633's `layout_finish_deferred_boxed_object(ptr, saw_pointer)` +to `(ptr, false)` — every JSON-parsed record claiming `POINTER_FREE` while +holding heap strings — and got byte-identical correct output under +`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` and under +`PERRY_GC_FORCE_EVACUATE=1`, with copying minors and retired quarantine sets +observed live. Every future change to layout-state bookkeeping was about to +inherit that sentence as evidence. + +**The instruments were never at fault; the probe's subject never existed.** +`js_json_parse` routes a 1 KB–16 MB top-level array through the lazy tape +(`json_tape`), so `parse_object` — the function carrying the sabotage — runs +when an element is first *read*, not at `JSON.parse` time. The probe read its +records only after the churn, so every misdeclared record was materialised after +the last collection. A temporary audit in `heap_payload_slot_selection` on the +sabotaged build counted zero `POINTER_FREE` objects with pointer-bearing payload +words ever handed to the collector. CLAUDE.md hazard 4, applied to a probe. + +Re-run so the records live across a collection and everything fires: under +`PERRY_JSON_TAPE=0` the same sabotage SIGSEGVs, `PERRY_GC_FROMSPACE_SCAN=1` +reports `dangling=8000 owners=4000` (exactly 4,000 records × 2 fields, against +`dangling=0` clean) and `PERRY_GC_PROTECT_FROMSPACE=1` names the faulting +address; with the records merely touched before the churn, the default lazy path +reads back 7,872 of 8,000 values wrong. + +Ships four workload-free regression tests +(`gc/tests/copying/deferred_finalize_7635.rs`) that cannot be defeated by a lazy +path or a GC that did not happen to run: the finalize's two exact outcomes, the +child-slot enumerator, relocation across a copying minor gated on +`copied_objects`, the same invariant through the real `js_json_parse` entry +point so the `json/parser.rs` call site is covered, and a permanent sabotage arm +asserting a misdeclared record enumerates ZERO slots. Sabotage-verified in both +directions: #7635's exact parser mutation reddens the parse test, neutering +`layout_finish_deferred_boxed_object` reddens all four. + +The doc comment on `GC_LAYOUT_POINTER_FREE` now records what can and cannot +verify a claim about this state — including that `PERRY_GC_VERIFY_EVACUATION` is +blind to a misdeclaration by construction (it walks the same enumeration the +rewrite pass walks) and that `PERRY_GC_FROMSPACE_SCAN` is the +layout-independent instrument. No GC behaviour change, no new knobs, no codegen +change. diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 5c50db7531..cc2f4894d4 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -18,6 +18,35 @@ pub(super) const GC_COPY_PROMOTION_SURVIVALS: u8 = 4; // Low bits remain object freeze/seal/preventExtensions flags. pub const GC_LAYOUT_STATE_MASK: u16 = 0xC000; pub(super) const GC_LAYOUT_UNKNOWN: u16 = 0x0000; +/// No payload slot holds a pointer, so `heap_payload_slot_selection` skips the +/// WHOLE payload without consulting any mask. This is the one layout state that +/// is not a precision hint: marking, the evacuation rewrite and the +/// remembered-set dirty scan all funnel through that same enumeration, so an +/// object left here while holding a heap pointer loses that child outright — it +/// is neither kept alive nor rewritten when it moves. +/// +/// **How to verify a change to this state (#7635).** The end-to-end knobs do +/// catch a misdeclaration, but only if the workload actually holds a misdeclared +/// object across a collection, and it is easy to build one that never does: +/// #7635 forced every JSON-parsed record to `POINTER_FREE` while it held heap +/// strings and got byte-identical correct output under `PERRY_GC_ZEAL=1 +/// PERRY_GC_PROTECT_FROMSPACE=1` and `PERRY_GC_FORCE_EVACUATE=1`, because +/// `js_json_parse` is LAZY for 1 KB–16 MB top-level arrays (`json_tape`) and the +/// probe read its records only after the last GC. Under `PERRY_JSON_TAPE=0` the +/// same sabotage SIGSEGVs. So: +/// +/// - "clean under zeal + from-space protect" is evidence only once you have +/// shown the misdeclared object EXISTED during a collection; +/// - `PERRY_GC_FROMSPACE_SCAN=1` is the instrument to prefer — its +/// whole-payload word scan consults no layout state, and it reported the +/// stranded children at exactly `dangling=8000 owners=4000`; +/// - `PERRY_GC_VERIFY_EVACUATION` is blind here by construction: it walks the +/// same enumeration the rewrite pass walks, which is to say it asks this +/// state which slots exist. +/// +/// The workload-free detectors are the child-slot enumerator and relocation +/// across a copying minor; worked example, sabotage-verified in both +/// directions: `gc/tests/copying/deferred_finalize_7635.rs`. pub const GC_LAYOUT_POINTER_FREE: u16 = 0x4000; pub(crate) const GC_LAYOUT_SIDE_MASK: u16 = 0x8000; // A side-layout payload whose entire live prefix contains pointers. Bit 13 is diff --git a/crates/perry-runtime/src/gc/tests/copying.rs b/crates/perry-runtime/src/gc/tests/copying.rs index acce664f66..37cff3ceee 100644 --- a/crates/perry-runtime/src/gc/tests/copying.rs +++ b/crates/perry-runtime/src/gc/tests/copying.rs @@ -1,5 +1,6 @@ mod adaptive_tenuring; mod all_pointer_elements_7469; +mod deferred_finalize_7635; mod pointer_publish_7154; mod promise_side_tables; mod survival_and_malloc; diff --git a/crates/perry-runtime/src/gc/tests/copying/deferred_finalize_7635.rs b/crates/perry-runtime/src/gc/tests/copying/deferred_finalize_7635.rs new file mode 100644 index 0000000000..a0fd5f9393 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copying/deferred_finalize_7635.rs @@ -0,0 +1,374 @@ +//! #7635 — is a `POINTER_FREE` MISDECLARATION detectable at all? +//! +//! Invariant under test: +//! +//! > `layout_finish_deferred_boxed_object(ptr, saw_pointer)` is the ONLY thing +//! > that moves a materialiser-built record off its `GC_LAYOUT_POINTER_FREE` +//! > birth state. When any pointer was stored, that call is load-bearing for GC +//! > correctness: `heap_payload_slot_selection` short-circuits on `POINTER_FREE` +//! > and skips the WHOLE payload without consulting any mask, so a record left +//! > in that state neither keeps its children alive nor has its slots rewritten +//! > when they move. +//! +//! # Why a unit test, when the end-to-end probe reported clean +//! +//! #7633 deferred the JSON materialiser's per-slot layout notes to one +//! finalize. Auditing it, #7635 sabotaged that finalize to +//! `(ptr, /* saw_pointer */ false)` — every parsed record claiming +//! `POINTER_FREE` while holding heap strings — and got **byte-identical correct +//! output** from a 4,000-record Perry-compiled workload under `PERRY_GC_ZEAL=1 +//! PERRY_GC_PROTECT_FROMSPACE=1` and under `PERRY_GC_FORCE_EVACUATE=1`, with +//! copying minors and retired quarantine sets observed live. +//! +//! **The instruments were not at fault; the probe's subject never existed.** +//! `js_json_parse` routes a top-level array of 1 KB–16 MB through the LAZY TAPE +//! (`json_tape`, default since #179), so `parse_object` does not run at +//! `JSON.parse` time — it runs when an element is first read. The probe read +//! its records only *after* the churn, so every misdeclared record was +//! materialised after the last collection. A traced-object audit added to +//! `heap_payload_slot_selection` on the sabotaged build confirms it: **zero** +//! objects in `POINTER_FREE` state with pointer-bearing payload words were ever +//! handed to the collector. Nothing was stranded because nothing was there. +//! +//! Re-run so the misdeclared records actually live across a collection and +//! every instrument fires (measured on the sabotaged build, this branch): +//! +//! | arm | clean | sabotaged | +//! |---|---|---| +//! | default (lazy), read after churn | exit 0 | exit 0, byte-identical, `dangling=0` | +//! | `PERRY_JSON_TAPE=0`, read after churn | exit 0 | **SIGSEGV**; `dangling=8000 owners=4000` on the first scanned cycle; `PERRY_GC_PROTECT_FROMSPACE=1` prints `FAULT: signal 10 at 0x…` | +//! | default (lazy), records touched BEFORE the churn | exit 0 | 7,872 of 8,000 values read back wrong | +//! +//! So the end-to-end lesson is about *probe construction*, not instrument +//! capability — with one real exception. `PERRY_GC_VERIFY_EVACUATION` walks the +//! same enumeration the rewrite pass walks, i.e. it asks this very layout state +//! which slots exist, and is blind to a misdeclaration by construction; +//! `gc/fromspace_scan.rs`'s module header makes the same point about the +//! verifier generally. **`PERRY_GC_FROMSPACE_SCAN=1` is the layout-independent +//! one** — a whole-payload word scan that consults no root enumeration and no +//! layout state — and it is the knob to reach for on this hazard class. +//! +//! What this file adds on top is a detector that needs no workload at all, so +//! it cannot be defeated by a lazy path, a GC that did not happen to run, or +//! conservative-scan residue: +//! +//! 1. **The child-slot enumerator itself** — `gc_child_slots` is the single +//! question every collector pass funnels through, so asking it directly is +//! deterministic regardless of GC timing. +//! 2. **Relocation** — after a copying minor that actually moved things, a +//! traced child has a NEW address and the holding slot says so. A stranded +//! child's slot still holds its pre-cycle address, and that comparison never +//! reads the stale memory. +//! +//! [`a_misdeclared_pointer_free_record_strands_its_child`] is the SABOTAGE ARM, +//! made permanent: it performs the identical construction with the finalize's +//! `saw_pointer` forced to `false` and asserts the child is stranded. A green +//! run of the positive tests therefore means the finalize was load-bearing, not +//! that nothing was tried. + +use super::*; + +use crate::object::ObjectHeader; + +const FIELD_VALUES: [&[u8]; 2] = [b"value_alpha", b"value_bravo"]; + +fn fresh_string(bytes: &[u8]) -> usize { + crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) as usize +} + +unsafe fn field_bits(obj: *mut ObjectHeader, index: usize) -> u64 { + let fields = (obj as *const u8).add(std::mem::size_of::()) as *const u64; + *fields.add(index) +} + +unsafe fn layout_state_of(user_ptr: usize) -> u16 { + (*header_from_user_ptr(user_ptr as *const u8))._reserved & GC_LAYOUT_STATE_MASK +} + +/// Addresses of the slots the collector says it will visit inside `user_ptr`. +/// A `POINTER_FREE` payload contributes NOTHING here — that is the failure mode +/// this file guards. +unsafe fn enumerated_slot_addrs(user_ptr: usize) -> Vec { + test_heap_child_slots_for_user(user_ptr as *mut u8) + .into_iter() + .filter_map(|slot| match slot { + HeapChildSlot::Child(p, _) => Some(p as usize), + HeapChildSlot::PointerFreeRange(_) => None, + }) + .collect() +} + +unsafe fn field_slot_addr(obj: *mut ObjectHeader, index: usize) -> usize { + let fields = (obj as *const u8).add(std::mem::size_of::()) as *const u64; + fields.add(index) as usize +} + +/// The materialiser's construction loop, byte for byte: allocate the record, +/// store each field through the layout-deferred slot helper (no per-slot note), +/// accumulate the one fact the elided notes were computing, and settle the +/// layout state once. +/// +/// `honest_finalize == false` is #7635's sabotage — the exact +/// `(ptr, /* saw_pointer */ false)` mutation, expressed as an argument so both +/// arms run identical code up to that single boolean. +unsafe fn materialise_record(honest_finalize: bool) -> *mut ObjectHeader { + let obj = crate::object::js_object_alloc(0, FIELD_VALUES.len() as u32); + assert_eq!( + layout_state_of(obj as usize), + GC_LAYOUT_POINTER_FREE, + "test premise: a fresh record is born POINTER_FREE, which is why the \ + finalize is the only thing that can move it off that state" + ); + let mut saw_pointer = false; + for (index, bytes) in FIELD_VALUES.iter().enumerate() { + let child = fresh_string(bytes); + saw_pointer |= + crate::object::store_object_field_slot_layout_deferred(obj, index, string_bits(child)); + } + assert!( + saw_pointer, + "test premise: storing heap strings must be reported as pointer-bearing" + ); + layout_finish_deferred_boxed_object(obj as usize, saw_pointer && honest_finalize); + obj +} + +/// The finalize's two exact outcomes, pinned so a refactor cannot quietly widen +/// either one. A record with no pointer stored KEEPS its `POINTER_FREE` birth +/// state (that is the state's whole value); any pointer stored lands in +/// `GC_LAYOUT_UNKNOWN`, the tag-checked scan-all-slots state — never in a mask. +#[test] +fn finalize_settles_pointer_free_or_unknown_and_nothing_else() { + let _guard = CopyingNurseryTestGuard::new(1); + unsafe { + let numeric = crate::object::js_object_alloc(0, 2); + for index in 0..2usize { + assert!( + !crate::object::store_object_field_slot_layout_deferred( + numeric, + index, + crate::value::JSValue::number(index as f64 + 1.0).bits(), + ), + "a number store must not be reported as pointer-bearing" + ); + } + layout_finish_deferred_boxed_object(numeric as usize, false); + assert_eq!( + layout_state_of(numeric as usize), + GC_LAYOUT_POINTER_FREE, + "a record that stored no pointer keeps the birth state — the \ + tracer skips its whole payload, which is what #7630 bought" + ); + assert_eq!( + test_heap_child_slot_count(numeric as *mut u8), + 0, + "and the collector enumerates zero payload slots on it" + ); + + let pointered = materialise_record(/* honest_finalize = */ true); + assert_eq!( + layout_state_of(pointered as usize), + GC_LAYOUT_UNKNOWN, + "any pointer stored must settle in the conservative scan-all state" + ); + assert!( + !layout_has_typed_descriptor(pointered as usize), + "the finalize routes through `layout_mark_unknown`, so a mask a \ + slow-path by-name store created mid-construction is REMOVED, not \ + stranded" + ); + } +} + +/// The positive arm. A record built exactly as the JSON materialiser builds it, +/// holding the ONLY reference to each of its children, must have every field +/// enumerated as a child edge and must survive a copying minor with both +/// children relocated and both slots rewritten. +#[test] +fn a_materialised_record_keeps_its_children_traced_and_rewritten_7635() { + let _guard = CopyingNurseryTestGuard::new(1); + + let obj = unsafe { + materialise_record(/* honest_finalize = */ true) + }; + let before: Vec = (0..FIELD_VALUES.len()) + .map(|index| unsafe { (field_bits(obj, index) & POINTER_MASK) as usize }) + .collect(); + assert_eq!( + test_heap_child_slot_count(obj as *mut u8), + FIELD_VALUES.len(), + "the collector must enumerate every pointer-bearing field of a \ + finalized record; a POINTER_FREE record enumerates ZERO" + ); + + // The record's slots are now the sole path to each child. + js_shadow_slot_set(0, ptr_bits(obj as usize)); + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert!( + trace.copying_nursery.copied_objects >= FIELD_VALUES.len() + 1, + "this test proves nothing unless the cycle actually MOVED the record \ + and both children (copied_objects = {})", + trace.copying_nursery.copied_objects + ); + + let moved = (js_shadow_slot_get(0) & POINTER_MASK) as usize as *mut ObjectHeader; + assert_ne!(moved as usize, obj as usize, "the record itself must move"); + unsafe { + for (index, bytes) in FIELD_VALUES.iter().enumerate() { + let child = (field_bits(moved, index) & POINTER_MASK) as usize; + assert_ne!( + child, before[index], + "field {index} must have been relocated and its slot rewritten" + ); + assert!( + crate::arena::pointer_in_nursery(child) || crate::arena::pointer_in_old_gen(child), + "field {index} must name a live heap object, not a stale address" + ); + assert_string_bytes(child as *const crate::StringHeader, bytes); + } + } + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); +} + +/// The same invariant driven through the REAL entry point, `js_json_parse`, so +/// the finalize CALL SITE in `json/parser.rs` is covered and not merely the +/// helper it calls. #7635's sabotage was applied at that call site, and the two +/// tests above would stay green through it. +/// +/// The parsed record holds the only reference to each of its string values — +/// only the KEYS are interned into the (rooted) parse-key cache — so a +/// misdeclared record strands them. +#[test] +fn json_parse_record_keeps_its_string_values_traced_and_rewritten_7635() { + let _guard = CopyingNurseryTestGuard::new(1); + + // Values are 11 bytes, well above `SHORT_STRING_MAX_LEN`, so they are real + // heap `StringHeader`s in the nursery — collectable and movable — rather + // than inline short strings that no layout state could strand. + let text = br#"{"alpha":"value_alpha","bravo":"value_bravo"}"#; + let parsed = unsafe { + crate::json::js_json_parse(crate::string::js_string_from_bytes( + text.as_ptr(), + text.len() as u32, + )) + }; + js_shadow_slot_set(0, parsed.bits()); + + let obj = (js_shadow_slot_get(0) & POINTER_MASK) as usize as *mut ObjectHeader; + let before: Vec = (0..FIELD_VALUES.len()) + .map(|index| unsafe { (field_bits(obj, index) & POINTER_MASK) as usize }) + .collect(); + unsafe { + assert_ne!( + layout_state_of(obj as usize), + GC_LAYOUT_POINTER_FREE, + "a parsed record holding heap strings must NOT be left in the \ + birth state — that is #7635's sabotage" + ); + for (index, bytes) in FIELD_VALUES.iter().enumerate() { + assert_eq!( + field_bits(obj, index) & TAG_MASK, + STRING_TAG, + "test premise: field {index} must hold a HEAP string" + ); + assert_string_bytes(before[index] as *const crate::StringHeader, bytes); + } + let enumerated = enumerated_slot_addrs(obj as usize); + for index in 0..FIELD_VALUES.len() { + assert!( + enumerated.contains(&field_slot_addr(obj, index)), + "the collector must enumerate parsed field {index} as a child \ + edge; it reported {enumerated:?}" + ); + } + } + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert!( + trace.copying_nursery.copied_objects >= FIELD_VALUES.len() + 1, + "this test proves nothing unless the cycle actually MOVED the record \ + and both values (copied_objects = {})", + trace.copying_nursery.copied_objects + ); + + let moved = (js_shadow_slot_get(0) & POINTER_MASK) as usize as *mut ObjectHeader; + unsafe { + for (index, bytes) in FIELD_VALUES.iter().enumerate() { + let child = (field_bits(moved, index) & POINTER_MASK) as usize; + assert_ne!( + child, before[index], + "parsed field {index} must have been relocated and its slot \ + rewritten" + ); + assert_string_bytes(child as *const crate::StringHeader, bytes); + } + } + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); +} + +/// SABOTAGE ARM, made permanent — #7635's exact mutation. +/// +/// The identical construction with the finalize's `saw_pointer` forced to +/// `false`: the record stays `POINTER_FREE`, `heap_payload_slot_selection` +/// skips the whole payload, and the collector enumerates NOTHING. That is the +/// stranded-live-child hazard, and asserting it here is what makes the positive +/// test above a detector rather than a formality. +/// +/// Asserted on the ENUMERATOR, not through a collection, so nothing here leaves +/// a stale pointer behind for a later cycle on this thread. +/// +/// If a future change makes the collector reach these children anyway — a +/// conservative payload sweep, a layout-independent rescue pass — this test +/// goes red. That is the intended signal, not a nuisance: it would mean the +/// `POINTER_FREE` trace-skip had stopped being load-bearing, and both this file +/// and the doc comment on `GC_LAYOUT_POINTER_FREE` would need rewriting. +#[test] +fn a_misdeclared_pointer_free_record_strands_its_child() { + let _guard = CopyingNurseryTestGuard::new(1); + + let honest = unsafe { + materialise_record(/* honest_finalize = */ true) + }; + let sabotaged = unsafe { + materialise_record(/* honest_finalize = */ false) + }; + + unsafe { + assert_eq!( + layout_state_of(sabotaged as usize), + GC_LAYOUT_POINTER_FREE, + "the sabotage must actually leave the record misdeclared, or this \ + arm tests nothing" + ); + assert_eq!( + test_heap_child_slot_count(honest as *mut u8), + FIELD_VALUES.len() + ); + assert_eq!( + test_heap_child_slot_count(sabotaged as *mut u8), + 0, + "a POINTER_FREE record skips its whole payload — the children are \ + invisible to marking, to the evacuation rewrite, and to the \ + remembered-set scan alike" + ); + + // Same fields, same bits, same stores: only the finalize differed. + for index in 0..FIELD_VALUES.len() { + assert_eq!( + field_bits(sabotaged, index) & TAG_MASK, + STRING_TAG, + "field {index} really does hold a heap string in both arms" + ); + } + + // Leave no stale-pointer landmine: put the misdeclared record back into + // the conservative state before the guard drops. + layout_mark_unknown(sabotaged as *mut u8); + assert_eq!( + test_heap_child_slot_count(sabotaged as *mut u8), + FIELD_VALUES.len(), + "and the very same record becomes fully enumerable the moment its \ + layout state is corrected — the state is the ONLY difference" + ); + } +}