Skip to content

feat(gc): seeded GC-schedule fuzzing (PERRY_GC_SCHEDULE_SEED) — a failing schedule you can replay - #7317

Open
jdalton wants to merge 1 commit into
PerryTS:mainfrom
jdalton:feat/gc-schedule-seed-fuzzing
Open

feat(gc): seeded GC-schedule fuzzing (PERRY_GC_SCHEDULE_SEED) — a failing schedule you can replay#7317
jdalton wants to merge 1 commit into
PerryTS:mainfrom
jdalton:feat/gc-schedule-seed-fuzzing

Conversation

@jdalton

@jdalton jdalton commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What

PERRY_GC_SCHEDULE_SEED=<u64> — collect at a safepoint iff a deterministic pseudo-random function of the seed and a per-thread safepoint ordinal says so, at a density set by PERRY_GC_SCHEDULE_RATE (default 0.05). Plus scripts/gc_schedule_fuzz.sh <binary> [seeds], which sweeps seeds and prints a reproduce command per failure.

Why

A #7154-class bug is a value live but not rooted across a collection point. Whether it is caught is a property of the GC schedule, not of the bug — so re-running one binary sixty times re-runs one schedule sixty times and explores almost nothing. With zero failures in N runs the 95% upper bound on the true rate is only ~3/N: 120 clean runs bound a 1.7% bug at 2.5%, i.e. no evidence at all.

Two settings existed. Normal pacing puts collections tens of megabytes apart. PERRY_GC_ZEAL=1 collects at every safepoint — maximum pressure, but one fixed schedule, slow, and timing-distorting enough that it cannot be used on the registry at all (it dies in node-machine-id before the interesting code runs). This is the middle, and unlike either it hands back a reproducer.

The result that matters

