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