diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2258b96728..b4179f6fc7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1244,16 +1244,20 @@ jobs: # GATING, and deliberately so. CLAUDE.md's GC knob kill-policy requires # every GC knob to have an arm that exercises it; the #7154 instruments - # (PERRY_GC_PROTECT_FROMSPACE, PERRY_GC_ZEAL) would otherwise be dark - # knobs on the subsystem with this repo's worst history of configuration - # rot. This asserts BOTH defaults — inert with the knobs unset, live with - # them set — and refuses to pass unless the zeal arm forced strictly more - # collections than the pressure-only arm, so it cannot go green having - # run zero copying minors (the #6942 / #7024 / #7025 failure mode). + # (PERRY_GC_PROTECT_FROMSPACE, PERRY_GC_SCHEDULE_SEED) + # would otherwise be dark knobs on the subsystem with this repo's worst + # history of configuration rot. This asserts BOTH defaults — inert with + # the knobs unset, live with them set — and refuses to pass unless the + # stress arms (the rate-1 seeded schedule, and the #7254 RATE=1 + + # VERIFY_EVACUATION pairing) + # forced strictly more collections than the pressure-only arm, so it + # cannot go green having run zero copying minors (the #6942 / #7024 / + # #7025 failure mode). # The detection property itself is a required-gate unit test: # gc/tests/fromspace_protect.rs::quarantine_catches_a_planted_stale_from_space_deref. # Arms 1-3/5 use a fixture sized for ~1200 back-edge polls (not #7154's - # 240k), pinned to every-poll zeal. Arm 6 (#7728) is the budgeted one: + # 240k), pinned to every-poll candidacy + # (PERRY_GC_SCHEDULE_ALLOC_KB=0). Arm 6 (#7728) is the budgeted one: # a realistic poll count at the SHIPPED default, which is the axis that # a ~1200-poll fixture structurally cannot see. - name: GC rooting-bug instruments (inert-when-off, live-when-on) diff --git a/CLAUDE.md b/CLAUDE.md index 958a111b07..71c13e0f0d 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.1457 +**Current Version:** 0.5.1458 ## TypeScript Parity Status @@ -130,23 +130,22 @@ 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` 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. +**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 the #7154 stress instrument (`PERRY_GC_SCHEDULE_SEED`). 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 -A "GC value live but not rooted across a collection point" bug is invisible at collection time: there is nothing for the collector to find. It surfaces one or more cycles later, in a different function, as `TypeError: value is not a function`. These three knobs exist to collapse that latency. **All default-off; every boolean knob's OFF state is asserted in `gc/tests/fromspace_protect.rs`** (`…_DEPTH` is a magnitude, not a mode, so its floor and default are asserted instead). The instruments are **sabotage-tested**, not merely exercised: `quarantine_catches_a_planted_stale_from_space_deref` plants a #7184/#7192-shaped stale from-space pointer and asserts the instrument distinguishes it from the live object that would otherwise be recycled into those bytes — so a green protected run means the detector works, not that nothing was tried. +A "GC value live but not rooted across a collection point" bug is invisible at collection time: there is nothing for the collector to find. It surfaces one or more cycles later, in a different function, as `TypeError: value is not a function`. These knobs exist to collapse that latency. **All default-off; every knob's OFF state is asserted — the quarantine's in `gc/tests/fromspace_protect.rs`, the schedule's in `gc/tests/schedule.rs`** (`…_DEPTH` and `…_RATE` are magnitudes, not modes, so their floors, defaults and clamping are asserted instead). The instruments are **sabotage-tested**, not merely exercised: `quarantine_catches_a_planted_stale_from_space_deref` plants a #7184/#7192-shaped stale from-space pointer and asserts the instrument distinguishes it from the live object that would otherwise be recycled into those bytes — so a green protected run means the detector works, not that nothing was tried. | knob | gates EXACTLY | does NOT | |---|---|---| | `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 **GC safepoints**: `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. **Allocation-PACED since #7728** — see the row below; it used to collect at every single poll, which cost 24 minutes on a 19 s program once #7721 made polls default-ON. | 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 come from the **compile-time** `PERRY_GC_MOVING_LOOP_POLLS` (**default ON since #7721**; it was off from #7161 until then, which is why zeal used to look free — it was collecting nothing), 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 loop_polls=P paced_polls=Q stride_bytes=S` 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_ZEAL_ALLOC_KB=N` (default 4) | how much NEW nursery material must accumulate between zeal-forced collections. Zeal's cost is ~511 us of fixed root-scan per collection to relocate a mean of 5.9 objects, so unpaced "every back-edge poll" is one whole collection per loop iteration. The stride is a monotone high-water mark (rearmed to `from_space_after + N`), so total forced collections are bounded by `bytes_allocated / N` even when a collection reclaims nothing. **`=0` restores the literal every-poll mode** — use it for a small fixture, or for a bug window executed exactly once. | change WHICH safepoints are eligible, or weaken evacuation: a paced collection is the same collection, just less often. A recurring window is still caught, after N KB of allocation rather than on the first iteration. | +| `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 at `PERRY_GC_SCHEDULE_RATE=1`). #7154's `new C(…)` reproducer needs `800` — its constructor crosses 600 polls, so the default 4 misses it silently. | — | | `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_SCHEDULE_SEED=` | seeded GC-schedule fuzzing — the middle setting between normal pacing and zeal. Three things, exactly: (1) `js_gc_loop_safepoint` stops requiring `GC_SAFEPOINT_PENDING` before descending into `gc_safepoint_moving_minor`, the same bypass zeal performs; (2) inside `gc_safepoint_moving_minor`, **past the entry guards**, a per-thread safepoint counter advances once per handled safepoint and, when `gc_budgeted_due_trigger()` reports nothing due, a minor runs anyway iff `splitmix64(splitmix64(seed) ^ counter) < threshold`; (3) `gc_force_evacuate_enabled()` becomes true, so survivors MOVE. **A value that does not parse as `u64` reads as OFF, not as seed 0.** The seed is printed at startup, at `atexit`, and on panic/SIGSEGV/SIGBUS/SIGABRT/SIGILL/SIGTRAP — the signal reporter chains to (and is re-layered on top of) the from-space quarantine's, so the two compose. Live-subject counters: `gc::gc_schedule_safepoints()` / `gc::gc_schedule_forced_collections()`. | bypass `gc_safepoint_moving_minor`'s entry guards — and a blocked safepoint deliberately does **not** tick the counter, so the ordinal sequence tracks the program's safepoints rather than its allocation state. Nor override `PERRY_GEN_GC_EVACUATE=0`. Nor emit loop polls (compile-time `PERRY_GC_MOVING_LOOP_POLLS=1`, as for zeal). Nor *suppress* pressure-driven collections — the rate is additional density, never less. Determinism is **per-thread**: the counter is thread-local, so a single-threaded program replays exactly, while a `perry/thread` program is only as reproducible as its OS scheduling. Say which you measured. | -| `PERRY_GC_SCHEDULE_RATE=<0..1>` (default `0.05`) | **only** the threshold `PERRY_GC_SCHEDULE_SEED`'s hash is compared against — the expected fraction of handled safepoints that collect. Out-of-range values clamp (a `2` reads as 1.0); unparseable and NaN fall back to the default. | do anything at all without a seed. It is inert alone. `=0` is an on-but-selects-nothing control (banner and reporters still install), `=1` is zeal's density. | +| `PERRY_GC_SCHEDULE_SEED=` | seeded GC-schedule fuzzing — the collection schedule as a knob, from normal pacing up to a collection at every handled safepoint (`PERRY_GC_SCHEDULE_RATE=1`). Three things, exactly: (1) `js_gc_loop_safepoint` stops requiring `GC_SAFEPOINT_PENDING` before descending into `gc_safepoint_moving_minor`; (2) inside `gc_safepoint_moving_minor`, **past the entry guards**, a per-thread safepoint counter advances once per handled safepoint and, when `gc_budgeted_due_trigger()` reports nothing due, a minor runs anyway iff `splitmix64(splitmix64(seed) ^ counter) < threshold`; (3) `gc_force_evacuate_enabled()` becomes true, so survivors MOVE. **A value that does not parse as `u64` reads as OFF, not as seed 0.** The seed is printed at startup, from the process-exit teardown funnel every exit path routes through (`report_exit_summary`, on the collection-side-allocation release — perry's `_exit` paths never reach `atexit`, which is only a libc-return backstop), and on panic/SIGSEGV/SIGBUS/SIGABRT/SIGILL/SIGTRAP — the signal reporter chains to (and is re-layered on top of) the from-space quarantine's, so the two compose. Live-subject counters: `gc::gc_schedule_safepoints()` / `gc::gc_schedule_forced_collections()`. | bypass `gc_safepoint_moving_minor`'s entry guards — and a blocked safepoint deliberately does **not** tick the counter, so the ordinal sequence tracks the program's safepoints rather than its allocation state. The forced-evacuation implication is **unconditional**, per #7611 (the `PERRY_GEN_GC_EVACUATE` veto is deleted). Nor emit loop polls — those are a compile-time property (`PERRY_GC_MOVING_LOOP_POLLS`, default ON since #7721; a binary compiled with `=0` has none), and without them a seeded run only fires at event-loop boundaries and a compute-only loop never collects. Nor *suppress* pressure-driven collections — the rate is additional density, never less. Determinism is **per-thread**: the counter is thread-local, so a single-threaded program replays exactly, while a `perry/thread` program is only as reproducible as its OS scheduling. Say which you measured. | +| `PERRY_GC_SCHEDULE_RATE=<0..1>` (default `0.05`) | **only** the threshold `PERRY_GC_SCHEDULE_SEED`'s hash is compared against — the expected fraction of handled safepoints that collect. Out-of-range values clamp (a `2` reads as 1.0); unparseable and NaN fall back to the default. | do anything at all without a seed. It is inert alone. `=0` is an on-but-selects-nothing control (banner and reporters still install), `=1` collects at every handled safepoint — the maximum-density endpoint, where the seed stops mattering because every ordinal is selected whatever it hashes to. There is deliberately **no allocation-point level**: the alloc-point arm forces a conservative stack scan, which makes the copying minor ineligible, so an "every allocation" density would run non-moving minors and move nothing. | +| `PERRY_GC_SCHEDULE_ALLOC_KB=N` (default 4) | how much NEW nursery material must accumulate before a loop back-edge poll becomes a candidate the seed may select (#7728). A high-water mark measured AFTER each collection, not a delta, so a collection that frees nothing cannot loop. `0` restores the literal every-poll candidate set — right for a small fixture or a window that executes once, and far slower. | change the schedule itself: the seed still decides which candidates collect, so `(seed, counter)` replay is unaffected. Nor apply to microtask-pump safepoints — it paces the loop arm only. | -`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` together is the pairing that turns a #7154 bug into an immediate precise fault. Loop polls are default-ON since #7721, so in-loop coverage no longer needs a flag — check `loop_polls=` in the exit verdict rather than assuming. If a hunt needs maximum sensitivity on a small program, add `PERRY_GC_ZEAL_ALLOC_KB=0`. Where zeal is too blunt — it distorts timing enough that some workloads die somewhere uninteresting first — `PERRY_GC_SCHEDULE_SEED` is the same pairing at a tunable density, and it hands back a reproducer. `scripts/gc_schedule_fuzz.sh [seeds]` sweeps it and prints a reproduce command per failing seed. +`PERRY_GC_SCHEDULE_SEED= PERRY_GC_PROTECT_FROMSPACE=1` together is the pairing that turns a #7154 bug into an immediate precise fault. Loop polls are default-ON since #7721, so in-loop coverage no longer needs a flag — check `loop_polls=` in the exit verdict rather than assuming. If a hunt needs maximum sensitivity on a small program, add `PERRY_GC_SCHEDULE_ALLOC_KB=0`. ### GC knob kill-policy (binding) diff --git a/Cargo.lock b/Cargo.lock index 48da068253..3e6652d7ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1457" +version = "0.5.1458" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1457" +version = "0.5.1458" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1457" +version = "0.5.1458" [[package]] name = "perry-ui-tvos" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1457" +version = "0.5.1458" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 24a63423ff..62d023206c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1457" +version = "0.5.1458" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7741-retire-gc-zeal-for-the-seeded-schedule.md b/changelog.d/7741-retire-gc-zeal-for-the-seeded-schedule.md new file mode 100644 index 0000000000..8355c00f22 --- /dev/null +++ b/changelog.d/7741-retire-gc-zeal-for-the-seeded-schedule.md @@ -0,0 +1,76 @@ +### Removed + +**`PERRY_GC_ZEAL` and `PERRY_GC_ZEAL_ALLOC_KB` are gone.** The seeded GC +schedule (`PERRY_GC_SCHEDULE_SEED`) covers everything they did: +`PERRY_GC_SCHEDULE_RATE=1` resolves to the always-threshold, so it selects +every candidate safepoint — the same points the removed knob collected at — and +`PERRY_GC_SCHEDULE_ALLOC_KB` carries the same allocation pacing with the same +default (4 KB) and the same `0` escape hatch for literal every-poll candidacy. + +Two knobs that differ only in how they pick safepoints are two configurations +to keep exercised, and CLAUDE.md's GC knob kill-policy is explicit that a mode +which still exists is a decision that has not been made. Keeping both had +already forced a precedence rule — one owned the poll arm when both were set, +purely so their forced-collection counters could not double-count the same +minor — which is coupling that exists only to reconcile a redundancy. The +schedule is a strict superset: normal pacing at rate 0, maximum density at rate +1, everything in between, and it is the only one of the two that hands back a +reproducer. + +The instrument-liveness counters (`copying_minor_cycles`, +`moved_objects_total`, `loop_polls_reached`, #7604) now live in +`gc/instruments.rs`. They count what the **collector** did, not what forced it, +so a counter no longer dies with the knob that happened to carry it; they also +appear in the schedule's exit summary, so even a sub-endpoint run states +whether it exercised anything. The #7604 verdict — same three failure causes, +same exit 70 — is `schedule_liveness_report`, and it fires **only at the rate-1 +endpoint**: below that, a sampling run that forces nothing is a legitimate +outcome (`RATE=0` is the documented on-but-selects-nothing control arm), so +holding those runs to the endpoint's contract would turn every quiet seed into +a false failure. + +**Coverage moved, not shrank.** The pacing bound and its two liveness +assertions, the `ALLOC_KB=0` every-poll arm, the high-water-mark rearm, the +poll-word arming contract, the forced-evacuation implication and the +quarantine-compose pairing all have schedule counterparts; three tests were +dropped as exact duplicates of schedule tests that already existed. +`scripts/gc_instrument_smoke.sh`'s two stress arms — the #7254 +`VERIFY_EVACUATION` pairing and #7728's termination budget — run against the +schedule with their non-vacuity assertions intact, so neither can go green from +a run that collected or moved nothing. + +**Validation.** `cargo check -p perry-runtime -p perry-codegen --all-targets` +clean (remaining warnings are `main`'s pre-existing `-D warnings` debt, +byte-identical on the parent). 207 tests green under `--test-threads=1` across +`gc::tests::schedule`, `gc::tests::fromspace_protect`, `gc::tests::evacuation`, +`gc::tests::triggers`, `gc::tests::copying` and `gc::poll_arm`. +`scripts/check_file_size.sh` OK; `bash -n` clean on the smoke script. + +**Review follow-ups.** A blocked safepoint no longer charges the pacing stride: +`gc_safepoint_moving_minor` now reports whether it handled the safepoint, and +the poll arm rearms only when it did — a safepoint blocked by an entry guard +consumes no schedule slot, so charging it a stride silently dropped the realised +density below the requested rate. `PERRY_GC_SCHEDULE_ALLOC_KB` clamps at 1 GiB +instead of saturating, because a stride nothing can reach is an off switch +wearing an on label. The poll-word arming test now runs the real resolution in +both directions instead of reading the startup value, which it could do because +the resolution became a resettable flag rather than a `std::sync::Once`; it +fails if either direction is broken. `scripts/gc_schedule_fuzz.sh` rejects an +out-of-range rate rather than letting the runtime clamp it and then printing a +reproduce command for a density it did not run at, and pins allocation pacing +into both the runs and the printed command. + +**Fixed during the merge audit — worker-teardown abort under any resolved +seed.** The exit summary runs on the per-thread teardown funnel +(`js_gc_release_current_thread_collection_side_allocations`), where a tokio +worker's TLS is already destroyed; its main-thread gate called +`std::thread::current()` (which panics there), and even past the gate a +TLS-dead worker printing would panic inside `eprintln!`'s reentrant stderr +lock. Under the mode's own panic hook that aborted every seeded run +(exit 134) after correct output. The gate now compares OS thread ids +(`pthread_self`, no TLS) against an owner recorded when the seed resolves +(`native_handle::record_diagnostics_owner_thread` — its own word, not +piggy-backed on the handle-scheme capture, which a handle call could have won +first and left the gate open). The rate-1 verdict gets the same owner gate +plus a once-guard, so a worker can never `exit(70)` on non-final counts. +Regression test: `the_diagnostics_owner_gate_blocks_other_threads`. diff --git a/crates/perry-codegen/src/lower_call/extern_timers.rs b/crates/perry-codegen/src/lower_call/extern_timers.rs index f8604f38f3..9f59fa15d8 100644 --- a/crates/perry-codegen/src/lower_call/extern_timers.rs +++ b/crates/perry-codegen/src/lower_call/extern_timers.rs @@ -32,7 +32,8 @@ //! //! `setTimeout(() => …, churn())` is legal JS — the delay is an arbitrary //! expression — so `%r8` crosses a real user call with back-edge polls while -//! nothing roots it. Under `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` the +//! nothing roots it. Under `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 +//! PERRY_GC_PROTECT_FROMSPACE=1` the //! baseline throws #7210's own symptom text, `The "callback" argument must be of //! type function. Received an instance of Object`, where node prints the //! scheduled timer. diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index d66b2c178d..9e67e21048 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -5425,8 +5425,8 @@ pub(crate) fn emit_gc_loop_safepoint( /// return without doing anything (`perry-runtime/src/gc/poll_arm.rs`), so the /// guard is not a heuristic and does not change when a collection happens: the /// word is armed by the same transition that sets `GC_SAFEPOINT_PENDING`, and -/// under `PERRY_GC_ZEAL` it is armed for the life of the process so zeal still -/// forces a collection at every poll. +/// under `PERRY_GC_SCHEDULE_SEED` it is armed for the life of the process so the +/// seeded schedule still sees every poll it is entitled to select. /// /// The load is **volatile** for one reason: this word is written by the runtime /// from calls LLVM cannot see through, and a poll whose load got hoisted out of diff --git a/crates/perry-runtime/src/arena/quarantine.rs b/crates/perry-runtime/src/arena/quarantine.rs index 5a1b044545..2603e711a6 100644 --- a/crates/perry-runtime/src/arena/quarantine.rs +++ b/crates/perry-runtime/src/arena/quarantine.rs @@ -37,7 +37,8 @@ //! proves nothing. `quarantine_stats()` reports `sets_retired` precisely so a //! green result can be checked against its subject having been live (CLAUDE.md, //! "four ways a gate can be unable to fail", #4). Pair it with -//! `PERRY_GC_ZEAL=1` to guarantee evacuating minors actually run. +//! `PERRY_GC_SCHEDULE_SEED= PERRY_GC_SCHEDULE_RATE=1` to guarantee +//! evacuating minors actually run at every handled safepoint. //! //! # Modes //! @@ -77,7 +78,7 @@ //! //! **Depth is the knob to raise when a suspected bug does not fault.** A stale //! pointer is only caught while the page-set it names is still quarantined, and -//! under `PERRY_GC_ZEAL=1` a value can cross *hundreds* of collections between +//! at `PERRY_GC_SCHEDULE_RATE=1` a value can cross *hundreds* of collections between //! its last valid observation and its stale use — one per loop back-edge poll. //! Measured on #7154's `new C(…)` reproducer: the constructor body runs 600 //! polls, so the caller's stale register is 600 retirements old by the time @@ -91,7 +92,7 @@ //! - Quarantined bytes are subtracted from `ARENA_TOTAL_BYTES` when the block //! leaves the arena, so `arena_total_bytes()` — and therefore the arena-bytes //! GC trigger — under-reports real RSS by up to `depth × from-space bytes`. -//! Fewer automatic triggers, not more. Pair with `PERRY_GC_ZEAL=1` if the +//! Fewer automatic triggers, not more. Pair with a seeded schedule if the //! point of the run is collection frequency. //! - RSS is genuinely higher than an unprotected run for the same reason. This //! is a debug instrument; do not benchmark under it. diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 52761c04aa..a410fdf450 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1569,7 +1569,7 @@ pub(super) fn gc_collect_minor_copying_fast_path_with_eligibility( // #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( + super::instruments::note_copying_minor_moved( collector.stats.copied_objects, collector.stats.promoted_objects, ); diff --git a/crates/perry-runtime/src/gc/instruments.rs b/crates/perry-runtime/src/gc/instruments.rs new file mode 100644 index 0000000000..545785ee43 --- /dev/null +++ b/crates/perry-runtime/src/gc/instruments.rs @@ -0,0 +1,60 @@ +//! Instrument-liveness counters (#7604): did the run's GC stress actually +//! exercise anything? +//! +//! They are not properties of any one stress mode — they count what the +//! COLLECTOR did (copying minors completed, objects relocated, loop back-edge +//! polls reached) regardless of what forced it. That is why they live here +//! rather than inside `schedule.rs`: a counter that lives in the knob it +//! measures dies with the knob. +//! +//! Process-global rather than thread-local: the report is about the run. + +use std::sync::atomic::{AtomicU64, Ordering}; + +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) +} diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 0422eb48fa..dcdbe1ef2c 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -29,13 +29,13 @@ pub(super) const GC_LAYOUT_UNKNOWN: u16 = 0x0000; /// 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 +/// strings and got byte-identical correct output under `PERRY_GC_SCHEDULE_RATE=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 +/// - "clean at rate 1 + 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 diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 56cb9f6aef..76baed3973 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -121,11 +121,6 @@ use old_free::*; pub(crate) use old_free::{old_free_bytes, old_free_filter_range, old_free_take_exact}; mod tenuring; use tenuring::*; -/// #7742: the measured policy behind whole-block in-place promotion. The -/// mechanism is `arena/promote.rs`; this decides when to use it. -mod promote_in_place; -use promote_in_place::*; -pub use promote_in_place::{in_place_promoted_objects, in_place_promotion_cycles}; mod oldgen; use oldgen::*; mod oldgen_defrag; @@ -138,26 +133,33 @@ mod verify; /// the rewrite pass own root enumeration. Debug-only /// (`PERRY_GC_FROMSPACE_SCAN=1`). mod fromspace_scan; +/// #7742: the measured policy behind whole-block in-place promotion. The +/// mechanism is `arena/promote.rs`; this decides when to use it. +mod promote_in_place; +use promote_in_place::*; +pub use promote_in_place::{in_place_promoted_objects, in_place_promotion_cycles}; +/// Instrument-liveness counters (#7604): copying minors completed, objects +/// relocated, loop back-edge polls reached. Mode-independent — they count what +/// the COLLECTOR did, not what forced it, so they outlive any one stress knob. +pub(crate) mod instruments; /// The loop back-edge poll's arming word: the one load that decides whether /// `js_gc_loop_safepoint` is worth calling at all. Not debug-only — it is on /// the hot path of every allocating loop. -mod poll_arm; -/// #7154 tooling: force an evacuating minor at every safepoint so an unrooted -/// value dies/moves on its FIRST exposure. Debug-only (`PERRY_GC_ZEAL=1`). -mod zeal; +pub(crate) mod poll_arm; +/// #7154 tooling: collect on a deterministic pseudo-random schedule derived from +/// a seed, at a density `PERRY_GC_SCHEDULE_RATE` tunes from "never" up to every +/// handled safepoint, so a failing seed is a reproducer. Debug-only +/// (`PERRY_GC_SCHEDULE_SEED=`). +pub(crate) mod schedule; +pub(crate) use instruments::note_loop_poll_reached; +pub use instruments::{copying_minor_cycles, loop_polls_reached, moved_objects_total}; pub use poll_arm::PERRY_GC_POLL_ARMED; pub(crate) use poll_arm::{arm_poll, disarm_poll, poll_armed, resolve_poll_seed}; -/// #7154 tooling: the middle setting between normal pacing and zeal — collect on -/// a deterministic pseudo-random schedule derived from a seed, so a failing seed -/// is a reproducer. Debug-only (`PERRY_GC_SCHEDULE_SEED=`). -pub(crate) mod schedule; -pub use schedule::{gc_schedule_forced_collections, gc_schedule_safepoints}; -pub use verify::*; -pub use zeal::{ - copying_minor_cycles, loop_polls_reached, moved_objects_total, zeal_forced_collections, - zeal_liveness_report, zeal_polls_paced, +pub use schedule::{ + gc_schedule_forced_collections, gc_schedule_safepoints, schedule_liveness_report, + schedule_polls_paced, }; -pub(crate) use zeal::{gc_zeal_enabled, note_loop_poll_reached, note_zeal_forced_collection}; +pub use verify::*; #[cfg(feature = "diagnostics")] mod heap_snapshot; #[cfg(feature = "diagnostics")] @@ -372,8 +374,8 @@ pub fn gen_gc_enabled() -> bool { // // **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 +// silently disarmed the #7154 stress instrument (`PERRY_GC_SCHEDULE_SEED`), +// so a stress 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. // @@ -391,11 +393,13 @@ pub fn gen_gc_enabled() -> bool { // 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. - gc_zeal_enabled() - || schedule::gc_schedule_enabled() + // `PERRY_GC_SCHEDULE_SEED` implies forced evacuation (#7154 tooling): a + // scheduled minor that leaves survivors in place would move nothing, and + // "an unrooted value moves on its first exposure" is the entire contract of + // the mode — without this it would be a knob whose name promises relocation + // stress and whose effect is sweep pressure. Unconditional, per #7611's + // deletion note above. + schedule::gc_schedule_enabled() || matches!( std::env::var("PERRY_GC_FORCE_EVACUATE").as_deref(), Ok("1") | Ok("on") | Ok("true") @@ -984,17 +988,16 @@ pub extern "C" fn js_gc_init() { /// 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`. +/// ★ It is also where the **schedule 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_schedule_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(); // Every process-exit path funnels through here — the generated exit // epilogue, `js_process_exit`, and the fatal-path teardown — and perry's own // exits call `_exit`, so `atexit` alone would not see them. Print the seeded @@ -1002,29 +1005,46 @@ pub extern "C" fn js_gc_release_current_thread_collection_side_allocations() { // safepoints the schedule actually saw. Inert (one cached-`Option` load) and // once-only when the mode is off. schedule::report_exit_summary(); + emit_schedule_liveness_verdict(); } -/// Print what `PERRY_GC_ZEAL=1` actually did, and **fail the process** when the -/// answer is "nothing" (#7604). +/// Print what the rate-1 schedule endpoint 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 +/// itself. A rate-1 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. +/// nothing — and before #7604 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. +/// Exiting non-zero rather than warning is deliberate. The schedule 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. Sub-endpoint +/// rates get no verdict (a sparse sample legitimately forcing nothing is not a +/// broken instrument); their liveness counters are in the exit-summary line +/// above. /// /// 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() { +/// `libc::_exit` and never reaches this boundary, so a run that ends that way +/// gets no verdict. An uncaught throw is the same. Both already bypass every +/// other exit callback. +fn emit_schedule_liveness_verdict() { + // Same discipline as `report_exit_summary`, for the same reason: every + // thread routes through the teardown funnel, the counters are + // process-global, and a worker tearing down first would judge — and at + // rate 1 `exit(70)` on — counts that are not yet final. Main-thread-only + // (via the TLS-free OS-id compare) and once-only. + if !crate::native_handle::is_main_thread_or_unrecorded() { + return; + } + static VERDICT_EMITTED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + if VERDICT_EMITTED.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; + } + match schedule_liveness_report() { None => {} Some(Ok(summary)) => eprintln!("{summary}"), Some(Err(complaint)) => { diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 3a142093a8..2c730ec4b6 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -2389,7 +2389,13 @@ fn gc_budgeted_due_trigger() -> Option { /// arm; this is purely additive (the alloc-point fallback is untouched) and /// gated by `gc_moving_safepoint_enabled` (**default ON**; the kill switch is /// `PERRY_GC_MOVING_SAFEPOINT=0`). -pub(crate) fn gc_safepoint_moving_minor() { +/// +/// Returns whether the safepoint was HANDLED — false when an entry guard +/// blocked it (mid-allocation, suppressed, unsafe FFI zone, non-zero root-lock +/// depth, active budgeted cycle), true otherwise, including when it was handled +/// and nothing was due. A blocked safepoint consumes no schedule slot, so the +/// caller must not charge it a pacing stride either. +pub(crate) fn gc_safepoint_moving_minor() -> bool { // Same start guards the budgeted collector uses, minus the (here // irrelevant) scanner block: never collect mid-allocation, inside a // runtime handle scope, in an unsafe FFI zone, or during a budgeted cycle. @@ -2401,7 +2407,7 @@ pub(crate) fn gc_safepoint_moving_minor() { if in_alloc || unsafe_zone || root_lock || budgeted { // Blocked right now — leave GC_SAFEPOINT_PENDING set so the next poll // retries; do not clear it here. - return; + return false; } // We are handling this safepoint (collect or find nothing due): clear the // deferral flag set by the alloc-point arm (Phase 2/3). @@ -2429,7 +2435,7 @@ pub(crate) fn gc_safepoint_moving_minor() { // the bounded slack valve. Some(BudgetedGcTrigger::OldReclaim) => { if GC_OLD_RECLAIM_IN_PROGRESS.with(Cell::get) { - return; + return true; } let _reentry = OldReclaimReentryGuard::enter(); GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false)); @@ -2439,28 +2445,21 @@ pub(crate) fn gc_safepoint_moving_minor() { )) .emit_after_current(); super::record_safepoint_drain(super::SafepointDrainKind::OldReclaim); - return; + return true; } _ => { - // No nursery-pressure trigger is due — nothing to collect here... - // unless zeal is on (#7154 tooling), in which case the point of the - // mode is to collect anyway so an unrooted value moves on its first - // exposure. `gc_force_evacuate_enabled()` is true under zeal, so - // this minor MOVES survivors rather than sweeping in place. - // - // ...or unless the seeded schedule selected this safepoint, which is - // the same bargain at a tunable density instead of all-or-nothing. - // `gc_force_evacuate_enabled()` is true in that mode too, for the - // same reason. Zeal wins when both are set: it is the strictly - // denser schedule, and attributing the collection to the mode that - // actually determined it keeps both live-subject counters honest. - if super::gc_zeal_enabled() { - super::note_zeal_forced_collection(); - } else if scheduled { - super::schedule::note_schedule_forced_collection(); - } else { - return; + // No nursery-pressure trigger is due — nothing to collect here, + // unless the seeded schedule (#7154 tooling) selected this + // safepoint, in which case the point of the mode is to collect + // anyway so an unrooted value moves on its first exposure (at the + // default `PERRY_GC_SCHEDULE_RATE` of 5% that is one handled + // safepoint in twenty, at rate 1 every one of them). + // `gc_force_evacuate_enabled()` is true whenever a seed resolved, + // so this minor MOVES survivors rather than sweeping in place. + if !scheduled { + return true; } + super::schedule::note_schedule_forced_collection(); GcTriggerKind::ArenaBytes } }; @@ -2481,6 +2480,7 @@ pub(crate) fn gc_safepoint_moving_minor() { // the precise collection that replaced it actually ran (CLAUDE.md, four // ways a gate cannot fail — #4, the gate runs but its subject never did). super::record_safepoint_drain(super::SafepointDrainKind::NurseryMinor); + true } /// The ONLY writer of `GC_SAFEPOINT_PENDING`. @@ -2543,58 +2543,61 @@ pub extern "C" fn js_gc_loop_safepoint() { /// return — no frame, no spills. #[inline(never)] fn js_gc_loop_safepoint_armed() { - // Releases the startup seed unless zeal wants every poll. Must run before - // the opt-in check below: a build with the polls killed still has to get - // the word back to zero, or every back-edge keeps paying for the call. + // Releases the startup seed unless a resolved seed wants the poll kept + // reachable. Must run before the opt-in check below: a build with the polls + // killed still has to get the word back to zero, or every back-edge keeps + // paying for the call. super::resolve_poll_seed(); if !gc_moving_loop_polls_enabled() { return; } // #7604: the only reliable answer to "did the compile-time half take - // effect". Exhaustive exactly under zeal, which is where `zeal_verdict` - // reads it — see `resolve_poll_seed` and `loop_polls_reached`. + // effect". Exhaustive exactly under a resolved seed, which is where + // `schedule_verdict` reads it — see `resolve_poll_seed` and + // `loop_polls_reached`. super::note_loop_poll_reached(); - // Zeal (#7154 tooling) collects at polls the deferral flag would skip, 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 `gc/zeal.rs` for why that means "compile AND run - // with `PERRY_GC_MOVING_LOOP_POLLS=1`". - // - // ★ #7728: "at polls", not "at EVERY poll". Unpaced, this arm cost ~511 µs - // per loop iteration to relocate a mean of 5.9 objects, which made zeal - // unusable on any real workload the moment #7721 turned back-edge polls on - // by default (24 minutes for a 19 s program). The stride is a bound on - // forced collections, not a heuristic — see `gc/zeal.rs`. - // - // The zeal work all sits inside the `!pending` branch on purpose: a default - // (zeal-off) build reaches exactly the same one cached-bool read and return - // it did before, and the deferral-drain path below is untouched. + // The schedule work all sits inside the `!pending` branch on purpose: a + // default (mode-off) build reaches exactly the same one cached-bool read + // and return it did before, and the deferral-drain path below is untouched. if !GC_SAFEPOINT_PENDING.with(Cell::get) { - if !super::gc_zeal_enabled() { - // The seeded schedule (`PERRY_GC_SCHEDULE_SEED`) needs the same - // bypass as zeal, and needs it here rather than at the decision - // point: a schedule cannot select a safepoint this gate already - // returned from. The decision itself — and the counter tick it is - // a function of — happens inside `gc_safepoint_moving_minor`, past - // the entry guards. Same compile-time caveat as zeal. When both - // modes are set, zeal's paced path below runs instead and the - // schedule ticks on each collection it forces (zeal wins — it is - // the strictly denser schedule). - if super::schedule::gc_schedule_enabled() { - gc_safepoint_moving_minor(); - } + // The seeded schedule (#7154 tooling) considers polls the deferral flag + // would skip, so it needs this gate bypassed — and needs it here rather + // than at the decision point: a schedule cannot select a safepoint this + // gate already returned from. The decision itself, and the ordinal tick + // it is a function of, happen inside `gc_safepoint_moving_minor`, past + // the entry guards. A resolved seed cannot conjure a poll codegen never + // emitted, so the `gc_moving_loop_polls_enabled()` gate above still + // applies — see `gc/schedule.rs`. + if !super::schedule::gc_schedule_enabled() { return; } - if !super::zeal::zeal_poll_collection_due(crate::arena::copying_from_space_in_use_bytes()) { - super::zeal::note_zeal_poll_paced(); + // ★ #7728, ported: "polls", not "EVERY poll". A poll only becomes a + // candidate the seed can select once `PERRY_GC_SCHEDULE_ALLOC_KB` of + // new nursery material has accumulated; unpaced, the rate-1 endpoint + // cost ~511 µs per loop iteration and turned a 19 s program into a + // 24-minute one. The stride is a bound on candidates, not a heuristic. + if !super::schedule::schedule_poll_collection_due( + crate::arena::copying_from_space_in_use_bytes(), + ) { + super::schedule::note_schedule_poll_paced(); return; } - gc_safepoint_moving_minor(); - // Rearm from the level measured AFTER the collection, so the next - // forced one costs a full stride of new allocation on top of whatever - // survived — see `gc/zeal.rs` for why this is a high-water mark and not - // a delta. - super::zeal::note_zeal_poll_collection(crate::arena::copying_from_space_in_use_bytes()); + // Rearm ONLY when the safepoint was handled. A blocked safepoint + // consumes no schedule slot (see `schedule_tick`'s placement past the + // entry guards), so charging it a full stride would silently drop the + // realised density below the requested rate — and a loop that polls + // while a guard is held would lose candidate after candidate with + // nothing in the exit summary to say so. + // + // Rearm from the level measured AFTER the safepoint, so the next + // candidate costs a full stride of new allocation on top of whatever + // survived — see `gc/schedule.rs` for why this is a high-water mark and + // not a delta. + if gc_safepoint_moving_minor() { + super::schedule::note_schedule_poll_collection( + crate::arena::copying_from_space_in_use_bytes(), + ); + } return; } gc_safepoint_moving_minor(); diff --git a/crates/perry-runtime/src/gc/poll_arm.rs b/crates/perry-runtime/src/gc/poll_arm.rs index a70d170731..493fb00708 100644 --- a/crates/perry-runtime/src/gc/poll_arm.rs +++ b/crates/perry-runtime/src/gc/poll_arm.rs @@ -18,7 +18,7 @@ //! 3. `GC_SAFEPOINT_PENDING.with(Cell::get)` — a thread-local read, and on //! Darwin **a call to `_tlv_get_addr`**, Mach-O having no local-exec TLS //! model, -//! 4. `gc_zeal_enabled()` — a second `OnceLock` acquire load, +//! 4. `schedule::gc_schedule_enabled()` — a second `OnceLock` acquire load, //! //! all of it behind an out-of-line `extern "C"` call the user module cannot //! inline. Measured on the quiet bench host at ~3 ns per back-edge, which is @@ -55,7 +55,7 @@ //! the `Cell` and this counter are one piece of state with two representations, //! and `pending_transitions_arm_and_disarm` pins them together. -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; /// Process-global count of reasons `js_gc_loop_safepoint` must do more than /// return; see the module docs for the invariant. @@ -68,10 +68,11 @@ use std::sync::atomic::{AtomicU32, Ordering}; /// pins the pair. /// /// **It starts at 1** — the seed. Resolving "should this word be permanently -/// armed?" means asking `gc_zeal_enabled()`, and asking costs exactly what the -/// word exists to avoid, so the process starts armed and the FIRST poll to get -/// through resolves the seed once (see [`resolve_poll_seed`]). Zeal keeps it; -/// every other configuration releases it and the poll goes quiet. +/// armed?" means asking whether a #7154 stress mode is on, and asking costs +/// exactly what the word exists to avoid, so the process starts armed and the +/// FIRST poll to get through resolves the seed once (see +/// [`resolve_poll_seed`]). A resolved `PERRY_GC_SCHEDULE_SEED` keeps it; every +/// other configuration releases it and the poll goes quiet. #[no_mangle] pub static PERRY_GC_POLL_ARMED: AtomicU32 = AtomicU32::new(1); @@ -104,31 +105,38 @@ pub(crate) fn disarm_poll() { /// Release the startup seed, once, on the first poll that gets through. /// -/// Under `PERRY_GC_ZEAL` the seed is KEPT, and keeping it is load-bearing twice -/// over. Zeal's contract is that it collects at *every* safepoint, not only at -/// ones an alloc-point trigger already deferred — with the seed released, a -/// zeal run would poll, read zero, skip the call and force nothing, and -/// `zeal_liveness_report` would correctly declare the whole run vacuous. And -/// `note_loop_poll_reached` lives past this gate, so the `loop_polls` figure in -/// `zeal_verdict` is exhaustive exactly when it is read: under zeal. +/// Under `PERRY_GC_SCHEDULE_SEED` the seed is KEPT, and keeping it is +/// load-bearing twice over. The schedule's contract is that it selects among +/// safepoints the deferral flag would skip, not only ones an alloc-point +/// trigger already deferred — with the word released, a seeded run would poll, +/// read zero, skip the call and force nothing, and `schedule_liveness_report` +/// would correctly declare the whole run vacuous. And `note_loop_poll_reached` +/// lives past this gate, so the `loop_polls` figure in `schedule_verdict` is +/// exhaustive exactly when it is read: under a resolved seed. /// -/// Outside zeal the counter is no longer a count of back-edges executed, and -/// [`super::loop_polls_reached`] says so. +/// Outside the seeded mode the counter is no longer a count of back-edges +/// executed, and [`super::loop_polls_reached`] says so. pub(crate) fn resolve_poll_seed() { - static SEED: std::sync::Once = std::sync::Once::new(); - SEED.call_once(|| { - // #7781: the seeded schedule keeps the word armed for exactly zeal's - // reason — its collection decision lives inside the safepoint, so a - // disarmed poll never presents the safepoint to decide at. Measured - // before this line existed: `PERRY_GC_SCHEDULE_RATE=1` on #7606's - // reproduction saw SIX safepoints against zeal's 9,648 loop polls — - // the "collect at every opportunity" end of the dial was an - // event-loop-boundary instrument only, and the loop-safepoint bypass - // #7317 added was downstream of a gate that never opened. - if !super::gc_zeal_enabled() && !super::schedule::gc_schedule_enabled() { - disarm_poll(); - } - }); + // A plain once-flag rather than `std::sync::Once`, for two reasons. It is + // resettable, so `the_startup_seed_is_kept_only_for_a_resolved_seed` can + // exercise BOTH directions instead of reading whatever the first poll in + // the test binary happened to leave behind. And the body only ever + // releases the seed, so a second thread that races past while the winner + // is still inside costs one poll taking the slow path and returning — + // there is nothing here for it to observe half-done. + if SEED_RESOLVED.swap(true, Ordering::Relaxed) { + return; + } + if !super::schedule::gc_schedule_enabled() { + disarm_poll(); + } +} + +static SEED_RESOLVED: AtomicBool = AtomicBool::new(false); + +#[cfg(test)] +pub(crate) fn reset_poll_seed_for_test() { + SEED_RESOLVED.store(false, Ordering::Relaxed); } #[cfg(test)] @@ -182,7 +190,7 @@ mod tests { /// The default is ARMED, and it has to be: the seed is what guarantees the /// first poll reaches [`resolve_poll_seed`] at all. A word that started at - /// zero would never let a zeal run take its own opt-in. + /// zero would never let a seeded run take its own opt-in. #[test] fn the_process_starts_armed_so_the_first_poll_gets_through() { // Not `poll_armed()` — by the time this test runs another test may have diff --git a/crates/perry-runtime/src/gc/promote_in_place.rs b/crates/perry-runtime/src/gc/promote_in_place.rs index 03f09ba538..85c2e1f09d 100644 --- a/crates/perry-runtime/src/gc/promote_in_place.rs +++ b/crates/perry-runtime/src/gc/promote_in_place.rs @@ -41,7 +41,8 @@ //! promotion until a full runs and actually reclaims them, so a workload //! that sits just above the threshold forever cannot bleed footprint //! indefinitely. -//! * `PERRY_GC_FORCE_EVACUATE` / `PERRY_GC_ZEAL` turn it off outright. Those +//! * `PERRY_GC_FORCE_EVACUATE` — and every mode that implies it, a resolved +//! `PERRY_GC_SCHEDULE_SEED` included — turns it off outright. Those //! knobs exist to make objects MOVE; a promoting cycle moves nothing, and an //! instrument that silently stops exercising its subject is exactly the //! failure mode CLAUDE.md's "a gate must assert its subject was live" rule @@ -143,8 +144,10 @@ pub(super) fn should_promote_young_in_place() -> bool { if !promote_in_place_enabled() { return false; } - // Both of these exist to make objects move. Leave them a copier to drive. - if gc_force_evacuate_enabled() || gc_zeal_enabled() { + // Forced evacuation exists to make objects move — and it is what every + // stress mode implies (a resolved schedule seed included), so this one + // predicate covers them all. Leave them a copier to drive. + if gc_force_evacuate_enabled() { return false; } if PROMOTED_DEAD_BYTES.with(Cell::get) >= PROMOTED_DEAD_BUDGET_BYTES { diff --git a/crates/perry-runtime/src/gc/schedule.rs b/crates/perry-runtime/src/gc/schedule.rs index f7fe3e7b0b..e5addfde94 100644 --- a/crates/perry-runtime/src/gc/schedule.rs +++ b/crates/perry-runtime/src/gc/schedule.rs @@ -1,28 +1,25 @@ //! Seeded GC-schedule fuzzing (#7154 tooling) — `PERRY_GC_SCHEDULE_SEED`. //! -//! # Why a third setting between "normal" and "zeal" +//! # Why the collection schedule is a knob at all //! //! A #7154-class bug is a value that is live but not rooted across a collection //! point. Whether it is *caught* depends entirely on whether a collection lands //! inside that window, so the observed failure rate is a property of the -//! *schedule*, not of the bug. Two settings existed: +//! *schedule*, not of the bug. Normal pacing puts collections tens of megabytes +//! apart: Socket Firewall's `sfw-registry --help` fails about 1 run in 60 there. +//! Confirming a fix by repetition at that rate needs ~1000 runs; with zero +//! failures in `N` runs the 95% upper bound on the true rate is only ~`3/N`, so +//! 120 clean runs bound a 1.7% bug at 2.5% — no evidence at all. //! -//! - **Normal.** Collections are tens of megabytes apart. Socket Firewall's -//! `sfw-registry --help` fails about 1 run in 60 here. Confirming a fix by -//! repetition at that rate needs ~1000 runs; with zero failures in `N` runs -//! the 95% upper bound on the true rate is only ~`3/N`, so 120 clean runs -//! bound a 1.7% bug at 2.5% — no evidence at all. -//! - **`PERRY_GC_ZEAL=1`.** Collect at *every* safepoint. Maximum pressure, but -//! all-or-nothing: it is one fixed schedule, it is slow, and it changes the -//! program's timing enough that some workloads never reach the interesting -//! code (on Socket Firewall's registry it dies in `node-machine-id` first, so -//! zeal cannot be used there at all). -//! -//! This mode is the middle. `PERRY_GC_SCHEDULE_SEED=` makes the decision -//! *"should this safepoint collect?"* a deterministic pseudo-random function of -//! the seed and a monotonically increasing per-thread safepoint counter, at a -//! tunable density (`PERRY_GC_SCHEDULE_RATE`, default 5%). Two properties -//! follow, and they are the whole point: +//! `PERRY_GC_SCHEDULE_SEED=` makes the decision *"should this safepoint +//! collect?"* a deterministic pseudo-random function of the seed and a +//! monotonically increasing per-thread safepoint counter, at a density +//! `PERRY_GC_SCHEDULE_RATE` tunes (default 5%). The whole range is one knob: +//! rate 1 collects at *every* handled safepoint — maximum pressure, one fixed +//! schedule, slow, and distorting enough that some workloads never reach the +//! interesting code (on Socket Firewall's registry a rate-1 run dies in +//! `node-machine-id` first, so the useful rates there are the low ones). Two +//! properties follow from the seed, and they are the whole point: //! //! 1. **Amplification.** Varying *when* collections fire explores the actual bug //! space. Re-running one fixed schedule explores almost nothing — which is @@ -40,9 +37,8 @@ //! `PERRY_GC_SCHEDULE_SEED=` (unset ⇒ mode OFF, and OFF is inert): //! //! 1. `js_gc_loop_safepoint` stops requiring `GC_SAFEPOINT_PENDING` before it -//! descends into `gc_safepoint_moving_minor` — exactly the bypass zeal -//! performs, and for the same reason: the schedule cannot select a safepoint -//! that the gate returned from. +//! descends into `gc_safepoint_moving_minor`. The schedule cannot select a +//! safepoint that the gate returned from. //! 2. In `gc_safepoint_moving_minor`, the per-thread safepoint counter is //! advanced once per *handled* safepoint (i.e. after the entry guards, at the //! point `GC_SAFEPOINT_PENDING` is cleared), and when @@ -62,12 +58,16 @@ //! a budgeted cycle still returns without collecting **and without ticking the //! counter**, so the schedule stays aligned with safepoints that could have //! collected; -//! - override an explicit `PERRY_GEN_GC_EVACUATE=0` — that wins, and with it set -//! this mode moves nothing and surfaces nothing, exactly as with zeal; -//! - emit loop back-edge polls. Those need the **compile-time** -//! `PERRY_GC_MOVING_LOOP_POLLS=1` (default off since #7161). Without them the -//! mode only sees event-loop-boundary safepoints and a compute-only loop never -//! collects. Compile *and* run with the poll opt-in; +//! - leave survivors in place: a resolved seed implies forced evacuation +//! UNCONDITIONALLY. `PERRY_GEN_GC_EVACUATE`, whose `=0` used to veto that, +//! was deleted by #7611 precisely because an ambient veto silently turned +//! the #7154 instrument into a no-op — the vacuous-green shape the knob +//! kill-policy exists to catch; +//! - emit loop back-edge polls. Those are a **compile-time** property +//! (`PERRY_GC_MOVING_LOOP_POLLS`, default ON since #7721; a binary compiled +//! with `=0` has none). Without them the mode only sees event-loop-boundary +//! safepoints and a compute-only loop never collects — check the exit +//! summary's `loop_polls=` before trusting a clean sweep; //! - suppress or replace pressure-driven collections. The rate is *additional* //! density on top of what the budgeted collector already does, never less. //! @@ -77,8 +77,8 @@ //! `PERRY_GC_SCHEDULE_SEED` is set. `0` means never (a deliberately inert-but-on //! configuration, useful as a control: the banner and reporter still install, so //! an A/B against `rate>0` isolates the schedule from the reporting). `1` means -//! every handled safepoint, which is zeal's density — reachable, but if that is -//! what you want, `PERRY_GC_ZEAL=1` says so more plainly. +//! every handled safepoint — the maximum-pressure endpoint, where the seed stops +//! mattering because every ordinal is selected whatever it hashes to. //! //! # Determinism: the guarantee, and its limit //! @@ -106,8 +106,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; /// Default expected fraction of handled safepoints that collect. Chosen as a /// middle: ~20x normal density on a poll-heavy workload, but two orders of -/// magnitude cheaper than zeal, and low enough that the program's own timing is -/// not so distorted that it fails somewhere uninteresting first. +/// magnitude cheaper than the rate-1 endpoint, and low enough that the program's +/// own timing is not so distorted that it fails somewhere uninteresting first. pub(crate) const DEFAULT_SCHEDULE_RATE: f64 = 0.05; /// Collections this mode has forced that would not otherwise have run. The @@ -118,6 +118,15 @@ static SCHEDULE_FORCED: AtomicU64 = AtomicU64::new(0); /// Handled safepoints seen by the schedule, summed across threads. Diagnostic /// only — the per-thread counter that actually drives the schedule is the /// thread-local below. +/// +/// **Both counters are process-global, so a test asserting an exact delta on +/// them must hold `COPYING_NURSERY_TEST_LOCK`** (via `CopyingNurseryTestGuard`) +/// for the whole before/act/after window. Only a safepoint reached with the +/// mode ON ticks them, and the mode is a thread-local override in tests, so +/// today every ticking test already holds that lock and the deltas cannot +/// race. That is an invariant, not an accident: a new test that drives a +/// safepoint under `ScheduleGuard` without the nursery guard would make every +/// other test's `before + 1` flaky. static SCHEDULE_SAFEPOINTS: AtomicU64 = AtomicU64::new(0); thread_local! { @@ -173,9 +182,9 @@ const fn splitmix64(x: u64) -> u64 { /// Sentinel threshold meaning "every handled safepoint". `u64::MAX` is also a /// legitimate hash value, so `hit` cannot be expressed as a plain `<` at rate 1 -/// without losing one safepoint in 2^64 — irrelevant in practice, but a mode -/// whose rate-1 arm is not *exactly* zeal density is the sort of off-by-epsilon -/// that costs an investigation round when someone diffs the two. +/// without losing one safepoint in 2^64 — irrelevant in practice, but a rate-1 +/// arm that is not *exactly* 100% density is the sort of off-by-epsilon that +/// costs an investigation round when someone checks the endpoint. const THRESHOLD_ALWAYS: u64 = u64::MAX; /// Map a rate in `[0, 1]` onto the threshold the schedule hash is compared @@ -251,8 +260,8 @@ fn resolved() -> Option<(u64, u64)> { }) } -/// Is seeded GC-schedule fuzzing on? One cached-`Option` load — the same cost -/// class as the zeal check beside it. +/// Is seeded GC-schedule fuzzing on? One cached-`Option` load, so the default +/// path pays a predictable-branch check and nothing else. pub(crate) fn gc_schedule_enabled() -> bool { resolved().is_some() } @@ -297,6 +306,89 @@ pub fn gc_schedule_safepoints() -> u64 { SCHEDULE_SAFEPOINTS.load(Ordering::Relaxed) } +/// The verdict a rate-1 run gets at exit: what the instrument actually did +/// (#7604). +/// +/// `Some(Ok(summary))` when the maximum-density schedule moved something, +/// `Some(Err(complaint))` when it exercised nothing and every "clean at rate 1" +/// claim from the run is vacuous. `None` when the mode is off **or the rate is +/// below the every-safepoint endpoint**: at a sampling rate, a short run that +/// forces nothing is a legitimate outcome (`PERRY_GC_SCHEDULE_RATE=0` is the +/// documented on-but-selects-nothing control arm, and a sparse sweep seed that +/// fires late is not a broken instrument), so only the arm that PROMISED +/// maximum pressure is held to having produced it. Sub-endpoint runs still get +/// their liveness counters printed by `report_exit_summary`. +pub fn schedule_liveness_report() -> Option> { + let (_, threshold) = resolved()?; + if threshold < rate_threshold(1.0) { + return None; + } + Some(schedule_verdict( + gc_schedule_forced_collections(), + super::instruments::copying_minor_cycles(), + super::instruments::moved_objects_total(), + super::instruments::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 +/// collections would otherwise paper over. +pub(crate) fn schedule_verdict( + forced: u64, + cycles: u64, + moved: u64, + loop_polls: u64, + polls_requested: bool, +) -> Result { + let summary = format!( + "[gc-schedule] 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-schedule] THIS RUN EXERCISED NOTHING WORTH TRUSTING. \ + PERRY_GC_SCHEDULE_RATE=1 was set and {cause}. Any \"clean at \ + rate 1\" conclusion from this run is vacuous.\n\ + [gc-schedule] 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." + )), + } +} + /// RAII test override. `threshold` is taken directly so tests can pin the /// always/never arms without going through float parsing. #[cfg(test)] @@ -308,9 +400,9 @@ pub(crate) struct ScheduleGuard { #[cfg(test)] impl ScheduleGuard { pub(crate) fn set(seed: u64, threshold: u64) -> Self { - // #7781: mirror `ZealGuard` — a schedule that cannot reach the poll - // decides at six event-loop boundaries instead of thousands of - // back-edges. Arm on set, release on drop. + // #7781: a schedule that cannot reach the poll decides at six + // event-loop boundaries instead of thousands of back-edges. Arm on + // set, release on drop. // // The bookkeeping is deliberately ASYMMETRIC: only `set` arms, and only // its own `Drop` releases. `off()` must NOT disarm-then-let-Drop-rearm: @@ -321,7 +413,7 @@ impl ScheduleGuard { let prev = SCHEDULE_OVERRIDE.with(|cell| cell.replace(Some((seed, threshold)))); let armed = prev.is_none(); if armed { - super::arm_poll(); + super::poll_arm::arm_poll(); } Self { prev, armed } } @@ -336,7 +428,7 @@ impl Drop for ScheduleGuard { fn drop(&mut self) { SCHEDULE_OVERRIDE.with(|cell| cell.set(self.prev)); if self.armed { - super::disarm_poll(); + super::poll_arm::disarm_poll(); } } } @@ -346,6 +438,130 @@ pub(crate) fn reset_thread_counter_for_test() { SAFEPOINT_COUNTER.with(|cell| cell.set(0)); } +// --------------------------------------------------- allocation pacing (#7728) +// +// `PERRY_GC_SCHEDULE_ALLOC_KB`: the poll arm only offers a safepoint to the +// seed once a stride of NEW nursery material has accumulated; `=0` restores the +// literal every-poll candidate set. Unpaced, the every-poll arm costs ~511 µs +// per loop iteration to relocate a mean of 5.9 objects — 24 minutes for a 19 s +// program (#7728) — which is an instrument nobody switches on. Pacing by allocation keeps the schedule deterministic: +// a deterministic program allocates deterministically, so `(seed, counter)` +// replay is unaffected; the stride only bounds which polls become candidates. + +const SCHEDULE_DEFAULT_STRIDE_BYTES: usize = 4 * 1024; + +/// Largest accepted stride: 1 GiB of new nursery material between candidates. +/// +/// The cap exists because an UNBOUNDED stride is a silent off switch. A value +/// big enough to saturate — or merely bigger than the program ever allocates — +/// leaves `schedule_poll_collection_due` false after the first poll forever, so +/// the seed selects nothing on the poll path and the run reports a clean sweep +/// having tested nothing. That is the failure this project keeps paying for +/// (#6942 / #7024), and a debug instrument must not have a spelling that +/// disables it while still looking on. +const SCHEDULE_MAX_STRIDE_BYTES: usize = 1024 * 1024 * 1024; + +/// Pure knob parse for `PERRY_GC_SCHEDULE_ALLOC_KB`, in KB. +/// +/// `Some(0)` is a deliberate, meaningful value — "every poll is a candidate" — +/// so it must not be filtered out the way a nonsense value is. Anything above +/// [`SCHEDULE_MAX_STRIDE_BYTES`] CLAMPS to it rather than saturating, for the +/// reason given on that constant. +pub(crate) fn parse_schedule_alloc_kb(raw: Option<&str>) -> usize { + raw.and_then(|s| s.trim().parse::().ok()) + .map(|kb| kb.saturating_mul(1024).min(SCHEDULE_MAX_STRIDE_BYTES)) + .unwrap_or(SCHEDULE_DEFAULT_STRIDE_BYTES) +} + +/// Bytes of new nursery material required between poll-arm candidates. +pub(crate) fn schedule_poll_stride_bytes() -> usize { + #[cfg(test)] + if let Some(stride) = SCHEDULE_STRIDE_OVERRIDE.with(std::cell::Cell::get) { + return stride; + } + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + parse_schedule_alloc_kb(std::env::var("PERRY_GC_SCHEDULE_ALLOC_KB").ok().as_deref()) + }) +} + +#[cfg(test)] +thread_local! { + /// Test-only stride override, thread-local for the same reason + /// `SCHEDULE_OVERRIDE` is. + static SCHEDULE_STRIDE_OVERRIDE: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +/// RAII test override for the pacing stride. +#[cfg(test)] +pub(crate) struct ScheduleStrideGuard(Option); + +#[cfg(test)] +impl ScheduleStrideGuard { + pub(crate) fn set(stride_bytes: usize) -> Self { + Self(SCHEDULE_STRIDE_OVERRIDE.with(|cell| cell.replace(Some(stride_bytes)))) + } +} + +#[cfg(test)] +impl Drop for ScheduleStrideGuard { + fn drop(&mut self) { + SCHEDULE_STRIDE_OVERRIDE.with(|cell| cell.set(self.0)); + } +} + +thread_local! { + /// From-space high-water mark at or above which the next poll-arm candidate + /// is due. Per-thread because the arena it measures is. + /// + /// Starts at 0 so the FIRST poll is always a candidate: a program that + /// allocates less than one stride in total must still exercise the + /// instrument rather than silently becoming a run in which the schedule + /// selected nothing. + static SCHEDULE_NEXT_CANDIDATE_BYTES: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + +/// Is this poll a pacing candidate, given current from-space bytes? +#[inline] +pub(crate) fn schedule_poll_collection_due(from_space_bytes: usize) -> bool { + from_space_bytes >= SCHEDULE_NEXT_CANDIDATE_BYTES.with(std::cell::Cell::get) +} + +/// Rearm the pacing high-water mark after a candidate poll ran the safepoint. +/// +/// Takes the from-space level measured *after* the safepoint, so the next +/// candidate needs a full stride of genuinely new allocation on top of whatever +/// survived — a high-water mark rather than a delta, for the same reason as +/// #7728's original. +#[inline] +pub(crate) fn note_schedule_poll_collection(from_space_bytes_after: usize) { + let next = from_space_bytes_after.saturating_add(schedule_poll_stride_bytes()); + SCHEDULE_NEXT_CANDIDATE_BYTES.with(|cell| cell.set(next)); +} + +/// Polls the pacing skipped before the seed ever saw them. +static SCHEDULE_POLLS_PACED: AtomicU64 = AtomicU64::new(0); + +#[inline] +pub(crate) fn note_schedule_poll_paced() { + SCHEDULE_POLLS_PACED.fetch_add(1, Ordering::Relaxed); +} + +/// How many back-edge polls the pacing skipped. Reported in the exit summary so +/// a run states its own pacing rather than leaving the operator to infer it +/// from a safepoint count that looks lower than it "should" be. +pub fn schedule_polls_paced() -> u64 { + SCHEDULE_POLLS_PACED.load(Ordering::Relaxed) +} + +#[cfg(test)] +pub(crate) fn reset_schedule_pacing_for_test() { + SCHEDULE_NEXT_CANDIDATE_BYTES.with(|cell| cell.set(0)); +} + // --------------------------------------------------------------------------- // Reporting the seed. Requirement: if the process crashes or aborts under this // mode, the seed must appear in the output. @@ -377,6 +593,13 @@ fn install_failure_reporter() { if REPORTER_INSTALLED.swap(true, Ordering::SeqCst) { return; } + // Capture the installing thread as the runtime main thread. The exit + // summary is once-only and gated on `is_main_thread_or_unrecorded`, whose + // unrecorded arm passes EVERY thread while no main thread is registered — + // so a worker tearing down first could win the swap with non-final + // counts. The schedule activates on the thread that owns its lifecycle, + // which makes this the right owner for its summary. + crate::native_handle::runtime_main_thread_id(); let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { report_to_stderr("panic"); @@ -426,13 +649,29 @@ pub(crate) fn report_exit_summary() { let Some((seed, _)) = resolved() else { return; }; + // Emit only from the main thread. Every thread routes through the + // collection-side-allocation release on teardown, and the counters are + // process-global atomics; a worker tearing down first would win the + // once-only `swap` and print counts that are not yet final — and + // `gc_schedule_fuzz.sh` reads `safepoints=0` as the vacuous case. The + // main thread tears down at process exit, so it sees the true totals. + // Falls back to emitting when the main thread was never recorded, so the + // summary is never silently dropped. + if !crate::native_handle::is_main_thread_or_unrecorded() { + return; + } if SUMMARY_EMITTED.swap(true, Ordering::SeqCst) { return; } eprintln!( - "[gc-schedule] done: seed={seed} safepoints={} scheduled_collections={}", + "[gc-schedule] done: seed={seed} safepoints={} scheduled_collections={} \ + polls_paced={} copying_minors={} moved_objects={} loop_polls={}", gc_schedule_safepoints(), gc_schedule_forced_collections(), + schedule_polls_paced(), + super::instruments::copying_minor_cycles(), + super::instruments::moved_objects_total(), + super::instruments::loop_polls_reached(), ); } @@ -521,7 +760,18 @@ fn install_signal_reporter_inner() { if libc::sigaction(signum, &action, &mut old) != 0 { continue; } - let previous = old.sa_sigaction as u64; + // Only a handler installed with `SA_SIGINFO` has a valid + // `sa_sigaction` (3-argument) member; a 1-argument `sa_handler` + // installer leaves `sa_sigaction` meaningless to read, and the + // chain site transmutes the stored value to a 3-argument fn. Store + // 0 (treated as "nothing to chain to") unless the previous handler + // was itself `SA_SIGINFO`, so we never call a 1-arg handler through + // the wrong signature. SIG_DFL/SIG_IGN already read as 0/1. + let previous = if old.sa_flags & libc::SA_SIGINFO != 0 { + old.sa_sigaction as u64 + } else { + 0 + }; // Never chain to ourselves: `reinstall_signal_reporter_after` can be // reached twice, and a self-chain is an infinite recursion inside a // signal handler. @@ -611,21 +861,13 @@ extern "C" fn schedule_fault_handler( let slot = FATAL_SIGNALS.iter().position(|&s| s == signum); let previous = slot.map_or(0, |slot| PREVIOUS_HANDLERS[slot].load(Ordering::Relaxed)); - // 0 = SIG_DFL, 1 = SIG_IGN: nothing to chain to. Restore the default - // disposition and return, so the instruction re-faults and the process dies - // exactly where it should — core file, debugger and crash reporter all see - // the real site. - if previous > 1 { - // SAFETY: the only handler this can chain to is one installed with - // `SA_SIGINFO` by this process (today: the from-space quarantine's - // reporter), so the three-argument form is its true signature. - unsafe { - let chained: extern "C" fn(libc::c_int, *mut libc::siginfo_t, *mut libc::c_void) = - std::mem::transmute(previous as usize as *const ()); - chained(signum, info, ctx); - } - return; - } + // Restore the default disposition for THIS signal *before* anything else. + // Returning from a synchronous fault handler (SIGSEGV/SIGBUS/SIGILL) re-runs + // the faulting instruction; if the chained handler below also returns + // without resolving the fault, a disposition still pointing here would + // re-enter this handler forever. With SIG_DFL restored first, the re-fault + // dies at the real site — core file, debugger and crash reporter all see + // it — no matter what the chained handler does. // SAFETY: standard handler teardown. unsafe { let mut action: libc::sigaction = std::mem::zeroed(); @@ -633,6 +875,19 @@ extern "C" fn schedule_fault_handler( libc::sigemptyset(&mut action.sa_mask); libc::sigaction(signum, &action, std::ptr::null_mut()); } + // 0 = SIG_DFL, 1 = SIG_IGN: nothing to chain to — fall through to the + // now-restored default and re-fault. + if previous > 1 { + // SAFETY: `install_signal_reporter_inner` only stores a `previous` + // value here when the predecessor was installed with `SA_SIGINFO` + // (today: the from-space quarantine's reporter), so the three-argument + // form is its true signature. + unsafe { + let chained: extern "C" fn(libc::c_int, *mut libc::siginfo_t, *mut libc::c_void) = + std::mem::transmute(previous as usize as *const ()); + chained(signum, info, ctx); + } + } } /// Publish the seed where the signal handler can read it without allocating. @@ -643,3 +898,81 @@ fn publish_seed(seed: u64) { #[cfg(not(unix))] fn publish_seed(_seed: u64) {} + +#[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_rate_one_run_that_exercised_nothing_is_an_error() { + let no_safepoint = + schedule_verdict(0, 0, 0, 0, false).expect_err("forced=0 must be an error"); + assert!(no_safepoint.contains("no safepoint ever forced a collection")); + + // The schedule 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 = + schedule_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 the every-safepoint arm 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 = schedule_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-only run is a legitimate, weaker mode, and + // failing it would make the verdict wrong rather than strict. + assert!(schedule_verdict(5, 5, 4, 0, false).is_ok()); + } + + /// ...and YES, with the numbers, when it did fire. + #[test] + fn a_rate_one_run_that_moved_objects_is_reported_ok() { + let ok = schedule_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!(schedule_verdict(1, 1, 0, 1, true).is_ok()); + } + + /// The verdict is an endpoint-only contract: below rate 1 a run that forces + /// nothing is a legitimate sampling outcome (rate 0 is the documented + /// control arm), so `schedule_liveness_report` must return `None` rather + /// than an `Err` that would turn every sparse sweep seed into a false + /// failure. + #[test] + fn sub_endpoint_rates_get_no_verdict() { + let _g = ScheduleGuard::set(7, rate_threshold(0.05)); + assert!(schedule_liveness_report().is_none()); + let _g = ScheduleGuard::set(7, rate_threshold(1.0)); + assert!(schedule_liveness_report().is_some()); + } +} 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 index a0fd5f9393..f05864fa38 100644 --- a/crates/perry-runtime/src/gc/tests/copying/deferred_finalize_7635.rs +++ b/crates/perry-runtime/src/gc/tests/copying/deferred_finalize_7635.rs @@ -16,7 +16,7 @@ //! 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 +//! output** from a 4,000-record Perry-compiled workload under `PERRY_GC_SCHEDULE_RATE=1 //! PERRY_GC_PROTECT_FROMSPACE=1` and under `PERRY_GC_FORCE_EVACUATE=1`, with //! copying minors and retired quarantine sets observed live. //! diff --git a/crates/perry-runtime/src/gc/tests/evacuation.rs b/crates/perry-runtime/src/gc/tests/evacuation.rs index 46d5039e86..80e280d0aa 100644 --- a/crates/perry-runtime/src/gc/tests/evacuation.rs +++ b/crates/perry-runtime/src/gc/tests/evacuation.rs @@ -1004,9 +1004,9 @@ fn test_evacuated_old_parent_re_remembers_young_child_canary() { /// 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. +/// A resolved seed is what turns forced evacuation on here because +/// `ScheduleGuard` 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 @@ -1016,10 +1016,11 @@ fn explicit_gc_under_forced_evacuation_runs_a_moving_minor() { // 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); + let _schedule = + super::super::schedule::ScheduleGuard::set(7, super::super::schedule::rate_threshold(1.0)); assert!( gc_force_evacuate_enabled(), - "test premise: zeal must imply forced evacuation" + "test premise: a resolved seed must imply forced evacuation" ); let frame = js_shadow_frame_push(1); diff --git a/crates/perry-runtime/src/gc/tests/fromspace_protect.rs b/crates/perry-runtime/src/gc/tests/fromspace_protect.rs index ac5035a7ae..6a55282cf4 100644 --- a/crates/perry-runtime/src/gc/tests/fromspace_protect.rs +++ b/crates/perry-runtime/src/gc/tests/fromspace_protect.rs @@ -1,5 +1,9 @@ //! Teeth for the #7154 detection-latency instruments: from-space quarantine -//! (`PERRY_GC_PROTECT_FROMSPACE`) and GC zeal (`PERRY_GC_ZEAL`). +//! (`PERRY_GC_PROTECT_FROMSPACE`) and the seeded GC schedule +//! (`PERRY_GC_SCHEDULE_SEED`), which this file exercises at its +//! maximum-density endpoint (`PERRY_GC_SCHEDULE_RATE=1`) because that is the +//! setting the quarantine is paired with. The schedule's own knob parsing, +//! determinism and density claims live in `gc/tests/schedule.rs`. //! //! Every test asserts BOTH directions of its knob. The GC knob kill-policy in //! CLAUDE.md requires an exercised OFF state for every knob, and the reason is @@ -12,6 +16,7 @@ //! than no instrument, and one that changes the collector when it is switched //! off is a landmine in every future bisect. +use super::super::schedule::*; use super::super::*; use super::support::*; use crate::arena::FromSpaceProtection; @@ -67,17 +72,6 @@ fn quarantine_depth_rejects_zero_and_garbage() { assert_eq!(parse_quarantine_depth(None), 4); } -#[test] -fn zeal_knob_parses_both_states() { - use super::super::zeal::parse_zeal; - for raw in [None, Some("0"), Some("off"), Some("false"), Some("2")] { - assert!(!parse_zeal(raw), "{raw:?} must leave zeal OFF"); - } - for raw in ["1", "on", "true"] { - assert!(parse_zeal(Some(raw)), "{raw} must enable zeal"); - } -} - /// The gap this closes: `PERRY_GC_FROMSPACE_SCAN_ABORT=1` used to be completely /// inert on its own — `run_fromspace_scan` returned at the /// `fromspace_scan_enabled()` gate, so there was never anything to abort and the @@ -331,75 +325,59 @@ fn quarantine_catches_a_planted_stale_from_space_deref() { } // --------------------------------------------------------------------------- -// Zeal +// The seeded schedule at its maximum-density endpoint, paired with the +// quarantine. Any seed selects every ordinal at rate 1, so this one is +// arbitrary and fixed only so the test reads as a reproducible recipe. // --------------------------------------------------------------------------- -/// Zeal's whole contract: collect at a safepoint where nothing is due. Both -/// arms, because the OFF arm is what proves the safepoint was genuinely idle — -/// without it, a passing ON arm could just be ordinary heap pressure. -#[test] -fn zeal_collects_at_a_safepoint_with_no_pressure_due() { - let _guard = CopyingNurseryTestGuard::new(1); - let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); - reset_scan_fallback_counters(); - - // OFF: an idle safepoint must collect nothing. - { - let _zeal = super::super::zeal::ZealGuard::set(false); - gc_safepoint_moving_minor(); - } - assert_eq!( - safepoint_drain_count(SafepointDrainKind::NurseryMinor), - 0, - "test premise: with no trigger due and zeal off, the safepoint must be idle" - ); - - // ON: the same idle safepoint must now run a minor. - let forced_before = zeal_forced_collections(); - { - let _zeal = super::super::zeal::ZealGuard::set(true); - gc_safepoint_moving_minor(); - } - assert_eq!( - safepoint_drain_count(SafepointDrainKind::NurseryMinor), - 1, - "PERRY_GC_ZEAL=1 must force a minor at every safepoint" - ); - assert!( - zeal_forced_collections() > forced_before, - "the forced collection must be COUNTED — a zeal run reporting 0 forced \ - collections exercised nothing, and a clean verdict from it is vacuous" - ); -} +const ENDPOINT_SEED: u64 = 7; // --------------------------------------------------------------------------- -// Zeal pacing (#7728). Zeal used to force a collection at EVERY back-edge poll. -// That was affordable only while the polls themselves were a compile-time -// opt-in nobody took; #7721 made them default-ON and the same instrument became -// ~511 us of fixed collection cost per loop iteration — 24 minutes for a 19 s -// program, which is an instrument nobody switches on. +// Allocation pacing (#7728). +// The poll arm used to offer EVERY back-edge to the seed. That was affordable +// only while the polls themselves were a compile-time opt-in nobody took; #7721 +// made them default-ON and the same instrument became ~511 us of fixed +// collection cost per loop iteration — 24 minutes for a 19 s program, which is +// an instrument nobody switches on. // // Both directions are asserted, per the kill-policy: the stride must BOUND the // forced collections, and `=0` must still give the literal every-poll mode. // --------------------------------------------------------------------------- #[test] -fn zeal_alloc_stride_knob_parses_both_states() { - use super::super::zeal::parse_zeal_alloc_kb; +fn schedule_alloc_stride_knob_parses_both_states() { + use parse_schedule_alloc_kb; // Default when unset or unparseable — a typo must not silently select the // unusable every-poll mode. - assert_eq!(parse_zeal_alloc_kb(None), 4096); - assert_eq!(parse_zeal_alloc_kb(Some("banana")), 4096); - assert_eq!(parse_zeal_alloc_kb(Some("")), 4096); - // 0 is MEANINGFUL, not garbage: it restores pre-#7728 every-poll zeal. - assert_eq!(parse_zeal_alloc_kb(Some("0")), 0); - assert_eq!(parse_zeal_alloc_kb(Some("16")), 16 * 1024); - assert_eq!(parse_zeal_alloc_kb(Some(" 64 ")), 64 * 1024); + assert_eq!(parse_schedule_alloc_kb(None), 4096); + assert_eq!(parse_schedule_alloc_kb(Some("banana")), 4096); + assert_eq!(parse_schedule_alloc_kb(Some("")), 4096); + // 0 is MEANINGFUL, not garbage: it restores pre-#7728 every-poll candidacy. + assert_eq!(parse_schedule_alloc_kb(Some("0")), 0); + assert_eq!(parse_schedule_alloc_kb(Some("16")), 16 * 1024); + assert_eq!(parse_schedule_alloc_kb(Some(" 64 ")), 64 * 1024); + + // A stride nothing can ever reach is an OFF switch wearing an ON label: + // the first poll rearms above every level the program will hit, so the + // seed selects nothing on the poll path and the run reports a clean sweep + // having tested nothing. Huge values clamp rather than saturate. + let cap = 1024 * 1024 * 1024; + assert_eq!(parse_schedule_alloc_kb(Some("18446744073709551615")), cap); + assert_eq!(parse_schedule_alloc_kb(Some("1073741824")), cap); + assert_eq!( + parse_schedule_alloc_kb(Some("1048576")), + cap, + "exactly the cap, expressed in KB, must stay the cap" + ); + assert!( + parse_schedule_alloc_kb(Some("1048575")) < cap, + "and just under it must not be clamped" + ); } /// ★ The regression test for #7728, and the one that would have caught it. /// -/// Drives a hot poll loop — the shape of every real workload under zeal — and +/// Drives a hot poll loop — the shape of every real workload under the schedule — and /// asserts the forced collections are BOUNDED well below the poll count. Before /// the fix this ratio was exactly 1.0 (70,968 forced collections for 70,963 /// polls on the measured workload), so this assertion fails on the old code. @@ -409,18 +387,18 @@ fn zeal_alloc_stride_knob_parses_both_states() { /// the one being fixed (CLAUDE.md, four ways a gate cannot fail — #4): the run /// must still force collections, and those collections must still MOVE objects. #[test] -fn zeal_pacing_bounds_forced_collections_but_still_moves_objects() { +fn pacing_bounds_forced_collections_but_still_moves_objects() { let _guard = CopyingNurseryTestGuard::new(1); let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let _polls = super::super::policy::MovingLoopPollsGuard::on(); - let _zeal = super::super::zeal::ZealGuard::set(true); - let _stride = super::super::zeal::ZealStrideGuard::set(4096); - super::super::zeal::reset_zeal_pacing_for_test(); + let _schedule = ScheduleGuard::set(ENDPOINT_SEED, rate_threshold(1.0)); + let _stride = ScheduleStrideGuard::set(4096); + reset_schedule_pacing_for_test(); const POLLS: u64 = 2_000; - let forced_before = zeal_forced_collections(); + let forced_before = gc_schedule_forced_collections(); let moved_before = moved_objects_total(); - let paced_before = zeal_polls_paced(); + let paced_before = schedule_polls_paced(); for _ in 0..POLLS { // Allocate, root it, then poll — a loop body that produces new nursery @@ -431,9 +409,9 @@ fn zeal_pacing_bounds_forced_collections_but_still_moves_objects() { js_gc_loop_safepoint(); } - let forced = zeal_forced_collections() - forced_before; + let forced = gc_schedule_forced_collections() - forced_before; let moved = moved_objects_total() - moved_before; - let paced = zeal_polls_paced() - paced_before; + let paced = schedule_polls_paced() - paced_before; // THE BOUND. Each `young_leaf` is a few tens of bytes, so 2000 of them is // well under 200 KB; at a 4 KB stride that is a few dozen collections, not @@ -441,7 +419,7 @@ fn zeal_pacing_bounds_forced_collections_but_still_moves_objects() { // while still failing loudly on the pre-fix 1:1 behaviour. assert!( forced < POLLS / 4, - "zeal must PACE its forced collections: {forced} forced for {POLLS} polls \ + "the schedule must PACE its forced collections: {forced} forced for {POLLS} polls \ (pre-#7728 this was 1:1, which cost 24 minutes on a 19 s program)" ); assert_eq!( @@ -451,19 +429,19 @@ fn zeal_pacing_bounds_forced_collections_but_still_moves_objects() { (forced={forced} paced={paced})" ); - // LIVENESS 1: pacing must not have turned zeal off. A run that forces zero + // LIVENESS 1: pacing must not have turned the schedule off. A run that forces zero // collections is the vacuous-green shape, not a fix. assert!( forced > 0, - "zeal must still force collections — a paced instrument that never \ + "the schedule must still force collections — a paced instrument that never \ collects is a worse regression than the slow one it replaced" ); - // LIVENESS 2: and those collections must still RELOCATE. Zeal exists to + // LIVENESS 2: and those collections must still RELOCATE. The mode exists to // make an unrooted value move on its first exposure; a paced minor that // leaves survivors in place would surface nothing. assert!( moved > 0, - "zeal's paced collections must still MOVE survivors (moved={moved})" + "the schedule's paced collections must still MOVE survivors (moved={moved})" ); } @@ -471,25 +449,25 @@ fn zeal_pacing_bounds_forced_collections_but_still_moves_objects() { /// literal every-poll semantics, which is the right setting for a small fixture /// (`gc_instrument_smoke.sh` pins it) or a window executed exactly once. #[test] -fn zeal_alloc_stride_zero_restores_every_poll_collection() { +fn schedule_alloc_stride_zero_restores_every_poll_collection() { let _guard = CopyingNurseryTestGuard::new(1); let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let _polls = super::super::policy::MovingLoopPollsGuard::on(); - let _zeal = super::super::zeal::ZealGuard::set(true); - let _stride = super::super::zeal::ZealStrideGuard::set(0); - super::super::zeal::reset_zeal_pacing_for_test(); + let _schedule = ScheduleGuard::set(ENDPOINT_SEED, rate_threshold(1.0)); + let _stride = ScheduleStrideGuard::set(0); + reset_schedule_pacing_for_test(); const POLLS: u64 = 32; - let forced_before = zeal_forced_collections(); + let forced_before = gc_schedule_forced_collections(); for _ in 0..POLLS { let leaf = young_leaf(); js_shadow_slot_set(0, string_bits(leaf)); js_gc_loop_safepoint(); } assert_eq!( - zeal_forced_collections() - forced_before, + gc_schedule_forced_collections() - forced_before, POLLS, - "PERRY_GC_ZEAL_ALLOC_KB=0 must collect at EVERY poll — that escape \ + "PERRY_GC_SCHEDULE_ALLOC_KB=0 must collect at EVERY poll — that escape \ hatch is what a once-executed bug window needs" ); } @@ -503,62 +481,42 @@ fn zeal_alloc_stride_zero_restores_every_poll_collection() { /// the collection makes the next one cost a full stride of genuinely new /// allocation no matter what the collector managed to free. #[test] -fn zeal_pacing_rearms_above_survivors_so_a_useless_collection_cannot_loop() { - use super::super::zeal::{ - note_zeal_poll_collection, reset_zeal_pacing_for_test, zeal_poll_collection_due, - ZealStrideGuard, +fn pacing_rearms_above_survivors_so_a_useless_collection_cannot_loop() { + use super::super::schedule::{ + note_schedule_poll_collection, reset_schedule_pacing_for_test, + schedule_poll_collection_due, ScheduleStrideGuard, }; - let _stride = ZealStrideGuard::set(4096); - reset_zeal_pacing_for_test(); + let _stride = ScheduleStrideGuard::set(4096); + reset_schedule_pacing_for_test(); // A collection that freed NOTHING: from-space still holds 1 MB afterwards. - note_zeal_poll_collection(1024 * 1024); + note_schedule_poll_collection(1024 * 1024); assert!( - !zeal_poll_collection_due(1024 * 1024), + !schedule_poll_collection_due(1024 * 1024), "a collection that reclaimed nothing must NOT be immediately due again \ — that is the #7592 livelock shape" ); assert!( - !zeal_poll_collection_due(1024 * 1024 + 4095), + !schedule_poll_collection_due(1024 * 1024 + 4095), "still short of one full stride of new allocation" ); assert!( - zeal_poll_collection_due(1024 * 1024 + 4096), + schedule_poll_collection_due(1024 * 1024 + 4096), "one full stride of NEW material above the survivors makes it due again" ); } -/// 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, 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() { - 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(), - "zeal must force evacuation in every environment (force_off={off})" - ); -} - -/// Zeal and protection are designed to compose — that pairing is what turns a -/// #7154 bug into an immediate fault instead of a cycle-late `TypeError`. This -/// asserts they actually run together rather than one disabling the other. +/// The schedule and the quarantine are designed to compose — that pairing is +/// what turns a #7154 bug into an immediate fault instead of a cycle-late +/// `TypeError`. This asserts they actually run together rather than one +/// disabling the other. #[test] -fn zeal_and_protection_compose() { +fn the_schedule_and_protection_compose() { let _guard = CopyingNurseryTestGuard::new(1); let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let _mode = crate::arena::ProtectionModeGuard::set(FromSpaceProtection::PoisonOnly); - let _zeal = super::super::zeal::ZealGuard::set(true); + let _schedule = ScheduleGuard::set(ENDPOINT_SEED, rate_threshold(1.0)); + reset_thread_counter_for_test(); reset_scan_fallback_counters(); let before = crate::arena::quarantine_stats(); @@ -569,18 +527,18 @@ fn zeal_and_protection_compose() { assert_eq!( safepoint_drain_count(SafepointDrainKind::NurseryMinor), 1, - "zeal must have forced the minor" + "the schedule at rate 1 must have forced the minor" ); let after = crate::arena::quarantine_stats(); assert_eq!( after.sets_retired, before.sets_retired + 1, - "the zeal-forced minor's from-space must have been quarantined" + "the schedule-forced minor's from-space must have been quarantined" ); assert_eq!( unsafe { *(from_space_addr as *const u64) }, crate::arena::QUARANTINE_POISON_WORD, - "zeal + protection: the address the value moved out of must be poison \ - immediately, not on some later cycle" + "schedule + protection: the address the value moved out of must be \ + poison immediately, not on some later cycle" ); } diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/fs_options_object.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/fs_options_object.rs index 0ed36e67cb..f66623fabd 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/fs_options_object.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/fs_options_object.rs @@ -104,7 +104,7 @@ fn readdir_options_object_survives_the_with_file_types_key_allocation() { // correctly refuses to certify an empty run. `force_alloc_point_minor_pacing` // is the combination this test was written against and the only one in which // both halves hold. The moving default's rooting coverage for these helpers - // is the gap suite's `test_gap_gc_*_rooting.ts` cases plus the zeal + + // is the gap suite's `test_gap_gc_*_rooting.ts` cases plus the rate-1 schedule + // from-space-protect runs, not this vehicle. let _alloc_point_pacing = crate::gc::policy::force_alloc_point_minor_pacing(); let _guard = CopyingNurseryTestGuard::new(1); @@ -168,7 +168,7 @@ fn readdir_options_without_the_field_stays_false_across_the_key_allocation() { // correctly refuses to certify an empty run. `force_alloc_point_minor_pacing` // is the combination this test was written against and the only one in which // both halves hold. The moving default's rooting coverage for these helpers - // is the gap suite's `test_gap_gc_*_rooting.ts` cases plus the zeal + + // is the gap suite's `test_gap_gc_*_rooting.ts` cases plus the rate-1 schedule + // from-space-protect runs, not this vehicle. let _alloc_point_pacing = crate::gc::policy::force_alloc_point_minor_pacing(); let _guard = CopyingNurseryTestGuard::new(1); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs index b16f0da49d..e08b2eda40 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs @@ -27,7 +27,7 @@ //! Both tests here force the collection into that window deterministically — //! `force_next_general_arena_alloc_slow` + `make_arena_trigger_due` make the //! next arena block allocation collect, and the next one is the callee's own — -//! so neither depends on `PERRY_GC_ZEAL` or on the timing luck the #7577 +//! so neither depends on `PERRY_GC_SCHEDULE_SEED` or on the timing luck the #7577 //! reproducer needs. Each asserts its subject was live (the receiver actually //! moved), per CLAUDE.md's "a gate must assert its subject was live": a run in //! which nothing moved proves nothing, and says so rather than passing. diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs index 772b71f32f..fb984c5dcc 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs @@ -84,7 +84,7 @@ fn shape_template_element_survives_the_date_field_allocation() { // correctly refuses to certify an empty run. `force_alloc_point_minor_pacing` // is the combination this test was written against and the only one in which // both halves hold. The moving default's rooting coverage for these helpers - // is the gap suite's `test_gap_gc_*_rooting.ts` cases plus the zeal + + // is the gap suite's `test_gap_gc_*_rooting.ts` cases plus the rate-1 schedule + // from-space-protect runs, not this vehicle. let _alloc_point_pacing = crate::gc::policy::force_alloc_point_minor_pacing(); let _guard = CopyingNurseryTestGuard::new(0); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs index 8304a641e3..57bb6ce46c 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs @@ -76,7 +76,7 @@ fn a_symbols_description_survives_reclamation_of_the_string_it_came_from() { // whose collection point this assertion describes, exactly as the // `debt_pacer` tests do. The moving default's rooting coverage for these // helpers is the gap suite's `test_gap_gc_*_rooting.ts` cases plus the - // zeal + from-space-protect runs, not this vehicle. + // rate-1 schedule + from-space-protect runs, not this vehicle. let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); diff --git a/crates/perry-runtime/src/gc/tests/schedule.rs b/crates/perry-runtime/src/gc/tests/schedule.rs index 77c9a2982f..3ae837f780 100644 --- a/crates/perry-runtime/src/gc/tests/schedule.rs +++ b/crates/perry-runtime/src/gc/tests/schedule.rs @@ -12,9 +12,10 @@ //! `gc()`-driven test for months because only its ON arm was ever asserted, //! and the ON arm did nothing. //! -//! A third claim — that the density is actually tunable — is what separates this -//! from zeal, so the realised rate is measured against the requested one instead -//! of being taken on trust from the arithmetic. +//! A third claim — that the density is actually tunable — is what lets one knob +//! span everything from normal pacing to a collection at every safepoint, so the +//! realised rate is measured against the requested one instead of being taken on +//! trust from the arithmetic. use super::super::schedule::*; use super::super::*; @@ -89,7 +90,7 @@ fn rate_zero_never_collects_and_rate_one_always_does() { ); assert!( schedule_hit(12345, counter, always), - "rate 1 must select every safepoint — zeal density, exactly \ + "rate 1 must select EVERY safepoint — 100% density, exactly \ (counter={counter})" ); } @@ -150,9 +151,10 @@ fn adjacent_seeds_explore_different_schedules() { } } -/// The tunable density is what makes this a middle setting rather than a second -/// zeal. Measured, because a threshold computed with the wrong exponent still -/// produces a perfectly deterministic schedule and would pass every test above. +/// The tunable density is what lets one knob reach every setting between normal +/// pacing and a collection at every safepoint. Measured, because a threshold +/// computed with the wrong exponent still produces a perfectly deterministic +/// schedule and would pass every test above. #[test] fn realised_density_tracks_the_requested_rate() { const N: u64 = 200_000; @@ -296,12 +298,11 @@ fn a_blocked_safepoint_consumes_no_schedule_slot() { /// A scheduled minor that leaves survivors in place would move nothing, so it /// could not surface a stale-pointer bug at all — the mode would be a knob whose -/// name promises relocation stress and whose effect is sweep pressure. -/// -/// (This test originally also asserted the schedule loses to an explicit -/// `PERRY_GEN_GC_EVACUATE=0`. That knob was DELETED in #7611 — its one unique -/// effect was vetoing forced evacuation, i.e. silently disarming exactly the -/// instruments this mode joins — so the veto branch is gone with it.) +/// name promises relocation stress and whose effect is sweep pressure. A +/// resolved seed therefore implies forced evacuation, UNCONDITIONALLY: +/// `PERRY_GEN_GC_EVACUATE`, whose `=0` an earlier draft granted precedence over +/// the seed, was deleted by #7611 as the one way an ambient environment +/// variable could silently turn a #7154 instrument into a no-op. #[test] fn the_schedule_implies_forced_evacuation() { let _off = ScheduleGuard::off(); @@ -309,7 +310,7 @@ fn the_schedule_implies_forced_evacuation() { let _on = ScheduleGuard::set(7, rate_threshold(1.0)); assert!( gc_force_evacuate_enabled(), - "a resolved seed must force evacuation (force_off={off})" + "a resolved seed must force evacuation in every environment (force_off={off})" ); } @@ -322,7 +323,7 @@ fn unset_is_inert_for_evacuation_policy() { let baseline = matches!( std::env::var("PERRY_GC_FORCE_EVACUATE").as_deref(), Ok("1") | Ok("on") | Ok("true") - ) || super::super::gc_zeal_enabled(); + ); assert_eq!( gc_force_evacuate_enabled(), baseline, diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 86615734d5..bf46a64720 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -1134,7 +1134,7 @@ fn a_deferral_arms_the_poll_word_and_draining_disarms_it() { #[test] fn an_unarmed_poll_touches_nothing() { let _isolation = GcTestIsolationGuard::new(); - let _zeal = super::super::zeal::ZealGuard::set(false); + let _schedule = super::super::schedule::ScheduleGuard::off(); crate::gc::set_safepoint_pending(false); let restore = crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed); crate::gc::PERRY_GC_POLL_ARMED.store(0, std::sync::atomic::Ordering::Relaxed); @@ -1158,53 +1158,77 @@ fn an_unarmed_poll_touches_nothing() { ); } -/// Zeal's contract is a collection at EVERY safepoint, not only at ones an -/// alloc-point trigger already deferred. That is expressible only if the word -/// stays armed with nothing pending, so zeal owns a permanent arm. +/// The seeded schedule selects among safepoints an alloc-point trigger did NOT +/// already defer. That is expressible only if the poll word stays armed with +/// nothing pending, so a resolved seed owns a permanent arm — and a run +/// WITHOUT a seed must give that arm back, or every program pays for the slow +/// path forever. /// -/// Without this, `PERRY_GC_ZEAL=1` would silently become a no-op on the poll -/// path: every back-edge would read zero, skip the call, and force nothing — -/// and `zeal_liveness_report` would be left to report the vacuity after the +/// Without the first half, `PERRY_GC_SCHEDULE_SEED` silently becomes a no-op on +/// the poll path: every back-edge reads zero, skips the call and forces +/// nothing, leaving `schedule_liveness_report` to report the vacuity after the /// fact instead of the instrument simply working. /// -/// The release half is asserted, not narrated. `ZealGuard::set(true)` arms the -/// process-global word; if its `Drop` ever stopped giving that arm back, the -/// word would stay non-zero for the life of the test binary and every later -/// test would silently take the poll's slow path — with this test still green, -/// because it only ever looked INSIDE the scope. A test that cannot fail is not -/// a test. +/// Both directions run the REAL resolution (`resolve_poll_seed`) rather than +/// reading the startup value, because the startup value is 1 whatever the mode +/// is — an assertion against it passes without the guard having done anything, +/// and fails outright if some earlier test in this binary already resolved the +/// seed. That is why the resolution is a resettable flag rather than a +/// `std::sync::Once`. #[test] -fn zeal_holds_the_poll_word_armed_with_nothing_pending() { +fn the_startup_seed_is_kept_only_for_a_resolved_seed() { let _isolation = GcTestIsolationGuard::new(); crate::gc::set_safepoint_pending(false); - let base = crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed); + + // The word is process-global; put it back whatever this test proves. + let restore = crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed); + let seed_armed = |armed: u32| { + crate::gc::PERRY_GC_POLL_ARMED.store(armed, std::sync::atomic::Ordering::Relaxed); + super::super::poll_arm::reset_poll_seed_for_test(); + }; + + // WITH a seed: resolution must KEEP the startup arm. + seed_armed(1); + { + let _schedule = super::super::schedule::ScheduleGuard::set( + 7, + super::super::schedule::rate_threshold(1.0), + ); + super::super::poll_arm::resolve_poll_seed(); + assert!( + crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed) > 0, + "a resolved seed must keep the poll reachable with no deferral \ + outstanding — released, every back-edge reads zero and the mode \ + selects nothing at all" + ); + } + + // WITHOUT one: resolution must RELEASE it, or the poll's fast path stays + // needlessly live for the rest of the process. + seed_armed(1); { - let _zeal = super::super::zeal::ZealGuard::set(true); + let _schedule = super::super::schedule::ScheduleGuard::off(); + super::super::poll_arm::resolve_poll_seed(); assert_eq!( crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed), - base + 1, - "zeal must keep the poll reachable even with no deferral outstanding" + 0, + "with no seed resolved the startup arm must be given back" ); } - // And it gives the arm back, so one zeal test does not leave every later - // test in this binary paying for the slow path. - assert_eq!( - crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed), - base, - "dropping the ZealGuard must release the arm it took — a leaked arm \ - pins the poll on for the rest of the process, and nothing else in this \ - binary would notice" - ); + + crate::gc::PERRY_GC_POLL_ARMED.store(restore, std::sync::atomic::Ordering::Relaxed); + super::super::poll_arm::reset_poll_seed_for_test(); crate::gc::set_safepoint_pending(false); } -/// #7781: the schedule's mirror of the zeal test above, and the regression +/// #7781: the regression /// test for the gap that made `PERRY_GC_SCHEDULE_RATE=1` an event-loop-only -/// instrument: on #7606's reproduction it saw SIX safepoints against zeal's -/// 9,648 loop polls, because nothing armed the poll word for the mode whose -/// decision lives inside the safepoint the word gates. +/// instrument: on #7606's reproduction it saw SIX safepoints against the +/// 9,648 loop polls the retired every-safepoint instrument reached, because +/// nothing armed the poll word for the mode whose decision lives inside the +/// safepoint the word gates. #[test] -fn the_schedule_holds_the_poll_word_armed_like_zeal() { +fn the_schedule_holds_the_poll_word_armed() { let _isolation = GcTestIsolationGuard::new(); crate::gc::set_safepoint_pending(false); let base = crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed); diff --git a/crates/perry-runtime/src/gc/zeal.rs b/crates/perry-runtime/src/gc/zeal.rs deleted file mode 100644 index a408b74101..0000000000 --- a/crates/perry-runtime/src/gc/zeal.rs +++ /dev/null @@ -1,550 +0,0 @@ -//! GC zeal mode (#7154 tooling) — `PERRY_GC_ZEAL`. -//! -//! # Why -//! -//! A #7154-class bug is a value that is live but not rooted across a collection -//! point. Whether it is *caught* depends entirely on whether a collection -//! happens to land inside that window. In a normal run the window is a few -//! instructions wide and collections are tens of megabytes apart, so the bug is -//! observed only when an unrelated allocation burst lines up with it — which is -//! why the #7154 hunt needed a `zod` workload and ten rounds. -//! -//! Zeal removes the coincidence. Modelled on V8's `--stress-scavenge` and -//! SpiderMonkey's `gcZeal`, it forces an **evacuating** minor at GC safepoints, -//! so an unrooted value moves on its first exposure, deterministically. Since -//! #7728 that is allocation-PACED rather than literally every safepoint — see -//! the pacing section below for why, and for the escape hatch that restores it. -//! -//! # What the knob actually gates -//! -//! `PERRY_GC_ZEAL=1`: -//! -//! 1. A loop back-edge poll (`js_gc_loop_safepoint`) runs a minor, instead of -//! only draining an already-deferred one (`GC_SAFEPOINT_PENDING`) — at the -//! first poll past each `PERRY_GC_ZEAL_ALLOC_KB` of new nursery material -//! (#7728); at EVERY poll when that is `0`. -//! 2. Every outermost microtask-pump safepoint runs a minor, instead of only -//! when `gc_budgeted_due_trigger()` reports nursery/old pressure. -//! 3. `gc_force_evacuate_enabled()` becomes true, so the minor **moves** every -//! marked non-pinned nursery object rather than leaving survivors in place. -//! Without this a zealous minor could run and move nothing, which would be a -//! gate that cannot fail. -//! -//! It does **not** change which collections are *sound* — every forced -//! collection runs at a point the collector already treats as a precise-root -//! safepoint. It only changes how often. -//! -//! ## Point 1 requires a compile-time opt-in too -//! -//! Loop back-edge polls are only *emitted* when the compiler ran with -//! `PERRY_GC_MOVING_LOOP_POLLS` on — **default ON since #7721**, off from #7161 -//! until then. Zeal cannot conjure a poll that codegen never emitted. A binary -//! compiled with `PERRY_GC_MOVING_LOOP_POLLS=0` still gets (2) and (3) — -//! event-loop-boundary zeal — but a compute-only loop that never yields will not -//! collect at all. That configuration is exactly what made zeal look free before -//! #7721: it was collecting nothing. Check `loop_polls=` in the exit verdict. -//! -//! 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. -//! -//! # Pacing: why "every poll" is not a usable default (#7254, #7728) -//! -//! Point 1 above says "every loop back-edge poll", and until #7728 it meant -//! that literally. That was affordable only for as long as back-edge polls were -//! a compile-time opt-in nobody took: with `PERRY_GC_MOVING_LOOP_POLLS` default -//! OFF (#7161), a compute-only program reached **no** loop safepoint, so zeal -//! forced nothing, cost nothing — and proved nothing. #7721 made the poll -//! default ON, which is a large throughput win for the collector and turned -//! zeal from free-and-vacuous into correct-but-unusable in the same commit. -//! -//! Measured on the pinned quiet host, a tree-walking-interpreter workload -//! (`iso_miss.ts`, 4.5 s without zeal there): -//! -//! | rounds | polls | forced collections | wall | -//! |---|--:|--:|--:| -//! | 1 | 70,963 | 70,968 | 36.3 s | -//! | 2 | 141,926 | 141,931 | 72.7 s | -//! | 40 (extrapolated, linear) | ~2.84 M | ~2.84 M | ~24 min | -//! -//! Perfectly linear — this was never a livelock, it was ~511 µs of fixed -//! per-collection cost (root scan over the shadow stack plus ~55 side-table -//! scanners) paid once per loop iteration, to relocate a mean of **5.9 -//! objects**. Practically all of that work is the collection's fixed overhead, -//! not the relocation zeal exists to stress. An instrument whose smallest -//! honest run takes 24 minutes is an instrument nobody switches on, and #7254 -//! had already logged "a striking concentration of multi-minute-plus runs" -//! under this pairing without triaging it. -//! -//! So zeal is **allocation-paced**: it forces a collection at the first poll at -//! which `PERRY_GC_ZEAL_ALLOC_KB` of new nursery material has accumulated since -//! the last one (default 4 KB — roughly a nursery block's worth of objects, and -//! ~1/4000th of the 16 MB cap the ordinary scavenge trigger uses). This is V8's -//! `--gc-interval` model and SpiderMonkey's `gcZeal(mode, frequency)`, both of -//! which pace for the same reason. -//! -//! Measured on the same workload after the change: **98.8 s and the correct -//! answer**, with 193,087 forced collections out of 2,838,560 polls, all of them -//! copying minors, relocating 3,115,719 objects. Unpaced the same run is -//! ~1,426 s. See `ZEAL_DEFAULT_STRIDE_BYTES` for the stride sweep that picked -//! the default. -//! -//! **`PERRY_GC_ZEAL_ALLOC_KB=0` restores the literal every-poll semantics**, and -//! that is the right setting for a small fixture (`gc_instrument_smoke.sh` pins -//! it) or for a window executed only once. What pacing gives up is precisely -//! that: a bug window crossed a single time may now sit between two forced -//! collections. A window that *recurs* — every shape in the #7154 family, which -//! is why the reproducers are loops — is still caught, just after N KB of -//! allocation rather than on the first iteration. -//! -//! The pacing is a **monotone** high-water mark, not a "bytes since" delta: -//! each forced collection rearms to `from_space_after + stride`, so a collection -//! that reclaims nothing (an escalation to a non-moving full mark-sweep) still -//! demands another `stride` bytes of real allocation before the next one. Total -//! forced collections are therefore bounded by `bytes_allocated / stride` -//! whatever the collector does with them — the property that makes the fix a -//! bound rather than a hope, and the one #7592's livelock lacked. -//! -//! # Why there is no allocation-point level -//! -//! An obvious `PERRY_GC_ZEAL=2` would collect at every allocation. It was -//! deliberately not implemented: the allocation-point arm in `gc_check_trigger` -//! takes `ManualGcScanGuard::force_full_scan`, and a forced conservative stack -//! scan makes the copying minor ineligible -//! (`CopiedMinorFallbackReason::ConservativeStack`). A level 2 would therefore -//! run many *non-moving* minors and move nothing — a knob whose name promises -//! relocation stress and whose effect is sweep pressure. That is precisely the -//! failure `PERRY_GC_FORCE_EVACUATE` already cost this project once (#6942 / -//! #6946), so the level does not exist rather than existing untrustworthy. - -use std::sync::atomic::{AtomicU64, Ordering}; - -/// Collections zeal has forced that would not otherwise have run. The live- -/// subject counter for every zeal-based verdict. -static ZEAL_FORCED: AtomicU64 = AtomicU64::new(0); - -/// Pure knob parse, so the mapping is testable without mutating the process -/// environment (the live reader caches in a `OnceLock`). -pub(crate) fn parse_zeal(raw: Option<&str>) -> bool { - matches!(raw, Some("1") | Some("on") | Some("true")) -} - -#[cfg(test)] -thread_local! { - /// Test-only override. Thread-local, so one test turning zeal on cannot - /// change collector behaviour for any other test. - static ZEAL_OVERRIDE: std::cell::Cell> = const { std::cell::Cell::new(None) }; -} - -/// `PERRY_GC_ZEAL=1`/`on`/`true` — force an evacuating minor at every safepoint. -pub(crate) fn gc_zeal_enabled() -> bool { - #[cfg(test)] - if let Some(zeal) = ZEAL_OVERRIDE.with(std::cell::Cell::get) { - return zeal; - } - use std::sync::OnceLock; - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| parse_zeal(std::env::var("PERRY_GC_ZEAL").ok().as_deref())) -} - -/// RAII test override for zeal: the previous override, and whether this guard -/// took an arm on the back-edge poll's global word that it owes back. -#[cfg(test)] -pub(crate) struct ZealGuard(Option, bool); - -#[cfg(test)] -impl ZealGuard { - pub(crate) fn set(enabled: bool) -> Self { - // Mirror production: zeal keeps the back-edge poll's global arming word - // non-zero (`gc/poll_arm.rs::resolve_poll_seed`), because a poll that - // reads zero never calls in and so can force nothing. A test vehicle - // that skipped this would let `js_gc_loop_safepoint` no-op under a - // `ZealGuard` and report the collection zeal never got to run. - if enabled { - super::arm_poll(); - } - Self( - ZEAL_OVERRIDE.with(|cell| cell.replace(Some(enabled))), - enabled, - ) - } -} - -#[cfg(test)] -impl Drop for ZealGuard { - fn drop(&mut self) { - ZEAL_OVERRIDE.with(|cell| cell.set(self.0)); - if self.1 { - super::disarm_poll(); - } - } -} - -#[inline] -pub(crate) fn note_zeal_forced_collection() { - ZEAL_FORCED.fetch_add(1, Ordering::Relaxed); -} - -// ------------------------------------------------------------- pacing (#7728) - -/// Default stride: 4 KB of new nursery material between zeal-forced collections. -/// -/// Measured, not picked. The whole sweep below is ONE binary and ONE env var on -/// the pinned quiet host — the interpreter workload at a quarter scale, whose -/// `loop_polls` is **283,852 in every row**, so the only thing the knob changes -/// is the decision to collect, not the number of safepoints: -/// -/// | `ALLOC_KB` | forced collections | moved objects | wall | -/// |---|--:|--:|--:| -/// | 0 (pre-#7728) | 283,857 | 1,629,647 | 142.6 s | -/// | 1 | 70,929 | 815,460 | 36.1 s | -/// | **4 (default)** | **19,314** | **325,830** | **10.2 s** | -/// | 16 | 5,070 | 129,959 | 3.0 s | -/// | 64 | 1,291 | 52,357 | 1.1 s | -/// -/// Row 0 is the pre-fix behaviour reproduced on the shipped binary: 283,857 -/// collections for 283,852 polls — one per back-edge poll, plus a handful from -/// the other safepoint zeal forces at (the outermost microtask-pump boundary, -/// which `note_loop_poll_reached` does not count). Near 1:1, not exactly; the -/// bound that matters is one per poll, and it is what made the full-scale -/// workload take ~24 minutes. -/// -/// 4 KB rather than the faster 16/64 is deliberate: this is a *correctness* -/// instrument, so the default errs toward sensitivity. It still collects once -/// per ~15 loop iterations, which catches a recurring window almost -/// immediately, while being 14x cheaper than unpaced. An operator who wants -/// speed raises it; one who wants a once-executed window sets `0`. -/// -/// Every row keeps `copying_minors == forced_collections` and `moved > 0`, so -/// no stride silently degrades the instrument into non-moving sweeps. -const ZEAL_DEFAULT_STRIDE_BYTES: usize = 4 * 1024; - -/// Pure knob parse for `PERRY_GC_ZEAL_ALLOC_KB`, in KB. `Some(0)` is a -/// deliberate, meaningful value — "collect at every poll", the pre-#7728 -/// semantics — so it must not be filtered out the way a nonsense value is. -pub(crate) fn parse_zeal_alloc_kb(raw: Option<&str>) -> usize { - raw.and_then(|s| s.trim().parse::().ok()) - .map(|kb| kb.saturating_mul(1024)) - .unwrap_or(ZEAL_DEFAULT_STRIDE_BYTES) -} - -/// Bytes of new nursery material required between zeal-forced collections. -pub(crate) fn zeal_poll_stride_bytes() -> usize { - #[cfg(test)] - if let Some(stride) = ZEAL_STRIDE_OVERRIDE.with(std::cell::Cell::get) { - return stride; - } - use std::sync::OnceLock; - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| { - parse_zeal_alloc_kb(std::env::var("PERRY_GC_ZEAL_ALLOC_KB").ok().as_deref()) - }) -} - -#[cfg(test)] -thread_local! { - /// Test-only stride override, thread-local for the same reason `ZEAL_OVERRIDE` is. - static ZEAL_STRIDE_OVERRIDE: std::cell::Cell> = - const { std::cell::Cell::new(None) }; -} - -/// RAII test override for the pacing stride. -#[cfg(test)] -pub(crate) struct ZealStrideGuard(Option); - -#[cfg(test)] -impl ZealStrideGuard { - pub(crate) fn set(stride_bytes: usize) -> Self { - Self(ZEAL_STRIDE_OVERRIDE.with(|cell| cell.replace(Some(stride_bytes)))) - } -} - -#[cfg(test)] -impl Drop for ZealStrideGuard { - fn drop(&mut self) { - ZEAL_STRIDE_OVERRIDE.with(|cell| cell.set(self.0)); - } -} - -thread_local! { - /// From-space high-water mark at or above which the next zeal-forced - /// collection is due. Per-thread because the arena it measures is. - /// - /// Starts at 0 so the FIRST poll always collects: a program that allocates - /// less than one stride in total must still exercise the instrument rather - /// than silently becoming a run in which zeal did nothing. - static ZEAL_NEXT_FORCE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Is a zeal-forced collection due at this poll, given current from-space bytes? -#[inline] -pub(crate) fn zeal_poll_collection_due(from_space_bytes: usize) -> bool { - from_space_bytes >= ZEAL_NEXT_FORCE_BYTES.with(std::cell::Cell::get) -} - -/// Rearm the pacing high-water mark after a zeal poll ran the safepoint. -/// -/// Takes the from-space level measured *after* the collection, so the next -/// forced collection needs a full stride of genuinely new allocation on top of -/// whatever survived. See the module docs for why this is a high-water mark -/// rather than a delta. -#[inline] -pub(crate) fn note_zeal_poll_collection(from_space_bytes_after: usize) { - let next = from_space_bytes_after.saturating_add(zeal_poll_stride_bytes()); - ZEAL_NEXT_FORCE_BYTES.with(|cell| cell.set(next)); -} - -/// Polls at which zeal declined to collect because the stride was not yet met. -static ZEAL_POLLS_PACED: AtomicU64 = AtomicU64::new(0); - -#[inline] -pub(crate) fn note_zeal_poll_paced() { - ZEAL_POLLS_PACED.fetch_add(1, Ordering::Relaxed); -} - -/// How many back-edge polls the pacing skipped. Reported in the zeal verdict so -/// a run states its own pacing rather than leaving the operator to infer it -/// from a collection count that looks lower than it "should" be. -pub fn zeal_polls_paced() -> u64 { - ZEAL_POLLS_PACED.load(Ordering::Relaxed) -} - -#[cfg(test)] -pub(crate) fn reset_zeal_pacing_for_test() { - ZEAL_NEXT_FORCE_BYTES.with(|cell| cell.set(0)); -} - -/// How many collections zeal has forced. A zeal run that reports `0` here -/// exercised nothing (most often: the binary was compiled without -/// `PERRY_GC_MOVING_LOOP_POLLS=1` and the workload never reached the event -/// loop). -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. -/// -/// **Exhaustive exactly under zeal**, which is the one place it is read -/// (`zeal_verdict`). A back-edge whose `PERRY_GC_POLL_ARMED` load reads zero -/// never calls into the runtime at all — that is the point of `gc/poll_arm.rs` -/// — so outside zeal this counts polls that had something to consider, not -/// back-edges executed. Zeal keeps the word armed for the life of the process -/// (`resolve_poll_seed`), so under zeal the two are the same number and the -/// "not one back-edge poll was reached" diagnosis stays sound. -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(), - zeal_polls_paced(), - zeal_poll_stride_bytes(), - )) -} - -/// 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, - paced_polls: u64, - stride_bytes: usize, -) -> Result { - let summary = format!( - "[gc-zeal] forced_collections={forced} copying_minors={cycles} \ - moved_objects={moved} loop_polls={loop_polls} \ - paced_polls={paced_polls} stride_bytes={stride_bytes}" - ); - 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, 0, 4096).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, 0, 4096).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, 0, 4096) - .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, 0, 4096).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, 0, 4096) - .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, 0, 4096).is_ok()); - } -} diff --git a/crates/perry-runtime/src/iterator_helpers.rs b/crates/perry-runtime/src/iterator_helpers.rs index da5d468710..f92848b407 100644 --- a/crates/perry-runtime/src/iterator_helpers.rs +++ b/crates/perry-runtime/src/iterator_helpers.rs @@ -29,7 +29,8 @@ use crate::closure::{is_closure_ptr, js_closure_call1, js_closure_call2, ClosureHeader}; // #7564: `make_iter_result` used to be a local five-allocation copy whose // intermediates were all bare Rust locals. It was the copy that faulted under -// `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` — `make_iter_result + 188`, a +// `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1` +// — `make_iter_result + 188`, a // retired from-space object — and it now comes from the one rooted, // shared-shape constructor in `crate::iter_result`. use crate::iter_result::make_iter_result; diff --git a/crates/perry-runtime/src/native_handle.rs b/crates/perry-runtime/src/native_handle.rs index d475db9602..aeaa95b90c 100644 --- a/crates/perry-runtime/src/native_handle.rs +++ b/crates/perry-runtime/src/native_handle.rs @@ -51,7 +51,34 @@ fn current_thread_id() -> u64 { hasher.finish() } -fn runtime_main_thread_id() -> u64 { +/// The OS thread id, with NO thread-local storage behind it. +/// +/// `std::thread::current()` panics when called for the first time on a thread +/// whose TLS has already been destroyed — and the per-thread teardown funnel +/// (`js_gc_release_current_thread_collection_side_allocations`) runs exactly +/// there: a tokio worker unwinding at process exit aborted the whole run +/// through the schedule's exit summary (#7741 audit). Handle-ownership checks +/// keep the hashed-`ThreadId` scheme above (an OS id can be recycled after a +/// thread dies, which those checks must not confuse); this one exists solely +/// for teardown-path "am I the main thread" reads, where the main thread is +/// alive by definition and the caller may already be TLS-dead. +fn current_os_thread_id() -> u64 { + #[cfg(unix)] + { + unsafe { libc::pthread_self() as u64 } + } + #[cfg(windows)] + { + extern "system" { + fn GetCurrentThreadId() -> u32; + } + u64::from(unsafe { GetCurrentThreadId() }) + } +} + +static MAIN_OS_THREAD_ID: AtomicU64 = AtomicU64::new(0); + +pub(crate) fn runtime_main_thread_id() -> u64 { let current = current_thread_id(); match MAIN_THREAD_ID.compare_exchange(0, current, Ordering::AcqRel, Ordering::Acquire) { Ok(_) => current, @@ -59,6 +86,38 @@ fn runtime_main_thread_id() -> u64 { } } +/// Record the CALLING thread as the owner of teardown-path once-only +/// diagnostics, first caller wins. Separate from [`runtime_main_thread_id`]'s +/// capture on purpose: that one races among every handle-creating thread, so +/// piggy-backing the OS id on its winning arm leaves the word 0 whenever a +/// handle call recorded main first — and a 0 here reads as "unrecorded", +/// which waves EVERY thread through [`is_main_thread_or_unrecorded`] and +/// reintroduces the worker-teardown print this exists to prevent. +pub(crate) fn record_diagnostics_owner_thread() { + let _ = MAIN_OS_THREAD_ID.compare_exchange( + 0, + current_os_thread_id(), + Ordering::AcqRel, + Ordering::Acquire, + ); +} + +/// True on the runtime's main thread, or when the main thread has not been +/// recorded yet. The unrecorded case returns `true` on purpose: callers use +/// this to gate a once-only diagnostic, and never emitting is worse than +/// emitting from a not-yet-identified thread. Pure read — unlike +/// [`runtime_main_thread_id`] it does not capture the caller as main. +/// +/// Compares OS thread ids, not the hashed `ThreadId`, because its callers sit +/// on the per-thread teardown funnel where `std::thread::current()` is not +/// callable — see [`current_os_thread_id`]. +pub(crate) fn is_main_thread_or_unrecorded() -> bool { + match MAIN_OS_THREAD_ID.load(Ordering::Acquire) { + 0 => true, + main => current_os_thread_id() == main, + } +} + #[cold] fn throw_type_error(message: &str) -> ! { let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); @@ -349,6 +408,47 @@ pub(crate) unsafe fn finalize_native_handle_for_gc(handle: *mut NativeHandleHead mod tests { use super::*; use std::os::raw::c_int; + + /// The teardown-diagnostics gate must block every thread except the + /// recorded owner — the unrecorded arm waving workers through is exactly + /// how a TLS-dead worker ends up printing (and panicking) at process exit. + /// Runs the whole protocol on ONE spawned thread so it cannot disturb, or + /// be disturbed by, another test having recorded an owner already. + #[test] + fn the_diagnostics_owner_gate_blocks_other_threads() { + let recorded_before = MAIN_OS_THREAD_ID.load(Ordering::Acquire); + if recorded_before == 0 { + record_diagnostics_owner_thread(); + } + let owner = MAIN_OS_THREAD_ID.load(Ordering::Acquire); + assert_ne!(owner, 0, "recording must set the owner word"); + if owner == current_os_thread_id() { + assert!( + is_main_thread_or_unrecorded(), + "the owner itself must pass the gate" + ); + } + let other = std::thread::spawn(is_main_thread_or_unrecorded) + .join() + .expect("gate thread panicked"); + assert!( + !other, + "a non-owner thread must be blocked once an owner is recorded" + ); + // Second capture must not steal ownership. + let owner_after = { + std::thread::spawn(|| { + record_diagnostics_owner_thread(); + }) + .join() + .expect("recorder thread panicked"); + MAIN_OS_THREAD_ID.load(Ordering::Acquire) + }; + assert_eq!( + owner, owner_after, + "first recorder wins; later calls are no-ops" + ); + } use std::sync::atomic::{AtomicUsize, Ordering}; static FINALIZER_CALLS: AtomicUsize = AtomicUsize::new(0); diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index f739c070be..57356c1294 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -1089,7 +1089,7 @@ pub unsafe extern "C" fn js_new_function_construct( // moves the instance and this arm returns the pre-move address; // reproduced by `new inst.ctor(x)` where `inst.ctor` is a plain // function, 200/200 iterations wrong under - // `PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1`. + // `PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1`. let scope = crate::gc::RuntimeHandleScope::new(); let inst_handle = scope.root_nanbox_f64(nan_boxed); let prev_this = crate::object::js_implicit_this_get(); @@ -1440,7 +1440,8 @@ unsafe fn construct_registered_class_ref( // function returns the PRE-MOVE address. Every field the constructor wrote // then reads back as garbage through the stale handle — measured on // `new inst.ctor(x)` under - // `PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1`, 200/200 iterations wrong, + // `PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1`, + // 200/200 iterations wrong, // and as a `signal 10` on retired from-space under // `PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800`. // @@ -1701,7 +1702,7 @@ pub unsafe extern "C" fn js_new_function_construct_with_new_target( // `nan_boxed` and the three displaced cell values cross a user // constructor body. Reproduced by // `Reflect.construct(plainFn, [x], otherFn)`, 200/200 iterations wrong - // under `PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1`. + // under `PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1`. let scope = crate::gc::RuntimeHandleScope::new(); let inst_handle = scope.root_nanbox_f64(nan_boxed); let prev_this = crate::object::js_implicit_this_get(); diff --git a/crates/perry-runtime/src/path/value_args.rs b/crates/perry-runtime/src/path/value_args.rs index f8b9626e8b..d1dcc47782 100644 --- a/crates/perry-runtime/src/path/value_args.rs +++ b/crates/perry-runtime/src/path/value_args.rs @@ -90,7 +90,7 @@ pub(crate) fn path_arg_header(value: f64) -> *const StringHeader { /// allocation point and the pre-bound address stays valid. Reverting this to /// `let a_ptr = a_ptr0;` was measured against 400k SSO-pair joins under /// `PERRY_GC_FORCE_EVACUATE=1` + from-space protection and against the #7621 gap -/// test under `PERRY_GC_ZEAL=1` (402k copying minors): zero faults, byte- +/// test under the rate-1 seeded schedule (402k copying minors): zero faults, byte- /// identical output. It is written this way because it is the shape the /// invariant asks for and it costs nothing, not because an instrument caught it. fn with_two_headers( diff --git a/docs/engine-plan.md b/docs/engine-plan.md index eff499e635..1f73b38aad 100644 --- a/docs/engine-plan.md +++ b/docs/engine-plan.md @@ -76,7 +76,7 @@ condition in slice 8, so the remaining rooting work is runtime-side). **Gate debt still open:** #7554 (gc-ratchet CI has measured nothing since 2026-08-05 — REPAIR THIS BEFORE the next GC-pacing change, which needs it), #7502–#7507 (root-lowering suites partly vacuous), #7300 (flaky codegen tests), -#7604 (zeal can arm without firing on compute-only benches), #7606 (two macOS +#7604 (the stress schedule can arm without firing on compute-only benches), #7606 (two macOS gc-rooting gap crashes, untriaged), #6847 reopened (zlib link on macOS). ### GC correctness — the four layers diff --git a/docs/src/internals/gc-rooting-invariant.md b/docs/src/internals/gc-rooting-invariant.md index 42eece3d23..eb356be4d3 100644 --- a/docs/src/internals/gc-rooting-invariant.md +++ b/docs/src/internals/gc-rooting-invariant.md @@ -47,7 +47,7 @@ wrong: dereferenced; - **no runtime GC probe can see it.** At the moment of the collection there is nothing for the collector to find, so a from-space scan, a verify-roots pass - and a zeal run all come back clean. `PERRY_GC_VERIFY_EVACUATION` checks that + and a rate-1 seeded run all come back clean. `PERRY_GC_VERIFY_EVACUATION` checks that reachable slots were forwarded; it cannot check a register it does not know exists; - it is **visible by default only where a poll is emitted.** The back-edge poll @@ -124,7 +124,7 @@ not intermittently, suggests this class rather than a stale register. **`scripts/gc_root_dominance_check.py` is structurally blind to this class** — it reads emitted LLVM IR and cannot see a runtime table. The instruments that catch it -are `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800` +are `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800` on a real workload. When adding a cache of a heap pointer, register it in `gc_register_mutable_root_scanner` in `gc/mod.rs` in the same commit. @@ -151,7 +151,7 @@ evidence for any of them:** and 31 stale uses were dropped by `--moving-only`. **Audit these sets against what codegen actually emits, the way #7227 audits `ALLOC_RE`.** -For the classes above, the instruments that catch them are the zeal/quarantine +For the classes above, the instruments that catch them are the schedule/quarantine arms below and a *dependency-scale* workload — #7280 records 25 curated corpus files passing while 20 lines of stock zod fail. @@ -361,21 +361,23 @@ python3 scripts/gc_root_dominance_check.py .perry-trace/llvm \ From #7196: -- `PERRY_GC_ZEAL=1` — collect at safepoints, allocation-paced (#7728): one forced - collection per `PERRY_GC_ZEAL_ALLOC_KB` (default 4) of new nursery material. - Slow, thorough. Add `PERRY_GC_ZEAL_ALLOC_KB=0` for the literal every-poll mode +- `PERRY_GC_SCHEDULE_RATE=1` (with a seed) — collect at every candidate + safepoint, allocation-paced (#7728): one candidate per + `PERRY_GC_SCHEDULE_ALLOC_KB` (default 4) of new nursery material. Slow, + thorough. Add `PERRY_GC_SCHEDULE_ALLOC_KB=0` for the literal every-poll mode when the window you are hunting executes only once — it is far slower. - `PERRY_GC_PROTECT_FROMSPACE=1` — `mprotect` from-space after evacuation so a stale read faults immediately instead of reading plausible garbage. - `PERRY_GC_FROMSPACE_SCAN_ABORT` — now actually runs. - `PERRY_GC_SCHEDULE_SEED=` (+ `PERRY_GC_SCHEDULE_RATE`, default `0.05`) — - collect on a deterministic pseudo-random schedule instead of at every - safepoint. Reach for this when zeal is *too* blunt: on a workload whose timing - zeal distorts enough to kill it somewhere uninteresting, and — more often — - when you need the failure to come back. The schedule is a pure function of - `(seed, per-thread safepoint ordinal)`, so a seed that fails is a reproducer, - which is what turns "1 run in 60" into something you can bisect against. - `scripts/gc_schedule_fuzz.sh [seeds]` sweeps seeds and prints a + collect on a deterministic pseudo-random schedule. At `RATE=1` it collects at + every safepoint: slow, thorough, maximum pressure. Drop the rate when that is + *too* blunt — on a workload whose timing it distorts enough to kill somewhere + uninteresting — and the schedule thins out without losing the property that + matters. The schedule is a pure function of `(seed, per-thread safepoint + ordinal)`, so a seed that fails is a reproducer, which is what turns "1 run in + 60" into something you can bisect against. + `scripts/gc_schedule_fuzz.sh [seed-count]` sweeps seeds and prints a reproduce command per failure. > **A rate is not a substitute for a schedule.** Re-running one binary 60 times diff --git a/docs/src/internals/memory-model.md b/docs/src/internals/memory-model.md index a3689b73ae..99f2301ecf 100644 --- a/docs/src/internals/memory-model.md +++ b/docs/src/internals/memory-model.md @@ -132,10 +132,9 @@ 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 **GC safepoints** — loop back-edge polls and the outermost microtask-pump boundary — instead of only when nursery pressure is due. **Allocation-paced since #7728** (`PERRY_GC_ZEAL_ALLOC_KB`, default 4; `=0` restores the literal every-poll mode): unpaced, one collection per loop iteration cost ~511 µs to relocate a mean of 5.9 objects, which made zeal unusable on real workloads once #7721 turned back-edge polls on by default. 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. | -| `PERRY_GC_SCHEDULE_SEED=` | **Seeded GC-schedule fuzzing** — collect at a safepoint iff a deterministic pseudo-random function of the seed and a per-thread safepoint ordinal says so. The middle setting between normal pacing and zeal: enough extra collections to turn a rare "what was live when the collector ran" bug into a frequent one, and — because the schedule is a pure function of `(seed, counter)` — **a failing seed is a reproducer**. Implies `PERRY_GC_FORCE_EVACUATE` for the same reason zeal does. A value that does not parse as a `u64` reads as OFF, not as seed 0. | -| `PERRY_GC_SCHEDULE_RATE=<0..1>` | Expected fraction of handled safepoints that collect (default `0.05`). Inert without a seed. `0` selects nothing but still installs the banner and reporters, so it is a clean control arm; `1` is zeal's density. Out-of-range values clamp. | +| `PERRY_GC_SCHEDULE_SEED=` | **Seeded GC-schedule fuzzing** — when nursery pressure is not due, add a minor collection at a handled safepoint when a deterministic pseudo-random function of the seed and a per-thread safepoint ordinal selects it. It never *suppresses* a pressure-driven collection; the rate is additional density on top of normal pacing. The collection schedule as a knob: enough extra collections to turn a rare "what was live when the collector ran" bug into a frequent one, and — because the schedule is a pure function of `(seed, counter)` — **a failing seed is a reproducer**. Implies forced evacuation **unconditionally**, so survivors actually move — `PERRY_GEN_GC_EVACUATE`, whose `=0` used to veto that, was deleted in #7611 precisely because an ambient veto silently turned a #7154 instrument into a no-op. It 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, and does not consume a schedule slot. A value that does not parse as a `u64` reads as OFF, not as seed 0. Composes with the two above; that pairing is what turns a rooting bug into an immediate precise fault. | +| `PERRY_GC_SCHEDULE_RATE=<0..1>` | Expected fraction of eligible handled safepoints that receive an *additional* schedule-triggered collection (default `0.05`). Inert without a seed. `0` selects nothing but still installs the banner and reporters, so it is a clean control arm; `1` collects at every handled safepoint — maximum pressure, in the spirit of V8's `--stress-scavenge`, and the point where the seed stops mattering because every ordinal is selected. Out-of-range values clamp. | These instruments have explicit caveats, because each has burned a prior investigation: @@ -145,26 +144,28 @@ investigation: for a `[gc-fromspace-protect] retired_set=#N` line under `PERRY_GC_DIAG=1`. - **Depth is the knob to raise when a suspected bug does not fault.** A stale pointer is only caught while the page-set it names is still quarantined, and - under zeal a value can cross hundreds of collections between its last valid - observation and its stale use — one per loop back-edge poll. On #7154's + at `PERRY_GC_SCHEDULE_SEED= PERRY_GC_SCHEDULE_RATE=1 + PERRY_GC_SCHEDULE_ALLOC_KB=0` a value can cross hundreds of collections between + its last valid observation and its stale use — one per loop back-edge poll. + (`ALLOC_KB=0` is what makes that literally per-poll: rate 1 selects every + *candidate*, and the default 4 KB stride only makes a poll a candidate once + that much new nursery material has accumulated.) On #7154's `new C(…)` reproducer the constructor body runs 600 polls, so the caller's stale register is 600 retirements old by the time the return-override publishes it: the default depth of 4 misses it silently, and `PERRY_GC_PROTECT_FROMSPACE_DEPTH=800` faults on the first use. Rule of thumb: depth ≥ the number of safepoints the suspect value survives. -- `PERRY_GC_ZEAL` cannot force a collection at a loop back-edge poll codegen - never produced. Those come from the **compile-time** - `PERRY_GC_MOVING_LOOP_POLLS`, which is **default ON since #7721** (kill switch - `=0`); it was default off from #7161 until then, and that is why zeal used to - look free — it was collecting nothing. Two gaps survive the new default: - 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). On a poll-free binary zeal fires only at - event-loop boundaries, so a compute-only loop never collects at all. You no - longer have to remember to check: since #7604 a zeal run prints - `[gc-zeal] forced_collections=… copying_minors=… moved_objects=… loop_polls=…` - at exit and **exits 70** if it forced or moved nothing, so a vacuous run is - red rather than green.- **`PERRY_GC_SCHEDULE_SEED`'s determinism is per-thread, and that is the honest +- `PERRY_GC_SCHEDULE_SEED` cannot select loop back-edge polls that codegen never + produced. Those are a **compile-time** property (`PERRY_GC_MOVING_LOOP_POLLS`, default + ON since #7721; a binary compiled with `=0` has none). Without them, a seeded + run only fires at event-loop boundaries and a compute-only loop never + collects at all — check the exit summary's `loop_polls=` before trusting a + clean sweep. At `PERRY_GC_SCHEDULE_RATE=1` you no longer have to remember: + the run prints a `[gc-schedule]` verdict at exit and **exits 70** when it + forced or moved nothing, so a vacuous run is a red run rather than a green + one. Sub-endpoint rates get the summary line but no hard verdict (a sparse + seed legitimately forcing nothing is not a broken instrument). +- **`PERRY_GC_SCHEDULE_SEED`'s determinism is per-thread, and that is the honest scope.** The safepoint counter is thread-local: no wall clock, no address, no thread identity enters the decision, so a **single-threaded** program replays a seed exactly. A `perry/thread` program gets a deterministic schedule *per diff --git a/docs/src/internals/rfc-rooting-by-construction.md b/docs/src/internals/rfc-rooting-by-construction.md index 5105250a01..f9913333b8 100644 --- a/docs/src/internals/rfc-rooting-by-construction.md +++ b/docs/src/internals/rfc-rooting-by-construction.md @@ -25,7 +25,7 @@ the mistake: |---|---|---| | code review | what a reviewer happens to notice | minutes, unreliable | | `gc_root_dominance_check.py` | dominance violations in emitted IR | one CI run | -| `PERRY_GC_ZEAL` / from-space protect | the *consequence*, if timing cooperates | a test run, flaky | +| `PERRY_GC_SCHEDULE_SEED` / from-space protect | the *consequence*, if timing cooperates | a test run, flaky | | a user's crash | everything, eventually | days | The static checker is a genuine improvement and should stay. But it is still a diff --git a/docs/statepoint-gc-experiment.md b/docs/statepoint-gc-experiment.md index 10d8fe3fc3..0771cf8b8a 100644 --- a/docs/statepoint-gc-experiment.md +++ b/docs/statepoint-gc-experiment.md @@ -712,7 +712,7 @@ chain versus `PERRY_STACKMAP_WALKER=unwind` — is a dead heat (0.24 s vs 0.24 s; 1.01 s vs 1.01 s). The DWARF CFI parsing that `perf` measured at ~22% of samples is simply no longer hot. The other variable between the two runs is the rebase onto main's 64 commits of GC work (root-store -dominance #7192, from-space protection and zeal #7196, and #7148's precise +dominance #7192, from-space protection and forced-collection tooling #7196, and #7148's precise safepoint drains replacing conservative-scan fallbacks), which plausibly reduced how often the native stack is walked at all. Shadow itself got faster on the same probes (469.2 → 429.2 ms geo), which is consistent with diff --git a/run_parity_tests.sh b/run_parity_tests.sh index f3377f9203..8d8edae97a 100755 --- a/run_parity_tests.sh +++ b/run_parity_tests.sh @@ -460,6 +460,14 @@ for raw in sys.stdin: echo "$decoded" | \ # Normalize line endings tr -d '\r' | \ + # Strip the seeded GC schedule's diagnostics. A test carrying + # `parity-env: … PERRY_GC_SCHEDULE_SEED=…` gets a startup banner and an + # exit summary on stderr, which this harness merges into the compared + # stream; Node prints no such thing, so every one of those tests would + # diff as an output mismatch. Instrument noise, not program output. + # A crash under the instrument is still caught: abnormal exits are + # detected from the exit status, before either comparison runs. + sed -E '/^\[gc-schedule\]/d' | \ # Strip Node v22+ MODULE_TYPELESS_PACKAGE_JSON warnings (4 lines # printed to stderr when running .ts files without "type": # "module" in package.json — pure environmental noise that diff --git a/scripts/gc_instrument_smoke.sh b/scripts/gc_instrument_smoke.sh index cb9c448337..ae51ecfba0 100755 --- a/scripts/gc_instrument_smoke.sh +++ b/scripts/gc_instrument_smoke.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # End-to-end exercised arm for the #7154 rooting-bug instruments -# (`PERRY_GC_PROTECT_FROMSPACE`, `PERRY_GC_ZEAL`). +# (`PERRY_GC_PROTECT_FROMSPACE`, `PERRY_GC_SCHEDULE_SEED`). # # WHY THIS EXISTS # @@ -13,16 +13,17 @@ # asserted as a unit test in the required `cargo-test` gate # (`gc/tests/fromspace_protect.rs::quarantine_catches_a_planted_stale_from_space_deref`). # What a unit test cannot cover is the INTEGRATED path: codegen actually -# emitting back-edge polls, zeal actually firing on them, the copying minor -# actually running, and the quarantine actually retiring its from-space in a -# real compiled program. That is this script. +# emitting back-edge polls, the schedule actually firing on them, the copying +# minor actually running, and the quarantine actually retiring its from-space in +# a real compiled program. That is this script. # # NON-VACUITY IS THE POINT. Per CLAUDE.md's "four ways a gate can be unable to # fail" #4, a gate must assert its subject was live. A protected run with zero # copying minors protects nothing and would pass silently. So this script does -# not merely check the program's output: it requires the zeal arm to produce -# strictly MORE quarantine retirements than the no-zeal arm, which can only -# happen if zeal genuinely forced collections that pressure would not have. +# not merely check the program's output: it requires the rate-1 arm to produce +# strictly MORE quarantine retirements than the pressure-only arm, which can +# only happen if the schedule genuinely forced collections that pressure would +# not have. # # Usage: scripts/gc_instrument_smoke.sh [path-to-perry] # Expects target/release/perry and PERRY_RUNTIME_DIR-resolvable staticlibs. @@ -39,12 +40,13 @@ export PERRY_RUNTIME_DIR="${PERRY_RUNTIME_DIR:-$(dirname "$PERRY_BIN")}" export PERRY_NO_AUTO_OPTIMIZE=1 # Arms 1-3 and 5 drive the SMALL fixture below, which is sized so that literal -# every-poll zeal costs seconds. Pin the strongest semantics for them -# explicitly (#7728 made the shipped default allocation-paced), so this gate -# keeps testing "a collection at every single poll" rather than silently -# following whatever the default becomes. Arm 6 is the one that asserts the -# DEFAULT is usable, and it deliberately does not set this. -export PERRY_GC_ZEAL_ALLOC_KB=0 +# every-poll collection costs seconds. Pin the strongest semantics for them +# explicitly (#7728's allocation pacing makes the shipped default paced), so +# this gate keeps testing "a collection at every single poll" rather than +# silently following whatever the default becomes. +# Arm 6 is the one that asserts the DEFAULT is usable, and it deliberately does +# not set this. +export PERRY_GC_SCHEDULE_ALLOC_KB=0 WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT @@ -54,7 +56,7 @@ trap 'rm -rf "$WORK"' EXIT # and the instance survives a collection inside the callee — the #7192 shape), # called in an outer loop, with the caller reading a field back afterwards so a # stale read cannot go unnoticed. Sized for ~1200 polls, not #7154's 240k, so -# the zeal arm costs seconds rather than minutes. +# the rate-1 arm costs seconds rather than minutes. cat > "$WORK/fixture.ts" <<'TS' class Holder { payload: any; @@ -84,7 +86,7 @@ function run(): number { console.log("bad", run()); TS -echo "== compiling fixture with PERRY_GC_MOVING_LOOP_POLLS=1 (zeal needs the polls) ==" +echo "== compiling fixture with PERRY_GC_MOVING_LOOP_POLLS=1 (the schedule needs the polls) ==" PERRY_GC_MOVING_LOOP_POLLS=1 "$PERRY_BIN" compile "$WORK/fixture.ts" -o "$WORK/fixture" >/dev/null # $1 = label, rest = env assignments. Echoes the retirement count. @@ -106,56 +108,102 @@ run_arm() { grep '^bad' <<<"$out" >&2 || echo "(no 'bad' line)" >&2 exit 1 fi - echo " [$label] correct output, exit 0, quarantine retirements=$retired" + # Human line to stderr so stdout is purely the retirement count; a caller can + # then `x="$(run_arm ...)" || exit 1` and see the count while a crashed arm's + # non-zero exit still propagates. Piping run_arm through `tail` would swallow + # that exit (the pipeline reports tail's status, and the `exit 1` above only + # leaves the command-substitution subshell). + echo " [$label] correct output, exit 0, quarantine retirements=$retired" >&2 echo "$retired" } echo "== arm 1: instruments OFF (baseline correctness) ==" -off_retired="$(run_arm off | tail -1)" +off_retired="$(run_arm off)" || exit 1 if [[ "$off_retired" -ne 0 ]]; then echo "FAIL: the instrument retired $off_retired page-sets with the knob OFF." >&2 echo " Default-off must mean inert." >&2 exit 1 fi -echo "== arm 2: PROTECT_FROMSPACE=1 without zeal (pressure-only) ==" -nozeal_retired="$(run_arm protect PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 | tail -1)" +echo "== arm 2: PROTECT_FROMSPACE=1, no schedule (pressure-only) ==" +pressure_retired="$(run_arm protect PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64)" || exit 1 -echo "== arm 3: PROTECT_FROMSPACE=1 + ZEAL=1 (the investigation pairing) ==" -zeal_retired="$(run_arm protect+zeal PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 | tail -1)" +# The maximum-density endpoint of the same knob the middle arms use. At rate 1 +# every handled safepoint is selected whatever it hashes to, so the seed is +# immaterial here and fixed only so the arm reads as a reproducible recipe. +echo "== arm 3: PROTECT_FROMSPACE=1 + SCHEDULE_RATE=1 (the investigation pairing) ==" +rate1_retired="$(run_arm protect+rate1 PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 \ + PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64)" || exit 1 echo "== arm 4: PROTECT_FROMSPACE=1 + SCHEDULE_SEED (the tunable middle) ==" sched_retired="$(run_arm protect+schedule PERRY_GC_SCHEDULE_SEED=20260803 PERRY_GC_SCHEDULE_RATE=0.25 \ - PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 | tail -1)" + PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64)" || exit 1 echo "== arm 5: the same seed again (the reproducer property) ==" sched_repeat="$(run_arm protect+schedule-repeat PERRY_GC_SCHEDULE_SEED=20260803 PERRY_GC_SCHEDULE_RATE=0.25 \ - PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 | tail -1)" + PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64)" || exit 1 echo "== arm 6: a different seed (the sweep must explore something) ==" sched_other="$(run_arm protect+schedule-other PERRY_GC_SCHEDULE_SEED=20260804 PERRY_GC_SCHEDULE_RATE=0.25 \ - PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 | tail -1)" + PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64)" || exit 1 # ---- non-vacuity gate ------------------------------------------------------- # The subject must have been LIVE. Without this, every arm above could pass # having run zero copying minors — the exact failure mode #6942/#7024/#7025 # were filed for. -if [[ "$zeal_retired" -eq 0 ]]; then - echo "FAIL: zeal + protection retired ZERO from-space page-sets." >&2 +if [[ "$rate1_retired" -eq 0 ]]; then + echo "FAIL: rate 1 + protection retired ZERO from-space page-sets." >&2 echo " The instruments did not run, so a clean result proves nothing." >&2 echo " Most likely: codegen emitted no back-edge polls, or the copying" >&2 echo " minor was ineligible (conservative stack scan / pinned young)." >&2 exit 1 fi -if [[ "$zeal_retired" -le "$nozeal_retired" ]]; then - echo "FAIL: zeal did not force any additional collection" >&2 - echo " (no-zeal=$nozeal_retired, zeal=$zeal_retired)." >&2 - echo " PERRY_GC_ZEAL is inert on this build — it must collect at" >&2 - echo " safepoints where no trigger is due." >&2 +if [[ "$rate1_retired" -le "$pressure_retired" ]]; then + echo "FAIL: the schedule forced no additional collection at rate 1" >&2 + echo " (pressure-only=$pressure_retired, rate-1=$rate1_retired)." >&2 + echo " PERRY_GC_SCHEDULE_SEED is inert on this build — at rate 1 it must" >&2 + echo " collect at every safepoint where no trigger is due." >&2 exit 1 fi -# ---- arm 4: the quarantine, aimed at real programs -------------------------- +# ---- the seeded schedule's three claims ------------------------------------- +# The rate knob spans a RANGE: a mid rate must land strictly between pressure +# alone and the rate-1 endpoint. A mid rate that collapsed onto either endpoint +# would mean the knob is decoration. +if [[ "$sched_retired" -le "$pressure_retired" ]]; then + echo "FAIL: PERRY_GC_SCHEDULE_SEED forced no additional collection" >&2 + echo " (pressure-only=$pressure_retired, seeded=$sched_retired)." >&2 + exit 1 +fi +if [[ "$sched_retired" -ge "$rate1_retired" ]]; then + echo "FAIL: the seeded schedule at rate 0.25 collected at least as often as" >&2 + echo " rate 1 (seeded=$sched_retired, rate-1=$rate1_retired). The rate" >&2 + echo " knob is not gating anything." >&2 + exit 1 +fi +# It is a REPRODUCER: the same seed must select the same safepoints, so the +# realised collection count is identical. This is the property the whole mode +# exists for; if it can drift, a "failing seed" is a rumour. +if [[ "$sched_retired" -ne "$sched_repeat" ]]; then + echo "FAIL: the same seed produced two different schedules" >&2 + echo " ($sched_retired vs $sched_repeat retirements). A failing seed" >&2 + echo " would not reproduce, which is the entire point of the mode." >&2 + exit 1 +fi +# It EXPLORES: a sweep over adjacent seeds must not be one experiment repeated. +# Equal counts are not proof of an identical schedule, but differing counts ARE +# proof of a differing one, and that is the direction that can fail usefully. +if [[ "$sched_other" -eq "$sched_retired" ]]; then + echo "WARNING: seeds 20260803 and 20260804 retired the same number of" >&2 + echo " page-sets ($sched_retired). Not necessarily the same schedule," >&2 + echo " but check gc/tests/schedule.rs if a sweep stops finding things." >&2 +fi + +echo +echo " [seeded schedule] pressure-only=$pressure_retired < seeded(0.25)=$sched_retired < rate-1=$rate1_retired" +echo " [seeded schedule] same seed twice: $sched_retired == $sched_repeat (reproducible)" + +# ---- arm 7: the quarantine, aimed at real programs -------------------------- # # #7341. Everything above drives the instrument with PERRY_GC_MOVING_LOOP_POLLS # over ONE synthetic fixture. Back-edge polls fire only while user JS runs, so @@ -172,7 +220,7 @@ fi PROBES="$(dirname "$0")/../benchmarks/gc_ratchet/probes" if [[ -d "$PROBES" ]]; then echo - echo "== arm 4: quarantine over the gc_ratchet probes (allocation-point route) ==" + echo "== arm 7: quarantine over the gc_ratchet probes (allocation-point route) ==" probe_count=0 probe_failed=0 for probe in "$PROBES"/*.ts; do @@ -208,22 +256,22 @@ if [[ -d "$PROBES" ]]; then echo " $probe_count/$probe_count probes clean over from-space quarantine" fi -# ---- arm 5: PERRY_GC_ZEAL + PERRY_GC_VERIFY_EVACUATION, #7254's pairing ---- +# ---- arm 5: PERRY_GC_SCHEDULE_RATE=1 + PERRY_GC_VERIFY_EVACUATION, #7254's pairing ---- # -# Both knobs are individually exercised above (ZEAL by arms 2/3, +# Both knobs are individually exercised above (the rate-1 schedule by arms 2/3, # PERRY_GC_VERIFY_EVACUATION nowhere in this script) and in # gc_repsel_matrix.sh (VERIFY_EVACUATION by `verify_evac`/`force_verify`, -# ZEAL nowhere in that script either) -- but no CI arm anywhere sets them +# the schedule nowhere in that script either) -- but no CI arm anywhere sets them # TOGETHER, which is exactly the CLAUDE.md knob-kill-policy hole #7254 found: # the pair panics 10/10 on `test_gap_repsel_p4a3_ptr_numarray` # (`gc evacuation verification failed: stale forwarded pointer in ...`) and # nothing in CI would have said a word. # -# Deliberately NOT routed through gc_repsel_matrix.sh: a `zeal_verify` arm +# Deliberately NOT routed through gc_repsel_matrix.sh: a `rate1_verify` arm # registered there joins EVERY corpus file via `--arms all`, and #7254's own # sizing sweep (59 files) found a striking concentration of multi-minute-plus # runs under this exact pairing on the test_gap_gc_* reproducer corpus -- -# ZEAL forces a full evacuating minor at EVERY back-edge poll, which no other +# RATE=1 forces a full evacuating minor at EVERY back-edge poll, which no other # matrix arm does, so a corpus built for arms that collect only when a real # trigger fires is not this pairing's natural home. That population is not # yet triaged (host contention during the investigation made timeout vs. @@ -233,14 +281,15 @@ fi # and produces no false positive on known-good code), plus ONE pinned # regression witness against the exact file and exact panic #7254 reports. echo -echo "== arm 5: PERRY_GC_ZEAL + PERRY_GC_VERIFY_EVACUATION (#7254's pairing) ==" +echo "== arm 5: PERRY_GC_SCHEDULE_RATE=1 + PERRY_GC_VERIFY_EVACUATION (#7254's pairing) ==" -zeal_verify_env=(PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off - PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_DIAG=1) +rate1_verify_env=(PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off + PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_SCHEDULE_SEED=7 PERRY_GC_SCHEDULE_RATE=1 + PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_DIAG=1) echo "-- 5a: the pairing on known-good code must stay clean, and must be LIVE --" set +e -fixture_out="$(env "${zeal_verify_env[@]}" "$WORK/fixture" 2>&1)" +fixture_out="$(env "${rate1_verify_env[@]}" "$WORK/fixture" 2>&1)" fixture_rc=$? set -e if [[ $fixture_rc -ne 0 ]]; then @@ -270,7 +319,7 @@ if [[ ! -f "$REPRO" ]]; then fi PERRY_GC_MOVING_LOOP_POLLS=1 "$PERRY_BIN" compile "$REPRO" -o "$WORK/repro7254" >/dev/null set +e -repro_out="$(env "${zeal_verify_env[@]}" "$WORK/repro7254" 2>&1)" +repro_out="$(env "${rate1_verify_env[@]}" "$WORK/repro7254" 2>&1)" repro_rc=$? set -e # PINNED REGRESSION, not a correctness assertion: #7254 is a real, open, @@ -300,11 +349,11 @@ if ! grep -q 'stale forwarded pointer' <<<"$repro_out"; then fi echo " reproduced as pinned (exit $repro_rc, stale forwarded pointer) -- #7254 still open, tracked not silent" -# ---- arm 6: zeal must TERMINATE at the shipped default (#7728) -------------- +# ---- arm 6: the schedule must TERMINATE at the shipped default (#7728) ------ # # Every arm above runs the ~1200-poll fixture, which is deliberately tiny # ("costs seconds rather than minutes"). That sizing is exactly why this gate -# could not see #7728: zeal forced a collection at EVERY back-edge poll, and on +# could not see #7728: the instrument forced a collection at EVERY back-edge poll, and on # a small enough fixture that is indistinguishable from a paced instrument. The # moment #7721 turned back-edge polls on by default, the same instrument cost # ~511 us per loop iteration on real code -- 24 minutes for a 19 s program, i.e. @@ -328,7 +377,7 @@ echo " reproduced as pinned (exit $repro_rc, stale forwarded pointer) -- #7254 # The wall-clock budget is kept as the weaker "does it terminate AT ALL" guard, # sized generously on purpose. echo -echo "== arm 6: zeal terminates at the DEFAULT stride, on a realistic poll count ==" +echo "== arm 6: the schedule terminates at the DEFAULT stride, on a realistic poll count ==" # The records must ESCAPE. The obvious version of this fixture allocates a # record per iteration and drops it, which scalar-replaces into nothing: it @@ -365,20 +414,21 @@ TS # 90s, against a paced cost of a few seconds and an unpaced ~200s on the quiet # host. Wide enough that a slow shared runner does not flake it, narrow enough # that the unpaced 1:1 behaviour cannot fit inside it. -ZEAL_BUDGET_S="${PERRY_ZEAL_SMOKE_BUDGET_S:-90}" +SCHED_BUDGET_S="${PERRY_SCHEDULE_SMOKE_BUDGET_S:-90}" scale_start=$(date +%s) set +e # `env -u` so the every-poll pin from the top of the file does NOT apply: this # arm's entire subject is the SHIPPED DEFAULT. -scale_out="$(env -u PERRY_GC_ZEAL_ALLOC_KB PERRY_GC_ZEAL=1 \ - perl -e 'alarm shift; exec @ARGV' "$ZEAL_BUDGET_S" "$WORK/scale" 2>&1)" +scale_out="$(env -u PERRY_GC_SCHEDULE_ALLOC_KB PERRY_GC_SCHEDULE_SEED=7 PERRY_GC_SCHEDULE_RATE=1 \ + perl -e 'alarm shift; exec @ARGV' "$SCHED_BUDGET_S" "$WORK/scale" 2>&1)" scale_rc=$? set -e scale_elapsed=$(( $(date +%s) - scale_start )) if [[ $scale_rc -ne 0 ]]; then - echo "FAIL [arm6]: zeal did not complete in ${ZEAL_BUDGET_S}s (exit $scale_rc," >&2 - echo " elapsed ${scale_elapsed}s). PERRY_GC_ZEAL is the primary instrument" >&2 + echo "FAIL [arm6]: the schedule did not complete in ${SCHED_BUDGET_S}s (exit $scale_rc," >&2 + echo " elapsed ${scale_elapsed}s). PERRY_GC_SCHEDULE_SEED is the primary" >&2 + echo " instrument" >&2 echo " for moving-GC correctness bugs; one that does not terminate is one" >&2 echo " nobody will use. This is #7728's shape: a forced collection at" >&2 echo " EVERY back-edge poll, ~511 us each, once polls became default-ON." >&2 @@ -386,26 +436,27 @@ if [[ $scale_rc -ne 0 ]]; then exit 1 fi if ! grep -q '^sum 200000$' <<<"$scale_out"; then - echo "FAIL [arm6]: wrong answer under zeal at the default stride:" >&2 + echo "FAIL [arm6]: wrong answer under the schedule at the default stride:" >&2 grep '^sum' <<<"$scale_out" >&2 || echo "(no 'sum' line)" >&2 exit 1 fi -# NON-VACUITY. A fast arm proves nothing unless zeal actually collected and -# actually moved -- "fast because it collects nothing" would be a worse +# NON-VACUITY. A fast arm proves nothing unless the schedule actually collected +# and actually moved -- "fast because it collects nothing" would be a worse # regression than the slow instrument it replaced. -zeal_line="$(grep -m1 '^\[gc-zeal\] forced_collections=' <<<"$scale_out" || true)" -if [[ -z "$zeal_line" ]]; then - echo "FAIL [arm6]: no [gc-zeal] verdict line -- cannot tell whether zeal ran." >&2 +sched_line="$(grep -m1 '^\[gc-schedule\] forced_collections=' <<<"$scale_out" || true)" +if [[ -z "$sched_line" ]]; then + echo "FAIL [arm6]: no [gc-schedule] verdict line -- cannot tell whether the" >&2 + echo " instrument ran." >&2 exit 1 fi -scale_forced="$(grep -oE 'forced_collections=[0-9]+' <<<"$zeal_line" | cut -d= -f2)" -scale_minors="$(grep -oE 'copying_minors=[0-9]+' <<<"$zeal_line" | cut -d= -f2)" -scale_moved="$(grep -oE 'moved_objects=[0-9]+' <<<"$zeal_line" | cut -d= -f2)" -scale_polls="$(grep -oE 'loop_polls=[0-9]+' <<<"$zeal_line" | cut -d= -f2)" +scale_forced="$(grep -oE 'forced_collections=[0-9]+' <<<"$sched_line" | cut -d= -f2)" +scale_minors="$(grep -oE 'copying_minors=[0-9]+' <<<"$sched_line" | cut -d= -f2)" +scale_moved="$(grep -oE 'moved_objects=[0-9]+' <<<"$sched_line" | cut -d= -f2)" +scale_polls="$(grep -oE 'loop_polls=[0-9]+' <<<"$sched_line" | cut -d= -f2)" if [[ "$scale_forced" -eq 0 || "$scale_minors" -eq 0 || "$scale_moved" -eq 0 ]]; then - echo "FAIL [arm6]: zeal finished fast because it did NOTHING" >&2 - echo " ($zeal_line)." >&2 + echo "FAIL [arm6]: the schedule finished fast because it did NOTHING" >&2 + echo " ($sched_line)." >&2 echo " Pacing must bound the instrument, not disable it." >&2 exit 1 fi @@ -418,55 +469,20 @@ if [[ "$scale_polls" -le 0 ]]; then exit 1 fi if [[ $(( scale_forced * 4 )) -ge "$scale_polls" ]]; then - echo "FAIL [arm6]: zeal forced $scale_forced collections for $scale_polls polls" >&2 + echo "FAIL [arm6]: the schedule forced $scale_forced collections for $scale_polls polls" >&2 echo " (threshold: fewer than one per 4 polls). That is the unpaced" >&2 echo " behaviour #7728 removed -- one whole evacuating minor per loop" >&2 echo " iteration, which took a 5 s program to ~24 minutes." >&2 exit 1 fi -echo " correct output in ${scale_elapsed}s (budget ${ZEAL_BUDGET_S}s), $zeal_line" -# ---- the seeded schedule's three claims ------------------------------------- -# It is a MIDDLE setting: denser than pressure alone, sparser than zeal. A -# schedule that landed on either endpoint would be a second name for something -# that already exists. -if [[ "$sched_retired" -le "$nozeal_retired" ]]; then - echo "FAIL: PERRY_GC_SCHEDULE_SEED forced no additional collection" >&2 - echo " (pressure-only=$nozeal_retired, seeded=$sched_retired)." >&2 - exit 1 -fi -if [[ "$sched_retired" -ge "$zeal_retired" ]]; then - echo "FAIL: the seeded schedule at rate 0.25 collected at least as often as" >&2 - echo " zeal (seeded=$sched_retired, zeal=$zeal_retired). The rate knob is" >&2 - echo " not gating anything, so the mode is zeal with extra steps." >&2 - exit 1 -fi -# It is a REPRODUCER: the same seed must select the same safepoints, so the -# realised collection count is identical. This is the property the whole mode -# exists for; if it can drift, a "failing seed" is a rumour. -if [[ "$sched_retired" -ne "$sched_repeat" ]]; then - echo "FAIL: the same seed produced two different schedules" >&2 - echo " ($sched_retired vs $sched_repeat retirements). A failing seed" >&2 - echo " would not reproduce, which is the entire point of the mode." >&2 - exit 1 -fi -# It EXPLORES: a sweep over adjacent seeds must not be one experiment repeated. -# Equal counts are not proof of an identical schedule, but differing counts ARE -# proof of a differing one, and that is the direction that can fail usefully. -if [[ "$sched_other" -eq "$sched_retired" ]]; then - echo "WARNING: seeds 20260803 and 20260804 retired the same number of" >&2 - echo " page-sets ($sched_retired). Not necessarily the same schedule," >&2 - echo " but check gc/tests/schedule.rs if a sweep stops finding things." >&2 -fi +echo " correct output in ${scale_elapsed}s (budget ${SCHED_BUDGET_S}s), $sched_line" -echo -echo " [seeded schedule] pressure-only=$nozeal_retired < seeded(0.25)=$sched_retired < zeal=$zeal_retired" -echo " [seeded schedule] same seed twice: $sched_retired == $sched_repeat (reproducible)" echo echo "PASS: instruments inert when off (0 retirements), live when on" -echo " (no-zeal=$nozeal_retired, zeal=$zeal_retired retirements), program correct in all arms." +echo " (pressure-only=$pressure_retired, rate-1=$rate1_retired retirements), program correct in all arms." echo " Quarantine clean over $probe_count real probes (allocation-point route)." -echo " ZEAL+VERIFY_EVACUATION pairing live and correct on known-good code," +echo " RATE=1+VERIFY_EVACUATION pairing live and correct on known-good code," echo " and still pins #7254's open reproducer rather than staying silent about it." -echo " Zeal terminates at the shipped default on a realistic poll count" -echo " (${scale_elapsed}s of a ${ZEAL_BUDGET_S}s budget) while still forcing" +echo " The schedule terminates at the shipped default on a realistic poll count" +echo " (${scale_elapsed}s of a ${SCHED_BUDGET_S}s budget) while still forcing" echo " $scale_forced collections that moved $scale_moved objects." diff --git a/scripts/gc_schedule_fuzz.sh b/scripts/gc_schedule_fuzz.sh index dd4926b592..9102234079 100755 --- a/scripts/gc_schedule_fuzz.sh +++ b/scripts/gc_schedule_fuzz.sh @@ -20,7 +20,7 @@ # # The seeded schedule can only select safepoints that exist. Loop back-edge # polls are emitted only when the COMPILER ran with -# `PERRY_GC_MOVING_LOOP_POLLS=1` (default off since #7161); without them a +# `PERRY_GC_MOVING_LOOP_POLLS` enabled (default ON since #7721; `=0` kills them); without them a # compute-only program has no safepoints between event-loop turns and every seed # behaves identically. This script warns when a run reports zero safepoints, # because that is the shape of a sweep that cannot fail. @@ -63,6 +63,14 @@ if [[ "${1:-}" == "--" ]]; then TARGET_ARGS=("$@") fi +# A non-integer or zero seed count would run the sweep loop zero times and then +# report "PASS: no seed failed" having proven nothing — the vacuous green this +# whole harness exists to avoid. Reject it at parse time. +if [[ ! "$SEED_COUNT" =~ ^[0-9]+$ || "$SEED_COUNT" -eq 0 ]]; then + echo "gc_schedule_fuzz: seed-count must be a positive integer, got '$SEED_COUNT'" >&2 + exit 2 +fi + if [[ ! -x "$BIN" ]]; then echo "gc_schedule_fuzz: no executable at '$BIN'" >&2 exit 2 @@ -70,6 +78,23 @@ fi BIN="$(cd "$(dirname "$BIN")" && pwd)/$(basename "$BIN")" RATE="${RATE:-0.05}" +# The runtime CLAMPS an out-of-range rate, so `RATE=2` would run at 1 while every +# line this script prints -- including the reproduce command -- claimed 2. A +# reproduce command that does not describe the run it reproduces is worse than +# none, so reject rather than clamp. +if ! awk -v r="$RATE" 'BEGIN { exit !(r == r + 0 && r >= 0 && r <= 1) }' /dev/null; then + echo "gc_schedule_fuzz: RATE must be a number in [0,1] (got '$RATE')." >&2 + echo " The runtime clamps out-of-range values, so a sweep at RATE='$RATE'" >&2 + echo " would report a density it did not run at." >&2 + exit 2 +fi + +# Allocation pacing is inherited from the environment and changes which polls +# the seed can select, so it belongs in every command this script prints. Pin it +# explicitly to the shipped default when the caller has not chosen one, so a +# reproduce command is complete rather than dependent on the reader's shell. +ALLOC_KB="${PERRY_GC_SCHEDULE_ALLOC_KB:-4}" +export PERRY_GC_SCHEDULE_ALLOC_KB="$ALLOC_KB" FIRST_SEED="${FIRST_SEED:-1}" TIMEOUT="${TIMEOUT:-300}" BASELINE="${BASELINE:-0}" @@ -153,6 +178,7 @@ for ((n = 0; n < SEED_COUNT; n++)); do log="$OUTDIR/seed-$seed.log" rc=0 PERRY_GC_SCHEDULE_SEED="$seed" PERRY_GC_SCHEDULE_RATE="$RATE" \ + PERRY_GC_SCHEDULE_ALLOC_KB="$ALLOC_KB" \ run_once "$log" || rc=$? # Liveness, per CLAUDE.md's "a gate must assert its subject was live": a @@ -191,16 +217,20 @@ echo " wall clock : ${elapsed}s ($(awk -v e="$elapsed" -v n="$SEED_COUNT" \ 'BEGIN { printf "%.1f", e / (n > 0 ? n : 1) }')s/run)" if [[ "$saw_safepoints" -eq 0 ]]; then + # A clean sweep that selected nothing proves nothing — refuse to call it a + # PASS. Reporting success here is exactly the vacuous green this harness + # exists to catch. echo - echo " WARNING: no run reported a nonzero safepoint count." + echo "INCONCLUSIVE: no run reported a nonzero safepoint count." echo " The seeded schedule had nothing to select, so a clean sweep here" - echo " means nothing. Compile the target with PERRY_GC_MOVING_LOOP_POLLS=1" - echo " so codegen emits loop back-edge polls (default off since #7161)." + echo " means nothing. Check the target was not compiled with" + echo " PERRY_GC_MOVING_LOOP_POLLS=0 (polls are default ON since #7721)." + exit 2 fi if [[ ${#FAILED_SEEDS[@]} -eq 0 ]]; then echo - echo "PASS: no seed failed." + echo "PASS: no seed failed ($SEED_COUNT seeds, safepoints exercised)." exit 0 fi @@ -209,7 +239,8 @@ echo "== reproduce ==" for i in "${!FAILED_SEEDS[@]}"; do seed="${FAILED_SEEDS[$i]}" echo " # ${FAILED_CAUSES[$i]}" - echo " PERRY_GC_SCHEDULE_SEED=$seed PERRY_GC_SCHEDULE_RATE=$RATE $BIN ${TARGET_ARGS[*]:-}" + echo " PERRY_GC_SCHEDULE_SEED=$seed PERRY_GC_SCHEDULE_RATE=$RATE \\" + echo " PERRY_GC_SCHEDULE_ALLOC_KB=$ALLOC_KB $BIN ${TARGET_ARGS[*]:-}" echo " # log: $OUTDIR/seed-$seed.log" echo " # for a precise fault site, add:" echo " # PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800" diff --git a/test-files/test_gap_7564_iter_result_rooting.ts b/test-files/test_gap_7564_iter_result_rooting.ts index 3f20b9e366..915dace881 100644 --- a/test-files/test_gap_7564_iter_result_rooting.ts +++ b/test-files/test_gap_7564_iter_result_rooting.ts @@ -26,7 +26,8 @@ // `Iterator.from(...).map(...)` helper chain — while allocating hard enough // that a copying minor lands inside the constructor. // -// Run under `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1`, compiled with +// Run under `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 +// PERRY_GC_PROTECT_FROMSPACE=1`, compiled with // `PERRY_GC_MOVING_LOOP_POLLS=1`, and confirm `PERRY_GC_DIAG=1` prints a // `[gc-fromspace-protect] mode=... retired_set=#N` line — a run with zero // copying minors protects nothing and proves nothing. diff --git a/test-files/test_gap_gc_call_argument_rooting.ts b/test-files/test_gap_gc_call_argument_rooting.ts index d37e6ad10c..38c55fec1c 100644 --- a/test-files/test_gap_gc_call_argument_rooting.ts +++ b/test-files/test_gap_gc_call_argument_rooting.ts @@ -56,9 +56,9 @@ // The literal arm needs the collection EARLY: a string literal is allocated by // `__perry_init_strings_*` at startup, so it is young for the first couple of // minors and tenured after that, and only a young object is evacuated. Under -// `PERRY_GC_ZEAL=1` the first back-edge poll inside `churn` already runs an +// `PERRY_GC_SCHEDULE_RATE=1` the first back-edge poll inside `churn` already runs an // evacuating minor, so iteration 0 is where the literal arm bites. The loop is -// short on purpose — zeal collects at every safepoint. +// short on purpose — rate 1 collects at every safepoint. import { joinArgs } from "./fixtures/gc_call_arg_rooting_pkg/callee.ts"; diff --git a/test-files/test_gap_gc_regexp_receiver_rooting.ts b/test-files/test_gap_gc_regexp_receiver_rooting.ts index c30e12b737..9f8a7ce4f5 100644 --- a/test-files/test_gap_gc_regexp_receiver_rooting.ts +++ b/test-files/test_gap_gc_regexp_receiver_rooting.ts @@ -34,10 +34,10 @@ // read is observable rather than latent. Clean under `PERRY_GEN_GC=0`, so the // evacuating arms are the ones that bite. -// The loop is what matters, not its trip count: under `PERRY_GC_ZEAL=1` the +// The loop is what matters, not its trip count: under `PERRY_GC_SCHEDULE_RATE=1` the // FIRST back-edge poll inside it already runs an evacuating minor, which is // the collection the receiver has to survive. The count is kept modest on -// purpose — zeal collects at every safepoint, so a 4000-trip churn (what the +// purpose — rate 1 collects at every safepoint, so a 4000-trip churn (what the // sibling #7154 tests use, where the collection has to arrive on its own // budget) turns this file into a multi-hour run for no extra coverage. function churn(tag: string): string { diff --git a/test-files/test_gap_gc_rest_argument_rooting.ts b/test-files/test_gap_gc_rest_argument_rooting.ts index 4d8ad0f89e..94d07c3e41 100644 --- a/test-files/test_gap_gc_rest_argument_rooting.ts +++ b/test-files/test_gap_gc_rest_argument_rooting.ts @@ -1,4 +1,4 @@ -// parity-env: PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1 +// parity-env: PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 // // #7154: a cross-module call to a callee with a trailing `...rest` must root // its fixed parameters AND its accumulating rest array. @@ -7,7 +7,7 @@ // without it the harness compiles and runs in the default configuration, the // broken compiler prints `bad 0` 10/10, and this file gates nothing. Polls are // off by default since #7161, so the IR has no back-edge safepoint for a minor -// to land on; and without zeal the only collections are allocation-triggered, +// to land on; and without the seeded schedule the only collections are allocation-triggered, // which take `ManualGcScanGuard::force_full_scan` and make the copying minor // ineligible — so nothing MOVES and a stale register still names a live // object. `run_parity_tests.sh` applies `parity-env` to the perry compile AND @@ -62,7 +62,7 @@ // // The literal arm needs the collection EARLY: `__perry_init_strings_*` runs at // startup, so a literal is young for the first couple of minors and tenured -// after that, and only a young object is evacuated. Under `PERRY_GC_ZEAL=1` the +// after that, and only a young object is evacuated. At `PERRY_GC_SCHEDULE_RATE=1` the // first back-edge poll inside `churn` already runs an evacuating minor, so // iteration 0 is where the literal arm bites. diff --git a/test-files/test_gap_gc_same_module_call_argument_rooting.ts b/test-files/test_gap_gc_same_module_call_argument_rooting.ts index fdaf336d48..870aa39b21 100644 --- a/test-files/test_gap_gc_same_module_call_argument_rooting.ts +++ b/test-files/test_gap_gc_same_module_call_argument_rooting.ts @@ -1,4 +1,4 @@ -// parity-env: PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1 +// parity-env: PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 // // #7154: a call to a top-level function in the SAME module must root its // arguments, exactly as the cross-module call #7240 fixed does. diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 8406a96226..92b91fc148 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -260,8 +260,8 @@ test_gap_gc_closure_this_capture_rooting # Measured on `origin/main` (91170973c), compiled AND run with # `PERRY_GC_MOVING_LOOP_POLLS=1`, oracle node 26.5.1 (`bad 0` for both): # method_receiver_rooting `TypeError: value is not a function`, and -# exit=139 (SIGSEGV) under PERRY_GC_ZEAL=1 + -# PERRY_GC_PROTECT_FROMSPACE=1 +# exit=139 (SIGSEGV) under PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 +# + PERRY_GC_PROTECT_FROMSPACE=1 # index_get_receiver_rooting `TypeError: Cannot read properties of undefined # (reading 'v')` # Both are `bad 0` with #7206 applied, and `bad 0` on the shipped default on @@ -293,7 +293,8 @@ test_gap_gc_index_get_receiver_rooting # closure_call_callee_rooting `TypeError: value is not a function` # closure_call_this_rooting `TypeError: value is not a function` # closure_call_argument_rooting `TypeError: value is not a function` -# All three are `bad 0` with #7214 applied, including under PERRY_GC_ZEAL=1, +# All three are `bad 0` with #7214 applied, including under +# PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1, # and clean on the shipped default on BOTH sides -- so they certify nothing on # the `default` arm and belong with the `requires=move` rows. test_gap_gc_closure_call_callee_rooting @@ -516,8 +517,8 @@ test_gap_gc_symbol_local_rooting # blind to it (it reads LLVM IR; none of this is in LLVM IR). # # Measured on this branch, release, `PERRY_GC_MOVING_LOOP_POLLS=1` at compile: -# before the fix, POLLS=1 + ZEAL=1 200/200 iterations wrong on EVERY route -# after the fix, POLLS=1 + ZEAL=1 10/10 runs byte-exact with the oracle +# before the fix, POLLS=1 + RATE=1 200/200 iterations wrong on EVERY route +# after the fix, POLLS=1 + RATE=1 10/10 runs byte-exact with the oracle # control, + PERRY_GEN_GC=0 clean on both sides # and under the loop_polls arm env # (`HEAP_LIMIT=8 INCREMENTAL=0 CONSERVATIVE_STACK_SCAN=off POLLS=1 FORCE_EVACUATE=1`) @@ -553,12 +554,12 @@ test_gap_gc_dynamic_construct_receiver_rooting # with only the first exclusion applied. # # Measured on this branch, release, `PERRY_GC_MOVING_LOOP_POLLS=1` at compile: -# before, POLLS=1 + ZEAL=1 200/200 iterations wrong, both shapes -# after, POLLS=1 + ZEAL=1 byte-exact with the oracle +# before, POLLS=1 + RATE=1 200/200 iterations wrong, both shapes +# after, POLLS=1 + RATE=1 byte-exact with the oracle # after, loop_polls arm env byte-exact # control, + PERRY_GEN_GC=0 clean on both sides # and on the real workload, `sfw-registry --help` under -# `PROTECT_FROMSPACE=1 DEPTH=800 POLLS=1` (no zeal): 10/10 FAULT before, +# `PROTECT_FROMSPACE=1 DEPTH=800 POLLS=1` (no schedule): 10/10 FAULT before, # 40/40 clean after. test_gap_gc_optional_param_receiver_rooting