From 4e9c364f061f911b8db5192ed36dedb87ebaefb8 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 4 Aug 2026 15:02:01 -0400 Subject: [PATCH] feat(gc)!: seeded GC-schedule fuzzing (PERRY_GC_SCHEDULE_SEED) A rooting bug (#7154 family) is a value live but not rooted across a collection point. Whether it is caught is decided by the GC schedule, not by the bug, so re-running one binary sixty times re-runs one schedule sixty times and explores almost nothing. This makes the schedule itself the knob, at any density from normal pacing up to a collection at every handled safepoint, and it hands back a reproducer. PERRY_GC_SCHEDULE_SEED= makes "should this safepoint collect?" a deterministic function of the seed and a per-thread safepoint ordinal, at a density set by PERRY_GC_SCHEDULE_RATE (default 0.05). It does exactly three things: js_gc_loop_safepoint stops requiring GC_SAFEPOINT_PENDING before it descends; past the entry guards a per-thread counter ticks once per handled safepoint and, when nothing is otherwise due, a minor runs iff splitmix64(splitmix64(seed) ^ counter) < threshold; and gc_force_evacuate_enabled() becomes true so a scheduled minor MOVES survivors. It does not bypass the entry guards, does not override PERRY_GEN_GC_EVACUATE=0, cannot emit loop polls codegen never produced, and never suppresses a pressure-driven collection. A value that does not parse as u64 reads as OFF, not as seed 0. scripts/gc_schedule_fuzz.sh [seed-count] sweeps seeds and prints a reproduce command per failure. On Socket Firewall's sfw-registry --help (loop polls compiled and run) the control failed 0/16; seeds 1..12 at rate 0.05 failed 6/12 in under two seconds, seed 1 reproducing 5/5 at the identical site. The schedule installer registers itself as the runtime main thread so it owns the once-only exit summary: while no main thread is recorded every thread passes is_main_thread_or_unrecorded and a worker tearing down first could claim the summary with non-final counts. The unrecorded fallback remains for paths where the schedule never activates. The seed is never lost: printed at startup, at exit (from the process-exit teardown funnel, since perry's _exit paths never reach atexit), on panic, and from an async-signal-safe handler for SIGSEGV/SIGBUS/SIGABRT/SIGILL/ SIGTRAP that chains rather than clobbers and that the from-space quarantine re-layers, so PERRY_GC_SCHEDULE_SEED + PERRY_GC_PROTECT_FROMSPACE reports both the seed and the precise fault site. Default off and proven inert: with no seed set, PERRY_GC_DIAG collector traces are byte-identical to the parent across five configurations on two fixtures. gc/tests/schedule.rs asserts both directions of both knobs (11 tests: parse, threshold endpoints, determinism, adjacent-seed divergence, realised density, collect/decline/blocked at a real safepoint, and the evacuation implication with its PERRY_GEN_GC_EVACUATE=0 precedence arm), and gc_instrument_smoke.sh gains three integrated arms gating that the rate knob spans a range (strictly between pressure-only and the rate-1 endpoint) and that the same seed retires exactly the same page-sets. cargo test -p perry-runtime --lib --test-threads=1: 1687 passed, 0 failed, 3 ignored. BREAKING CHANGE: PERRY_GC_ZEAL is removed. PERRY_GC_SCHEDULE_RATE=1 resolves to the always-threshold and selects every handled safepoint, so a seeded run at rate 1 forces an evacuating minor at exactly the points the retired knob did, at 100% density rather than an approximation of it. Keeping both meant two configurations to hold exercised under the GC knob kill-policy, and the pair had grown a precedence rule (zeal won when both were set) whose only job was to stop their live-subject counters from disagreeing. crate::gc::zeal_forced_collections() is replaced by crate::gc::gc_schedule_forced_collections(). --- .github/workflows/test.yml | 13 +- CLAUDE.md | 9 +- .../7196-gc-rooting-bug-instruments.md | 6 +- .../7219-registry-gc-unrooted-caches.md | 14 +- ...exp-receiver-rooting-and-alloc-re-audit.md | 7 +- changelog.d/7253-gc-gate-main-line-run.md | 2 +- ...t-and-same-module-call-argument-rooting.md | 7 +- ...276-interned-string-cache-root-coverage.md | 2 +- ...nal-param-and-dynamic-construct-rooting.md | 4 +- .../7311-dep-scale-corpus-and-root-reload.md | 2 +- .../7317-seeded-gc-schedule-fuzzing.md | 152 ++++ changelog.d/7487-temp-root-allocas.md | 2 +- changelog.d/7499-json-reparse-materialize.md | 2 +- changelog.d/7501-layout-declared-at-alloc.md | 2 +- ...7532-typed-shape-declared-at-allocation.md | 2 +- changelog.d/7537-early-batch-flip.md | 2 +- .../7540-dense-array-spread-fast-path.md | 2 +- .../7550-anon-shape-numeric-field-types.md | 2 +- changelog.d/7552-for-init-local-types.md | 4 +- changelog.d/7561-map-view-for-of.md | 2 +- changelog.d/7565-tls-direct-tsd.md | 4 +- changelog.d/7566-inline-new-in-loops.md | 2 +- .../7579-iter-result-one-allocation.md | 2 +- ...7584-generator-attach-prototype-rooting.md | 2 +- .../7602-array-push-barrier-parent-gate.md | 2 +- crates/perry-runtime/src/arena/quarantine.rs | 20 +- crates/perry-runtime/src/gc/mod.rs | 30 +- crates/perry-runtime/src/gc/policy.rs | 40 +- crates/perry-runtime/src/gc/schedule.rs | 653 ++++++++++++++++++ .../src/gc/tests/fromspace_protect.rs | 112 +-- crates/perry-runtime/src/gc/tests/mod.rs | 1 + .../generator_attach_prototype.rs | 2 +- crates/perry-runtime/src/gc/tests/schedule.rs | 344 +++++++++ crates/perry-runtime/src/gc/zeal.rs | 115 --- crates/perry-runtime/src/iterator_helpers.rs | 3 +- crates/perry-runtime/src/native_handle.rs | 14 +- .../src/object/class_registry/construct.rs | 17 +- docs/src/internals/gc-rooting-invariant.md | 23 +- docs/src/internals/memory-model.md | 31 +- .../internals/rfc-rooting-by-construction.md | 2 +- docs/statepoint-gc-experiment.md | 3 +- run_parity_tests.sh | 8 + scripts/gc_instrument_smoke.sh | 109 ++- scripts/gc_schedule_fuzz.sh | 230 ++++++ .../test_gap_7564_iter_result_rooting.ts | 3 +- .../test_gap_gc_call_argument_rooting.ts | 8 +- .../test_gap_gc_regexp_receiver_rooting.ts | 6 +- .../test_gap_gc_rest_argument_rooting.ts | 10 +- ...ap_gc_same_module_call_argument_rooting.ts | 2 +- test-parity/gc_repsel_corpus.txt | 18 +- 50 files changed, 1706 insertions(+), 348 deletions(-) create mode 100644 changelog.d/7317-seeded-gc-schedule-fuzzing.md create mode 100644 crates/perry-runtime/src/gc/schedule.rs create mode 100644 crates/perry-runtime/src/gc/tests/schedule.rs delete mode 100644 crates/perry-runtime/src/gc/zeal.rs create mode 100755 scripts/gc_schedule_fuzz.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 50f036aa4f..cb645ab135 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1145,12 +1145,13 @@ 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 + # PERRY_GC_SCHEDULE_RATE=1 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). # The detection property itself is a required-gate unit test: # gc/tests/fromspace_protect.rs::quarantine_catches_a_planted_stale_from_space_deref. # ~20s: the fixture is sized for ~1200 back-edge polls, not #7154's 240k. diff --git a/CLAUDE.md b/CLAUDE.md index 1b20620c71..155d0062cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,16 +134,17 @@ Generational mark-sweep GC in `crates/perry-runtime/src/gc.rs` (default since v0 ### 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 every **GC safepoint**: `js_gc_loop_safepoint` (loop back-edge) and the outermost microtask-pump safepoint. It bypasses exactly two things — the `GC_SAFEPOINT_PENDING` requirement in `js_gc_loop_safepoint`, and the `gc_budgeted_due_trigger()` "is anything due?" test in `gc_safepoint_moving_minor`. Also makes `gc_force_evacuate_enabled()` true, so survivors actually MOVE. | bypass `gc_safepoint_moving_minor`'s **entry guards**: a safepoint reached mid-allocation (`GC_FLAG_IN_ALLOC`), suppressed (`GC_FLAG_SUPPRESSED`), inside an unsafe FFI zone, under a non-zero `GC_ROOT_LOCK_DEPTH`, or during a budgeted cycle still returns without collecting. Nor does it override an explicit `PERRY_GEN_GC_EVACUATE=0` — that wins, and with it set zeal moves nothing and surfaces nothing. Nor does it emit loop polls — those need the **compile-time** `PERRY_GC_MOVING_LOOP_POLLS=1` (default off since #7161). Zeal on a binary compiled without polls only fires at event-loop boundaries; a compute-only loop never collects. Check `crate::gc::zeal_forced_collections()` is nonzero. There is deliberately **no level 2**: the alloc-point arm forces a conservative stack scan, which makes the copying minor ineligible, so an "every allocation" zeal would run non-moving minors and move nothing. | +| `PERRY_GC_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 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. Nor override `PERRY_GEN_GC_EVACUATE=0`. Nor emit loop polls (those need the compile-time `PERRY_GC_MOVING_LOOP_POLLS=1`, default off since #7161; 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_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` together is the pairing that turns a #7154 bug into an immediate precise fault. Compile *and* run with `PERRY_GC_MOVING_LOOP_POLLS=1` for in-loop coverage. +`PERRY_GC_SCHEDULE_SEED= PERRY_GC_PROTECT_FROMSPACE=1` together is the pairing that turns a #7154 bug into an immediate precise fault. Compile *and* run with `PERRY_GC_MOVING_LOOP_POLLS=1` for in-loop coverage. Reach for `PERRY_GC_SCHEDULE_RATE=1` when you want maximum pressure and can afford the slowdown, and for a lower rate when rate 1 distorts the workload's timing enough that it dies somewhere uninteresting first — either way the seed hands back a reproducer. `scripts/gc_schedule_fuzz.sh [seed-count]` sweeps it (see `changelog.d/7317-seeded-gc-schedule-fuzzing.md`). ### GC knob kill-policy (binding) diff --git a/changelog.d/7196-gc-rooting-bug-instruments.md b/changelog.d/7196-gc-rooting-bug-instruments.md index 9f0b318a70..e0952db2ce 100644 --- a/changelog.d/7196-gc-rooting-bug-instruments.md +++ b/changelog.d/7196-gc-rooting-bug-instruments.md @@ -4,12 +4,12 @@ A GC value that is live but not rooted across a collection point leaves nothing **1. From-space quarantine / protection — `PERRY_GC_PROTECT_FROMSPACE`.** After an evacuating minor, do not recycle from-space. `arena/quarantine.rs` detaches the retired Eden + active-survivor blocks (leaving `data = null` tombstones so block-index semantics are unchanged), fills them with a poison word whose first byte reads as an invalid `obj_type` (`0xDE`), and `mprotect(PROT_NONE)`s their page-aligned interior. A stale dereference now SIGSEGVs **at the faulting instruction**, with the holder still live on the stack. The installed SIGSEGV/SIGBUS reporter prints the faulting address, which minor retired it, the last-known object that occupied that offset (`obj_type` + size, from a census taken before poisoning) and a native backtrace, then restores `SIG_DFL` and returns so the instruction re-faults — a core file, debugger or crash reporter still sees the real site. `=poison` selects poison without `mprotect`, which is also what the sub-page block edges `mprotect` cannot cover always get; protected and poisoned byte counts are reported separately so a run can never claim page protection it did not get. `PERRY_GC_PROTECT_FROMSPACE_DEPTH` (default 4, minimum 1) bounds memory: the quarantine is a ring, and expired sets are restored to read/write and recycled **back into Eden** rather than freed, so nothing that was ever `mprotect`ed is handed to the system allocator and steady-state footprint is `depth × from-space bytes`. -**2. GC zeal — `PERRY_GC_ZEAL=1`.** Force an evacuating minor at every GC safepoint (loop back-edge polls and the outermost microtask-pump boundary) instead of only when nursery pressure is due, so an unrooted value moves on its FIRST exposure rather than whenever an unrelated allocation burst happens to line up. Implies `gc_force_evacuate_enabled()` — a zealous minor that left survivors in place would move nothing and could not surface the bug — while still losing to an explicit `PERRY_GEN_GC_EVACUATE=0`, so the two knobs cannot silently disagree. +**2. Forced collections — `PERRY_GC_SCHEDULE_SEED= PERRY_GC_SCHEDULE_RATE=1`.** Force an evacuating minor at every GC safepoint (loop back-edge polls and the outermost microtask-pump boundary) instead of only when nursery pressure is due, so an unrooted value moves on its FIRST exposure rather than whenever an unrelated allocation burst happens to line up. Implies `gc_force_evacuate_enabled()` — a forced minor that left survivors in place would move nothing and could not surface the bug — while still losing to an explicit `PERRY_GEN_GC_EVACUATE=0`, so the two knobs cannot silently disagree. **3. Verify-roots gap closures.** `PERRY_GC_FROMSPACE_SCAN_ABORT=1` now **implies** `PERRY_GC_FROMSPACE_SCAN=1`; on its own it used to be completely inert (`run_fromspace_scan` returned at the enablement gate, so there was nothing to abort and the run reported success — an investigator reaching for the abort switch mid-hunt got a green run and no scan). The abort path now also prints a collector backtrace, and every offender sample reports the **target's** `obj_type` alongside the owner's, which is the field that distinguishes a dead closure (`4`) from a dead object (`2`) when triaging `value is not a function`. -**Documented against the knob kill-policy.** CLAUDE.md and `docs/src/internals/memory-model.md` gain a table stating what each knob gates *exactly* and, as importantly, what it does not — prior rounds were misled by knobs whose real effect differed from their name. Two caveats are called out explicitly because both can produce a vacuously green run: `PERRY_GC_PROTECT_FROMSPACE` gates **only** the copying minor's from-space reset (a run with zero copying minors protects nothing — check for the `[gc-fromspace-protect] retired_set=#N` line under `PERRY_GC_DIAG=1`), and `PERRY_GC_ZEAL` cannot emit loop back-edge polls that codegen never produced (those need the compile-time `PERRY_GC_MOVING_LOOP_POLLS=1`, default off since #7161; `crate::gc::zeal_forced_collections()` is the live-subject counter). There is deliberately no `PERRY_GC_ZEAL=2` "every allocation" level: the allocation-point arm forces a conservative stack scan, which makes the copying minor ineligible, so that level would run non-moving minors and move nothing — the exact shape of the `PERRY_GC_FORCE_EVACUATE` inertness defect (#6942 / #6946). +**Documented against the knob kill-policy.** CLAUDE.md and `docs/src/internals/memory-model.md` gain a table stating what each knob gates *exactly* and, as importantly, what it does not — prior rounds were misled by knobs whose real effect differed from their name. Two caveats are called out explicitly because both can produce a vacuously green run: `PERRY_GC_PROTECT_FROMSPACE` gates **only** the copying minor's from-space reset (a run with zero copying minors protects nothing — check for the `[gc-fromspace-protect] retired_set=#N` line under `PERRY_GC_DIAG=1`), and the seeded schedule cannot select loop back-edge polls that codegen never produced (those need the compile-time `PERRY_GC_MOVING_LOOP_POLLS=1`, default off since #7161; `crate::gc::gc_schedule_forced_collections()` is the live-subject counter). There is deliberately no "every allocation" level: the allocation-point arm forces a conservative stack scan, which makes the copying minor ineligible, so that level would run non-moving minors and move nothing — the exact shape of the `PERRY_GC_FORCE_EVACUATE` inertness defect (#6942 / #6946). -**Exercised, not dark.** The kill-policy's requirement is an arm that exercises the knob, and these get two. `gc/tests/fromspace_protect.rs` (12 tests, in the required `cargo-test` gate) asserts **both** states of every boolean knob — the OFF arm proves the collector is byte-for-byte unchanged when the instrument is off, the ON arm asserts its subject was live (an object actually moved) before believing the result — and `quarantine_catches_a_planted_stale_from_space_deref` **sabotage-tests** the detector: it plants the #7184 / #7192 shape (a mutator keeping a pre-collection address across an evacuating minor), then asserts the instrument reports poison where the un-instrumented control reads a valid recycled object. That control is what makes the verdict meaningful — it demonstrates the bug is genuinely invisible without the instrument. On top of that, `scripts/gc_instrument_smoke.sh` runs in `gc-stress` as a gating step covering the integrated path a unit test cannot reach (codegen emitting back-edge polls → zeal firing on them → the copying minor → quarantine retirement in a real compiled program); it fails unless the zeal arm forces strictly more collections than the pressure-only arm, so it cannot go green having run zero copying minors. +**Exercised, not dark.** The kill-policy's requirement is an arm that exercises the knob, and these get two. `gc/tests/fromspace_protect.rs` (12 tests, in the required `cargo-test` gate) asserts **both** states of every boolean knob — the OFF arm proves the collector is byte-for-byte unchanged when the instrument is off, the ON arm asserts its subject was live (an object actually moved) before believing the result — and `quarantine_catches_a_planted_stale_from_space_deref` **sabotage-tests** the detector: it plants the #7184 / #7192 shape (a mutator keeping a pre-collection address across an evacuating minor), then asserts the instrument reports poison where the un-instrumented control reads a valid recycled object. That control is what makes the verdict meaningful — it demonstrates the bug is genuinely invisible without the instrument. On top of that, `scripts/gc_instrument_smoke.sh` runs in `gc-stress` as a gating step covering the integrated path a unit test cannot reach (codegen emitting back-edge polls → the schedule firing on them → the copying minor → quarantine retirement in a real compiled program); it fails unless the rate-1 arm forces strictly more collections than the pressure-only arm, so it cannot go green having run zero copying minors. Platform note: page protection is Unix-only. `mprotect` / `sigaction` / `sysconf` are not exposed by the `libc` crate on `x86_64-pc-windows-msvc`, a target `perry-runtime` is genuinely built for (`test.yml`'s `windows-build`, and `release-packages.yml` via `perry-ui-windows`). The syscall helpers are `cfg(unix)`-gated per the existing `pty::native` precedent; off Unix `=1` degrades to `poison`, visibly rather than silently, because `bytes_protected` stays 0 while `bytes_poisoned` counts the whole retired range. diff --git a/changelog.d/7219-registry-gc-unrooted-caches.md b/changelog.d/7219-registry-gc-unrooted-caches.md index 7643dfbae3..c873477cd9 100644 --- a/changelog.d/7219-registry-gc-unrooted-caches.md +++ b/changelog.d/7219-registry-gc-unrooted-caches.md @@ -22,8 +22,8 @@ | found by | `scripts/gc_root_dominance_check.py` over emitted IR | nothing static — the tool cannot see a runtime table | A perfectly reproducible GC bug is evidence *against* a stale register. The - detector here was `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 - PERRY_GC_PROTECT_FROMSPACE_DEPTH=800`, whose reporter named it outright: + detector here was `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 + PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800`, whose reporter named it outright: ``` [gc-fromspace-protect] FAULT: signal 10 at 0x…558a4 @@ -122,15 +122,17 @@ Gap tests: |---|---|---| | `test_gap_gc_typeof_string_cache_rooting.ts`, `POLLS=1` | `bad 444` **10/10** | `bad 0` **10/10** | | same, `POLLS=1` + `PERRY_GEN_GC=0` | `bad 0` | `bad 0` | -| `test_gap_gc_closure_call_prev_this_rooting.ts`, `POLLS=1` + `PERRY_GC_ZEAL=1` | `bad 400` **5/5** | `bad 0` **10/10** | -| same, `PERRY_GEN_GC=0` + zeal | `bad 0` | `bad 0` | +| `test_gap_gc_closure_call_prev_this_rooting.ts`, `POLLS=1` + `PERRY_GC_SCHEDULE_RATE=1` | `bad 400` **5/5** | `bad 0` **10/10** | +| same, `PERRY_GEN_GC=0` + rate 1 | `bad 0` | `bad 0` | -The `prev_this` test needs zeal and the reason is worth recording: the window +The `prev_this` test needs a forced moving collection and the reason is worth +recording: the window is a user call, so the collection that exploits it has to be a *moving* one, and the only moving collections are the loop back-edge poll and the microtask-pump safepoint. Allocation-triggered collections take `ManualGcScanGuard::force_full_scan`, which makes the copying minor ineligible. -Zeal is the sanctioned instrument for exactly that, and the `PERRY_GEN_GC=0` +The seeded schedule at rate 1 is the sanctioned instrument for exactly that, +and the `PERRY_GEN_GC=0` arm proves the test tracks collector mode rather than merely being flaky. `ClassExprFresh` has **no runtime gap test, deliberately**, and the same diff --git a/changelog.d/7227-regexp-receiver-rooting-and-alloc-re-audit.md b/changelog.d/7227-regexp-receiver-rooting-and-alloc-re-audit.md index 2a66dd37ce..0b9721c57d 100644 --- a/changelog.d/7227-regexp-receiver-rooting-and-alloc-re-audit.md +++ b/changelog.d/7227-regexp-receiver-rooting-and-alloc-re-audit.md @@ -138,15 +138,16 @@ Gap test — `test_gap_gc_regexp_receiver_rooting.ts`, compiled **and** run with | | parent | this PR | |---|---|---| -| `POLLS=1` + `PERRY_GC_ZEAL=1` | **0/10 — SIGSEGV/SIGBUS every run** | `bad 0` **10/10** | -| `POLLS=1` + zeal + `PERRY_GEN_GC=0` | `bad 0` | `bad 0` | +| `POLLS=1` + `PERRY_GC_SCHEDULE_RATE=1` | **0/10 — SIGSEGV/SIGBUS every run** | `bad 0` **10/10** | +| `POLLS=1` + rate 1 + `PERRY_GEN_GC=0` | `bad 0` | `bad 0` | The parent arm is a hard fault rather than a nonzero `bad`, and that is the honest signature: with a regex **literal** receiver — the registry's shape — `js_regexp_new`'s result is held only in the register, so the evacuating minor retires the block under it and the deref lands in from-space. The `PERRY_GEN_GC=0` arm proves the test tracks collector mode rather than being -flaky. Zeal is required for the same structural reason #7226 recorded for +flaky. A forced moving collection is required for the same structural reason +#7226 recorded for `prev_this`: the window is a user call, so only a *moving* collection exploits it, and allocation-triggered collections take `ManualGcScanGuard::force_full_scan`, which makes the copying minor ineligible. diff --git a/changelog.d/7253-gc-gate-main-line-run.md b/changelog.d/7253-gc-gate-main-line-run.md index 249b97b4b1..f658310e4f 100644 --- a/changelog.d/7253-gc-gate-main-line-run.md +++ b/changelog.d/7253-gc-gate-main-line-run.md @@ -37,5 +37,5 @@ minor #0 lands, the head build collects less than the failing base did (`cycles=1 scavenged=3585` vs `cycles=2 scavenged=6515`), so green was re-established *above* the base's movement rather than below it: under loop - polls + `PERRY_GC_ZEAL=1` the test survives **14 373 evacuating minors** + polls + `PERRY_GC_SCHEDULE_RATE=1` the test survives **14 373 evacuating minors** byte-exact, 10/10. diff --git a/changelog.d/7270-rest-and-same-module-call-argument-rooting.md b/changelog.d/7270-rest-and-same-module-call-argument-rooting.md index 6fcb1aece1..e53651a42b 100644 --- a/changelog.d/7270-rest-and-same-module-call-argument-rooting.md +++ b/changelog.d/7270-rest-and-same-module-call-argument-rooting.md @@ -52,13 +52,14 @@ | | parent (`6aeef5baf`) | this branch | |---|---|---| | polls only | `bad 0` 10/10 | `bad 0` 10/10 | - | polls + `PERRY_GC_ZEAL=1` | **0/10 — SIGSEGV every run** | `bad 0` **10/10** | - | polls + zeal + `PERRY_GEN_GC=0` | `bad 0` 10/10 | `bad 0` 10/10 | + | polls + `PERRY_GC_SCHEDULE_RATE=1` | **0/10 — SIGSEGV every run** | `bad 0` **10/10** | + | polls + rate 1 + `PERRY_GEN_GC=0` | `bad 0` 10/10 | `bad 0` 10/10 | The first row is why both test files carry a `parity-env:` line. Without it the harness runs them in the default configuration, the broken compiler prints `bad 0`, and the files gate nothing: polls are off by default since #7161, so - the IR has no back-edge safepoint to collect on, and without zeal the only + the IR has no back-edge safepoint to collect on, and without a seeded + schedule the only collections are allocation-triggered, which take `ManualGcScanGuard::force_full_scan` and make the copying minor ineligible — nothing moves, so a stale register still names a live object. diff --git a/changelog.d/7276-interned-string-cache-root-coverage.md b/changelog.d/7276-interned-string-cache-root-coverage.md index 56cdfea04d..1831dd5bba 100644 --- a/changelog.d/7276-interned-string-cache-root-coverage.md +++ b/changelog.d/7276-interned-string-cache-root-coverage.md @@ -62,7 +62,7 @@ to keep it concise and put detail in `changelog.d/`. Nothing operational was dropped: the incident narrative is already in `changelog.d/7219-registry-gc-unrooted-caches.md`, and the detector knobs - the new bullet re-listed (`PERRY_GC_ZEAL`, `PERRY_GC_PROTECT_FROMSPACE`, + the new bullet re-listed (`PERRY_GC_SCHEDULE_SEED`, `PERRY_GC_PROTECT_FROMSPACE`, `PERRY_GC_PROTECT_FROMSPACE_DEPTH`) are documented in full, with their exact gating, two sections above under "Rooting-bug instruments". diff --git a/changelog.d/7280-optional-param-and-dynamic-construct-rooting.md b/changelog.d/7280-optional-param-and-dynamic-construct-rooting.md index f95a78056b..7fb3581749 100644 --- a/changelog.d/7280-optional-param-and-dynamic-construct-rooting.md +++ b/changelog.d/7280-optional-param-and-dynamic-construct-rooting.md @@ -62,9 +62,9 @@ runtime Rust frame. | arm | before | after | |---|---|---| -| `sfw-registry --help`, `PROTECT_FROMSPACE=1 DEPTH=800` (no zeal) | FAULT 10/10 | **40/40 clean** | +| `sfw-registry --help`, `PROTECT_FROMSPACE=1 DEPTH=800` (no schedule) | FAULT 10/10 | **40/40 clean** | | `sfw-registry --help`, plain polls | ~2/60 fail | **59/60** | -| 6 unit reproducers, `POLLS=1 ZEAL=1` | 200/200 iterations wrong | clean, `PERRY_GEN_GC=0` clean both sides | +| 6 unit reproducers, `POLLS=1 SCHEDULE_RATE=1` | 200/200 iterations wrong | clean, `PERRY_GEN_GC=0` clean both sides | Codegen cost, `sfw-registry` binary: **+33,088 bytes (+0.1231%)**, all of it from the `all_non_pointer` exclusion; the `precise_inference` exclusion and the diff --git a/changelog.d/7311-dep-scale-corpus-and-root-reload.md b/changelog.d/7311-dep-scale-corpus-and-root-reload.md index 096345f76e..ce6b6fedc2 100644 --- a/changelog.d/7311-dep-scale-corpus-and-root-reload.md +++ b/changelog.d/7311-dep-scale-corpus-and-root-reload.md @@ -136,7 +136,7 @@ appears with the codegen pass alone, so it is not caused by it. After: clean. No unit-test witness ships with it, and that is a deliberate statement rather than an omission: forcing a *copying* minor inside a runtime function needs a -collection at an allocation point, and `gc/zeal.rs` documents why that level +collection at an allocation point, and `gc/schedule.rs` documents why that level does not exist (an allocation-point collection takes `force_full_scan`, which makes the copying minor ineligible, so it would move nothing). A test was written, its liveness assert refused to pass, and it was deleted rather than diff --git a/changelog.d/7317-seeded-gc-schedule-fuzzing.md b/changelog.d/7317-seeded-gc-schedule-fuzzing.md new file mode 100644 index 0000000000..665d360ad3 --- /dev/null +++ b/changelog.d/7317-seeded-gc-schedule-fuzzing.md @@ -0,0 +1,152 @@ +### Removed + +**`PERRY_GC_ZEAL` is removed; `PERRY_GC_SCHEDULE_RATE=1` is its replacement**, +and an exact one. Rate 1 resolves to the always-threshold, which selects every +handled safepoint, so a seeded run at rate 1 forces an evacuating minor at every +point the retired knob did. + +| was | now | +|---|---| +| `PERRY_GC_ZEAL=1` | `PERRY_GC_SCHEDULE_SEED= PERRY_GC_SCHEDULE_RATE=1` | +| `crate::gc::zeal_forced_collections()` | `crate::gc::gc_schedule_forced_collections()` | + +Any seed does at rate 1, because every ordinal is selected whatever it hashes +to. The counter now reports every forced collection instead of splitting the +total across two knobs. + +Two knobs that differed only in density were two configurations to keep +exercised, and the pair had grown a precedence rule — the retired knob won when +both were set — that existed solely to keep their counters from disagreeing. One +knob spanning the whole range from normal pacing to every-safepoint has neither +problem, and CLAUDE.md's GC knob kill-policy is explicit that a mode which still +exists is a decision that has not been made. + +### Added + +**Seeded GC-schedule fuzzing: `PERRY_GC_SCHEDULE_SEED` (#7154 tooling).** + +A rooting bug is a value live but not rooted across a collection point. +Whether it is *caught* is decided by the GC schedule, not by the bug — so +re-running one binary sixty times re-runs one schedule sixty times and explores +almost nothing. Normal pacing puts collections tens of megabytes apart; this +makes the schedule itself the knob, at any density from that up to a collection +at every safepoint, and it hands back a reproducer. + +`PERRY_GC_SCHEDULE_SEED=` makes *"should this safepoint collect?"* a +deterministic pseudo-random function of the seed and a per-thread safepoint +ordinal, at a density set by `PERRY_GC_SCHEDULE_RATE` (default `0.05`). +`scripts/gc_schedule_fuzz.sh [seed-count]` sweeps seeds and prints a +reproduce command for each failure. + +**It converts Socket Firewall's registry ghost into a deterministic +reproducer.** `sfw-registry --help` (#7291's tree, `PERRY_FORCE_WELL_KNOWN=iovalkey`, +compiled *and* run with `PERRY_GC_MOVING_LOOP_POLLS=1`, `--debug-symbols`) fails +about **1 run in 60** in the plain-polls configuration, and that failure has cost +days precisely because it cannot be summoned. Measured on the same binary, +macOS arm64, four runs in parallel: + +| arm | failures | time per run | +|---|---|---| +| control, no seed | **0 / 16** | 55 s (all completed) | +| `PERRY_GC_SCHEDULE_SEED=1..12`, `RATE=0.05` | **6 / 12 failed in ≤ 2 s** | 6 remaining censored at 120 s | + +The six failing seeds split into two signatures, each stable across the seeds +that produce it: + +``` +seeds 1, 7, 12 → TypeError: value is not a function + at node_modules/zod/src/v4/classic/schemas.ts:1318 +seeds 8, 9, 11 → TypeError: Cannot convert undefined or null to object + at node_modules/node-machine-id/dist/index.js:1 +``` + +The first is the #7154-class signature the registry investigation has been +chasing. The second is the `node-machine-id` failure that makes rate 1 unusable +on this workload — it kills the program there before the interesting code runs. +At 5% density it is reachable *without* also losing the rest of the program, so +it can now be studied rather than routed around. That is the case a single +all-or-nothing setting could not serve. + +Seed 1 was re-run five times and failed **5/5** at the identical site in ≤ 1 s. +The control's 0/16 is consistent with the known ~1.7% rate (zero failures in 16 +runs bounds it at ~19%, a 95% Wilson upper bound, which is why re-running was never going to settle +anything); the point is the contrast with 6/12 in two seconds. + +**Cost.** A seeded run at rate 0.05 is roughly 5–10× slower than the unseeded +one on this workload, which is why half the sweep is censored rather than passed: +a seed that has not failed in 120 s has not finished either. Failing seeds cost +1–2 s, so a sweep's wall clock is dominated entirely by the seeds that do *not* +find anything — turn the rate down, or the timeout up, depending on which you are +buying. + +**What the knobs gate, precisely** — the GC-knob policy in `CLAUDE.md` is +binding, and this repo has repeatedly paid for knobs that gated something other +than their name (`PERRY_GC_FORCE_EVACUATE` inert for every `gc()`-driven test, +#6942/#6946; the matrix's `--pressure` knob disabling the path it measured, +#7024). `PERRY_GC_SCHEDULE_SEED` does exactly three things: + +1. `js_gc_loop_safepoint` stops requiring `GC_SAFEPOINT_PENDING` before it + descends into `gc_safepoint_moving_minor` — a schedule cannot select a + safepoint the gate already returned from. +2. Inside `gc_safepoint_moving_minor`, **past the entry guards**, a per-thread + counter advances once per handled safepoint; 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 a scheduled minor MOVES + survivors. Without this the mode would promise relocation stress and deliver + sweep pressure. + +It does not bypass the 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); it does not override `PERRY_GEN_GC_EVACUATE=0`; +it cannot emit loop polls codegen never produced (compile-time +`PERRY_GC_MOVING_LOOP_POLLS=1`); and it never *suppresses* a +pressure-driven collection — the rate is additional density, never less. +A value that does not parse as a `u64` reads as OFF, not as seed 0. + +**Determinism, scoped honestly.** The decision reads no wall clock, no address +and no thread identity, so a **single-threaded** program replays a seed exactly. +The counter is thread-local, so a `perry/thread` program gets a deterministic +schedule *per thread given that thread's own safepoint sequence* — but nothing +makes the OS schedule that sequence identically twice, and a global counter would +be strictly worse (it would make even one thread's schedule depend on +interleaving). Multi-threaded reproducers are only as reproducible as their +threading; say which you measured. + +**The seed is never lost.** It is printed at startup, at exit +(`[gc-schedule] done: seed=… safepoints=… scheduled_collections=…`, from the +process-exit teardown funnel — perry's exits call `_exit`, so `atexit` alone +would miss them), on panic, and from an async-signal-safe handler for +SIGSEGV/SIGBUS/SIGABRT/SIGILL/SIGTRAP. That handler **chains** rather than +clobbers, and the from-space quarantine re-layers it after installing its own, so +`PERRY_GC_SCHEDULE_SEED=… PERRY_GC_PROTECT_FROMSPACE=1` reports both the seed and +the precise fault site. A fuzzer that finds a bug and loses the reproducer is +worthless. + +**Default-off, and proven inert rather than assumed inert.** With no seed set, +`PERRY_GC_DIAG=1` collector traces are byte-identical to the parent commit's +across five configurations on two fixtures — 367-line traces under plain polls, +4941 at rate 1, 6151 at rate 1 + from-space protection, and the no-polls and +forced-evacuation arms besides. Unit coverage in +`gc/tests/schedule.rs` asserts both directions of both knobs (11 tests: parse, +threshold endpoints, 100k-ordinal determinism, adjacent-seed divergence, realised +density, collect/decline/blocked at a real safepoint, and the evacuation +implication with its `PERRY_GEN_GC_EVACUATE=0` precedence arm). +`scripts/gc_instrument_smoke.sh` gains three integrated arms that gate the three +claims end to end: at rate 0.25 the seeded schedule must retire strictly more +from-space page-sets than pressure alone and strictly fewer than the rate-1 +endpoint (the rate knob spans a *range*, rather than collapsing onto either +end), and the same seed twice must retire exactly the same number (it is a +*reproducer*). + +`cargo test -p perry-runtime --lib` on this branch: **1687 passed, 0 failed, 3 +ignored** (`--test-threads=1`). Pristine `main` measured the same way on the same +machine: 1679 passed, 0 failed — the 8-test delta is this change's net (+11 in +`gc/tests/schedule.rs`, −3 for the removed `gc/tests/zeal.rs`). The default +parallel mode is flaky on both: the `gc::tests::teardown` Map/Set +allocation-accounting tests fail non-deterministically depending on test order (a +different pair each run), which is a pre-existing test-isolation problem on +`main`, not this change. + +No collector policy changed. Every scheduled collection runs at a point the +collector already treats as a precise-root safepoint; only how often changes. diff --git a/changelog.d/7487-temp-root-allocas.md b/changelog.d/7487-temp-root-allocas.md index aba223d549..c604b24bba 100644 --- a/changelog.d/7487-temp-root-allocas.md +++ b/changelog.d/7487-temp-root-allocas.md @@ -26,7 +26,7 @@ emission is off the whole function falls back to the FFI stack byte-for-byte. `churn.ts`'s hot function drops from 9 temp-root FFI calls to 0. Validation: the #6951/#6971/#7200/#7211 reproducer shapes match Node under default GC, under `PERRY_CONSERVATIVE_STACK_SCAN=off` (the mode where an unrooted temp is -a live use-after-free), and under `PERRY_GC_ZEAL=1` + +a live use-after-free), and under `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1` + `PERRY_GC_PROTECT_FROMSPACE=1` with `PERRY_GC_MOVING_LOOP_POLLS=1` — with the instrument proven live (5 quarantined from-space sets per run, so survivors genuinely moved and the slots were genuinely rewritten). The diff --git a/changelog.d/7499-json-reparse-materialize.md b/changelog.d/7499-json-reparse-materialize.md index 150f251be3..4dd90cd0f1 100644 --- a/changelog.d/7499-json-reparse-materialize.md +++ b/changelog.d/7499-json-reparse-materialize.md @@ -63,7 +63,7 @@ only that nothing threw. Deleting the patch loop turns three tests red. An 11-scenario probe (600 records × 13 fields, the 10k `i * 3.14159` float array from the #7477 class, edge values, whitespace blobs, identity, sort, repeated materialization) is byte-identical to node 26.5.1 under `PERRY_JSON_TAPE=1`, -`=0` and auto, and stays byte-identical under `PERRY_GC_ZEAL=1 +`=0` and auto, and stays byte-identical under `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1` on a `PERRY_GC_MOVING_LOOP_POLLS=1` build with the from-space quarantine confirmed live (`retired_set=#0…#5`, a copying minor moving 144,174 objects, 120 reparses inside the protected run). diff --git a/changelog.d/7501-layout-declared-at-alloc.md b/changelog.d/7501-layout-declared-at-alloc.md index 8ccdf20cda..72a6358c1b 100644 --- a/changelog.d/7501-layout-declared-at-alloc.md +++ b/changelog.d/7501-layout-declared-at-alloc.md @@ -55,7 +55,7 @@ element relocated and every slot rewritten, and a permanent sabotage arm asserts the *undeclared* array enumerates zero child slots — so a green positive test means the declaration was load-bearing, not that nothing was tried. End to end, a compiled smoke over these shapes is byte-identical to `node` both normally and -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 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1`, with `PERRY_GC_DIAG=1` confirming thousands of protected from-space retirements actually ran. diff --git a/changelog.d/7532-typed-shape-declared-at-allocation.md b/changelog.d/7532-typed-shape-declared-at-allocation.md index fe96542392..5a231ea9b6 100644 --- a/changelog.d/7532-typed-shape-declared-at-allocation.md +++ b/changelog.d/7532-typed-shape-declared-at-allocation.md @@ -45,7 +45,7 @@ to the boxed setter, and downgrades the descriptor through `layout_note_slot` the same path any post-install contradiction has always taken. There is a witness for exactly this: 20,000 instances constructed with heap strings in a `number` field, collected hard, all 20,000 still readable and `typeof` `string`, -under `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1`. +under `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1`. Interleaved A/B (arms alternating per round, best-of-9 user CPU): diff --git a/changelog.d/7537-early-batch-flip.md b/changelog.d/7537-early-batch-flip.md index 83592df1af..ac7a2a452d 100644 --- a/changelog.d/7537-early-batch-flip.md +++ b/changelog.d/7537-early-batch-flip.md @@ -69,6 +69,6 @@ element-wise materializer can lose an object's key pointers under tape + generational GC, so `JSON.stringify` emits `field0`/`field1` instead of the real names (#7538 — this change reduces the exposure by retiring that path for scan shapes, but does not fix it), and -`PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_ZEAL=1` faults in +`PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1` faults in `test_json_tape_eager_materialization_handles_survive_copied_minor_gc`, A/B-confirmed identical on the merge base. diff --git a/changelog.d/7540-dense-array-spread-fast-path.md b/changelog.d/7540-dense-array-spread-fast-path.md index c4f5cde5b7..4fad15cea2 100644 --- a/changelog.d/7540-dense-array-spread-fast-path.md +++ b/changelog.d/7540-dense-array-spread-fast-path.md @@ -83,7 +83,7 @@ in the artifact by a wide margin; it is now among the better ones. Verified byte-identical to the node oracle under **both** link modes (auto-optimize and `PERRY_NO_AUTO_OPTIMIZE=1`), `scripts/auto_opt_app_patterns.sh` 12/12, and a 32-case `[...arr]` semantics matrix byte-identical to pre-change Perry. Under -`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 +`PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800` on a `PERRY_GC_MOVING_LOOP_POLLS=1` build: **50 005 retired page sets quarantined, zero faults**, correct checksum — the instrument proven live by its `[gc-fromspace-protect] mode=… retired_set=#N` diff --git a/changelog.d/7550-anon-shape-numeric-field-types.md b/changelog.d/7550-anon-shape-numeric-field-types.md index 45ead376ee..e16f363813 100644 --- a/changelog.d/7550-anon-shape-numeric-field-types.md +++ b/changelog.d/7550-anon-shape-numeric-field-types.md @@ -70,7 +70,7 @@ literals, and objects whose *numeric* fields are overwritten with freshly allocated heap strings and read back after two more collections. Its subject is verified live in the emitted IR (three `js_gc_declare_typed_shape_layout` calls, raw-f64 masks on the pure shapes, a pointer mask only on the mixed one), and it -runs clean under `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 +runs clean under `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800` with `PERRY_GC_MOVING_LOOP_POLLS=1` at compile and run time — 100 131 copying minors, 100 131 quarantined from-space page-sets, exit 0 — and under `PERRY_GC_VERIFY_EVACUATION=1`. diff --git a/changelog.d/7552-for-init-local-types.md b/changelog.d/7552-for-init-local-types.md index e22ca72bb4..bfd1c36746 100644 --- a/changelog.d/7552-for-init-local-types.md +++ b/changelog.d/7552-for-init-local-types.md @@ -74,7 +74,7 @@ not just compile-level: - 67 `test-files/` programs spread deterministically across the corpus, compiled and run under both arms: **identical output**, and identical again under - `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1`. (Three initial "diffs" were + `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1`. (Three initial "diffs" were thread ids inside a pre-existing panic message and are normalised out; they differ run-to-run on the same binary.) - `perry-hir` and `perry-runtime` suites green; `perry-codegen`'s failure set is @@ -82,5 +82,5 @@ not just compile-level: **Pre-existing, unrelated, and worth its own issue:** `test-files/test_gap_webcrypto_async_threadpool.ts` crashes (`Bus error`, -rc=138) under `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` on **both** arms, +rc=138) under `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1` on **both** arms, with nondeterministic output. Not introduced here. diff --git a/changelog.d/7561-map-view-for-of.md b/changelog.d/7561-map-view-for-of.md index fa50f96860..eb9b856d7e 100644 --- a/changelog.d/7561-map-view-for-of.md +++ b/changelog.d/7561-map-view-for-of.md @@ -100,7 +100,7 @@ sits in a bare register the collector cannot see or rewrite. A probe over 40 000 heap-string Map keys that allocates inside the loop body already gets a **wrong answer with no instruments at all** (one mismatched key, and a `chars` total of 3,944,095,011 where 1,548,890 is correct); under -`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 +`PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800` on a `PERRY_GC_MOVING_LOOP_POLLS=1` build, with the instrument proven live by its `[gc-fromspace-protect] mode=ProtectPages retired_set=#0 blocks=17` line, it diff --git a/changelog.d/7565-tls-direct-tsd.md b/changelog.d/7565-tls-direct-tsd.md index 1cff8eef37..b400ad0b63 100644 --- a/changelog.d/7565-tls-direct-tsd.md +++ b/changelog.d/7565-tls-direct-tsd.md @@ -107,7 +107,7 @@ equally, best-of-7 after a discarded warm-up: Peak RSS flat (25.2 → 25.2–25.4 MB); program output byte-identical across all three arms on all three probes. `cargo test -p perry-runtime`: 1811 passed, 0 failed. All three probes under -`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800` +`PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800` exit 0 with 110 `[gc-fromspace-protect] mode=ProtectPages retired_set=#N` lines each — the count is quoted rather than the exit code, because a run with zero copying minors protects nothing. @@ -134,7 +134,7 @@ six network aborts pass, and the sixth `main`**. The seventh crash, `test_gap_gc_same_module_call_argument_rooting`, is a harness timeout rather than a defect: standalone under its own `parity-env` -(`PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1`) it exits 0 in 13.5 s with +(`PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1`) it exits 0 in 13.5 s with output byte-identical to node, against the harness's 10 s cap. Worth recording separately: **the gap gate cannot render a verdict on macOS at all** — `run_gap_tests.sh` selects `test-parity/gap_snapshot.${platform}.json` for diff --git a/changelog.d/7566-inline-new-in-loops.md b/changelog.d/7566-inline-new-in-loops.md index 3e08e44b74..74d269e5bf 100644 --- a/changelog.d/7566-inline-new-in-loops.md +++ b/changelog.d/7566-inline-new-in-loops.md @@ -68,7 +68,7 @@ than disabling it — an empty string is `Some("")`. Testing: 43 `test-files/` programs across both arms, identical output (the one apparent diff was a `console.time` duration, which varies run-to-run on a single -binary), and identical under `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` — +binary), and identical under `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1` — the arm that would catch a mis-written object header, since the inline bump writes the header and zero-fills slots in generated code instead of in the runtime. diff --git a/changelog.d/7579-iter-result-one-allocation.md b/changelog.d/7579-iter-result-one-allocation.md index b49747fded..8166df0f61 100644 --- a/changelog.d/7579-iter-result-one-allocation.md +++ b/changelog.d/7579-iter-result-one-allocation.md @@ -30,7 +30,7 @@ One shared array means one shape id for every iterator result in the program. allocations back to back with every intermediate in a bare Rust local, so a copying minor inside allocation *k* moved what locals 1..k-1 named and rewrote only slots it could see — which a Rust local is not. Under -`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` this took a SIGBUS at +`PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1` this took a SIGBUS at `iterator_helpers::make_iter_result + 172`. The fix is structural rather than more roots: with one allocation, both pointers used after it are re-read from storage the collector rewrites — the object from its `RuntimeHandleScope` diff --git a/changelog.d/7584-generator-attach-prototype-rooting.md b/changelog.d/7584-generator-attach-prototype-rooting.md index 415b5a78da..048694f0c4 100644 --- a/changelog.d/7584-generator-attach-prototype-rooting.md +++ b/changelog.d/7584-generator-attach-prototype-rooting.md @@ -49,5 +49,5 @@ `RuntimeHandleScope` under test is decorative and the test passes for the wrong reason). Plus `test-files/test_gap_7577_generator_prototype_rooting.ts`, byte-identical to `node --experimental-strip-types` and clean under - `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 + `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800`. diff --git a/changelog.d/7602-array-push-barrier-parent-gate.md b/changelog.d/7602-array-push-barrier-parent-gate.md index e09541831f..9de2b391cf 100644 --- a/changelog.d/7602-array-push-barrier-parent-gate.md +++ b/changelog.d/7602-array-push-barrier-parent-gate.md @@ -115,7 +115,7 @@ class-field stores rather than array pushes — the honest scope limit, and the remaining #7511 lever. All ten bench binaries produce byte-identical stdout, including `cls_mistyped.ts`, and both arms re-run clean under `PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_VERIFY_MARK=1` and under -`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` with the instrument proven live +`PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1` with the instrument proven live (110 `[gc-fromspace-protect] mode=ProtectPages retired_set=#N` sets on `push_cls`). diff --git a/crates/perry-runtime/src/arena/quarantine.rs b/crates/perry-runtime/src/arena/quarantine.rs index 9d316de83b..59c37caa1c 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,8 +78,9 @@ //! //! **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 -//! its last valid observation and its stale use — one per loop back-edge poll. +//! under `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 //! `js_ctor_return_override` publishes it, and the default depth of 4 misses it @@ -91,7 +93,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. @@ -686,6 +688,16 @@ fn install_fault_reporter() { libc::sigaction(libc::SIGSEGV, &action, std::ptr::null_mut()); libc::sigaction(libc::SIGBUS, &action, std::ptr::null_mut()); } + // Seeded GC-schedule fuzzing installs its own reporter when the mode + // resolves, which is at the first safepoint — always BEFORE the first + // page-set retirement gets here. The install above would therefore drop the + // seed line from exactly the pairing an investigator reaches for + // (`PERRY_GC_SCHEDULE_SEED=… PERRY_GC_PROTECT_FROMSPACE=1`), and a fuzzer + // that finds a bug and loses the reproducer is worthless. Re-layer it on + // top; it chains back to the handler installed here, so the fault report + // below still prints. No-op when the mode is off, so a quarantine-only run + // keeps exactly today's signal disposition. + crate::gc::schedule::reinstall_signal_reporter(); } /// Minimal `write(2)`-based formatter. Deliberately avoids `format!`/`eprintln!` diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index b6d9537464..1ca25f4c39 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -116,12 +116,13 @@ mod verify; /// the rewrite pass own root enumeration. Debug-only /// (`PERRY_GC_FROMSPACE_SCAN=1`). mod fromspace_scan; -/// #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; +/// #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 use schedule::{gc_schedule_forced_collections, gc_schedule_safepoints}; pub use verify::*; -pub use zeal::zeal_forced_collections; -pub(crate) use zeal::{gc_zeal_enabled, note_zeal_forced_collection}; #[cfg(feature = "diagnostics")] mod heap_snapshot; #[cfg(feature = "diagnostics")] @@ -292,13 +293,15 @@ pub fn gen_gc_evacuate_enabled() -> bool { } 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. + // `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. // Still subject to `gen_gc_evacuate_enabled()` — an explicit - // `PERRY_GEN_GC_EVACUATE=0` wins, so the two knobs cannot silently disagree. + // `PERRY_GEN_GC_EVACUATE=0` wins, so the knobs cannot silently disagree. gen_gc_evacuate_enabled() - && (gc_zeal_enabled() + && (schedule::gc_schedule_enabled() || matches!( std::env::var("PERRY_GC_FORCE_EVACUATE").as_deref(), Ok("1") | Ok("on") | Ok("true") @@ -841,6 +844,13 @@ 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(); + // 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 + // GC-schedule summary here so a *passing* run still reports how many + // safepoints the schedule actually saw. Inert (one cached-`Option` load) and + // once-only when the mode is off. + schedule::report_exit_summary(); } /// #5093: parse a boolean-ish env var by value (not mere presence): true for diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index d330c56424..ec9466cb36 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -2060,6 +2060,13 @@ pub(crate) fn gc_safepoint_moving_minor() { } // We are handling this safepoint (collect or find nothing due): clear the // deferral flag set by the alloc-point arm (Phase 2/3). + // + // #7154 tooling: this is also the one place the seeded GC-schedule counter + // advances — after the entry guards, so a safepoint that could not have + // collected never consumes a schedule slot, and once per handled safepoint + // whichever arm reached us (loop back-edge poll or microtask-pump boundary). + // Inert (one cached-`Option` load) unless `PERRY_GC_SCHEDULE_SEED` is set. + let scheduled = super::schedule::schedule_tick(); GC_SAFEPOINT_PENDING.with(|p| p.set(false)); let _declared = DeclaredSafepointGuard::enter(); let kind = match gc_budgeted_due_trigger() { @@ -2087,15 +2094,20 @@ pub(crate) fn gc_safepoint_moving_minor() { return; } _ => { - // 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 + // 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. + // `gc_force_evacuate_enabled()` is true whenever a seed resolved, so // this minor MOVES survivors rather than sweeping in place. - if !super::gc_zeal_enabled() { + // + // The density is `PERRY_GC_SCHEDULE_RATE`'s to set: at the default + // 5% this arm fires on one handled safepoint in twenty, and at rate + // 1 on every one of them. + if !scheduled { return; } - super::note_zeal_forced_collection(); + super::schedule::note_schedule_forced_collection(); GcTriggerKind::ArenaBytes } }; @@ -2132,12 +2144,18 @@ pub extern "C" fn js_gc_loop_safepoint() { if !gc_moving_loop_polls_enabled() { return; } - // Zeal (#7154 tooling) collects at EVERY poll, not only when the alloc-point - // arm already deferred one. Zeal cannot conjure a poll codegen never emitted, - // so the `gc_moving_loop_polls_enabled()` gate above still applies — see - // `gc/zeal.rs` for why that means "compile AND run with + // The seeded schedule (#7154 tooling) considers EVERY poll, not only the + // ones where the alloc-point arm already deferred a collection, 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 counter 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` for why that means "compile AND run with // `PERRY_GC_MOVING_LOOP_POLLS=1`". - if !GC_SAFEPOINT_PENDING.with(Cell::get) && !super::gc_zeal_enabled() { + if !GC_SAFEPOINT_PENDING.with(Cell::get) && !super::schedule::gc_schedule_enabled() { return; } gc_safepoint_moving_minor(); diff --git a/crates/perry-runtime/src/gc/schedule.rs b/crates/perry-runtime/src/gc/schedule.rs new file mode 100644 index 0000000000..1bc8c9b689 --- /dev/null +++ b/crates/perry-runtime/src/gc/schedule.rs @@ -0,0 +1,653 @@ +//! Seeded GC-schedule fuzzing (#7154 tooling) — `PERRY_GC_SCHEDULE_SEED`. +//! +//! # 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. 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. +//! +//! `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 +//! why 60 identical runs find the same bug once. +//! 2. **A failing seed is a reproducer.** The schedule is a pure function of +//! `(seed, counter)`. Same seed + same program + same inputs ⇒ same +//! collection schedule, run to run. A fuzzer that finds a bug and loses the +//! reproducer is worthless, so the seed is also printed on startup and again +//! on any crash, abort or panic. +//! +//! # What the knobs actually gate +//! +//! Per CLAUDE.md's GC-knob policy, precisely: +//! +//! `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`. 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 +//! `gc_budgeted_due_trigger()` reports nothing due, a minor is run anyway iff +//! the schedule selected this counter value. +//! 3. `gc_force_evacuate_enabled()` becomes true, so a scheduled minor **moves** +//! survivors instead of sweeping in place. Without this the mode would be a +//! knob whose name promises relocation stress and whose effect is sweep +//! pressure — the failure `PERRY_GC_FORCE_EVACUATE` already cost this project +//! once (#6942 / #6946). +//! +//! It does **not**: +//! +//! - 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 **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; +//! - 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; +//! - suppress or replace pressure-driven collections. The rate is *additional* +//! density on top of what the budgeted collector already does, never less. +//! +//! `PERRY_GC_SCHEDULE_RATE=` (default `0.05`) gates **only** the +//! threshold the schedule hash is compared against — the expected fraction of +//! handled safepoints at which a collection is forced. It is inert unless +//! `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 — the maximum-pressure endpoint, where the seed stops +//! mattering because every ordinal is selected whatever it hashes to. +//! +//! # Determinism: the guarantee, and its limit +//! +//! The decision function is `schedule_hit(seed, counter, threshold)`: two rounds +//! of SplitMix64 over `(seed, counter)`. It reads **no** wall-clock time, **no** +//! address, **no** allocation state, and **no** thread identity. So for a +//! single-threaded program the schedule is reproducible run to run given the +//! same seed, binary and inputs. +//! +//! **The counter is thread-local, and that is the honest scope of the +//! guarantee.** Each thread in a `perry/thread` program has its own arena and +//! its own collector, and its own counter starting at zero; each thread's +//! schedule is therefore deterministic *given that thread's own sequence of +//! handled safepoints*. What is not guaranteed is that a multi-threaded program +//! executes the same sequence of safepoints per thread on every run — that +//! depends on OS scheduling, which this mode does not control and does not +//! pretend to. A global atomic counter would be strictly worse: it would make +//! even each individual thread's schedule depend on interleaving. So: +//! **deterministic for single-threaded programs; per-thread deterministic, but +//! not run-to-run reproducible, for multi-threaded ones.** Programs that read +//! the clock, the network or the filesystem are of course only as reproducible +//! as those inputs. + +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 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 +/// live-subject counter for every schedule-based verdict: a clean run with `0` +/// here exercised nothing (CLAUDE.md, "four ways a gate cannot fail" #4). +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. +static SCHEDULE_SAFEPOINTS: AtomicU64 = AtomicU64::new(0); + +thread_local! { + /// The monotonically increasing safepoint ordinal this thread's schedule is + /// a function of. Thread-local on purpose — see the determinism note above. + static SAFEPOINT_COUNTER: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +// --------------------------------------------------------------------------- +// Pure knob parsing + the decision function. +// +// Kept pure so both directions of both knobs are testable without mutating the +// process environment: the live readers cache in a `OnceLock`, so a test that +// set an env var would be at the mercy of which test ran first. +// --------------------------------------------------------------------------- + +/// `PERRY_GC_SCHEDULE_SEED` — `None` (mode off) unless the value parses as a +/// `u64`. A typo must not silently enable a mode that changes when the collector +/// runs, so garbage reads as OFF rather than as seed 0. +pub(crate) fn parse_seed(raw: Option<&str>) -> Option { + raw.map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| value.parse::().ok()) +} + +/// `PERRY_GC_SCHEDULE_RATE` — expected fraction of handled safepoints that +/// collect. Unset, empty or unparseable ⇒ [`DEFAULT_SCHEDULE_RATE`]; out-of-range +/// values are clamped into `[0, 1]` rather than rejected, so a `2` reads as +/// "everything" instead of silently reverting to the default. +pub(crate) fn parse_rate(raw: Option<&str>) -> f64 { + match raw.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => match value.parse::() { + // NaN is the one parseable value with no sensible clamp, so it joins + // the unparseable set. `inf` clamps to 1 like any other too-large + // number — silently reverting it to 5% would leave the operator + // believing they had turned the mode all the way up. + Ok(rate) if !rate.is_nan() => rate.clamp(0.0, 1.0), + _ => DEFAULT_SCHEDULE_RATE, + }, + None => DEFAULT_SCHEDULE_RATE, + } +} + +/// SplitMix64. A fixed, portable, arithmetic-only bit mixer: identical output on +/// every target and every build, which `DefaultHasher` (deliberately unspecified, +/// and randomly seeded per process for `HashMap`) would not be. +const fn splitmix64(x: u64) -> u64 { + let z = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + let z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + let z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// 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 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 +/// against. `0` ⇒ never (threshold `0`, and the comparison is strict `<`), +/// `1` ⇒ [`THRESHOLD_ALWAYS`]. +pub(crate) fn rate_threshold(rate: f64) -> u64 { + if rate.is_nan() || rate <= 0.0 { + return 0; + } + if rate >= 1.0 { + return THRESHOLD_ALWAYS; + } + // `2^64 * rate` saturated into u64. The product is computed in f64, so the + // realised rate is accurate to ~2^-53 — far tighter than any workload can + // resolve. + let scaled = rate * (2.0_f64).powi(64); + if scaled >= (u64::MAX as f64) { + u64::MAX - 1 + } else { + scaled as u64 + } +} + +/// The decision: does the safepoint with ordinal `counter` collect under `seed`? +/// +/// Pure. No clock, no address, no thread identity — see the determinism note at +/// the top of this file. The seed is mixed *through* SplitMix64 before being +/// combined with the counter so that adjacent seeds (`1`, `2`, `3`, … — exactly +/// what a sweep produces) give unrelated schedules rather than schedules that +/// agree on most safepoints. +pub(crate) fn schedule_hit(seed: u64, counter: u64, threshold: u64) -> bool { + if threshold == 0 { + return false; + } + if threshold == THRESHOLD_ALWAYS { + return true; + } + splitmix64(splitmix64(seed) ^ counter) < threshold +} + +// --------------------------------------------------------------------------- +// Live readers. +// --------------------------------------------------------------------------- + +#[cfg(test)] +thread_local! { + /// Test-only override. Thread-local, so one test turning the mode on cannot + /// change collector behaviour for any other test. + static SCHEDULE_OVERRIDE: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +/// Resolved `(seed, threshold)`, or `None` when the mode is off. Cached: the +/// environment is read exactly once per process. +fn resolved() -> Option<(u64, u64)> { + #[cfg(test)] + if let Some(over) = SCHEDULE_OVERRIDE.with(std::cell::Cell::get) { + return Some(over); + } + use std::sync::OnceLock; + static CACHED: OnceLock> = OnceLock::new(); + *CACHED.get_or_init(|| { + let seed = parse_seed(std::env::var("PERRY_GC_SCHEDULE_SEED").ok().as_deref())?; + let rate = parse_rate(std::env::var("PERRY_GC_SCHEDULE_RATE").ok().as_deref()); + let resolved = (seed, rate_threshold(rate)); + // Announce on resolution, i.e. at the first safepoint of the run. The + // seed must never be something the operator has to remember: it is in + // the output from the start AND on every failure path below. + publish_seed(seed); + announce(seed, rate); + install_failure_reporter(); + Some(resolved) + }) +} + +/// 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() +} + +/// Advance this thread's safepoint ordinal and report whether the schedule +/// selects it. Called **once per handled safepoint** from +/// `gc_safepoint_moving_minor`, after its entry guards. +/// +/// Returns `false` immediately when the mode is off, so the default path pays a +/// single cached load and nothing else. +pub(crate) fn schedule_tick() -> bool { + let Some((seed, threshold)) = resolved() else { + return false; + }; + let counter = SAFEPOINT_COUNTER.with(|cell| { + let next = cell.get().wrapping_add(1); + cell.set(next); + next + }); + SCHEDULE_SAFEPOINTS.fetch_add(1, Ordering::Relaxed); + schedule_hit(seed, counter, threshold) +} + +/// Record that the schedule forced a collection pressure would not have run. +#[inline] +pub(crate) fn note_schedule_forced_collection() { + SCHEDULE_FORCED.fetch_add(1, Ordering::Relaxed); +} + +/// How many collections the seeded schedule forced. A run that reports `0` here +/// exercised nothing — most often because the binary was compiled without +/// `PERRY_GC_MOVING_LOOP_POLLS=1` and the workload never reached the event loop, +/// or because `PERRY_GC_SCHEDULE_RATE=0`. +pub fn gc_schedule_forced_collections() -> u64 { + SCHEDULE_FORCED.load(Ordering::Relaxed) +} + +/// How many handled GC safepoints the schedule has seen, summed across threads. +/// The denominator for `gc_schedule_forced_collections()`: it distinguishes +/// "the schedule declined" from "there were no safepoints to decline". +pub fn gc_schedule_safepoints() -> u64 { + SCHEDULE_SAFEPOINTS.load(Ordering::Relaxed) +} + +/// RAII test override. `threshold` is taken directly so tests can pin the +/// always/never arms without going through float parsing. +#[cfg(test)] +pub(crate) struct ScheduleGuard(Option<(u64, u64)>); + +#[cfg(test)] +impl ScheduleGuard { + pub(crate) fn set(seed: u64, threshold: u64) -> Self { + Self(SCHEDULE_OVERRIDE.with(|cell| cell.replace(Some((seed, threshold))))) + } + pub(crate) fn off() -> Self { + Self(SCHEDULE_OVERRIDE.with(|cell| cell.replace(None))) + } +} + +#[cfg(test)] +impl Drop for ScheduleGuard { + fn drop(&mut self) { + SCHEDULE_OVERRIDE.with(|cell| cell.set(self.0)); + } +} + +#[cfg(test)] +pub(crate) fn reset_thread_counter_for_test() { + SAFEPOINT_COUNTER.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. +// +// Three layers, because the ways a perry process dies are not one thing: +// 1. a startup banner, so the seed is in the log even if the failure mode is +// a hang or a `_exit` that runs no handler at all; +// 2. a panic hook, chained to whatever hook was installed before it, for Rust +// panics and `panic = "abort"`; +// 3. a signal handler for the fatal set, chained to whatever handler was +// installed before it — notably the from-space quarantine's SIGSEGV +// reporter, which is the instrument this mode is expected to be paired +// with. +// --------------------------------------------------------------------------- + +static REPORTER_INSTALLED: AtomicBool = AtomicBool::new(false); + +fn announce(seed: u64, rate: f64) { + eprintln!( + "[gc-schedule] seeded GC-schedule fuzzing ACTIVE: seed={seed} rate={rate}\n\ + [gc-schedule] reproduce with: PERRY_GC_SCHEDULE_SEED={seed} PERRY_GC_SCHEDULE_RATE={rate}" + ); +} + +/// The one-line summary printed on every failure path. Built from the two +/// counters plus the resolved knobs, so a report also says whether the mode was +/// *doing* anything when the process died. +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"); + previous(info); + })); + install_signal_reporter(); + install_exit_summary(); +} + +/// `atexit` as a backstop for exit paths that return through libc. Perry's own +/// exits deliberately do not (`process/env_misc.rs::terminate_without_atexit` +/// calls `_exit` to dodge cleanup handlers that have SIGILL'd), which is why the +/// primary hook is [`report_exit_summary`] on the teardown funnel and this is +/// only the belt to its braces. [`SUMMARY_EMITTED`] keeps them from +/// double-printing. +#[cfg(unix)] +fn install_exit_summary() { + extern "C" fn summary() { + report_exit_summary(); + } + // SAFETY: `atexit` with a plain `extern "C" fn` that touches only atomics + // and stderr. + unsafe { + libc::atexit(summary); + } +} + +#[cfg(not(unix))] +fn install_exit_summary() {} + +static SUMMARY_EMITTED: AtomicBool = AtomicBool::new(false); + +/// A run that exits 0 must still say how much schedule it actually executed. +/// +/// Without this a sweep cannot tell "every seed passed" from "the binary was +/// compiled without `PERRY_GC_MOVING_LOOP_POLLS=1`, so there were no safepoints +/// to select and no seed could possibly have failed" — CLAUDE.md's fourth way a +/// gate cannot fail, and the one that would make this whole mode worthless. +/// `scripts/gc_schedule_fuzz.sh` reads the `safepoints=` field for exactly that +/// check. +/// +/// Called from `js_gc_release_current_thread_collection_side_allocations`, which +/// every process-exit path funnels through (the generated exit epilogue, +/// `js_process_exit`, and the fatal-path teardown), and once only. Inert when +/// the mode is off. +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_safepoints(), + gc_schedule_forced_collections(), + ); +} + +/// Async-signal-safety is irrelevant on the panic path, so this half can format +/// freely. +fn report_to_stderr(cause: &str) { + let Some((seed, _)) = resolved() else { + return; + }; + eprintln!( + "\n[gc-schedule] FAILURE ({cause}) under seed={seed}\n\ + [gc-schedule] safepoints={} scheduled_collections={}\n\ + [gc-schedule] REPRODUCER: re-run this exact command with \ + PERRY_GC_SCHEDULE_SEED={seed} set.", + gc_schedule_safepoints(), + gc_schedule_forced_collections(), + ); +} + +#[cfg(not(unix))] +fn install_signal_reporter() {} + +/// Re-layer the schedule reporter on top of a handler installed after it. +/// +/// The from-space quarantine installs its own SIGSEGV/SIGBUS reporter lazily, on +/// the first page-set retirement — i.e. always *after* this mode resolved, which +/// happens at the first safepoint. Without this hook the quarantine's install +/// would silently drop the seed line from precisely the pairing an investigator +/// reaches for (`PERRY_GC_SCHEDULE_SEED=… PERRY_GC_PROTECT_FROMSPACE=1`). Called +/// from `arena::quarantine::install_fault_reporter`; a no-op when this mode is +/// off, so a quarantine-only run keeps exactly today's signal disposition. +/// +/// Unix-only, and so is its single caller: on Windows the quarantine's +/// `ProtectPages` mode has already degraded to poison-only because `mprotect` / +/// `sigaction` are not exposed there, so there is no reporter to install and +/// nothing to re-layer. +#[cfg(unix)] +pub(crate) fn reinstall_signal_reporter() { + if !REPORTER_INSTALLED.load(Ordering::SeqCst) { + return; + } + install_signal_reporter_inner(); +} + +#[cfg(unix)] +fn install_signal_reporter() { + install_signal_reporter_inner(); +} + +/// Fatal signals worth reporting a seed for. `SIGABRT` covers `panic = "abort"` +/// and the runtime's own `abort()` paths; `SIGSEGV`/`SIGBUS` are the stale-deref +/// shapes this mode exists to provoke; `SIGILL`/`SIGTRAP` catch a corrupted code +/// pointer landing somewhere undecodable. +#[cfg(unix)] +const FATAL_SIGNALS: [libc::c_int; 5] = [ + libc::SIGSEGV, + libc::SIGBUS, + libc::SIGABRT, + libc::SIGILL, + libc::SIGTRAP, +]; + +/// Previously installed `sa_sigaction` per entry of [`FATAL_SIGNALS`], so the +/// handler can chain rather than clobber. `SIG_DFL` (0) and `SIG_IGN` (1) mean +/// "nothing to chain to". +#[cfg(unix)] +static PREVIOUS_HANDLERS: [AtomicU64; 5] = [ + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), +]; + +#[cfg(unix)] +fn install_signal_reporter_inner() { + for (slot, signum) in FATAL_SIGNALS.iter().copied().enumerate() { + // SAFETY: standard `sigaction` install with an `SA_SIGINFO` handler; the + // `old` out-parameter is a zeroed, correctly typed local. + unsafe { + let mut action: libc::sigaction = std::mem::zeroed(); + let mut old: libc::sigaction = std::mem::zeroed(); + action.sa_sigaction = schedule_fault_handler as *const () as usize; + action.sa_flags = libc::SA_SIGINFO | libc::SA_ONSTACK; + libc::sigemptyset(&mut action.sa_mask); + if libc::sigaction(signum, &action, &mut old) != 0 { + continue; + } + // 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. + if previous != schedule_fault_handler as *const () as u64 { + PREVIOUS_HANDLERS[slot].store(previous, Ordering::SeqCst); + } + } + } +} + +/// Minimal `write(2)` formatter. Deliberately avoids `format!` / `eprintln!` so +/// the handler does not allocate — the same discipline as the quarantine +/// reporter it chains to. +#[cfg(unix)] +struct SignalWriter { + buf: [u8; 512], + len: usize, +} + +#[cfg(unix)] +impl SignalWriter { + fn new() -> Self { + Self { + buf: [0; 512], + len: 0, + } + } + fn str(&mut self, s: &str) { + for &byte in s.as_bytes() { + if self.len < self.buf.len() { + self.buf[self.len] = byte; + self.len += 1; + } + } + } + fn dec(&mut self, mut value: u64) { + let mut digits = [0u8; 20]; + let mut n = 0; + if value == 0 { + digits[0] = b'0'; + n = 1; + } + while value != 0 { + digits[n] = b'0' + (value % 10) as u8; + n += 1; + value /= 10; + } + for i in (0..n).rev() { + if self.len < self.buf.len() { + self.buf[self.len] = digits[i]; + self.len += 1; + } + } + } + fn flush(&self) { + // SAFETY: writing `self.len` initialized bytes to stderr. + unsafe { + libc::write(2, self.buf.as_ptr() as *const libc::c_void, self.len); + } + } +} + +/// The resolved seed, cached into a plain atomic so the signal handler never +/// touches the `OnceLock`/env path. Written by `resolved()` on the first call. +#[cfg(unix)] +static REPORTED_SEED: AtomicU64 = AtomicU64::new(u64::MAX); + +#[cfg(unix)] +extern "C" fn schedule_fault_handler( + signum: libc::c_int, + info: *mut libc::siginfo_t, + ctx: *mut libc::c_void, +) { + let mut out = SignalWriter::new(); + out.str("\n[gc-schedule] FAILURE (signal "); + out.dec(signum as u64); + out.str(") under seed="); + out.dec(REPORTED_SEED.load(Ordering::Relaxed)); + out.str("\n[gc-schedule] safepoints="); + out.dec(SCHEDULE_SAFEPOINTS.load(Ordering::Relaxed)); + out.str(" scheduled_collections="); + out.dec(SCHEDULE_FORCED.load(Ordering::Relaxed)); + out.str("\n[gc-schedule] REPRODUCER: re-run with PERRY_GC_SCHEDULE_SEED="); + out.dec(REPORTED_SEED.load(Ordering::Relaxed)); + out.str("\n"); + out.flush(); + + let slot = FATAL_SIGNALS.iter().position(|&s| s == signum); + let previous = slot.map_or(0, |slot| PREVIOUS_HANDLERS[slot].load(Ordering::Relaxed)); + // 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(); + action.sa_sigaction = libc::SIG_DFL; + 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. +#[cfg(unix)] +fn publish_seed(seed: u64) { + REPORTED_SEED.store(seed, Ordering::SeqCst); +} + +#[cfg(not(unix))] +fn publish_seed(_seed: u64) {} diff --git a/crates/perry-runtime/src/gc/tests/fromspace_protect.rs b/crates/perry-runtime/src/gc/tests/fromspace_protect.rs index 3536f3b5cb..952e9fbd47 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,88 +325,24 @@ 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" - ); -} - -/// A zealous minor that leaves survivors in place would move nothing, so it -/// could not surface a stale-pointer bug at all. Zeal therefore implies forced -/// evacuation — but must still lose to an explicit `PERRY_GEN_GC_EVACUATE=0`, -/// so the two knobs can never silently disagree about whether objects move. -#[test] -fn zeal_implies_forced_evacuation() { - // Split by ambient policy so BOTH branches assert something. The previous - // `force_enabled() || !evacuate_enabled()` form was satisfied by its right - // operand alone: under an ambient `PERRY_GEN_GC_EVACUATE=0` it passed - // without exercising zeal at all, and reported nothing to say so — the - // exact vacuous-green shape the kill-policy exists to catch. - if !gen_gc_evacuate_enabled() { - // Precedence arm: an explicit `PERRY_GEN_GC_EVACUATE=0` must beat zeal, - // so the two knobs can never silently disagree about whether objects - // move. - let _zeal_on = super::super::zeal::ZealGuard::set(true); - assert!( - !gc_force_evacuate_enabled(), - "an explicit PERRY_GEN_GC_EVACUATE=0 must win over zeal" - ); - return; - } - // Implication arm: evacuation is permitted, so zeal must turn it on. - let _zeal_off = super::super::zeal::ZealGuard::set(false); - let off = gc_force_evacuate_enabled(); - let _zeal_on = super::super::zeal::ZealGuard::set(true); - assert!( - gc_force_evacuate_enabled(), - "evacuation is permitted, so zeal must force it (force_off={off})" - ); -} +const ENDPOINT_SEED: u64 = 7; -/// 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(); @@ -423,18 +353,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/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index eb1b0a2a21..82a03c4805 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -29,6 +29,7 @@ mod root_words; mod roots; mod runtime_roots; mod scan_fallback; +mod schedule; mod shadow_stack_ops; mod smoke; pub(super) mod support; 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 d020240569..dd6ebe5505 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 @@ -21,7 +21,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/schedule.rs b/crates/perry-runtime/src/gc/tests/schedule.rs new file mode 100644 index 0000000000..fd30e1902b --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/schedule.rs @@ -0,0 +1,344 @@ +//! Teeth for seeded GC-schedule fuzzing (`PERRY_GC_SCHEDULE_SEED`, +//! `PERRY_GC_SCHEDULE_RATE`). +//! +//! The mode's value rests on exactly two claims, so both are asserted directly +//! rather than inferred: +//! +//! 1. **A failing seed reproduces.** The schedule is a pure function of +//! `(seed, counter)`, so the same seed selects the same safepoints — checked +//! over 100k ordinals, not three. +//! 2. **Unset is inert.** Per CLAUDE.md's GC knob kill-policy, every knob's OFF +//! state is exercised: `PERRY_GC_FORCE_EVACUATE` was inert for every +//! `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 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::*; +use super::support::*; + +// --------------------------------------------------------------------------- +// Knob parsing — pure, so both states of both knobs are asserted without +// touching the process environment (the live reader caches in a `OnceLock`, so +// a test that set an env var would be at the mercy of which test ran first). +// --------------------------------------------------------------------------- + +#[test] +fn seed_knob_reads_as_off_unless_it_parses_as_u64() { + // OFF is the default and every unparseable spelling. A typo must not + // silently enable a mode that changes when the collector runs — and it must + // not silently become seed 0 either, because then a mistyped sweep would + // report N runs of one schedule as N distinct seeds. + for raw in [ + None, + Some(""), + Some(" "), + Some("banana"), + Some("-1"), + Some("1.5"), + Some("0x10"), + Some("18446744073709551616"), // u64::MAX + 1 + ] { + assert_eq!( + parse_seed(raw), + None, + "{raw:?} must leave seeded GC-schedule fuzzing OFF" + ); + } + assert_eq!(parse_seed(Some("0")), Some(0)); + assert_eq!(parse_seed(Some("42")), Some(42)); + assert_eq!( + parse_seed(Some(" 42 ")), + Some(42), + "surrounding space is fine" + ); + assert_eq!(parse_seed(Some("18446744073709551615")), Some(u64::MAX)); +} + +#[test] +fn rate_knob_defaults_and_clamps() { + for raw in [None, Some(""), Some("banana"), Some("nan")] { + assert_eq!( + parse_rate(raw), + DEFAULT_SCHEDULE_RATE, + "{raw:?} must fall back to the documented default" + ); + } + assert_eq!(parse_rate(Some("0")), 0.0); + assert_eq!(parse_rate(Some("1")), 1.0); + assert_eq!(parse_rate(Some("0.25")), 0.25); + // Clamped, not rejected: a `2` should read as "everything", not silently + // revert to 5% and leave the operator believing they turned the mode up. + assert_eq!(parse_rate(Some("2")), 1.0); + assert_eq!(parse_rate(Some("-3")), 0.0); + assert_eq!(parse_rate(Some("inf")), 1.0); +} + +#[test] +fn rate_zero_never_collects_and_rate_one_always_does() { + let never = rate_threshold(0.0); + let always = rate_threshold(1.0); + assert_eq!(never, 0, "rate 0 must be expressible as a threshold of 0"); + for counter in 0..10_000u64 { + assert!( + !schedule_hit(12345, counter, never), + "rate 0 must select nothing (counter={counter})" + ); + assert!( + schedule_hit(12345, counter, always), + "rate 1 must select EVERY safepoint — 100% density, exactly \ + (counter={counter})" + ); + } +} + +// --------------------------------------------------------------------------- +// Determinism: the property the whole mode exists for. +// --------------------------------------------------------------------------- + +/// The reproducer guarantee. If this can fail, a "failing seed" is a rumour. +#[test] +fn the_same_seed_selects_the_same_safepoints() { + let threshold = rate_threshold(0.05); + for seed in [0u64, 1, 7, 4242, u64::MAX] { + let first: Vec = (0..100_000u64) + .filter(|&counter| schedule_hit(seed, counter, threshold)) + .collect(); + let second: Vec = (0..100_000u64) + .filter(|&counter| schedule_hit(seed, counter, threshold)) + .collect(); + assert_eq!( + first, second, + "seed {seed} must select an identical safepoint set every time" + ); + assert!( + !first.is_empty(), + "seed {seed} selected nothing at rate 0.05 over 100k safepoints — \ + the determinism check would be vacuous" + ); + } +} + +/// A sweep runs adjacent seeds (`1`, `2`, `3`, …). If adjacent seeds produced +/// near-identical schedules the sweep would be re-running one experiment under +/// different names — the exact failure the mode exists to avoid. The seed is +/// mixed through SplitMix64 before it meets the counter for this reason. +#[test] +fn adjacent_seeds_explore_different_schedules() { + let threshold = rate_threshold(0.05); + const N: u64 = 20_000; + for seed in 1..8u64 { + let a: Vec = (0..N).map(|c| schedule_hit(seed, c, threshold)).collect(); + let b: Vec = (0..N) + .map(|c| schedule_hit(seed + 1, c, threshold)) + .collect(); + let hits_a = a.iter().filter(|hit| **hit).count(); + let shared = a.iter().zip(&b).filter(|(one, two)| **one && **two).count(); + // Independent schedules at rate r share ~r of each other's hits. Allow + // generous slack (25%) — the assertion that matters is "not most of + // them", i.e. the two runs are genuinely different experiments. + assert!( + shared * 4 < hits_a, + "seeds {seed} and {} agree on {shared} of {hits_a} selected \ + safepoints — a sweep over adjacent seeds would be re-running one \ + schedule", + seed + 1 + ); + } +} + +/// 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; + for rate in [0.01f64, 0.05, 0.2, 0.5] { + let threshold = rate_threshold(rate); + let hits = (0..N) + .filter(|&counter| schedule_hit(99, counter, threshold)) + .count(); + let realised = hits as f64 / N as f64; + // ±15% relative. Sampling noise at N=200k and rate 0.01 is ~2% relative + // (σ = sqrt(r(1-r)/N)/r ≈ 0.7%), so this is loose enough never to flake + // and tight enough to catch an off-by-a-factor threshold. + assert!( + (realised - rate).abs() < rate * 0.15, + "requested rate {rate}, realised {realised} over {N} safepoints" + ); + } +} + +// --------------------------------------------------------------------------- +// Behaviour at a real safepoint. Both arms, always: 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 the_schedule_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, and must not advance the + // schedule counter — an inert mode that still ticks would silently desync + // the reproducer from the run that found the bug. + let safepoints_before = gc_schedule_safepoints(); + { + let _schedule = ScheduleGuard::off(); + gc_safepoint_moving_minor(); + } + assert_eq!( + safepoint_drain_count(SafepointDrainKind::NurseryMinor), + 0, + "test premise: with no trigger due and the mode off, the safepoint must \ + be idle" + ); + assert_eq!( + gc_schedule_safepoints(), + safepoints_before, + "with no seed set the counter must not advance at all" + ); + + // ON at rate 1: the same idle safepoint must now run a minor. + let forced_before = gc_schedule_forced_collections(); + { + let _schedule = ScheduleGuard::set(7, rate_threshold(1.0)); + reset_thread_counter_for_test(); + gc_safepoint_moving_minor(); + } + assert_eq!( + safepoint_drain_count(SafepointDrainKind::NurseryMinor), + 1, + "a selected safepoint must force a minor" + ); + assert!( + gc_schedule_forced_collections() > forced_before, + "the forced collection must be COUNTED — a run reporting 0 scheduled \ + collections exercised nothing, and a clean verdict from it is vacuous" + ); + assert_eq!( + gc_schedule_safepoints(), + safepoints_before + 1, + "exactly one handled safepoint must have been ticked" + ); +} + +/// The complement: mode ON but the schedule declining. This is the arm that +/// distinguishes "seeded schedule" from "collect at every safepoint with extra +/// steps" — if a declined safepoint collected anyway, every seed would behave +/// identically and the rate knob would be decoration. +#[test] +fn a_declined_safepoint_does_not_collect_but_still_ticks() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + reset_scan_fallback_counters(); + + let safepoints_before = gc_schedule_safepoints(); + let forced_before = gc_schedule_forced_collections(); + { + let _schedule = ScheduleGuard::set(7, rate_threshold(0.0)); + reset_thread_counter_for_test(); + gc_safepoint_moving_minor(); + } + assert_eq!( + safepoint_drain_count(SafepointDrainKind::NurseryMinor), + 0, + "rate 0 must decline: no collection at an idle safepoint" + ); + assert_eq!( + gc_schedule_forced_collections(), + forced_before, + "a declined safepoint must not be counted as a forced collection" + ); + assert_eq!( + gc_schedule_safepoints(), + safepoints_before + 1, + "a declined safepoint is still a HANDLED safepoint and must consume its \ + schedule slot — otherwise the ordinal a bug is found at depends on how \ + many collections happened, and the seed stops being a reproducer" + ); +} + +/// An entry guard is not a declined safepoint: the collector could not have run, +/// so consuming a schedule slot there would make the ordinal sequence depend on +/// allocation state rather than on the program's safepoint sequence. +#[test] +fn a_blocked_safepoint_consumes_no_schedule_slot() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + reset_scan_fallback_counters(); + + let safepoints_before = gc_schedule_safepoints(); + { + let _schedule = ScheduleGuard::set(7, rate_threshold(1.0)); + reset_thread_counter_for_test(); + super::super::roots::enter_gc_root_lock(); + gc_safepoint_moving_minor(); + super::super::roots::exit_gc_root_lock(); + } + assert_eq!( + safepoint_drain_count(SafepointDrainKind::NurseryMinor), + 0, + "a non-zero root-lock depth must still block the collection — the mode \ + does not bypass the entry guards" + ); + assert_eq!( + gc_schedule_safepoints(), + safepoints_before, + "a blocked safepoint must not tick the schedule" + ); +} + +/// 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. It must +/// still lose to an explicit `PERRY_GEN_GC_EVACUATE=0`, so the knobs can never +/// silently disagree about whether objects move. +#[test] +fn the_schedule_implies_forced_evacuation() { + // Split by ambient policy so BOTH branches assert something. A single + // `force_enabled() || !evacuate_enabled()` assertion would be satisfied by + // its right operand alone: under an ambient `PERRY_GEN_GC_EVACUATE=0` it + // passes without exercising the schedule at all, and reports nothing to say + // so — the exact vacuous-green shape the kill-policy exists to catch. + if !gen_gc_evacuate_enabled() { + let _on = ScheduleGuard::set(7, rate_threshold(1.0)); + assert!( + !gc_force_evacuate_enabled(), + "an explicit PERRY_GEN_GC_EVACUATE=0 must win over the seeded schedule" + ); + return; + } + let _off = ScheduleGuard::off(); + let off = gc_force_evacuate_enabled(); + let _on = ScheduleGuard::set(7, rate_threshold(1.0)); + assert!( + gc_force_evacuate_enabled(), + "evacuation is permitted, so a resolved seed must force it (force_off={off})" + ); +} + +/// Inertness, stated as the collector sees it: with no seed the two knobs change +/// nothing about evacuation policy, which is the only collector decision this +/// mode reaches into outside the safepoint arm asserted above. +#[test] +fn unset_is_inert_for_evacuation_policy() { + let _off = ScheduleGuard::off(); + let baseline = matches!( + std::env::var("PERRY_GC_FORCE_EVACUATE").as_deref(), + Ok("1") | Ok("on") | Ok("true") + ); + assert_eq!( + gc_force_evacuate_enabled(), + gen_gc_evacuate_enabled() && baseline, + "with no seed set, forced evacuation must be decided exactly as it was \ + before this mode existed" + ); +} diff --git a/crates/perry-runtime/src/gc/zeal.rs b/crates/perry-runtime/src/gc/zeal.rs deleted file mode 100644 index d3736b1175..0000000000 --- a/crates/perry-runtime/src/gc/zeal.rs +++ /dev/null @@ -1,115 +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 every GC -//! safepoint, so an unrooted value moves on its FIRST exposure, deterministically. -//! -//! # What the knob actually gates -//! -//! `PERRY_GC_ZEAL=1`: -//! -//! 1. Every loop back-edge poll (`js_gc_loop_safepoint`) runs a minor, instead -//! of only draining an already-deferred one (`GC_SAFEPOINT_PENDING`). -//! 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=1` (default off since #7161). Zeal cannot -//! conjure a poll that codegen never emitted. A binary compiled without polls -//! still gets (2) and (3) — event-loop-boundary zeal — but a compute-only loop -//! that never yields will not collect at all. **For the #7154 hunt, compile AND -//! run with `PERRY_GC_MOVING_LOOP_POLLS=1`.** `zeal_forced_collections()` -//! reports how many collections zeal actually forced, so "clean under zeal" can -//! be checked against zeal having done anything. -//! -//! # 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. -#[cfg(test)] -pub(crate) struct ZealGuard(Option); - -#[cfg(test)] -impl ZealGuard { - pub(crate) fn set(enabled: bool) -> Self { - Self(ZEAL_OVERRIDE.with(|cell| cell.replace(Some(enabled)))) - } -} - -#[cfg(test)] -impl Drop for ZealGuard { - fn drop(&mut self) { - ZEAL_OVERRIDE.with(|cell| cell.set(self.0)); - } -} - -#[inline] -pub(crate) fn note_zeal_forced_collection() { - ZEAL_FORCED.fetch_add(1, Ordering::Relaxed); -} - -/// 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) -} diff --git a/crates/perry-runtime/src/iterator_helpers.rs b/crates/perry-runtime/src/iterator_helpers.rs index da5d468710..229283963b 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 90ac1962b7..2a6a3f3b9b 100644 --- a/crates/perry-runtime/src/native_handle.rs +++ b/crates/perry-runtime/src/native_handle.rs @@ -51,7 +51,7 @@ fn current_thread_id() -> u64 { hasher.finish() } -fn runtime_main_thread_id() -> u64 { +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 +59,18 @@ fn runtime_main_thread_id() -> u64 { } } +/// 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. +pub(crate) fn is_main_thread_or_unrecorded() -> bool { + match MAIN_THREAD_ID.load(Ordering::Acquire) { + 0 => true, + main => current_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); diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index d6a962e8c0..f6c756a7e7 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -1087,9 +1087,9 @@ pub unsafe extern "C" fn js_new_function_construct( // user constructor body — see the long note in // `construct_registered_class_ref`. Unrooted, the evacuating minor // 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`. + // 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_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1` — every safepoint collects. let scope = crate::gc::RuntimeHandleScope::new(); let inst_handle = scope.root_nanbox_f64(nan_boxed); let prev_this = crate::object::js_implicit_this_get(); @@ -1439,10 +1439,9 @@ unsafe fn construct_registered_class_ref( // receiver: the collector never sees it, never rewrites it, and this // 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, - // and as a `signal 10` on retired from-space under - // `PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800`. + // `new inst.ctor(x)` under `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`. // // This is the `RuntimeHandleScope` routing the `CURRENT_NEW_TARGET` // doc-comment at the top of this file called for. Reading back through the @@ -1700,8 +1699,8 @@ pub unsafe extern "C" fn js_new_function_construct_with_new_target( // #7280: same unrooted-receiver shape as the plain-`new` tail above — // `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`. + // `Reflect.construct(plainFn, [x], otherFn)`, 200/200 iterations wrong 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/docs/src/internals/gc-rooting-invariant.md b/docs/src/internals/gc-rooting-invariant.md index eabfc8cae4..9faa4c17c4 100644 --- a/docs/src/internals/gc-rooting-invariant.md +++ b/docs/src/internals/gc-rooting-invariant.md @@ -41,7 +41,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 **invisible by default**, because the back-edge poll that triggers it is @@ -114,7 +114,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. @@ -141,7 +141,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. @@ -264,10 +264,25 @@ python3 scripts/gc_root_dominance_check.py .perry-trace/llvm \ From #7196: -- `PERRY_GC_ZEAL=1` — collect at every safepoint. Slow, thorough. - `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. 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 +> re-runs one schedule 60 times; 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. Varying *when* collections fire is the only cheap +> way to explore the space the bug actually lives in. > **`PERRY_GC_PROTECT_FROMSPACE_DEPTH` defaults to 4, and that default produces > FALSE GREENS.** Four levels of retained from-space is not enough to still be diff --git a/docs/src/internals/memory-model.md b/docs/src/internals/memory-model.md index 31a84e8589..0d196b3dee 100644 --- a/docs/src/internals/memory-model.md +++ b/docs/src/internals/memory-model.md @@ -133,8 +133,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 **every GC safepoint** — loop back-edge polls and the outermost microtask-pump boundary — instead of only when nursery pressure is due. Implies `PERRY_GC_FORCE_EVACUATE`, so survivors actually move — but an explicit `PERRY_GEN_GC_EVACUATE=0` still wins, and with it set zeal moves nothing and therefore surfaces nothing. Zeal also does **not** bypass `gc_safepoint_moving_minor`'s entry guards (in-allocation, suppressed, unsafe FFI zone, non-zero root-lock depth, budgeted cycle): a safepoint reached in any of those states still declines to collect. Modelled on V8 `--stress-scavenge` / SpiderMonkey `gcZeal`. Composes with the two above; that pairing is what turns a rooting bug into an immediate precise fault. | | `PERRY_GC_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** — 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 `PERRY_GC_FORCE_EVACUATE`, so survivors actually move; an explicit `PERRY_GEN_GC_EVACUATE=0` still wins, and with it set the mode moves nothing and therefore surfaces nothing. 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: @@ -144,17 +145,33 @@ 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_RATE=1` 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 `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 emit loop back-edge polls that codegen never produced. - Those require the **compile-time** `PERRY_GC_MOVING_LOOP_POLLS=1` (default off - since #7161). Without it, zeal only fires at event-loop boundaries and a - compute-only loop never collects at all. Compile *and* run with the poll opt-in. +- `PERRY_GC_SCHEDULE_SEED` cannot select loop back-edge polls that codegen never + produced. Those require the **compile-time** `PERRY_GC_MOVING_LOOP_POLLS=1` + (default off since #7161). Without it, a seeded run only fires at event-loop + boundaries and a compute-only loop never collects at all. Compile *and* run + with the poll opt-in. +- **`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 + thread given that thread's own safepoint sequence*, but nothing makes the OS + schedule that sequence identically twice, so a multi-threaded reproducer is + only as reproducible as its threading. A global counter would be strictly + worse — it would make even a single thread's schedule depend on interleaving. + Report which case you measured. +- **A clean sweep means nothing without a safepoint count.** The mode prints + `[gc-schedule] done: seed=… safepoints=… scheduled_collections=…` at exit, and + `scripts/gc_schedule_fuzz.sh` refuses to call a sweep clean when every run + reported zero safepoints — the usual cause being a binary compiled without + `PERRY_GC_MOVING_LOOP_POLLS=1`, which has no in-loop safepoints for a schedule + to select. - **Page protection is Unix-only.** `mprotect` / `sigaction` / `sysconf` are not exposed by the `libc` crate on `x86_64-pc-windows-msvc`, a target `perry-runtime` is genuinely built for. On non-Unix hosts `=1` degrades to diff --git a/docs/src/internals/rfc-rooting-by-construction.md b/docs/src/internals/rfc-rooting-by-construction.md index 16176f40b9..1e76a7212d 100644 --- a/docs/src/internals/rfc-rooting-by-construction.md +++ b/docs/src/internals/rfc-rooting-by-construction.md @@ -19,7 +19,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..3b6de99d09 100644 --- a/docs/statepoint-gc-experiment.md +++ b/docs/statepoint-gc-experiment.md @@ -712,7 +712,8 @@ 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 ded4ee273e..a8738e246d 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. @@ -46,7 +47,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; @@ -76,7 +77,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. @@ -98,44 +99,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)" || 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)" || 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)" || 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 @@ -152,7 +211,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 @@ -190,5 +249,5 @@ fi 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)." diff --git a/scripts/gc_schedule_fuzz.sh b/scripts/gc_schedule_fuzz.sh new file mode 100755 index 0000000000..d3b7c91238 --- /dev/null +++ b/scripts/gc_schedule_fuzz.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# +# Sweep a compiled program across seeded GC schedules and report which seeds +# fail, with a copy-pasteable reproduce command for each. +# +# ./scripts/gc_schedule_fuzz.sh [seed-count] [-- args...] +# +# WHY THIS EXISTS +# +# A rooting bug (#7154 family) is a value live but not rooted across a +# collection point. Whether it is *caught* depends on whether a collection lands +# inside that window, so the observed failure rate is a property of the GC +# schedule, not of the bug. Re-running one program 60 times re-runs one schedule +# 60 times: it explores almost nothing, and with zero failures in N runs the 95% +# upper bound on the true rate is only ~3/N (120 clean runs bound a 1.7% bug at +# 2.5% -- no evidence at all). Varying WHEN collections fire explores the actual +# bug space, and `PERRY_GC_SCHEDULE_SEED` makes each variation replayable. +# +# WHAT YOU MUST HAVE DONE ALREADY +# +# 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 +# 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. +# +# Usage: +# scripts/gc_schedule_fuzz.sh ./myprog # seeds 1..40, rate 0.05 +# scripts/gc_schedule_fuzz.sh ./myprog 200 # seeds 1..200 +# RATE=0.3 scripts/gc_schedule_fuzz.sh ./myprog 200 # denser schedule +# scripts/gc_schedule_fuzz.sh ./myprog 200 -- --help # pass args to the target +# +# Environment: +# RATE=<0..1> PERRY_GC_SCHEDULE_RATE for every run (default 0.05). +# FIRST_SEED= First seed of the sweep (default 1). +# TIMEOUT= Per-run wall-clock cap; a run that exceeds it is recorded as +# a `timeout` failure (default 300, 0 disables). +# BASELINE= Also run control runs with NO seed set, to measure the +# unamplified failure rate on the same binary (default 0). +# KEEP=1 Keep the per-run logs instead of deleting the passing ones. +# OUTDIR= Where logs go (default a fresh mktemp -d). +# +# Exit status: 0 if every seed passed, 1 if any seed failed, 2 on misuse. + +set -uo pipefail + +if [[ $# -lt 1 ]]; then + sed -n '2,45p' "$0" >&2 + exit 2 +fi + +BIN="$1"; shift +SEED_COUNT="${1:-40}" +if [[ "${1:-}" == "--" ]]; then + SEED_COUNT=40 +else + shift || true +fi +TARGET_ARGS=() +if [[ "${1:-}" == "--" ]]; then + shift + 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 +fi +BIN="$(cd "$(dirname "$BIN")" && pwd)/$(basename "$BIN")" + +RATE="${RATE:-0.05}" +FIRST_SEED="${FIRST_SEED:-1}" +TIMEOUT="${TIMEOUT:-300}" +BASELINE="${BASELINE:-0}" +OUTDIR="${OUTDIR:-$(mktemp -d)}" +mkdir -p "$OUTDIR" + +# `timeout` is coreutils; macOS ships it as gtimeout when it ships it at all. +TIMEOUT_CMD=() +if [[ "$TIMEOUT" != "0" ]]; then + if command -v timeout >/dev/null 2>&1; then + TIMEOUT_CMD=(timeout "$TIMEOUT") + elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_CMD=(gtimeout "$TIMEOUT") + else + echo "gc_schedule_fuzz: no timeout(1) found; running without a per-run cap" >&2 + fi +fi + +# Classify a failure by the first line that looks like a cause, so the summary +# groups seeds that found the same bug instead of listing 40 exit codes. +classify() { + local log="$1" rc="$2" + if [[ "$rc" == "124" || "$rc" == "137" ]]; then + echo "timeout(${TIMEOUT}s)" + return + fi + local line + line="$(grep -m1 -E \ + 'gc-fromspace-protect\] FAULT|PERRY PANIC|panicked at|TypeError|ReferenceError|RangeError|Uncaught|Segmentation fault|Abort trap|signal: ' \ + "$log" 2>/dev/null | head -c 160)" + if [[ -n "$line" ]]; then + echo "${line//$'\n'/ }" + else + echo "exit $rc" + fi +} + +run_once() { + # $1 = log path; remaining env comes from the caller's exported vars. + local log="$1" + local rc=0 + ${TIMEOUT_CMD[@]+"${TIMEOUT_CMD[@]}"} "$BIN" ${TARGET_ARGS[@]+"${TARGET_ARGS[@]}"} >"$log" 2>&1 || rc=$? + return $rc +} + +echo "== gc_schedule_fuzz ==" +echo " binary : $BIN ${TARGET_ARGS[*]:-}" +echo " seeds : $FIRST_SEED..$((FIRST_SEED + SEED_COUNT - 1)) (rate=$RATE)" +echo " logs : $OUTDIR" +echo + +# --- control arm ------------------------------------------------------------- +baseline_failures=0 +if [[ "$BASELINE" -gt 0 ]]; then + echo "-- control: $BASELINE runs with NO seed (unamplified rate) --" + for ((i = 1; i <= BASELINE; i++)); do + log="$OUTDIR/baseline-$i.log" + rc=0 + env -u PERRY_GC_SCHEDULE_SEED -u PERRY_GC_SCHEDULE_RATE \ + ${TIMEOUT_CMD[@]+"${TIMEOUT_CMD[@]}"} "$BIN" ${TARGET_ARGS[@]+"${TARGET_ARGS[@]}"} >"$log" 2>&1 || rc=$? + if [[ $rc -ne 0 ]]; then + baseline_failures=$((baseline_failures + 1)) + printf ' run %-4s FAIL %s\n' "$i" "$(classify "$log" "$rc")" + else + [[ "${KEEP:-0}" == "1" ]] || rm -f "$log" + fi + done + echo " control: $baseline_failures/$BASELINE failed" + echo +fi + +# --- seeded arm -------------------------------------------------------------- +declare -a FAILED_SEEDS=() +declare -a FAILED_CAUSES=() +passed=0 +saw_safepoints=0 +start_epoch=$(date +%s) + +for ((n = 0; n < SEED_COUNT; n++)); do + seed=$((FIRST_SEED + n)) + log="$OUTDIR/seed-$seed.log" + rc=0 + PERRY_GC_SCHEDULE_SEED="$seed" PERRY_GC_SCHEDULE_RATE="$RATE" \ + run_once "$log" || rc=$? + + # Liveness, per CLAUDE.md's "a gate must assert its subject was live": a + # sweep in which the schedule saw zero safepoints proves nothing at all. + if grep -q '\[gc-schedule\] .*safepoints=[1-9]' "$log" 2>/dev/null; then + saw_safepoints=1 + fi + + if [[ $rc -ne 0 ]]; then + cause="$(classify "$log" "$rc")" + FAILED_SEEDS+=("$seed") + FAILED_CAUSES+=("$cause") + printf ' seed %-8s FAIL %s\n' "$seed" "$cause" + else + passed=$((passed + 1)) + [[ "${KEEP:-0}" == "1" ]] || rm -f "$log" + fi +done + +elapsed=$(( $(date +%s) - start_epoch )) + +echo +echo "== summary ==" +echo " seeds run : $SEED_COUNT" +echo " passed : $passed" +echo " failed : ${#FAILED_SEEDS[@]}" +if [[ "$SEED_COUNT" -gt 0 ]]; then + echo " failure rate : $(awk -v f="${#FAILED_SEEDS[@]}" -v n="$SEED_COUNT" \ + 'BEGIN { printf "%.1f%%", 100 * f / n }')" +fi +if [[ "$BASELINE" -gt 0 ]]; then + echo " control rate : $(awk -v f="$baseline_failures" -v n="$BASELINE" \ + 'BEGIN { printf "%.1f%%", 100 * f / n }') ($baseline_failures/$BASELINE, no seed)" +fi +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 "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)." + exit 2 +fi + +if [[ ${#FAILED_SEEDS[@]} -eq 0 ]]; then + echo + echo "PASS: no seed failed ($SEED_COUNT seeds, safepoints exercised)." + exit 0 +fi + +echo +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 " # log: $OUTDIR/seed-$seed.log" + echo " # for a precise fault site, add:" + echo " # PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800" + echo +done +exit 1 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..e0e47af92c 100644 --- a/test-files/test_gap_gc_call_argument_rooting.ts +++ b/test-files/test_gap_gc_call_argument_rooting.ts @@ -55,10 +55,10 @@ // // 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 -// evacuating minor, so iteration 0 is where the literal arm bites. The loop is -// short on purpose — zeal collects at every safepoint. +// minors and tenured 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. The +// loop is 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..a10462edfb 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 -// FIRST back-edge poll inside it already runs an evacuating minor, which is +// The loop is what matters, not its trip count: at `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..dce1f6cc77 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,8 @@ // 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,8 +63,9 @@ // // 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 -// first back-edge poll inside `churn` already runs an evacuating minor, so +// 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. import { joinRest } from "./fixtures/gc_call_arg_rooting_pkg/rest_callee.ts"; 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 d80de7de42..fc1c6c0b81 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -260,8 +260,9 @@ 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 +294,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 +518,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 +555,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