sfw-registry --help (#7291's tree, PERRY_FORCE_WELL_KNOWN=iovalkey, compiled and run with PERRY_GC_MOVING_LOOP_POLLS=1, --debug-symbols) fails ~1 run in 60 in the plain-polls configuration. Same binary, macOS arm64, four runs in parallel:

arm failures time
control, no seed 0 / 16 55 s per run, all completed
seeds 1..12, RATE=0.05 6 / 12 failed in ≤ 2 s the other 6 censored at 120 s

Two stable signatures:

seeds 1, 7, 12 → TypeError: value is not a function
                   at node_modules/zod/src/v4/classic/schemas.ts:1318
seeds 8, 9, 11 → TypeError: Cannot convert undefined or null to object
                   at node_modules/node-machine-id/dist/index.js:1

The first is the signature the registry hunt has been chasing. Seed 1 was re-run 5/5 and failed every time at the identical site in ≤ 1 s:

PERRY_FORCE_WELL_KNOWN=iovalkey PERRY_GC_MOVING_LOOP_POLLS=1 \
PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=0.05 \
  ./binaries/sfw-registry --help

The second signature is the node-machine-id path that makes zeal unusable here — at 5% density it is reachable without also losing the rest of the program.

Cost: a seeded run is ~5–10× slower on this workload, which is why half the sweep is censored rather than passed. Failing seeds cost 1–2 s, so a sweep's wall clock is dominated entirely by the seeds that find nothing.

What the knobs gate, precisely

PERRY_GC_SCHEDULE_SEED does exactly three things:

  1. js_gc_loop_safepoint stops requiring GC_SAFEPOINT_PENDING before descending into gc_safepoint_moving_minor — the bypass zeal performs, for the same reason: a schedule cannot select a safepoint the gate already returned from.
  2. Inside gc_safepoint_moving_minor, past the entry guards, a per-thread counter advances once per handled safepoint; with nothing due, a minor runs anyway iff splitmix64(splitmix64(seed) ^ counter) < threshold.
  3. gc_force_evacuate_enabled() becomes true, so a scheduled minor MOVES survivors — otherwise the mode would promise relocation stress and deliver sweep pressure (gc: no reachable configuration exercises an evacuating minor with unpinned runtime locals — the #6655/#6935 bug class is untestable #6942/GC testing: PERRY_GC_FORCE_EVACUATE is inert for gc()-driven tests (full mark-sweep + forced conservative scan) — stress claims may be unsupported #6946).

It does not bypass the entry guards, and a blocked safepoint deliberately does not tick the counter, so the ordinal sequence tracks the program's safepoints rather than its allocation state. It does not override PERRY_GEN_GC_EVACUATE=0. It cannot emit loop polls codegen never produced. It never suppresses a pressure-driven collection — the rate is additional density, never less. A value that does not parse as a u64 reads as OFF, not as seed 0.

PERRY_GC_SCHEDULE_RATE gates only the comparison threshold, and is inert without a seed. 0 is an on-but-selects-nothing control; 1 is zeal's density.

Determinism, scoped honestly

The decision reads no wall clock, no address, no thread identity — so a single-threaded program replays a seed exactly. The counter is thread-local, so a perry/thread program gets a deterministic schedule per thread given that thread's own safepoint sequence, but nothing makes the OS schedule that sequence identically twice. A global counter would be strictly worse: it would make even one thread's schedule depend on interleaving. Deterministic for single-threaded programs; per-thread but not run-to-run reproducible for multi-threaded ones.

Default off, proven inert

With no seed set, PERRY_GC_DIAG=1 collector traces are byte-identical to the branch parent across five configurations on two fixtures — 367-line traces under plain polls, 4941 under zeal, 6151 under zeal + from-space protection, plus the no-polls and forced-evacuation arms.

The seed is never lost

Printed at startup, at exit ([gc-schedule] done: seed=… safepoints=… scheduled_collections=…, from the process-exit teardown funnel — perry's exits call _exit, so atexit alone would miss them), on panic, and from an async-signal-safe handler for SIGSEGV/SIGBUS/SIGABRT/SIGILL/SIGTRAP. That handler chains rather than clobbers, and arena/quarantine.rs re-layers it after installing its own, so PERRY_GC_SCHEDULE_SEED=… PERRY_GC_PROTECT_FROMSPACE=1 reports both the seed and the precise fault site.

Tests

  • gc/tests/schedule.rs, 11 tests, both directions of both knobs: parse (including u64::MAX + 1, -1, 0x10 → OFF), threshold endpoints, 100k-ordinal determinism across five seeds, adjacent-seed divergence, realised density vs requested at four rates, collect / decline / blocked at a real safepoint, and the evacuation implication with its PERRY_GEN_GC_EVACUATE=0 precedence arm.
  • scripts/gc_instrument_smoke.sh gains three integrated arms that gate the three claims end to end. Measured on the fixture: pressure-only=0 < seeded(0.25)=989 < zeal=1230 — a middle setting, not a second name for an endpoint — and the same seed twice retires 989 == 989.

cargo test -p perry-runtime on this branch: 1670 passed, 0 failed (--test-threads=1, two consecutive runs). The branch parent, same machine, same conditions: 1658 passed, 1 failed (pty::…::js_pty_spawn_shell_data_and_exit, a 15 s pty wait that times out under load). The default parallel mode is flaky on both — three object:: failures on the branch, a different four on the parent, none overlapping — a pre-existing isolation problem, not this change.

No collector policy changed. Every scheduled collection runs at a point the collector already treats as a precise-root safepoint; only how often changes.

Summary by CodeRabbit

  • New Features
    • Added deterministic GC schedule fuzzing controlled by PERRY_GC_SCHEDULE_SEED and PERRY_GC_SCHEDULE_RATE.
    • Added reporting for selected safepoints, forced collections, and schedule summaries.
    • Added a utility for sweeping seeds, detecting failures, and reproducing results.
  • Documentation
    • Documented configuration, reproducibility considerations, troubleshooting, and usage guidance.
  • Tests
    • Added coverage for parsing, determinism, scheduling behavior, evacuation, and fuzzing scenarios.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change adds deterministic, rate-controlled GC schedule fuzzing. It integrates schedule-selected safepoints with minor collection and evacuation policy, adds exit and failure diagnostics, provides tests and fuzzing scripts, and documents configuration and reproduction workflows.

Changes

Seeded GC schedule fuzzing

Layer / File(s) Summary
Schedule configuration and selection
crates/perry-runtime/src/gc/schedule.rs
Adds seed and rate parsing, deterministic per-thread safepoint selection, cached configuration, counters, reporting state, and test overrides.
Safepoint collection and evacuation integration
crates/perry-runtime/src/gc/mod.rs, crates/perry-runtime/src/gc/policy.rs
Connects selected safepoints to moving minor collections, forced evacuation, collection attribution, exported counters, and exit-time summaries.
Failure and signal diagnostics
crates/perry-runtime/src/gc/schedule.rs, crates/perry-runtime/src/arena/quarantine.rs, crates/perry-runtime/src/native_handle.rs
Adds chained panic, exit, and fatal-signal reporting while preserving quarantine signal-handler chaining.
Schedule tests and fuzzing workflows
crates/perry-runtime/src/gc/tests/*, scripts/gc_instrument_smoke.sh, scripts/gc_schedule_fuzz.sh
Tests parsing, determinism, safepoint guards, collection behavior, evacuation policy, and seeded sweep execution.
Configuration and reproduction documentation
CLAUDE.md, docs/src/internals/*, changelog.d/7317-seeded-gc-schedule-fuzzing.md
Documents environment variables, deterministic behavior, diagnostics, sweep usage, and failure reproduction.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Runtime
  participant Safepoint
  participant Schedule
  participant Collector
  participant Reporter
  Runtime->>Schedule: resolve seed and rate
  Safepoint->>Schedule: advance handled safepoint
  Schedule-->>Safepoint: return collection selection
  Safepoint->>Collector: perform moving minor collection
  Collector->>Reporter: record schedule-forced collection
  Reporter-->>Runtime: report seed and counters on exit or failure
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the seeded GC-schedule fuzzing feature and its replayable failure capability.
Description check ✅ Passed The description clearly explains the change, rationale, behavior, tests, results, and limitations, but it omits the repository template headings and checklist.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jdalton
jdalton force-pushed the feat/gc-schedule-seed-fuzzing branch from 2467132 to 5d8ce73 Compare August 3, 2026 15:37

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (3)
crates/perry-runtime/src/gc/schedule.rs (1)

244-252: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Align the "startup banner" claim with the actual announcement point.

The module documentation at Lines 331-334 describes layer 1 as "a startup banner, so the seed is in the log even if the failure mode is a hang or a _exit that runs no handler at all". resolved() runs the announcement lazily, at the first call site. For a mode-ON run, that is the first safepoint or the first gc_force_evacuate_enabled() query. A hang or _exit before that point prints nothing, and no panic hook or signal handler is installed either.

Consider resolving the configuration eagerly from GC initialization, or narrow the documentation claim to "the first safepoint" so an operator does not read a missing banner as "the seed was not set".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/schedule.rs` around lines 244 - 252, The
startup-banner documentation does not match the lazy announcement in resolved().
Either eagerly resolve the configuration during GC initialization so
publish_seed and announce run before early hangs or _exit paths, or narrow the
layer-1 documentation to state that the banner appears at the first safepoint or
gc_force_evacuate_enabled() query; preserve the existing seed publication
behavior.
crates/perry-runtime/src/gc/tests/schedule.rs (1)

276-283: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Release the GC root lock with a guard so a panic cannot leak the depth.

enter_gc_root_lock() and exit_gc_root_lock() are paired manually. If gc_safepoint_moving_minor() panics, exit_gc_root_lock() never runs, the root-lock depth stays non-zero for this thread, and every later collection on that thread is blocked. That converts one failure into a cascade of confusing failures in the same test binary.

♻️ Proposed fix using a scope guard
     let safepoints_before = gc_schedule_safepoints();
     {
         let _schedule = ScheduleGuard::set(7, rate_threshold(1.0));
         reset_thread_counter_for_test();
-        super::super::roots::enter_gc_root_lock();
-        gc_safepoint_moving_minor();
-        super::super::roots::exit_gc_root_lock();
+        struct RootLock;
+        impl RootLock {
+            fn enter() -> Self {
+                super::super::roots::enter_gc_root_lock();
+                Self
+            }
+        }
+        impl Drop for RootLock {
+            fn drop(&mut self) {
+                super::super::roots::exit_gc_root_lock();
+            }
+        }
+        let _lock = RootLock::enter();
+        gc_safepoint_moving_minor();
     }

If the test support module already exposes a root-lock guard type, use it instead of the local shim.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/tests/schedule.rs` around lines 276 - 283, Update
the test block around gc_safepoint_moving_minor to use the existing GC root-lock
scope guard, if exposed by the test support module, instead of manually pairing
enter_gc_root_lock and exit_gc_root_lock. Ensure the guard releases the lock
during unwinding as well as normal completion, and remove the corresponding
explicit exit call.
docs/src/internals/gc-rooting-invariant.md (1)

271-279: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Keep the gc_schedule_fuzz.sh argument syntax deterministic.

The script accepts <binary> [seed-count], but CLAUDE.md still says [seeds]. Update that line so the two docs use the actual positional argument semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/src/internals/gc-rooting-invariant.md` around lines 271 - 279, Update
the gc_schedule_fuzz.sh usage text in CLAUDE.md to describe the second
positional argument as seed-count, matching the script’s actual <binary>
[seed-count] semantics. Also review the usage reference in
docs/src/internals/gc-rooting-invariant.md and
changelog.d/7307-seeded-gc-schedule-fuzzing.md at the specified ranges; update
any remaining [seeds] wording there to [seed-count], with no other changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CLAUDE.md`:
- Around line 145-146: Add a required CI workflow arm for the seeded GC schedule
OFF state, using a compiled program to test both an unset PERRY_GC_SCHEDULE_SEED
and PERRY_GC_SCHEDULE_RATE set without a seed. Verify both remain schedule-inert
while pressure-driven collections still occur, reusing the existing
scripts/gc_schedule_fuzz.sh or schedule test infrastructure where appropriate.

In `@crates/perry-runtime/src/gc/mod.rs`:
- Around line 778-784: The exit summary is emitted during per-thread teardown,
so SUMMARY_EMITTED can capture counts before other threads finish. Update the
report_exit_summary call in the exit path to emit only after all worker threads
have completed teardown—prefer the existing main-thread or final-thread
coordination mechanism—and preserve once-only reporting with complete safepoints
and scheduled_collections totals.

In `@crates/perry-runtime/src/gc/schedule.rs`:
- Around line 240-243: Add a required CI workflow arm that runs the GC schedule
tests or relevant test suite with PERRY_GC_SCHEDULE_SEED and
PERRY_GC_SCHEDULE_RATE unset, verifying their default/OFF behavior alongside
existing CI coverage. Anchor the change to the workflow job invoking the tests
and preserve the current configured-knob coverage.
- Around line 584-608: In the signal-handler teardown around the previous
handler lookup, restore SIG_DFL before entering the previous > 1 chaining path,
so the default disposition is installed before invoking the chained handler.
Keep the existing chained-handler call and early return, but remove the
later-only restoration structure so schedule_fault_handler cannot loop when the
chained handler returns.
- Around line 493-502: Update the previous-handler storage in
reinstall_signal_reporter_after to check old.sa_flags for libc::SA_SIGINFO
before saving old.sa_sigaction. Store 0 for handlers without SA_SIGINFO, while
preserving the existing self-chain prevention and storing the handler value only
when the flag is present.

In `@docs/src/internals/gc-rooting-invariant.md`:
- Around line 281-285: Update the paragraph beginning “A rate is not a
substitute for a schedule” to qualify the ~3/N confidence bound as applying only
to independent trials. State that repeated runs with a fixed seed or
deterministic schedule are correlated, so 0/N failures provide no statistical
bound, while preserving the guidance to vary collection timing.

In `@docs/src/internals/memory-model.md`:
- Around line 138-139: Update the PERRY_GC_SCHEDULE_SEED and
PERRY_GC_SCHEDULE_RATE documentation to describe the configured rate as
additional schedule density for minor collections only, applied when
gc_budgeted_due_trigger() reports no pressure-driven collection is due. Clarify
that pressure-driven collections still occur independently, so the rate is not
the total fraction of safepoints that collect, and replace the current “iff”
wording with this behavior.

In `@scripts/gc_instrument_smoke.sh`:
- Around line 119-129: Replace the `run_arm ... | tail -1` command substitutions
for `sched_retired`, `sched_repeat`, and `sched_other` with output capture that
does not use a pipeline, then explicitly check each `run_arm` exit status and
abort on failure before comparing results. Apply the same status-preserving
change to the other arms in this script that use the pipeline pattern, while
retaining extraction of the final output line.
- Around line 150-164: The strict schedule-density checks in the smoke fixture
can fail on low safepoint counts without demonstrating a broken rate knob.
Update the fixture to generate enough handled GC safepoints for distinct
retirement counts, or revise both failure paths around sched_retired,
nozeal_retired, and zeal_retired to report all three counts before exiting.

In `@scripts/gc_schedule_fuzz.sh`:
- Around line 53-59: Validate SEED_COUNT immediately after argument parsing as a
positive integer, rejecting zero and non-numeric values with an error and
nonzero exit. In the final summary around FAILED_SEEDS and the PASS output,
track executed runs via passed plus failed seeds and exit nonzero with a failure
message when that total is zero; only report PASS after at least one seed ran.

---

Nitpick comments:
In `@crates/perry-runtime/src/gc/schedule.rs`:
- Around line 244-252: The startup-banner documentation does not match the lazy
announcement in resolved(). Either eagerly resolve the configuration during GC
initialization so publish_seed and announce run before early hangs or _exit
paths, or narrow the layer-1 documentation to state that the banner appears at
the first safepoint or gc_force_evacuate_enabled() query; preserve the existing
seed publication behavior.

In `@crates/perry-runtime/src/gc/tests/schedule.rs`:
- Around line 276-283: Update the test block around gc_safepoint_moving_minor to
use the existing GC root-lock scope guard, if exposed by the test support
module, instead of manually pairing enter_gc_root_lock and exit_gc_root_lock.
Ensure the guard releases the lock during unwinding as well as normal
completion, and remove the corresponding explicit exit call.

In `@docs/src/internals/gc-rooting-invariant.md`:
- Around line 271-279: Update the gc_schedule_fuzz.sh usage text in CLAUDE.md to
describe the second positional argument as seed-count, matching the script’s
actual <binary> [seed-count] semantics. Also review the usage reference in
docs/src/internals/gc-rooting-invariant.md and
changelog.d/7307-seeded-gc-schedule-fuzzing.md at the specified ranges; update
any remaining [seeds] wording there to [seed-count], with no other changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 77947a25-2bab-41b8-b287-31da05030b5f

📥 Commits

Reviewing files that changed from the base of the PR and between 6bedb25 and 2467132.

📒 Files selected for processing (12)
  • CLAUDE.md
  • changelog.d/7307-seeded-gc-schedule-fuzzing.md
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/schedule.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/schedule.rs
  • docs/src/internals/gc-rooting-invariant.md
  • docs/src/internals/memory-model.md
  • scripts/gc_instrument_smoke.sh
  • scripts/gc_schedule_fuzz.sh

Comment thread CLAUDE.md Outdated
Comment on lines +145 to +146
| `PERRY_GC_SCHEDULE_SEED=<u64>` | 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. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 4 \
  'PERRY_GC_SCHEDULE_(SEED|RATE)|gc_schedule_fuzz|gc_instrument_smoke|unset .*PERRY_GC_SCHEDULE|env -u PERRY_GC_SCHEDULE' \
  .github scripts crates || true

Repository: PerryTS/perry

Length of output: 28592


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== workflow names around relevant steps =="
sed -n '1060,1110p' .github/workflows/test.yml
echo

echo "== scripts/gc_schedule_fuzz.sh key parsing and loop =="
sed -n '1,180p' scripts/gc_schedule_fuzz.sh
echo

echo "== scripts/gc_instrument_smoke.sh pre/post comparisons =="
sed -n '1,190p' scripts/gc_instrument_smoke.sh
echo

echo "== schedule tests =="
sed -n '1,260p' crates/perry-runtime/src/gc/tests/schedule.rs
echo

echo "== env var searches for schedule in CI files =="
rg -n -C 3 'PERRY_GC_SCHEDULE|schedule|gc_schedule_fuzz|gc_instrument_smoke|required.*gate|gate.*required' .github scripts crates/perry-runtime/src/gc/tests/schedule.rs || true

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== workflow files and schedule references =="
git ls-files .github/workflows | sort
echo

rg -n -C 3 \
  '(^name: .*|cargo test.*gc.*schedule|PERRY_GC_SCHEDULE_(SEED|RATE)|gc_instrument_smoke|gc_schedule_fuzz|PERRY_GC_SCHEDULE_SEED=|PERRY_GC_SCHEDULE_RATE=)' \
  .github/workflows || true

Repository: PerryTS/perry

Length of output: 143


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== workflow file count and names =="
git ls-files .github/workflows | sort | awk '{print NR": "$0}'

echo
echo "== workflow schedule references (counted and summarized) =="
python3 - <<'PY'
from pathlib import Path
for p in sorted(Path(".github/workflows").glob("*.yml")) + sorted(Path(".github/workflows").glob("*.yaml")):
    txt = p.read_text(errors="replace")
    lines = txt.splitlines()
    hits = [i for i,l in enumerate(lines,1) if any(s in l for s in [
        "PERRY_GC_SCHEDULE_SEED", "PERRY_GC_SCHEDULE_RATE", "PERRY_GC_SCHEDULE",
        "gc_schedule_fuzz", "gc_instrument_smoke", "cargo test --test gc_schedule"
    ])]
    if hits:
        print(f"\n{p}: {len(hits)} matches")
        for i in hits[:25]:
            print(f"  {i}: {lines[i-1]}")
        if len(hits) > 25:
            print(f"  ... plus {len(hits)-25}")
PY

Repository: PerryTS/perry

Length of output: 143


Add required CI for the seeded GC schedule OFF state.

scripts/gc_schedule_fuzz.sh has a local baseline arm, and crates/perry-runtime/src/gc/tests/schedule.rs tests ScheduleGuard::off(), but no CI workflow runs a required gate for PERRY_GC_SCHEDULE_SEED unset or PERRY_GC_SCHEDULE_RATE alone. Add a required CI arm that covers both OFF-state conditions in a compiled program and checks pressure-only behavior.

🧰 Tools
🪛 LanguageTool

[style] ~145-~145: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ..._GC_MOVING_LOOP_POLLS=1`, as for zeal). Nor suppress pressure-driven collections ...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 145 - 146, Add a required CI workflow arm for the
seeded GC schedule OFF state, using a compiled program to test both an unset
PERRY_GC_SCHEDULE_SEED and PERRY_GC_SCHEDULE_RATE set without a seed. Verify
both remain schedule-inert while pressure-driven collections still occur,
reusing the existing scripts/gc_schedule_fuzz.sh or schedule test infrastructure
where appropriate.

Source: Coding guidelines

Comment thread crates/perry-runtime/src/gc/mod.rs
Comment on lines +240 to +243
*CACHED.get_or_init(|| {
let seed = parse_seed(std::env::var("PERRY_GC_SCHEDULE_SEED").ok().as_deref())?;
let rate = parse_rate(std::env::var("PERRY_GC_SCHEDULE_RATE").ok().as_deref());
let resolved = (seed, rate_threshold(rate));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Look for a required CI arm that runs with the new GC knobs unset or OFF.
set -euo pipefail

fd -t f -e yml -e yaml . .github | xargs rg -n -C 5 'PERRY_GC_|gc_schedule|GC_ZEAL' || echo "no GC-knob references in workflows"

Repository: PerryTS/perry

Length of output: 13727


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow files =="
fd -t f -e yml -e yaml . .github

echo
echo "== schedule env var references in tracked files (excluding .git) =="
git ls-files | rg '(^crates/|^\.github/workflows/|Cargo\.lock$)' | xargs rg -n 'PERRY_GC_SCHEDULE_(SEED|RATE)|schedules_in_process|PERRY_RUNTIME_DIR' || true

echo
echo "== schedule.rs around module docs and resolved =="
sed -n '70,90p;220,250p' crates/perry-runtime/src/g c(schedule.rs) 2>/dev/null || sed -n '70,90p;220,250p' crates/perry-runtime/src/gc/schedule.rs

Repository: PerryTS/perry

Length of output: 7957


Add a required CI arm that exercises the GC schedule knobs in their default/OFF state.

This change adds PERRY_GC_SCHEDULE_SEED and PERRY_GC_SCHEDULE_RATE, but the workflows do not show a required CI arm exercising both knobs with defaults/unset state. Add such an arm or remove the knobs after the required soak period.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/schedule.rs` around lines 240 - 243, Add a
required CI workflow arm that runs the GC schedule tests or relevant test suite
with PERRY_GC_SCHEDULE_SEED and PERRY_GC_SCHEDULE_RATE unset, verifying their
default/OFF behavior alongside existing CI coverage. Anchor the change to the
workflow job invoking the tests and preserve the current configured-knob
coverage.

Source: Coding guidelines

Comment thread crates/perry-runtime/src/gc/schedule.rs
Comment thread crates/perry-runtime/src/gc/schedule.rs
Comment thread docs/src/internals/gc-rooting-invariant.md
Comment thread docs/src/internals/memory-model.md Outdated
Comment thread scripts/gc_instrument_smoke.sh Outdated
Comment thread scripts/gc_instrument_smoke.sh
Comment thread scripts/gc_schedule_fuzz.sh

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CLAUDE.md`:
- Line 145: Update the PERRY_GC_SCHEDULE_SEED documentation to name Perry’s
process-exit teardown funnel as the source of seed reporting for _exit-based
exits, while describing atexit only as an additional reporting path. Preserve
the existing panic and signal-reporting paths and all other seed behavior.
- Around line 145-148: Condense the PERRY_GC_SCHEDULE_SEED and
PERRY_GC_SCHEDULE_RATE entries in CLAUDE.md to their concise runtime contract,
removing implementation rationale, reproduction guidance, and historical
context. Move that detailed narrative, including measurement guidance, to
changelog.d/7317-seeded-gc-schedule-fuzzing.md while preserving the documented
behavior and configuration semantics.
- Line 148: Update the documented invocation of scripts/gc_schedule_fuzz.sh to
use the optional argument name [seed-count] instead of [seeds], while preserving
the existing binary argument and surrounding guidance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 91c7b636-1ffb-4ec0-ba19-591b2f2b1559

📥 Commits

Reviewing files that changed from the base of the PR and between 2467132 and 5d8ce73.

📒 Files selected for processing (12)
  • CLAUDE.md
  • changelog.d/7317-seeded-gc-schedule-fuzzing.md
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/schedule.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/schedule.rs
  • docs/src/internals/gc-rooting-invariant.md
  • docs/src/internals/memory-model.md
  • scripts/gc_instrument_smoke.sh
  • scripts/gc_schedule_fuzz.sh
🚧 Files skipped from review as they are similar to previous changes (10)
  • docs/src/internals/gc-rooting-invariant.md
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • scripts/gc_instrument_smoke.sh
  • crates/perry-runtime/src/gc/mod.rs
  • docs/src/internals/memory-model.md
  • crates/perry-runtime/src/gc/policy.rs
  • scripts/gc_schedule_fuzz.sh
  • crates/perry-runtime/src/gc/tests/schedule.rs
  • crates/perry-runtime/src/gc/schedule.rs

Comment thread CLAUDE.md Outdated
Comment thread CLAUDE.md Outdated
Comment on lines +145 to +148
| `PERRY_GC_SCHEDULE_SEED=<u64>` | 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_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` together is the pairing that turns a #7154 bug into an immediate precise fault. Compile *and* run with `PERRY_GC_MOVING_LOOP_POLLS=1` for in-loop coverage.
`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` together is the pairing that turns a #7154 bug into an immediate precise fault. Compile *and* run with `PERRY_GC_MOVING_LOOP_POLLS=1` for in-loop coverage. 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 <binary> [seeds]` sweeps it and prints a reproduce command per failing seed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Keep the detailed narrative out of CLAUDE.md.

This addition contains detailed implementation rationale and reproduction guidance that the changelog fragment already records. Keep CLAUDE.md to the concise runtime contract for these knobs. Move detailed rationale and measurements to changelog.d/7317-seeded-gc-schedule-fuzzing.md.

As per coding guidelines, CLAUDE.md must remain concise and detailed change history belongs in changelog.d/ fragments.

🧰 Tools
🪛 LanguageTool

[style] ~145-~145: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ..._GC_MOVING_LOOP_POLLS=1`, as for zeal). Nor suppress pressure-driven collections ...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 145 - 148, Condense the PERRY_GC_SCHEDULE_SEED and
PERRY_GC_SCHEDULE_RATE entries in CLAUDE.md to their concise runtime contract,
removing implementation rationale, reproduction guidance, and historical
context. Move that detailed narrative, including measurement guidance, to
changelog.d/7317-seeded-gc-schedule-fuzzing.md while preserving the documented
behavior and configuration semantics.

Source: Coding guidelines

Comment thread CLAUDE.md Outdated
@proggeramlug

Copy link
Copy Markdown
Contributor

The premise is right and it is the most useful framing anyone has put on this class:

Whether a #7154-class bug is caught is a property of the GC schedule, not of the bug — re-running one binary sixty times re-runs one schedule sixty times and explores almost nothing.

That explains something we have been misreading. #7280's acceptance arms read 6, 8, 9 out of 30 across three runs of the same parent — we have been treating that as noise to work around, when it is really one schedule being sampled repeatedly. A seeded sweep is the right instrument, and it arrives at exactly the moment it is most needed: the owner has chosen to make statepoints the default and delete the shadow stack, and the soak deciding that is running now.

Not merging yet, for two reasons:

  1. Two new knobs with no CI arm. PERRY_GC_SCHEDULE_SEED and PERRY_GC_SCHEDULE_RATE appear in no workflow. CLAUDE.md's kill-policy is binding — an arm exercising the OFF state each, or deletion after one release of soak, with at most one diagnostic-only knob labelled untested. There is an agent clearing exactly this debt for Native-frame GC roots via LLVM statepoints, opt-in (#7173, #7174) #7314's five knobs right now; adding two more uncovered ones while that runs would undo it. This repo has paid for unexercised modes repeatedly — PERRY_GC_FORCE_EVACUATE was inert for every gc()-driven test for months (gc: no reachable configuration exercises an evacuating minor with unpinned runtime locals — the #6655/#6935 bug class is untestable #6942/GC testing: PERRY_GC_FORCE_EVACUATE is inert for gc()-driven tests (full mark-sweep + forced conservative scan) — stress claims may be unsupported #6946).

  2. Six unaddressed Critical/Major review comments, including two on gc/schedule.rs about sigaction/SA_SIGINFO flags. Signal-handler code in the collector is not somewhere to merge on trust, and I have merged past Major comments three times today — twice harmlessly, once not (fix(gc): reload BOTH stale operands when one instruction has two (#7311 follow-up) #7316 had to fix a dropped operand rewrite that made a headline 137→0 count mean less than it appeared to).

What would make this land fast: the CI arm, and the two schedule.rs signal comments answered. The CLAUDE.md comment is the same kill-policy point as (1).

I have pointed the soak agent at this branch so it can use the sweep locally for schedule exploration without waiting on the merge — if it finds a failing seed on the statepoint arm, that is exactly the evidence the flip decision needs, and it would be a strong argument for landing this.

@jdalton
jdalton force-pushed the feat/gc-schedule-seed-fuzzing branch from 5d8ce73 to 9c43d77 Compare August 4, 2026 01:44
@jdalton

jdalton commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed 9c43d772e addressing the actionable items. Disposition below.

Fixed in 9c43d772e:

  • gc_schedule_fuzz.sh — vacuous PASS (Major). Seed-count is now validated as a positive integer at parse time (0/-1/abc exit 2), and a clean sweep that saw zero safepoints now reports INCONCLUSIVE and exits non-zero instead of PASS. This is the tool's own "assert the subject was live" discipline.
  • schedule.rs:502SA_SIGINFO guard (Major, latent UB). The stored previous handler is now 0 unless the predecessor was itself installed with SA_SIGINFO; a 1-argument sa_handler predecessor is no longer at risk of being called through the 3-argument signature at the chain site.
  • memory-model.md — "iff" wording (Minor). Reworded so the schedule reads as additional collection density on top of pressure, never "collect iff the hash selects"; the RATE row matches.
  • CLAUDE.md — trailing narrative + reporting path + [seed-count] (Major/Minor). Trimmed the trailing prose to one contract sentence pointing at the changelog fragment (kept the two knob-table rows); named the process-exit teardown funnel (report_exit_summary) as the primary reporting path with atexit as the libc-return backstop; [seeds][seed-count] here and in gc-rooting-invariant.md.
Already covered / won't-change, with reasons
  • Required OFF-state + live-subject CI arm (Major, CLAUDE.md:145 / schedule.rs:243). The required cargo-test path already carries this: gc::tests::schedule::the_schedule_collects_at_a_safepoint_with_no_pressure_due asserts gc_schedule_forced_collections() > before with a seed set (the "assert the subject ran" arm), and its OFF half asserts an idle safepoint neither collects nor ticks. Promoting the integrated gc_instrument_smoke.sh arm into branch-protection required contexts is a maintainer action on the protected repo (a fork PR can't, and shouldn't, edit branch protection or .github/workflows) — worth doing after one green run, per the "run once, then promote" corollary.
  • Exit summary from any thread (Minor, mod.rs:784). The counters are process-global atomics and the reproducibility scope is single-threaded, where the main thread's process-exit teardown prints the final totals. Gating on the main-thread mark risks suppressing the summary on exit paths where the mark isn't set — a worse failure on a signal/exit path than the multi-threaded partial-count it would fix (and multi-threaded runs are already documented as non-deterministic). Left as-is deliberately.
  • sigaction(SIG_DFL) ordering "infinite loop" (Major, schedule.rs:596). No loop exists: when previous > 1 the handler chains to a different handler (the from-space quarantine's SA_SIGINFO reporter) and returns; only previous <= 1 restores SIG_DFL so the instruction re-faults and the process dies at the real site. The self-chain is already prevented at install time. The SA_SIGINFO guard above is what makes the "previous > 1 ⇒ valid 3-arg handler" assumption sound.
  • | tail -1 masking arm status (Major, gc_instrument_smoke.sh). The exit status is captured from the arm directly, not from the pipeline tail; tail only shapes the printed line.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@changelog.d/7317-seeded-gc-schedule-fuzzing.md`:
- Around line 45-48: Update the 0/16 statistical statement in the changelog to
identify the confidence level and interval method used for the ~19% upper bound,
specifically describing it as a 95% Wilson upper bound.

In `@CLAUDE.md`:
- Around line 145-146: Update the CI workflow coverage for the GC scheduling
configuration to add required arms for an unset PERRY_GC_SCHEDULE_SEED and for
PERRY_GC_SCHEDULE_RATE configured without a seed. In each arm, verify
pressure-driven collections remain active while schedule-triggered collections
stay disabled, matching the documented OFF-state behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3b65e0cf-c3cf-401f-8906-3df203dedc2b

📥 Commits

Reviewing files that changed from the base of the PR and between 5d8ce73 and 9c43d77.

📒 Files selected for processing (12)
  • CLAUDE.md
  • changelog.d/7317-seeded-gc-schedule-fuzzing.md
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/schedule.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/schedule.rs
  • docs/src/internals/gc-rooting-invariant.md
  • docs/src/internals/memory-model.md
  • scripts/gc_instrument_smoke.sh
  • scripts/gc_schedule_fuzz.sh
🚧 Files skipped from review as they are similar to previous changes (10)
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/gc/policy.rs
  • docs/src/internals/gc-rooting-invariant.md
  • scripts/gc_schedule_fuzz.sh
  • docs/src/internals/memory-model.md
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/tests/schedule.rs
  • crates/perry-runtime/src/gc/schedule.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • scripts/gc_instrument_smoke.sh

Comment on lines +45 to +48
Seed 1 was re-run five times and failed **5/5** at the identical site in ≤ 1 s.
The control's 0/16 is consistent with the known ~1.7% rate (zero failures in 16
runs bounds it at ~19%, which is why re-running was never going to settle
anything); the point is the contrast with 6/12 in two seconds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

State the statistical method for the 0/16 bound.

The text reports a “~19%” bound but does not state the confidence level or interval method. Add that context, for example by identifying it as a 95% Wilson upper bound. Otherwise, readers cannot reproduce or interpret the claim.

Suggested wording
-The control's 0/16 is consistent with the known ~1.7% rate (zero failures in 16 runs bounds it at ~19%, which is why re-running was never going to settle anything); the point is the contrast with 6/12 in two seconds.
+The control's 0/16 is consistent with the known ~1.7% rate. Using a 95% Wilson upper bound, zero failures in 16 runs gives an upper bound of ~19%; the point is the contrast with 6/12 in two seconds.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Seed 1 was re-run five times and failed **5/5** at the identical site in ≤ 1 s.
The control's 0/16 is consistent with the known ~1.7% rate (zero failures in 16
runs bounds it at ~19%, which is why re-running was never going to settle
anything); the point is the contrast with 6/12 in two seconds.
Seed 1 was re-run five times and failed **5/5** at the identical site in ≤ 1 s.
The control's 0/16 is consistent with the known ~1.7% rate. Using a 95% Wilson upper bound, zero failures in 16 runs gives an upper bound of ~19%; the point is the contrast with 6/12 in two seconds.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7317-seeded-gc-schedule-fuzzing.md` around lines 45 - 48, Update
the 0/16 statistical statement in the changelog to identify the confidence level
and interval method used for the ~19% upper bound, specifically describing it as
a 95% Wilson upper bound.

Comment thread CLAUDE.md
Comment on lines +145 to +146
| `PERRY_GC_SCHEDULE_SEED=<u64>` | 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, from the process-exit teardown funnel every exit path routes through (`report_exit_summary`, on the collection-side-allocation release — perry's `_exit` paths never reach `atexit`, which is only a libc-return backstop), and on panic/SIGSEGV/SIGBUS/SIGABRT/SIGILL/SIGTRAP — the signal reporter chains to (and is re-layered on top of) the from-space quarantine's, so the two compose. Live-subject counters: `gc::gc_schedule_safepoints()` / `gc::gc_schedule_forced_collections()`. | bypass `gc_safepoint_moving_minor`'s entry guards — and a blocked safepoint deliberately does **not** tick the counter, so the ordinal sequence tracks the program's safepoints rather than its allocation state. Nor override `PERRY_GEN_GC_EVACUATE=0`. Nor emit loop polls (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. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add required CI coverage for both OFF states.

The documented kill-policy requires a required CI arm for every GC environment knob. The PR still has no workflow coverage for an unset PERRY_GC_SCHEDULE_SEED or for PERRY_GC_SCHEDULE_RATE without a seed. Add both cases and verify that pressure-driven collection remains active while schedule-triggered collection remains disabled.

🧰 Tools
🪛 LanguageTool

[style] ~145-~145: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ..._GC_MOVING_LOOP_POLLS=1`, as for zeal). Nor suppress pressure-driven collections ...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 145 - 146, Update the CI workflow coverage for the GC
scheduling configuration to add required arms for an unset
PERRY_GC_SCHEDULE_SEED and for PERRY_GC_SCHEDULE_RATE configured without a seed.
In each arm, verify pressure-driven collections remain active while
schedule-triggered collections stay disabled, matching the documented OFF-state
behavior.

Source: Coding guidelines

…cible

A rooting bug (PerryTS#7154 family) is a value live but not rooted across a
collection point. Whether it is caught is decided by the GC schedule, not
by the bug — so re-running one binary sixty times re-runs one schedule
sixty times and explores almost nothing. Two settings existed: normal
pacing and PERRY_GC_ZEAL=1 (every safepoint). This is the middle, and it
hands back a reproducer.

PERRY_GC_SCHEDULE_SEED=<u64> makes "should this safepoint collect?" a
deterministic function of the seed and a per-thread safepoint ordinal, at
a density set by PERRY_GC_SCHEDULE_RATE (default 0.05).
scripts/gc_schedule_fuzz.sh sweeps seeds and prints a reproduce command
per failure.

On Socket Firewall's sfw-registry --help (PerryTS#7291's tree, iovalkey forced,
loop polls compiled and run): control 0/16 failures at 55s each; seeds
1..12 at rate 0.05, 6/12 failed in under two seconds. Seed 1 reproduces
5/5 at the identical zod schemas.ts:1318 site. Three other seeds reach
the node-machine-id path that makes zeal unusable on this workload.

Default off and proven inert: with no seed, PERRY_GC_DIAG traces are
byte-identical to the parent across five configurations on two fixtures
(367 lines under plain polls, 4941 under zeal, 6151 under zeal +
from-space protection). gc/tests/schedule.rs asserts both directions of
both knobs; gc_instrument_smoke.sh gains three integrated arms gating
that the schedule is a middle setting (strictly between pressure-only and
zeal) and that the same seed retires exactly the same page-sets.

The seed is printed at startup, at exit, on panic, and from a chained
async-signal-safe handler that the from-space quarantine re-layers, so
the seed survives the pairing an investigator actually uses.
@jdalton
jdalton force-pushed the feat/gc-schedule-seed-fuzzing branch from 9c43d77 to ca397ef Compare August 4, 2026 02:02
@jdalton

jdalton commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up ca397efb9 — took the three I'd initially held; on a closer read two were real and one is worth doing anyway:

  • Signal-chain infinite loop (schedule.rs) — was real, fixed. Returning from a synchronous fault handler re-runs the faulting instruction, so if the chained (quarantine) handler also returns without resolving the fault, the disposition still pointing at us re-enters this handler forever. SIG_DFL for the signal is now restored before the chain, so the re-fault always dies at the real site regardless of what the chained handler does. My first pass was wrong to call this a false positive.
  • gc_instrument_smoke.sh | tail -1 masking — was real, fixed. run_arm's exit 1 only leaves the command-substitution subshell, and the pipeline reported tail's status, so a crashed arm was swallowed. run_arm now prints its human line to stderr (stdout is purely the count) and every arm is "$(run_arm …)" || exit 1, so a crash propagates.
  • Exit summary from any thread (mod.rs) — fixed. Gated on a new pure-read native_handle::is_main_thread_or_unrecorded() (main thread, or fallback to emitting when the main thread was never recorded — so it can't silently drop the summary). A worker tearing down first no longer wins the once-only swap with non-final counts.

On the OFF-state arm: the required cargo-test coverage is complete as-is — the_schedule_collects_at_a_safepoint_with_no_pressure_due asserts both the live subject (forced_collections > 0 with a seed) and the OFF arm (idle safepoint neither collects nor ticks), and the integrated smoke arm is now crash-propagating. The one remaining piece — promoting that smoke arm into branch-protection required contexts — is a settings change on the protected repo that a fork PR has no way to make; flagging it for a maintainer to do after one green run.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/native_handle.rs`:
- Around line 62-73: Update is_main_thread_or_unrecorded and its exit-summary
callers so an unrecorded MAIN_THREAD_ID does not allow a worker thread to claim
SUMMARY_EMITTED. Prefer ensuring js_native_handle_mark_main_thread() runs during
startup before workers begin, or make the teardown diagnostic skip callers while
MAIN_THREAD_ID remains 0; preserve acceptance only for the registered main
thread.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fe7a9c1-d4d9-4981-bddc-14cde04fae59

📥 Commits

Reviewing files that changed from the base of the PR and between 9c43d77 and ca397ef.

📒 Files selected for processing (13)
  • CLAUDE.md
  • changelog.d/7317-seeded-gc-schedule-fuzzing.md
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/schedule.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/schedule.rs
  • crates/perry-runtime/src/native_handle.rs
  • docs/src/internals/gc-rooting-invariant.md
  • docs/src/internals/memory-model.md
  • scripts/gc_instrument_smoke.sh
  • scripts/gc_schedule_fuzz.sh
🚧 Files skipped from review as they are similar to previous changes (10)
  • docs/src/internals/gc-rooting-invariant.md
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • scripts/gc_instrument_smoke.sh
  • docs/src/internals/memory-model.md
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/tests/schedule.rs
  • scripts/gc_schedule_fuzz.sh
  • crates/perry-runtime/src/gc/schedule.rs

Comment on lines +62 to +73
/// 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,
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Locate main-thread registration, exit-summary gating, and worker creation.
rg -n -C 8 \
  'runtime_main_thread_id|is_main_thread_or_unrecorded|MAIN_THREAD_ID|report_exit_summary|thread::spawn|thread::Builder' \
  --glob '*.rs' .

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '--- is_main_thread_or_unrecorded call sites ---\n'
rg -n -C 4 'is_main_thread_or_unrecorded' crates/perry-runtime src crates --glob '*.rs' || true

printf '\n--- SUMMARY_EMITTED / report_exit_summary occurrences ---\n'
rg -n -C 6 'SUMMARY_EMITTED|report_exit_summary' crates/perry-runtime src crates --glob '*.rs' || true

printf '\n--- js_native_handle_mark_main_thread call sites ---\n'
rg -n -C 4 'js_native_handle_mark_main_thread' crates/perry-runtime src crates --glob '*.rs' || true

printf '\n--- native_handle outline ---\n'
ast-grep outline crates/perry-runtime/src/native_handle.rs --view compact || true

printf '\n--- gc/schedule outline ---\n'
ast-grep outline crates/perry-runtime/src/gc/schedule.rs --view compact || true

Repository: PerryTS/perry

Length of output: 242


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- is_main_thread_or_unrecorded call sites ---'
rg -n -C 4 'is_main_thread_or_unrecorded' crates/perry-runtime src crates --glob '*.rs' || true

printf '%s\n' ''
printf '%s\n' '--- SUMMARY_EMITTED / report_exit_summary occurrences ---'
rg -n -C 6 'SUMMARY_EMITTED|report_exit_summary' crates/perry-runtime src crates --glob '*.rs' || true

printf '%s\n' ''
printf '%s\n' '--- js_native_handle_mark_main_thread call sites ---'
rg -n -C 4 'js_native_handle_mark_main_thread' crates/perry-runtime src crates --glob '*.rs' || true

printf '%s\n' ''
printf '%s\n' '--- native_handle outline ---'
ast-grep outline crates/perry-runtime/src/native_handle.rs --view compact || true

printf '%s\n' ''
printf '%s\n' '--- gc/schedule outline ---'
ast-grep outline crates/perry-runtime/src/gc/schedule.rs --view compact || true

Repository: PerryTS/perry

Length of output: 17747


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- native_handle relevant sections ---'
sed -n '1,90p' crates/perry-runtime/src/native_handle.rs | cat -n
sed -n '208,275p' crates/perry-runtime/src/native_handle.rs | cat -n

printf '%s\n' ''
printf '%s\n' '--- gc/schedule relevant sections ---'
sed -n '360,430p' crates/perry-runtime/src/gc/schedule.rs | cat -n
sed -n '745,790p' crates/perry-runtime/src/gc/mod.rs | cat -n

printf '%s\n' ''
printf '%s\n' '--- call sites with direct file context ---'
rg -n -C 4 'js_native_handle_mark_main_thread|runtime_main_thread_id|is_main_thread_or_unrecorded|report_exit_summary|install_exit_summary|js_gc_release_current_thread_collection_side_allocations|js_process_exit' crates/perry-runtime --glob '*.rs'

printf '%s\n' ''
printf '%s\n' '--- worker creation calls in crates/perry-runtime with concise context ---'
rg -n -C 3 'std::thread::spawn|thread::spawn\(|thread::Builder::new' crates/perry-runtime src crates --glob '*.rs' | head -n 200

Repository: PerryTS/perry

Length of output: 49624


Require explicit main-thread registration before accepting unrecorded callers.

is_main_thread_or_unrecorded() passes every thread while MAIN_THREAD_ID is 0, but the generated exit epilogue only calls js_native_handle_mark_main_thread() once all native work has drained. A worker thread can therefore take over SUMMARY_EMITTED before teardown from the main thread. Use explicit startup registration or make the exit summary skip unrecorded workers instead of treating them as main for this once-only diagnostic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/native_handle.rs` around lines 62 - 73, Update
is_main_thread_or_unrecorded and its exit-summary callers so an unrecorded
MAIN_THREAD_ID does not allow a worker thread to claim SUMMARY_EMITTED. Prefer
ensuring js_native_handle_mark_main_thread() runs during startup before workers
begin, or make the teardown diagnostic skip callers while MAIN_THREAD_ID remains
0; preserve acceptance only for the registered main thread.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants