diff --git a/CLAUDE.md b/CLAUDE.md index 1691eff0b5..77212fcfc2 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.1391 +**Current Version:** 0.5.1392 ## TypeScript Parity Status @@ -130,7 +130,7 @@ Key functions: `js_nanbox_string/pointer/bigint`, `js_nanbox_get_pointer`, `js_g Generational mark-sweep GC in `crates/perry-runtime/src/gc.rs` (default since v0.5.237 / Phase D). Two regions in the per-thread arena: nursery (`ARENA`, fills with new allocations, swept on minor GC) and old-gen (`OLD_ARENA`, holds tenured/evacuated objects). Precise shadow-stack roots + ~55 registered side-table scanners (`gc/mod.rs:298+`); a conservative stack scan exists but production mode resolves to SkipDisabled, so liveness rests on codegen shadow-stack spilling plus `RuntimeHandleScope` in runtime helpers. Write barriers populate a remembered set so minor GC can avoid retracing the old-gen. Two-bit aging (`HAS_SURVIVED` / `TENURED`) promotes nursery survivors after 2 minor cycles; the C4b evacuation policy moves non-pinned tenured objects into old-gen with full reference rewriting only when generated write barriers are active and nursery/RSS pressure plus measured movable candidates justify the work. Idle nursery blocks observed empty for 2 GC cycles are `dealloc`'d back to the OS (C4b-δ, v0.5.235), and the next-trigger calc is hard-capped at the initial threshold (64 MB) so >90%-freed step-doubling can't blow up peak occupancy (C4b-δ-tune, v0.5.236). Triggers on arena block allocation (1 MB blocks since v0.5.196), malloc count threshold, or explicit `gc()` call. 8-byte GcHeader per allocation. -**Escape hatches**: `PERRY_GEN_GC=0`/`off`/`false` reverts to full mark-sweep (bisection only). `PERRY_GEN_GC_EVACUATE=0`/`off`/`false` disables policy evacuation; `=1`/`on`/`true` is accepted as auto-policy allowed, not unconditional evacuation. `PERRY_GC_FORCE_EVACUATE=1` stress-copies every marked non-pinned nursery object only when generated write barriers are active and policy evacuation is allowed. `PERRY_GC_VERIFY_EVACUATION=1` panics if any mutable live slot still points at a forwarded nursery object after an evacuation/rewrite cycle. `PERRY_WRITE_BARRIERS=0`/`off`/`false` disables codegen-emitted write barriers at compile time and runtime exact helper barriers at runtime for benchmark/debug bisection; unset, `=1`/`on`/`true` keep barriers enabled. `PERRY_GC_DIAG=1` prints per-cycle diagnostics, including evacuation-policy decisions for considered cycles and `barriers_inactive` skips. +**Escape hatches**: `PERRY_GEN_GC=0`/`off`/`false` reverts to full mark-sweep (bisection only). (`PERRY_GEN_GC_EVACUATE` was **deleted** in #7611 — it moved 0 of 96 gc-ratchet cells, and its one unique effect was vetoing forced evacuation, i.e. silently disarming `PERRY_GC_ZEAL`. Policy evacuation is unconditional now except on budgeted low-pause cycles, which is the arm with a behavioural test.) `PERRY_GC_FORCE_EVACUATE=1` stress-copies every marked non-pinned nursery object when generated write barriers are active, **including on the explicit `gc()` path** — since #6946 a manual `gc()` under this knob runs an evacuating minor before its full mark-sweep, instead of a full sweep that moved nothing. `PERRY_GC_VERIFY_EVACUATION=1` panics if any mutable live slot still points at a forwarded nursery object after an evacuation/rewrite cycle. `PERRY_WRITE_BARRIERS=0`/`off`/`false` disables codegen-emitted write barriers at compile time and runtime exact helper barriers at runtime for benchmark/debug bisection; unset, `=1`/`on`/`true` keep barriers enabled. `PERRY_GC_DIAG=1` prints per-cycle diagnostics, including evacuation-policy decisions for considered cycles and `barriers_inactive` skips. ### Rooting-bug instruments (#7154 family) — what each knob ACTUALLY gates @@ -140,7 +140,7 @@ A "GC value live but not rooted across a collection point" bug is invisible at c |---|---|---| | `PERRY_GC_PROTECT_FROMSPACE=1` (or `poison`) | the from-space reset performed by the **copying minor** (`arena::copying_reset_from_spaces_and_flip`). Retired Eden + active-survivor blocks are detached into a bounded quarantine, poison-filled (`0xDEADBEEFBAADF0DE`, `obj_type = 0xDE`) and, at `=1`, `mprotect(PROT_NONE)`d. A stale deref then SIGSEGVs at the faulting instruction; the installed reporter names the address, the retiring minor, and the last-known object's `obj_type`/size, then restores `SIG_DFL` and re-faults so a core/debugger still sees the real site. `poison` skips `mprotect`. | change the non-moving minor's `arena_reset_empty_blocks`, the full mark-sweep's reclaim, old-gen defrag, or the malloc sweep. **A run with zero copying minors protects nothing** — check that `PERRY_GC_DIAG=1` prints a `[gc-fromspace-protect] retired_set=#N` line. | | `PERRY_GC_PROTECT_FROMSPACE_DEPTH=N` (default 4) | how many retired page-sets stay quarantined. Evicted sets are restored to RW and **recycled back into Eden**, never `dealloc`'d, so footprint is bounded at `N × from-space bytes`. `0` is clamped to 1 — a depth of 0 would read as ON and protect nothing. **Raise this when a suspected bug does not fault**: a value can cross hundreds of collections between its last valid observation and its stale use (one per back-edge poll under zeal). #7154's `new C(…)` reproducer needs `800` — its constructor crosses 600 polls, so the default 4 misses it silently. | — | -| `PERRY_GC_ZEAL=1` | forces an evacuating minor at every **GC safepoint**: `js_gc_loop_safepoint` (loop back-edge) and the outermost microtask-pump safepoint. It bypasses exactly two things — the `GC_SAFEPOINT_PENDING` requirement in `js_gc_loop_safepoint`, and the `gc_budgeted_due_trigger()` "is anything due?" test in `gc_safepoint_moving_minor`. Also makes `gc_force_evacuate_enabled()` true, so survivors actually MOVE. | bypass `gc_safepoint_moving_minor`'s **entry guards**: a safepoint reached mid-allocation (`GC_FLAG_IN_ALLOC`), suppressed (`GC_FLAG_SUPPRESSED`), inside an unsafe FFI zone, under a non-zero `GC_ROOT_LOCK_DEPTH`, or during a budgeted cycle still returns without collecting. Nor does it override an explicit `PERRY_GEN_GC_EVACUATE=0` — that wins, and with it set zeal moves nothing and surfaces nothing. Nor does it emit loop polls — those need the **compile-time** `PERRY_GC_MOVING_LOOP_POLLS=1` (default off since #7161). Zeal on a binary compiled without polls only fires at event-loop boundaries; a compute-only loop never collects. Check `crate::gc::zeal_forced_collections()` is nonzero. There is deliberately **no level 2**: the alloc-point arm forces a conservative stack scan, which makes the copying minor ineligible, so an "every allocation" zeal would run non-moving minors and move nothing. | +| `PERRY_GC_ZEAL=1` | forces an evacuating minor at every **GC safepoint**: `js_gc_loop_safepoint` (loop back-edge) and the outermost microtask-pump safepoint. It bypasses exactly two things — the `GC_SAFEPOINT_PENDING` requirement in `js_gc_loop_safepoint`, and the `gc_budgeted_due_trigger()` "is anything due?" test in `gc_safepoint_moving_minor`. Also makes `gc_force_evacuate_enabled()` true, so survivors actually MOVE. | bypass `gc_safepoint_moving_minor`'s **entry guards**: a safepoint reached mid-allocation (`GC_FLAG_IN_ALLOC`), suppressed (`GC_FLAG_SUPPRESSED`), inside an unsafe FFI zone, under a non-zero `GC_ROOT_LOCK_DEPTH`, or during a budgeted cycle still returns without collecting. Nor does it emit loop polls — those need the **compile-time** `PERRY_GC_MOVING_LOOP_POLLS=1` (default off since #7161), and even then codegen emits **no poll** for a provably alloc-free loop body (by design, `loop_purity::loop_may_allocate`) nor for the specialized `for` / `for-of` / `for-in` lowerings (by omission — see `emit_gc_loop_safepoint`'s COVERAGE note). Zeal on a poll-free binary only fires at event-loop boundaries; a compute-only loop never collects. **You no longer have to remember to check this**: since #7604 a zeal run prints `[gc-zeal] forced_collections=N copying_minors=M moved_objects=K` at exit and **exits 70** if N or M is zero, so a run that exercised nothing is a red run rather than a green one. (`process.exit()` and an uncaught throw bypass the exit boundary and get no verdict.) There is deliberately **no level 2**: the alloc-point arm forces a conservative stack scan, which makes the copying minor ineligible, so an "every allocation" zeal would run non-moving minors and move nothing. | | `PERRY_GC_FROMSPACE_SCAN_ABORT=1` | now **implies** `PERRY_GC_FROMSPACE_SCAN=1`. It used to be inert alone (the scan never ran, so nothing aborted, and the run reported success). | — | `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` together is the pairing that turns a #7154 bug into an immediate precise fault. Compile *and* run with `PERRY_GC_MOVING_LOOP_POLLS=1` for in-loop coverage. @@ -151,7 +151,7 @@ A "GC value live but not rooted across a collection point" bug is invisible at c This is not tidiness. An unexercised mode is a configuration nobody has verified, and this project has repeatedly paid for that: -- `PERRY_GC_FORCE_EVACUATE` was **inert** for every `gc()`-driven test — it is read only on the minor path, while `gc()` runs a full mark-sweep with a forced conservative scan (#6942/#6946). Months of "passes under evacuation" meant nothing. +- `PERRY_GC_FORCE_EVACUATE` was **inert** for every `gc()`-driven test — it is read only on the minor path, while `gc()` ran a full mark-sweep with a forced conservative scan (#6942/#6946). Months of "passes under evacuation" meant nothing. *(Both halves are closed: #7657 removed the forced scan, #6946 makes `gc()` under the knob run an evacuating minor first. Kept as the precedent it is.)* - The matrix's `--pressure` knob **disabled the very path it was measuring** — the defer hard cap and the arena-trigger ceiling shared a formula and collapsed together, so the `default` arm ran zero copying minors on all 22 rows (#7024). - `gc_incremental_enabled`'s doc said "EXPERIMENTAL — default OFF" eight lines above a body comment saying "DEFAULT ON" (#6987). A merge decision was made on the wrong one. diff --git a/Cargo.lock b/Cargo.lock index cd020231df..b224b8ae03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1391" +version = "0.5.1392" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1391" +version = "0.5.1392" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1391" +version = "0.5.1392" [[package]] name = "perry-ui-tvos" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1391" +version = "0.5.1392" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 9a63ca196a..9e25a5d900 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1391" +version = "0.5.1392" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7684-evacuation-knob-liveness.md b/changelog.d/7684-evacuation-knob-liveness.md new file mode 100644 index 0000000000..4b8ed8857f --- /dev/null +++ b/changelog.d/7684-evacuation-knob-liveness.md @@ -0,0 +1,129 @@ +### gc: the evacuation knobs stop lying about what they gate (#7611, #6946, #7604) + +Three knobs believed to exercise something that did not. One is deleted, one is +made real, one is made checkable. + +#### `PERRY_GEN_GC_EVACUATE` — **deleted** (#7611) + +Measured on the pinned quiet host, identical binaries and protocol, the only +difference being the knob: a cell-by-cell diff over all 12 gc-ratchet probes × +8 counters reported **0 of 96 cells moved** — bit-identical medians, and +`gc_ratchet.py check` exit 0 with the knob set. The same procedure with +`PERRY_GEN_GC=0` moved **79** cells and returned 90 findings, so the harness was +sensitive and this knob specifically was not. + +The mechanism, which #7611 correctly flagged as unconfirmed and which is +confirmed here from the code: the knob gated `evacuation_policy_allowed` — the +C4b tenured→old-gen policy evacuation in the **non-copying fallback** minor, and +the old-page defrag selection. Every counter the ratchet reads +(`copied_objects`, `copied_bytes`, `promoted_*`) comes from +`gc_collect_minor_copying_fast_path`, which the knob never gated and which is +reached *before* the fallback path. + +Its one unique live effect was a footgun: it vetoed +`gc_force_evacuate_enabled()`, so an ambient `PERRY_GEN_GC_EVACUATE=0` silently +disarmed `PERRY_GC_ZEAL` — the #7154 instrument — and a zeal run could report +"clean" having moved nothing. `zeal_implies_forced_evacuation` used to take that +precedence arm and `return` *without exercising zeal at all*, which is the +vacuous-green shape the kill-policy exists to catch. It now has one arm and that +arm always runs. + +**The branch is not deleted with the knob**, because the branch has another +controller that *is* exercised: `evacuation_policy_allowed` is still false on +every budgeted low-pause cycle, and `budgeted_low_pause_minor_does_not_evacuate` +asserts that arm behaviourally — nothing moved, no forwarding stub, old-page +selection skipped, `trace.evacuation_policy.reason == "low_pause_non_moving"`. +What stopped existing is the untested *configuration*, per CLAUDE.md's binding +kill-policy: *"a mode that still exists is a decision that hasn't been made."* + +#### `PERRY_GC_FORCE_EVACUATE` on the `gc()` path — **fixed** (#6946) + +The knob is read only on the minor path while `manual_gc_collect_now` ran a full +mark-sweep, which never evacuates. Five suites still drive collection as +`gc(); assertFreed()` under it and exercised no evacuation at all +(`gc_property_key_operand_rooting_6935`, `gc_dynamic_arith_operand_rooting_6655`, +`gc_string_coerce_property_key_rooting_6943`, `gc_side_table_roots_evacuation`, +`gc/tests/cycle_state.rs`). + +An explicit `gc()` under the knob now runs an **evacuating minor first**, then +the full sweep. #7657 is what made this possible: this site used to take +`ManualGcScanGuard::force_full_scan`, and a forced conservative scan makes the +copying minor ineligible outright (`CopiedMinorFallbackReason::ConservativeStack`). +With precise roots the copying minor here is exactly as sound as the full sweep +that follows. New `FullEscalation::Refused` keeps the two throughput-pacing +predicates from turning that minor back into a non-moving full sweep — the +original bug in a new place. Default-off knob, so an ordinary `gc()` is +unchanged. + +#### zeal liveness — **readable, and a vacuous run now fails** (#7604) + +`zeal_forced_collections()` was **unreachable from a compiled program**: no JS +API, no diagnostic line, no exit report — while CLAUDE.md's instrument table +instructed operators to check it. The only alternative, `PERRY_GC_DIAG=1` plus +grep, wrote **212 MB of stderr in ten minutes** on a 400k-iteration ratchet +probe, so it was not a usable check either. + +Three process-global counters are added — `copying_minor_cycles()`, +`moved_objects_total()` and `loop_polls_reached()` — because "zeal forced a +collection", "a collection moved something" and "a loop body was covered at all" +are three different claims and only the third distinguishes in-loop coverage +from event-loop-boundary zeal. At the process-exit boundary a zeal run now +prints its verdict and **exits 70** when any of them says the instrument did not +fire: + +``` +# an ALLOCATING loop, compiled and run with PERRY_GC_MOVING_LOOP_POLLS=1 +[gc-zeal] forced_collections=20069 copying_minors=20069 moved_objects=95506 loop_polls=20064 +exit 0 + +# the COMPUTE-ONLY loop, same flags: codegen emits no poll for a provably +# alloc-free body, so zeal only fired at event-loop boundaries +[gc-zeal] forced_collections=5 copying_minors=5 moved_objects=4 loop_polls=0 +[gc-zeal] THIS RUN EXERCISED NOTHING WORTH TRUSTING. … PERRY_GC_MOVING_LOOP_POLLS=1 +was set but NOT ONE back-edge poll was reached … +exit 70 +``` + +Note what a two-counter verdict would have done with the second run: `forced=5`, +`copying_minors=5`, `moved_objects=4` — every counter says "live". Only +`loop_polls` says the loop was never covered. + +`loop_polls` also exists because the obvious external check is wrong: +`nm`/`objdump -d BIN | grep -c js_gc_loop_safepoint` reports **0** on the +alloc-loop binary whose polls demonstrably fired 20,064 times, so an operator +following that advice concludes the polls are absent when they are not. The +error message says so explicitly. + +Stated limitation: `process.exit()` terminates via `libc::_exit` and never +reaches the boundary, as does an uncaught throw. + +**What #7604 reported does not reproduce on current `main`.** On +`01_nursery_churn` compiled with `PERRY_GC_MOVING_LOOP_POLLS=1` and run under +`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1`, the recipe produced **741,630** +`[gc-copy-minor] ran` lines with real `copied_objects`, and +`[gc-fromspace-protect] … retired_set=#N` throughout. What *is* real is the +issue's underlying worry, and it has a second cause the issue did not name: a +compute-only loop compiled *with* the flag contains **zero** +`js_gc_loop_safepoint` call sites, because codegen deliberately omits the poll +for provably alloc-free bodies (`loop_purity::loop_may_allocate`) and +incidentally omits it for the specialized `for` / `for-of` / `for-in` lowerings +(the COVERAGE note on `emit_gc_loop_safepoint`). Under zeal that binary collected +5 times, at event-loop boundaries only — and nothing said so. + +#### Shown able to fail + +* Deleting the forced minor from `manual_gc_collect_now` turns + `explicit_gc_under_forced_evacuation_runs_a_moving_minor` red with + `before=0 after=0` — verified with `error[` count 0 and `Running unittests` + present, so the red is the assertion and not a build break. +* The zeal verdict is a pure function with four unit tests: `forced=0` (no + safepoint ever fired), `cycles=0` (every forced collection escalated to a + non-moving full sweep, which `forced > 0` alone would have called live), + `loop_polls=0` with polls requested (#7604's own shape, with the measured + compute-only counters), and the passing case with its numbers. A fifth pins + that `cycles>0, moved=0` is *not* a failure and that `loop_polls=0` *without* + the request is fine, so a future "tighten it" edit has to argue with a test. +* End to end on real binaries built from this branch: the compute-only probe + exits **70** where it previously exited 0 silently; the allocating probe exits + 0 with its numbers; an unzealed run of the same binary prints no `[gc-zeal]` + line at all. diff --git a/crates/perry-runtime/src/array/iter_methods.rs b/crates/perry-runtime/src/array/iter_methods.rs index 06993d7a25..944a897a68 100644 --- a/crates/perry-runtime/src/array/iter_methods.rs +++ b/crates/perry-runtime/src/array/iter_methods.rs @@ -215,8 +215,10 @@ pub extern "C" fn js_array_map( // scan (copied-minor eligibility requires no conservative stack scan). // Closures are non-movable, so an unrooted one is swept in place mid- // loop → the next dispatch calls freed memory ("object is not a - // function" / wild-pointer crash). Masked by PERRY_GEN_GC_EVACUATE=0, - // whose non-moving minor DOES run the conservative scan. See gh #6206. + // function" / wild-pointer crash). It used to be masked by + // the non-copying fallback minor, which DOES run the + // conservative scan; that knob was deleted in #7611, so there is no + // longer a configuration in which this rooting is optional. See gh #6206. let cb_handle = scope.root_raw_const_ptr(callback); let _tg = DenseThisGuard::bind_undefined(); diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 54f6893a5a..1d8afd9552 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1386,6 +1386,13 @@ pub(super) fn gc_collect_minor_copying_fast_path_with_eligibility( // #7592: this is the promotion the survivor-promotion handoff exists to // enable, so it releases the latch that suppressed a repeat handoff. note_copying_minor_completed(); + // #7604: the process-wide liveness counters. A copying minor ran, and this + // is how much it actually relocated -- the only evidence that distinguishes + // "the instrument was armed" from "the instrument fired". + super::zeal::note_copying_minor_moved( + collector.stats.copied_objects, + collector.stats.promoted_objects, + ); // #7592: promoted bytes are live by construction — credit them to the // old-reclaim baseline BEFORE the pressure check below, or the check reads // the stale baseline and schedules a full that is guaranteed to free diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 3eaee16560..e5eede443e 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -134,8 +134,11 @@ mod fromspace_scan; /// value dies/moves on its FIRST exposure. Debug-only (`PERRY_GC_ZEAL=1`). mod zeal; pub use verify::*; -pub use zeal::zeal_forced_collections; -pub(crate) use zeal::{gc_zeal_enabled, note_zeal_forced_collection}; +pub use zeal::{ + copying_minor_cycles, loop_polls_reached, moved_objects_total, zeal_forced_collections, + zeal_liveness_report, +}; +pub(crate) use zeal::{gc_zeal_enabled, note_loop_poll_reached, note_zeal_forced_collection}; #[cfg(feature = "diagnostics")] mod heap_snapshot; #[cfg(feature = "diagnostics")] @@ -150,6 +153,38 @@ pub fn gc_collect_minor() -> u64 { } pub(super) fn gc_collect_minor_with_trigger(trigger: GcTriggerSnapshot) -> GcCollectOutcome { + gc_collect_minor_with_trigger_inner(trigger, FullEscalation::Allowed) +} + +/// May this minor be escalated to a full mark-sweep by the two THROUGHPUT +/// PACING predicates (`copied_minor_promotion_handoff_due`, +/// `arena_growth_full_escalation_due`)? +/// +/// Both exist so a long-running mutator does not accumulate array-growth stubs +/// the non-moving minor cannot reclaim, and on every automatic path the answer +/// is `Allowed`. `Refused` exists for exactly one caller: the explicit `gc()` +/// under `PERRY_GC_FORCE_EVACUATE`, which asked for a *moving* collection and +/// is followed immediately by a full mark-sweep anyway (#6946). A full sweep +/// moves nothing, so an escalation there hands the caller a non-moving +/// collection under a knob whose whole name is about relocation — which is +/// precisely how that knob came to be inert for every `gc()`-driven test. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum FullEscalation { + Allowed, + Refused, +} + +/// The minor an explicit `gc()` runs first when forced evacuation is on +/// (#6946). Refuses the pacing escalation, so the caller gets the moving +/// collection the knob promises rather than a full sweep that moves nothing. +pub(super) fn gc_collect_forced_evacuating_minor(trigger: GcTriggerSnapshot) -> GcCollectOutcome { + gc_collect_minor_with_trigger_inner(trigger, FullEscalation::Refused) +} + +fn gc_collect_minor_with_trigger_inner( + trigger: GcTriggerSnapshot, + escalation: FullEscalation, +) -> GcCollectOutcome { // PERRY_GC_SAFEPOINT_ONLY: held for the whole collection so every // consumer of the scan decision (root scan, copying eligibility, // evacuation pinning, verifier) sees the same healed answer. @@ -189,7 +224,8 @@ pub(super) fn gc_collect_minor_with_trigger(trigger: GcTriggerSnapshot) -> GcCol f.set(prev | GC_FLAG_IN_ALLOC); prev & GC_FLAG_IN_ALLOC }); - if copied_minor_promotion_handoff_due(trigger.kind) { + let may_escalate = escalation == FullEscalation::Allowed; + if may_escalate && copied_minor_promotion_handoff_due(trigger.kind) { // #7592: latch before running it. This full is non-moving and promotes // nothing, so it cannot relieve the survivor pressure that scheduled // it; without the latch the predicate is still true at the next minor @@ -206,7 +242,7 @@ pub(super) fn gc_collect_minor_with_trigger(trigger: GcTriggerSnapshot) -> GcCol // only a full mark-sweep reclaims stubs. Escalate to a full once the arena's // live bytes exceed K× the last full's live set (belt-and-suspenders for // callers that reach a minor outside the budgeted pressure path). - if arena_growth_full_escalation_due() { + if may_escalate && arena_growth_full_escalation_due() { let outcome = gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(trigger.kind)); restore_minor_in_alloc(prev_in_alloc); @@ -217,9 +253,13 @@ pub(super) fn gc_collect_minor_with_trigger(trigger: GcTriggerSnapshot) -> GcCol crate::arena::old_pages_begin_gc_cycle(); let previous_pause_us = gc_last_pause_us(); let current_rss_bytes = crate::process::get_rss_bytes(); - let evacuation_policy_allowed = gen_gc_evacuate_enabled(); + // Not budgeted, so the low-pause veto does not apply and the policy is + // always allowed to run here (#7611 deleted the env veto that used to sit + // in this slot). The variable stays rather than being folded away: it is + // recorded in the cycle trace and read back by the evacuation-policy tests. + let evacuation_policy_allowed = true; let force_evacuation = gc_force_evacuate_enabled(); - let old_page_selection = if evacuation_policy_allowed && old_to_young_tracking_complete() { + let old_page_selection = if old_to_young_tracking_complete() { select_old_page_defrag_pages(force_evacuation) } else { OldPageDefragSelection::default() @@ -290,33 +330,56 @@ pub fn gen_gc_enabled() -> bool { }) } -/// Gen-GC Phase C4b: evacuation is policy-driven by default. -/// `PERRY_GEN_GC_EVACUATE=0`, `=false`, or `=off` disables the -/// policy. `=1`, `=true`, and `=on` are accepted for compatibility -/// but mean "allow the auto-policy", not unconditional evacuation. -pub fn gen_gc_evacuate_enabled() -> bool { - use std::sync::OnceLock; - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| { - !matches!( - std::env::var("PERRY_GEN_GC_EVACUATE").as_deref(), - Ok("0") | Ok("off") | Ok("false") - ) - }) -} +// ★ `PERRY_GEN_GC_EVACUATE` was DELETED here (#7611). Read this before adding +// an "escape hatch" back. +// +// It used to gate `evacuation_policy_allowed` — the C4b tenured→old-gen policy +// evacuation and the old-page defrag selection — and to veto +// `gc_force_evacuate_enabled()` below. +// +// **It was measured inert where anyone was looking.** On the pinned quiet host, +// identical binaries and protocol, the only difference being the knob, a +// cell-by-cell diff over all 12 gc-ratchet probes × 8 counters +// (`minor_cycles`, `step_cycles`, `copied_objects`, `copied_bytes`, +// `promoted_objects`, `promoted_bytes`, `freed_bytes`, `heap_used_bytes`) +// reported **0 of 96 cells moved** — bit-identical medians, and +// `gc_ratchet.py check` exit 0 with the knob set. For contrast the same +// procedure with `PERRY_GEN_GC=0` moved 79 cells and returned 90 findings, so +// the harness was sensitive and this knob specifically was not. The mechanism: +// the counters the ratchet reads come from the COPYING minor +// (`gc_collect_minor_copying_fast_path`), which this knob never gated; what it +// gated is the non-copying fallback's policy evacuation, which those probes do +// not reach. +// +// **Its one unique live effect was a footgun.** Vetoing +// `gc_force_evacuate_enabled()` meant an ambient `PERRY_GEN_GC_EVACUATE=0` +// silently disarmed `PERRY_GC_ZEAL` — the #7154 instrument — so a zeal run +// could report "clean" having moved nothing. CLAUDE.md documented that as a +// caveat rather than treating it as the defect it is. Deleting the knob deletes +// the way to disarm the instrument by accident. +// +// **The branch it gated is NOT deleted with it, because the branch has another +// controller that IS exercised.** `evacuation_policy_allowed` is still false on +// every budgeted low-pause cycle (`low_pause_non_moving` in +// `gc_start_budgeted_minor_fallback_cycle_with_snapshot`), and +// `budgeted_low_pause_minor_does_not_evacuate` asserts that arm behaviourally — +// nothing moved, no forwarding stub, old-page selection skipped, and +// `trace.evacuation_policy.reason == "low_pause_non_moving"`. So the losing +// mode still compiles and still has a test; what stopped existing is the +// untested *configuration*. +// +// Per CLAUDE.md's binding GC knob kill-policy: "a mode that still exists is a +// decision that hasn't been made". fn gc_force_evacuate_enabled() -> bool { // `PERRY_GC_ZEAL=1` implies forced evacuation (#7154 tooling): a zealous // minor that leaves survivors in place would move nothing, and "an unrooted // value moves on its first exposure" is the entire contract of zeal mode. - // Still subject to `gen_gc_evacuate_enabled()` — an explicit - // `PERRY_GEN_GC_EVACUATE=0` wins, so the two knobs cannot silently disagree. - gen_gc_evacuate_enabled() - && (gc_zeal_enabled() - || matches!( - std::env::var("PERRY_GC_FORCE_EVACUATE").as_deref(), - Ok("1") | Ok("on") | Ok("true") - )) + gc_zeal_enabled() + || matches!( + std::env::var("PERRY_GC_FORCE_EVACUATE").as_deref(), + Ok("1") | Ok("on") | Ok("true") + ) } fn gc_verify_evacuation_enabled() -> bool { @@ -863,11 +926,48 @@ pub extern "C" fn js_gc_init() { /// headers remain owned by the arena, while the side-allocation registries own /// the separately allocated buffers. The operation is idempotent and is called /// only once no more JavaScript work can run on this thread. +/// +/// ★ It is also where the **zeal liveness verdict** is emitted (#7604). Codegen +/// calls this exactly once, at the real process-exit boundary after every exit +/// callback (`codegen/entry.rs`), which is the one point in a compiled program +/// where "what did this run actually exercise" is answerable. See +/// `emit_zeal_liveness_verdict`. #[no_mangle] pub extern "C" fn js_gc_release_current_thread_collection_side_allocations() { crate::map::release_current_thread_map_side_allocations(); crate::json_tape_store::release_current_thread_lazy_tapes(); crate::set::release_current_thread_set_side_allocations(); + emit_zeal_liveness_verdict(); +} + +/// Print what `PERRY_GC_ZEAL=1` actually did, and **fail the process** when the +/// answer is "nothing" (#7604). +/// +/// This is the "assert the subject was live" rule turned on the instrument +/// itself. A zeal run that forced zero collections, or whose every forced +/// collection was escalated to a non-moving full mark-sweep, has exercised +/// nothing — and until now it exited 0 and looked exactly like a run that had. +/// That is the fourth way a gate cannot fail, applied to a debug knob whose +/// entire purpose is to make a class of bug reproducible. +/// +/// Exiting non-zero rather than warning is deliberate. Zeal is never on in +/// production — the whole knob is debug-only, off by default, and set by hand +/// or by a CI stress arm. In both of those contexts a vacuous run is a result +/// the operator must not be allowed to read as a pass. +/// +/// Known limitation, stated rather than hidden: `process.exit()` terminates via +/// `libc::_exit` and never reaches this boundary, so a zeal run that ends that +/// way gets no verdict. An uncaught throw is the same. Both already bypass +/// every other exit callback. +fn emit_zeal_liveness_verdict() { + match zeal_liveness_report() { + None => {} + Some(Ok(summary)) => eprintln!("{summary}"), + Some(Err(complaint)) => { + eprintln!("{complaint}"); + std::process::exit(70); + } + } } /// #5093: parse a boolean-ish env var by value (not mere presence): true for diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index acb66ec3c2..d32eb48981 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -2180,6 +2180,9 @@ pub extern "C" fn js_gc_loop_safepoint() { if !gc_moving_loop_polls_enabled() { return; } + // #7604: the only reliable answer to "did the compile-time half take + // effect". Past the opt-in, so a default binary never touches it. + super::note_loop_poll_reached(); // Zeal (#7154 tooling) collects at EVERY poll, not only when the alloc-point // arm already deferred one. Zeal cannot conjure a poll codegen never emitted, // so the `gc_moving_loop_polls_enabled()` gate above still applies — see @@ -2259,7 +2262,11 @@ fn gc_start_budgeted_minor_fallback_cycle_with_snapshot( let previous_pause_us = gc_last_pause_us(); let current_rss_bytes = crate::process::get_rss_bytes(); let low_pause_non_moving = progress_kind.is_budgeted(); - let evacuation_policy_allowed = !low_pause_non_moving && gen_gc_evacuate_enabled(); + // #7611: `low_pause_non_moving` is now the ONLY controller of this flag — + // the `PERRY_GEN_GC_EVACUATE` conjunct that used to be here was deleted as + // an untested configuration, and this arm is the one with a behavioural + // test (`budgeted_low_pause_minor_does_not_evacuate`). + let evacuation_policy_allowed = !low_pause_non_moving; let force_evacuation = !low_pause_non_moving && gc_force_evacuate_enabled(); let evacuation_policy_disabled_reason = if low_pause_non_moving { EVACUATION_POLICY_LOW_PAUSE_NON_MOVING_REASON @@ -2774,6 +2781,39 @@ fn manual_gc_collect_now() { // never dropped. A full cycle reclaims them, matching V8/Node `--expose-gc` // semantics where `gc()` is a full collection. Automatic threshold-driven // minors are unaffected. + // + // ★ #6946: under forced evacuation, run an EVACUATING minor FIRST. + // + // `PERRY_GC_FORCE_EVACUATE` is read only on the minor path, so every test + // of the shape `gc(); assertFreed()` under that knob looked like evacuation + // stress coverage and was a full mark-sweep that moved nothing — + // CLAUDE.md's hazard 4, and one of the three worked examples in it. Five + // suites still drive collection exactly that way + // (`gc_property_key_operand_rooting_6935`, + // `gc_dynamic_arith_operand_rooting_6655`, + // `gc_string_coerce_property_key_rooting_6943`, + // `gc_side_table_roots_evacuation`, and `gc/tests/cycle_state.rs`). + // + // What made this impossible before and does not any more: this site used to + // take `ManualGcScanGuard::force_full_scan`, and a forced conservative scan + // makes the copying minor ineligible outright + // (`CopiedMinorFallbackReason::ConservativeStack`). #7657 removed it — see + // the doc comment above — so `gc()` now runs on precise roots and a copying + // minor here is exactly as sound as the full mark-sweep below. + // + // `FullEscalation::Refused`, because the two throughput-pacing predicates + // would hand this call a full sweep: a NON-MOVING collection under a knob + // whose entire name is about relocation, which is the original bug in a new + // place. The full sweep follows immediately anyway, so refusing the + // escalation here costs nothing it was protecting. + // + // Default-off knob, so nothing about an ordinary `gc()` changes. + if super::gc_force_evacuate_enabled() { + super::gc_collect_forced_evacuating_minor(GcTriggerSnapshot::capture( + GcTriggerKind::Manual, + )) + .emit_after_current(); + } gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Manual)) .emit_after_current(); crate::weakref::queue_pending_finalization_callbacks_after_gc(); diff --git a/crates/perry-runtime/src/gc/tests/cycle_state.rs b/crates/perry-runtime/src/gc/tests/cycle_state.rs index 69259cb541..00a16ed620 100644 --- a/crates/perry-runtime/src/gc/tests/cycle_state.rs +++ b/crates/perry-runtime/src/gc/tests/cycle_state.rs @@ -58,9 +58,11 @@ fn start_minor_fallback_state(trigger: GcTriggerSnapshot) -> GcCycleState { clear_mark_seeds(); let previous_pause_us = gc_last_pause_us(); let current_rss_bytes = crate::process::get_rss_bytes(); - let evacuation_policy_allowed = gen_gc_evacuate_enabled(); + // Mirrors `gc_collect_minor_with_trigger`: this is the non-budgeted path, + // so the policy is allowed (#7611 deleted the env veto). + let evacuation_policy_allowed = true; let force_evacuation = gc_force_evacuate_enabled(); - let old_page_selection = if evacuation_policy_allowed && old_to_young_tracking_complete() { + let old_page_selection = if old_to_young_tracking_complete() { select_old_page_defrag_pages(force_evacuation) } else { OldPageDefragSelection::default() diff --git a/crates/perry-runtime/src/gc/tests/evacuation.rs b/crates/perry-runtime/src/gc/tests/evacuation.rs index 97b6324c16..46d5039e86 100644 --- a/crates/perry-runtime/src/gc/tests/evacuation.rs +++ b/crates/perry-runtime/src/gc/tests/evacuation.rs @@ -988,3 +988,59 @@ fn test_evacuated_old_parent_re_remembers_young_child_canary() { js_shadow_frame_pop(frame); } + +/// #6946: an explicit `gc()` under forced evacuation must run a MOVING minor. +/// +/// `PERRY_GC_FORCE_EVACUATE` is read only on the minor path, and +/// `manual_gc_collect_now` ran a full mark-sweep — which never evacuates. So +/// every test of the shape `gc(); assertFreed()` under that knob looked like +/// evacuation stress coverage and exercised none; five suites still drive +/// collection exactly that way. +/// +/// This asserts the SUBJECT WAS LIVE rather than that nothing threw: a copying +/// minor must have completed (`copying_minor_cycles` advanced) **and** the +/// survivor must be at a different address afterwards. Either alone is weak — +/// the counter without the address cannot tell a copying minor that moved +/// nothing from one that moved this object, and the address without the counter +/// cannot tell a copying minor from any other reason a slot changed. +/// +/// Zeal is what turns forced evacuation on here because `ZealGuard` is +/// thread-local; an `EnvVarGuard` would set a process-global every other test +/// in this crate shares. +#[test] +fn explicit_gc_under_forced_evacuation_runs_a_moving_minor() { + // NB: deliberately NOT `copying_nursery_isolation_lock()`. That guard is + // held across a collection here, and `js_gc_collect` reaches the same + // global side table — the double-lock deadlocks the whole test binary + // rather than failing. `CopyingNurseryTestGuard` is what the sibling + // copying-minor tests use for exactly this shape. + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _zeal = super::super::zeal::ZealGuard::set(true); + assert!( + gc_force_evacuate_enabled(), + "test premise: zeal must imply forced evacuation" + ); + + let frame = js_shadow_frame_push(1); + let child = young_leaf(); + js_shadow_slot_set(0, ptr_bits(child)); + + let cycles_before = crate::gc::copying_minor_cycles(); + js_gc_collect(); + let after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + let cycles_after = crate::gc::copying_minor_cycles(); + js_shadow_frame_pop(frame); + + assert!( + cycles_after > cycles_before, + "an explicit gc() under forced evacuation must run a COPYING minor \ + (before={cycles_before} after={cycles_after}). A full mark-sweep moves \ + nothing, which is exactly how this knob came to be inert (#6946)" + ); + assert_ne!( + after, child, + "the forced evacuating minor must MOVE the survivor -- a cycle counter \ + that advanced while nothing relocated is the same vacuous green" + ); +} diff --git a/crates/perry-runtime/src/gc/tests/fromspace_protect.rs b/crates/perry-runtime/src/gc/tests/fromspace_protect.rs index 3536f3b5cb..ebaae098f2 100644 --- a/crates/perry-runtime/src/gc/tests/fromspace_protect.rs +++ b/crates/perry-runtime/src/gc/tests/fromspace_protect.rs @@ -374,33 +374,23 @@ fn zeal_collects_at_a_safepoint_with_no_pressure_due() { /// A zealous minor that leaves survivors in place would move nothing, so it /// could not surface a stale-pointer bug at all. Zeal therefore implies forced -/// evacuation — but must still lose to an explicit `PERRY_GEN_GC_EVACUATE=0`, -/// so the two knobs can never silently disagree about whether objects move. +/// evacuation, UNCONDITIONALLY. +/// +/// ★ #7611 deleted the `PERRY_GEN_GC_EVACUATE=0` veto this test used to have a +/// second arm for. That veto was the one way an ambient environment variable +/// could silently turn zeal into a no-op: this very test used to take the +/// precedence arm and `return` without exercising zeal at all, which is the +/// vacuous-green shape the kill-policy exists to catch. There is now no +/// environment in which zeal does not force evacuation, so there is one arm and +/// it always runs. #[test] fn zeal_implies_forced_evacuation() { - // Split by ambient policy so BOTH branches assert something. The previous - // `force_enabled() || !evacuate_enabled()` form was satisfied by its right - // operand alone: under an ambient `PERRY_GEN_GC_EVACUATE=0` it passed - // without exercising zeal at all, and reported nothing to say so — the - // exact vacuous-green shape the kill-policy exists to catch. - if !gen_gc_evacuate_enabled() { - // Precedence arm: an explicit `PERRY_GEN_GC_EVACUATE=0` must beat zeal, - // so the two knobs can never silently disagree about whether objects - // move. - let _zeal_on = super::super::zeal::ZealGuard::set(true); - assert!( - !gc_force_evacuate_enabled(), - "an explicit PERRY_GEN_GC_EVACUATE=0 must win over zeal" - ); - return; - } - // Implication arm: evacuation is permitted, so zeal must turn it on. let _zeal_off = super::super::zeal::ZealGuard::set(false); let off = gc_force_evacuate_enabled(); let _zeal_on = super::super::zeal::ZealGuard::set(true); assert!( gc_force_evacuate_enabled(), - "evacuation is permitted, so zeal must force it (force_off={off})" + "zeal must force evacuation in every environment (force_off={off})" ); } diff --git a/crates/perry-runtime/src/gc/zeal.rs b/crates/perry-runtime/src/gc/zeal.rs index d3736b1175..03d56f451f 100644 --- a/crates/perry-runtime/src/gc/zeal.rs +++ b/crates/perry-runtime/src/gc/zeal.rs @@ -37,9 +37,19 @@ //! conjure a poll that codegen never emitted. A binary compiled without polls //! still gets (2) and (3) — event-loop-boundary zeal — but a compute-only loop //! that never yields will not collect at all. **For the #7154 hunt, compile AND -//! run with `PERRY_GC_MOVING_LOOP_POLLS=1`.** `zeal_forced_collections()` -//! reports how many collections zeal actually forced, so "clean under zeal" can -//! be checked against zeal having done anything. +//! run with `PERRY_GC_MOVING_LOOP_POLLS=1`.** +//! +//! Codegen also emits no poll for a provably alloc-free loop body (by design — +//! `loop_purity::loop_may_allocate`) nor for the specialized `for` / `for-of` / +//! `for-in` lowerings (by omission — `emit_gc_loop_safepoint`'s COVERAGE note), +//! so "I passed the flag" is not the same claim as "my loop has a poll". +//! +//! ## You do not have to remember to check any of this (#7604) +//! +//! A zeal run prints its own verdict at the process-exit boundary and **exits +//! 70** when it exercised nothing — see `zeal_verdict`. Before that, the +//! liveness counter this module exposes was unreadable from a compiled program +//! and the documented check could not be performed. //! //! # Why there is no allocation-point level //! @@ -113,3 +123,208 @@ pub(crate) fn note_zeal_forced_collection() { pub fn zeal_forced_collections() -> u64 { ZEAL_FORCED.load(Ordering::Relaxed) } + +// --------------------------------------------------- instrument liveness (#7604) +// +// ★ `zeal_forced_collections()` above was, until #7604, UNREADABLE from a +// compiled program. CLAUDE.md's instrument table said "Check +// `crate::gc::zeal_forced_collections()` is nonzero" and there was no JS API, +// no diagnostic line and no exit report through which to do so. The only +// alternative — `PERRY_GC_DIAG=1` and grep — wrote **212 MB of stderr in ten +// minutes** on a 400k-iteration ratchet probe, so it is not a usable check +// either. A liveness counter nobody can read is the same thing as no liveness +// counter. +// +// Two more counters are needed alongside it, because "zeal forced a collection" +// and "a collection MOVED something" are different claims and only the second +// one is what zeal exists to produce. A forced minor can still be escalated to +// a full mark-sweep by the throughput-pacing predicates, and a full sweep moves +// nothing — which is #7604's "zero copying minors" in one sentence. +// +// Process-global rather than thread-local: the report is about the run. + +static COPYING_MINORS: AtomicU64 = AtomicU64::new(0); +static MOVED_OBJECTS: AtomicU64 = AtomicU64::new(0); + +/// Called once per COMPLETED copying minor, with what it relocated. +/// +/// `copied + promoted`, not `copied` alone: #7657 made the explicit-`gc()` path +/// precise, which lets `gc/tenuring.rs` seed the adaptive threshold from these +/// cycles, and on two ratchet probes survivors are now promoted on first copy +/// rather than copied into survivor space. A `copied_objects > 0` liveness +/// assertion would have been pinned permanently false on exactly those probes. +#[inline] +pub(crate) fn note_copying_minor_moved(copied_objects: usize, promoted_objects: usize) { + COPYING_MINORS.fetch_add(1, Ordering::Relaxed); + MOVED_OBJECTS.fetch_add( + (copied_objects + promoted_objects) as u64, + Ordering::Relaxed, + ); +} + +/// How many COPYING minors have completed in this process. +pub fn copying_minor_cycles() -> u64 { + COPYING_MINORS.load(Ordering::Relaxed) +} + +/// `copied_objects + promoted_objects` summed over every copying minor. +pub fn moved_objects_total() -> u64 { + MOVED_OBJECTS.load(Ordering::Relaxed) +} + +static LOOP_POLLS: AtomicU64 = AtomicU64::new(0); + +/// Every `js_gc_loop_safepoint` that got past the compile-time/runtime opt-in. +/// +/// This is the counter that answers "was the COMPILE-TIME half live", and it +/// exists because the obvious external check does not work: `nm`/`objdump +/// -d BIN | grep -c js_gc_loop_safepoint` reports **0** on a binary whose polls +/// then fire 20069 times, so an operator following that advice concludes the +/// polls are absent when they are not. Measured, not assumed. +#[inline] +pub(crate) fn note_loop_poll_reached() { + LOOP_POLLS.fetch_add(1, Ordering::Relaxed); +} + +/// How many loop back-edge polls this run reached. +pub fn loop_polls_reached() -> u64 { + LOOP_POLLS.load(Ordering::Relaxed) +} + +/// The verdict a zeal run gets at exit: what the instrument actually did. +/// +/// `Ok(summary)` when zeal moved something, `Err(summary)` when the run +/// exercised nothing and every "clean under zeal" claim from it is vacuous. +/// Returns `None` when zeal is off, so the report costs a bool read. +pub fn zeal_liveness_report() -> Option> { + if !gc_zeal_enabled() { + return None; + } + Some(zeal_verdict( + zeal_forced_collections(), + copying_minor_cycles(), + moved_objects_total(), + loop_polls_reached(), + super::policy::gc_moving_loop_polls_enabled(), + )) +} + +/// The verdict as a pure function of the counters, so the decision is testable +/// without mutating process-global state that every other test in this crate +/// shares. +/// +/// `polls_requested` is the RUNTIME half of `PERRY_GC_MOVING_LOOP_POLLS`. When +/// it is on and `loop_polls` is still zero, the operator asked for in-loop +/// coverage and got none — the exact "arms but never fires" shape #7604 +/// reported, and the one a `forced_collections > 0` from event-loop-boundary +/// zeal would otherwise paper over. +pub(crate) fn zeal_verdict( + forced: u64, + cycles: u64, + moved: u64, + loop_polls: u64, + polls_requested: bool, +) -> Result { + let summary = format!( + "[gc-zeal] forced_collections={forced} copying_minors={cycles} \ + moved_objects={moved} loop_polls={loop_polls}" + ); + let cause = if forced == 0 { + Some("no safepoint ever forced a collection") + } else if cycles == 0 { + Some( + "every forced collection was escalated to a non-moving full \ + mark-sweep, so nothing was relocated", + ) + } else if polls_requested && loop_polls == 0 { + Some( + "PERRY_GC_MOVING_LOOP_POLLS=1 was set but NOT ONE back-edge poll \ + was reached, so every collection came from an event-loop \ + boundary and no loop body was covered", + ) + } else { + None + }; + match cause { + None => Ok(summary), + Some(cause) => Err(format!( + "{summary}\n\ + [gc-zeal] THIS RUN EXERCISED NOTHING WORTH TRUSTING. \ + PERRY_GC_ZEAL=1 was set and {cause}. Any \"clean under zeal\" \ + conclusion from this run is vacuous.\n\ + [gc-zeal] The usual causes: the binary was COMPILED without \ + PERRY_GC_MOVING_LOOP_POLLS=1 (it is a compile-time opt-in as well \ + as a runtime one), or its hot loops are ones codegen emits no poll \ + for -- provably alloc-free bodies by design \ + (`loop_purity::loop_may_allocate`), and the specialized `for` / \ + `for-of` / `for-in` lowerings by omission (see \ + `emit_gc_loop_safepoint`'s COVERAGE note). `loop_polls` above is \ + the direct answer; do NOT try to count the call sites with \ + `nm`/`objdump`, which report 0 on a binary whose polls demonstrably \ + fire 20069 times." + )), + } +} + +#[cfg(test)] +mod verdict_tests { + use super::*; + + /// The verdict must be able to say NO. Every counter combination that means + /// "the instrument did not fire" is asserted individually, because they have + /// different causes and the message has to name the right one. + #[test] + fn a_zeal_run_that_exercised_nothing_is_an_error() { + let no_safepoint = zeal_verdict(0, 0, 0, 0, false).expect_err("forced=0 must be an error"); + assert!(no_safepoint.contains("no safepoint ever forced a collection")); + + // Zeal DID force collections and every one was escalated to a full + // mark-sweep, which moves nothing. `forced > 0` alone would have called + // this run live. + let all_escalated = + zeal_verdict(4096, 0, 0, 4096, true).expect_err("cycles=0 must be an error"); + assert!(all_escalated.contains("escalated to a non-moving full")); + assert!(all_escalated.contains("copying_minors=0")); + } + + /// ★ #7604's own shape, and the one a two-counter verdict would have passed. + /// + /// Measured on the compute-only probe: `PERRY_GC_MOVING_LOOP_POLLS=1` set at + /// both compile and run time, zero back-edge polls reached (codegen emits + /// none for a provably alloc-free body), and zeal still forced 5 collections + /// at event-loop boundaries which moved 4 objects. Every counter except + /// `loop_polls` says "live"; no loop body was covered at all. + #[test] + fn polls_requested_but_never_reached_is_an_error() { + let armed_never_fired = zeal_verdict(5, 5, 4, 0, true) + .expect_err("polls requested and none reached must be an error"); + assert!(armed_never_fired.contains("NOT ONE back-edge poll")); + assert!(armed_never_fired.contains("loop_polls=0")); + + // ...and the SAME counters without the request are fine: an + // event-loop-boundary zeal run is a legitimate, weaker mode, and + // failing it would make the verdict wrong rather than strict. + assert!(zeal_verdict(5, 5, 4, 0, false).is_ok()); + } + + /// ...and YES, with the numbers, when it did fire. + #[test] + fn a_zeal_run_that_moved_objects_is_reported_ok() { + let ok = zeal_verdict(741_630, 741_630, 8_899_560, 741_630, true) + .expect("a moving run must pass"); + assert!(ok.contains("forced_collections=741630")); + assert!(ok.contains("copying_minors=741630")); + assert!(ok.contains("moved_objects=8899560")); + assert!(ok.contains("loop_polls=741630")); + } + + /// A copying minor that relocated nothing THIS cycle is still a live + /// instrument — `moved=0` with `cycles>0` happens whenever the nursery had + /// no survivors, and failing on it would make the verdict flaky rather than + /// informative. Pinned so a future "tighten it to moved>0" edit has to + /// argue with a test. + #[test] + fn a_copying_minor_with_no_survivors_is_not_a_failure() { + assert!(zeal_verdict(1, 1, 0, 1, true).is_ok()); + } +} diff --git a/crates/perry-runtime/src/promise/mod.rs b/crates/perry-runtime/src/promise/mod.rs index fb0eca2102..4177c23759 100644 --- a/crates/perry-runtime/src/promise/mod.rs +++ b/crates/perry-runtime/src/promise/mod.rs @@ -581,9 +581,11 @@ pub(crate) static TASK_QUEUE: RefCell> // TODO: Move this snapshot into `Promise` once generational evacuation // becomes the default. Today promise objects are malloc-GC payloads whose // Rust fields are not dropped during sweep, so a side table lets us clean - // pending snapshots from the sweep path. With `PERRY_GEN_GC_EVACUATE=1`, - // however, promise addresses can change and this key will not be rewritten, - // so a pre-evacuation `.then()` snapshot can be missed after settlement. + // pending snapshots from the sweep path. Once evacuation moves promise + // objects their addresses change and this key is not rewritten, so a + // pre-evacuation `.then()` snapshot can be missed after settlement. + // (The condition used to be spelled `PERRY_GEN_GC_EVACUATE=1`; that knob + // was deleted in #7611 — policy evacuation is unconditional now.) pub(crate) static PROMISE_CONTEXTS: RefCell = RefCell::new(PromiseContextStore::default()); diff --git a/docs/src/internals/memory-model.md b/docs/src/internals/memory-model.md index 31a84e8589..d8ed0b03dd 100644 --- a/docs/src/internals/memory-model.md +++ b/docs/src/internals/memory-model.md @@ -113,7 +113,6 @@ Idle nursery blocks observed empty for 2 GC cycles are `dealloc`'d back to the O | Env var | Effect | |---|---| | `PERRY_GEN_GC=0` / `off` / `false` | Disable generational mode; fall back to full mark-sweep (intended for bisection only). | -| `PERRY_GEN_GC_EVACUATE=0` / `off` / `false` | Disable policy evacuation. `=1` / `on` / `true` is accepted as "allow the auto-policy", not as unconditional evacuation. | | `PERRY_GC_FORCE_EVACUATE=1` | With generated write barriers active and policy evacuation allowed, stress-copy every marked non-pinned nursery object instead of only tenured survivors. | | `PERRY_GC_VERIFY_EVACUATION=1` | After an evacuation that actually forwards objects, panic if any mutable live slot still points at a forwarded nursery object after rewrite. | | `PERRY_WRITE_BARRIERS=0` / `off` / `false` | Disable codegen-emitted write barriers at compile time and runtime exact helper barriers at runtime for benchmark/debug bisection. Unset, `=1`, `=on`, and `=true` keep barriers enabled. | @@ -133,7 +132,7 @@ to collapse that detection latency. **All are default-off and inert when off.** | `PERRY_GC_PROTECT_FROMSPACE=1` | After an **evacuating (copying) minor**, do not recycle from-space. Retired Eden and active-survivor blocks are detached into a bounded quarantine, filled with a poison pattern whose first byte reads as an invalid `obj_type` (`0xDE`), and `mprotect(PROT_NONE)`'d over their page-aligned interior. A stale dereference then SIGSEGVs **at the faulting instruction**, with the holder still on the stack. The installed reporter prints the faulting address, which minor retired it, and the last-known object that lived there (`obj_type`, size) plus a native backtrace, then restores `SIG_DFL` and returns so the instruction re-faults — a core file or debugger still sees the real crash site. | | `PERRY_GC_PROTECT_FROMSPACE=poison` | As above without `mprotect`: poison only. Use where a fault is unwanted, or for the sub-page block edges `mprotect` cannot cover (those are always poison-filled and counted separately). | | `PERRY_GC_PROTECT_FROMSPACE_DEPTH=N` | How many retired page-sets stay quarantined (default `4`, minimum `1`). Expired sets are restored to read/write and **recycled back into Eden**, never freed, so the quarantine is a ring: steady-state footprint is bounded by `N × from-space bytes` and no `mprotect`'d page is ever handed to the system allocator. | -| `PERRY_GC_ZEAL=1` | Force an evacuating minor at **every GC safepoint** — loop back-edge polls and the outermost microtask-pump boundary — instead of only when nursery pressure is due. Implies `PERRY_GC_FORCE_EVACUATE`, so survivors actually move — but an explicit `PERRY_GEN_GC_EVACUATE=0` still wins, and with it set zeal moves nothing and therefore surfaces nothing. Zeal also does **not** bypass `gc_safepoint_moving_minor`'s entry guards (in-allocation, suppressed, unsafe FFI zone, non-zero root-lock depth, budgeted cycle): a safepoint reached in any of those states still declines to collect. Modelled on V8 `--stress-scavenge` / SpiderMonkey `gcZeal`. Composes with the two above; that pairing is what turns a rooting bug into an immediate precise fault. | +| `PERRY_GC_ZEAL=1` | Force an evacuating minor at **every GC safepoint** — loop back-edge polls and the outermost microtask-pump boundary — instead of only when nursery pressure is due. Implies `PERRY_GC_FORCE_EVACUATE`, so survivors actually move. (Until #7611 an ambient `PERRY_GEN_GC_EVACUATE=0` silently vetoed that, leaving zeal moving nothing and therefore surfacing nothing — the knob was deleted for exactly that footgun.) Zeal also does **not** bypass `gc_safepoint_moving_minor`'s entry guards (in-allocation, suppressed, unsafe FFI zone, non-zero root-lock depth, budgeted cycle): a safepoint reached in any of those states still declines to collect. Modelled on V8 `--stress-scavenge` / SpiderMonkey `gcZeal`. Composes with the two above; that pairing is what turns a rooting bug into an immediate precise fault. | | `PERRY_GC_FROMSPACE_SCAN_ABORT=1` | Abort on the **first** offending slot the whole-heap from-space scan finds, printing slot, holder, target (including the target's `obj_type`) and a collector backtrace. Now implies `PERRY_GC_FROMSPACE_SCAN=1`; previously it was silently inert on its own. | These instruments have explicit caveats, because each has burned a prior