Skip to content

gc: assert the positive direction of arena-growth pacing recording, on one shared accessor (#7737 item 2) - #7752

Merged
proggeramlug merged 2 commits into
mainfrom
gc/7737-positive-pacing-test
Aug 10, 2026
Merged

gc: assert the positive direction of arena-growth pacing recording, on one shared accessor (#7737 item 2)#7752
proggeramlug merged 2 commits into
mainfrom
gc/7737-positive-pacing-test

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Addresses item 2 of #7737 ("No positive-direction test for the recording"). Item 1 landed in #7740; item 4 is a maintainer action; item 3 needs the pinned bench host and is left open — so this does not close the issue.

The gap

declining_to_escalate_records_no_pre_full_reading proves only the negative: a declined escalation leaves pre_in_use == 0. Nothing asserted that a true verdict from the real arena_growth_full_escalation_due() — not the test_note_full_cycle_reclaimed bypass, which sets the reading itself — leaves a non-zero one behind.

A negative-only test cannot tell "declined correctly" from "the recording never ran". Both produce 0. That is exactly the first-cut bug #7733's own changelog describes: the recording wired at the wrong call sites, update_major_pacing_backoff returning early on a zero pre-reading, and every test still green.

Why it wasn't already there, and what I did instead

The issue says forcing due=true "needs control over arena_in_use_bytes(), which isn't mockable today". Confirmed, and the reason is worth recording: the boundary is max(floor, baseline × growth + 1), and the floor always dominates from below, so no amount of baseline manipulation gets the threshold under it. PERRY_GC_MAJOR_PACING_FLOOR_MB can't help either — major_pacing_config is a process-wide OnceLock, so an env var only takes effect if this test happens to run first.

That leaves a 32 MB live heap, which is precisely what major_pacing_escalation_threshold_for was factored out to avoid ("unit-testable without a 32 MB live heap").

So the reading is injected, through a new pacing_arena_in_use_bytes()the one accessor both the escalation predicate and note_full_cycle_started now use.

The accessor is the more interesting half

update_major_pacing_backoff's doc already claims:

Deliberately measured on the SAME metric the escalation gate reads (arena_in_use_bytes) so the two cannot disagree about whether a full helped.

But both sites called arena_in_use_bytes() independently. The guarantee was a convention two call sites happened to honour, not a structure — the same shape as the snapshot-vs-predicate divergence #7733 fixed by collapsing two formulas into one. Routing an injected reading in and requiring the same value back out is what makes it checkable.

The override is #[cfg(test)]: it compiles out of every shipping build, so it is not a runtime knob and the GC knob kill-policy doesn't apply.

Tests, and proof they can fail

Three, all deterministic (thread-local, no timing, no allocation):

  • escalating_records_the_pre_full_arena_reading — the item-2 ask.
  • the_recorded_reading_is_the_one_the_predicate_decided_on — agreement across several readings.
  • the_floor_is_inclusive_and_below_it_declinesfloor - 1 declines and records nothing; exactly floor escalates. (The >= on the floor is why major_pacing_escalation_threshold_for adds its +1 to the growth boundary and not to the floor.)

Both sabotage arms were run:

sabotage result
rewire the recording away from the predicate (the #7726 bug) all 3 new tests FAIL
point note_full_cycle_started at a second, independent arena_in_use_bytes() (metric drift) all 3 new tests FAIL

And in both arms the pre-existing negative test still passed — so the gap this PR closes is demonstrated, not merely asserted.

Validation

  • cargo test -p perry-runtime --lib: 1991 passed, 0 failed.
  • cargo fmt --all --check, scripts/check_file_size.sh: clean.

One thing worth flagging for #7365 rather than this PR: across four repeat runs of the full suite under CPU load I saw 0–2 failures per run, always from promise::keyed_table::tests::{settling_many_keys,draining_a_heavily_populated_key_ahead_of_another}_is_not_quadratic and once gc::tests::inline_generation_gate_contract::sabotaged_parent_gate_strands_a_young_child_the_shipped_gate_keeps. Those are wall-clock-ratio assertions, which is a plausible mechanism for "#7365: fails a different number of tests on every run". None of the three is touched by this PR, and my three new tests never appeared in any run.

No version bump (maintainer bumps at merge).

Summary by CodeRabbit

  • Bug Fixes

    • Improved major garbage-collection pacing consistency by aligning escalation checks with recorded arena usage.
    • Ensured pacing behavior handles threshold values correctly, including the inclusive lower boundary.
  • Tests

    • Added coverage for positive pacing escalation, metric consistency, and below-threshold behavior.
    • Added safeguards against metric drift and incorrect pacing wiring.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4de30ab1-c146-4192-b9c0-4d7914b96431

📥 Commits

Reviewing files that changed from the base of the PR and between 58cca84 and 493a409.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml
📝 Walkthrough

Walkthrough

Major-GC pacing now uses a shared arena-usage accessor for escalation and pre-cycle recording. Test-only overrides support deterministic checks for positive readings, predicate agreement, and inclusive floor behavior.

Changes

Positive pacing recording

Layer / File(s) Summary
Shared pacing accessor and escalation wiring
crates/perry-runtime/src/gc/policy.rs
Pacing recording and escalation checks now use pacing_arena_in_use_bytes(). Tests can install and restore thread-local arena-usage overrides.
Pacing recording and boundary tests
crates/perry-runtime/src/gc/tests/triggers.rs, changelog.d/7752-positive-pacing-recording-test.md
Tests validate positive recording, matching predicate and recorded values, and inclusive floor behavior. The changelog records the coverage.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • PerryTS/perry issue 7737 — Covers positive-direction pacing recording tests and the shared pacing-reading override.

Possibly related PRs

  • PerryTS/perry#7733 — Directly overlaps in pacing state and escalation recording changes.
  • PerryTS/perry#7739 — Addresses consistent inclusive boundary semantics for pacing predicates and recorded thresholds.
  • PerryTS/perry#7020 — Shares test-only pacing controls in the policy and trigger tests.

Suggested reviewers: jdalton, thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the GC pacing recording change, shared accessor, and related issue item.
Description check ✅ Passed The description provides a detailed summary, related issue context, changes, tests, validation results, and scope notes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7737-positive-pacing-test

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.

@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 `@crates/perry-runtime/src/gc/policy.rs`:
- Around line 1591-1619: The escalation path currently samples arena usage
twice, allowing the recorded value to differ from the value used for the
decision. In crates/perry-runtime/src/gc/policy.rs:1591-1619, update
note_full_cycle_started to accept the captured arena-usage value; in
crates/perry-runtime/src/gc/policy.rs:2757-2757, have
arena_growth_full_escalation_due capture pacing_arena_in_use_bytes() once and
pass that same value to both the threshold comparison and
note_full_cycle_started.

In `@crates/perry-runtime/src/gc/tests/triggers.rs`:
- Around line 819-820: In the pacing-floor tests using major_pacing_config(),
update crates/perry-runtime/src/gc/tests/triggers.rs lines 819-820 and 870-871
to compute floor-plus-one and floor-plus-over with saturating arithmetic or
explicit maximum handling. Adjust the associated assertions so a reading equal
to the floor is accepted when usize::MAX prevents any larger value.
🪄 Autofix

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: ee29fa40-738b-4cda-808e-635aa0b28570

📥 Commits

Reviewing files that changed from the base of the PR and between 27d5358 and 58cca84.

📒 Files selected for processing (3)
  • changelog.d/7752-positive-pacing-recording-test.md
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tests/triggers.rs

Comment on lines +1591 to +1619
GC_FULL_CYCLE_PRE_IN_USE_BYTES.with(|bytes| bytes.set(pacing_arena_in_use_bytes()));
}

/// The arena reading BOTH halves of arena-growth pacing must use: the
/// escalation predicate's comparison against the boundary, and the pre-full
/// reading `note_full_cycle_started` records for `update_major_pacing_backoff`
/// to price the result against.
///
/// `update_major_pacing_backoff`'s doc already says these are "deliberately
/// measured on the SAME metric ... so the two cannot disagree about whether a
/// full helped". Reading `arena_in_use_bytes()` twice made that a convention;
/// one accessor makes it structural (#7737).
///
/// It is also the injection point the positive-direction test needs. Forcing a
/// `true` verdict from the REAL predicate otherwise requires an arena above
/// `PERRY_GC_MAJOR_PACING_FLOOR_MB` (32 MB by default), and the floor cannot be
/// lowered per-test: `major_pacing_config` is a process-wide `OnceLock`, so an
/// env var only takes effect if this test happens to run first. A 32 MB live
/// heap in a unit test is what `major_pacing_escalation_threshold_for` was
/// factored out to avoid, so the seam goes here instead — `#[cfg(test)]`, so it
/// compiles out of every shipping build and is not a mode anything can be
/// configured into (CLAUDE.md's GC knob kill-policy is about runtime knobs;
/// this is not one).
fn pacing_arena_in_use_bytes() -> usize {
#[cfg(test)]
if let Some(bytes) = TEST_PACING_ARENA_IN_USE.with(|cell| cell.get()) {
return bytes;
}
crate::arena::arena_in_use_bytes()

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 | 🟠 Major | ⚡ Quick win

Capture one arena reading for each escalation.

arena_growth_full_escalation_due() reads pacing_arena_in_use_bytes() for the predicate, then note_full_cycle_started() reads it again. In shipping builds, these are separate arena_in_use_bytes() samples. The recorded value can differ from the value that caused escalation.

Capture the value in arena_growth_full_escalation_due(). Pass it to both the predicate and note_full_cycle_started(). The fixed test override currently hides this two-read gap.

  • crates/perry-runtime/src/gc/policy.rs#L1591-L1619: make note_full_cycle_started accept the captured value.
  • crates/perry-runtime/src/gc/policy.rs#L2757-L2757: compare the captured value with the threshold.
📍 Affects 1 file
  • crates/perry-runtime/src/gc/policy.rs#L1591-L1619 (this comment)
  • crates/perry-runtime/src/gc/policy.rs#L2757-L2757
🤖 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/policy.rs` around lines 1591 - 1619, The
escalation path currently samples arena usage twice, allowing the recorded value
to differ from the value used for the decision. In
crates/perry-runtime/src/gc/policy.rs:1591-1619, update note_full_cycle_started
to accept the captured arena-usage value; in
crates/perry-runtime/src/gc/policy.rs:2757-2757, have
arena_growth_full_escalation_due capture pacing_arena_in_use_bytes() once and
pass that same value to both the threshold comparison and
note_full_cycle_started.

Comment on lines +819 to +820
let reading = floor_bytes + 1;
let previous_reading = test_set_pacing_arena_in_use(Some(reading));

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 | 🟡 Minor | ⚡ Quick win

Prevent overflow for a maximum configured pacing floor.

major_pacing_config() can produce usize::MAX after its saturating conversion. Both additions then overflow in debug test builds when PERRY_GC_MAJOR_PACING_FLOOR_MB is configured to that value.

Use saturating_add or handle the maximum floor explicitly. Update the assertions to allow a reading at the floor when no larger value exists.

  • crates/perry-runtime/src/gc/tests/triggers.rs#L819-L820: avoid floor_bytes + 1.
  • crates/perry-runtime/src/gc/tests/triggers.rs#L870-L871: avoid floor_bytes + over.
📍 Affects 1 file
  • crates/perry-runtime/src/gc/tests/triggers.rs#L819-L820 (this comment)
  • crates/perry-runtime/src/gc/tests/triggers.rs#L870-L871
🤖 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/triggers.rs` around lines 819 - 820, In the
pacing-floor tests using major_pacing_config(), update
crates/perry-runtime/src/gc/tests/triggers.rs lines 819-820 and 870-871 to
compute floor-plus-one and floor-plus-over with saturating arithmetic or
explicit maximum handling. Adjust the associated assertions so a reading equal
to the floor is accepted when usize::MAX prevents any larger value.

@proggeramlug
proggeramlug force-pushed the gc/7737-positive-pacing-test branch from 58cca84 to 493a409 Compare August 10, 2026 08:21
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1440

I filed item 2, so I checked this one harder than usual — and the load-bearing claim holds under my own hands.

The sabotage, re-run here

3 new tests pre-existing negative test
baseline ok, ok, ok ok
metric drift (recorder points at its own arena_in_use_bytes()) FAILED, FAILED, FAILED ok

That last cell is the whole argument: the negative-only test cannot distinguish "declined correctly" from "the recording never ran", because both produce 0. Demonstrating that — rather than asserting it — is what makes this worth landing as its own PR.

(One correction to my own process: my first run of this sabotage showed "4 passed" and no failures, and I nearly recorded it as not reproducing. I had filtered on pacing, which matches two zeal_pacing_* tests and #7733's two older ones — and none of the three new tests. Second time today I've checked a claim against the wrong subject; the filter is not the test.)

The accessor is the better half of this PR

update_major_pacing_backoff's doc already claimed the two sites read the same metric "so the two cannot disagree about whether a full helped" — but both called arena_in_use_bytes() independently. That was a convention two call sites happened to honour, not a structure. It is precisely the shape #7739 fixed by collapsing the snapshot and the predicate onto one boundary helper, and it recurred one layer down in the same subsystem.

Routing both through pacing_arena_in_use_bytes() and requiring the injected value back out is what makes the claim checkable rather than aspirational. Verified in code: note_full_cycle_started at policy.rs:1591 and the predicate at :2757 both call it, and the override is #[cfg(test)]-gated so it compiles out of shipping builds — correctly outside the GC knob kill-policy.

Why it wasn't testable before, recorded properly

The explanation is worth keeping: the boundary is max(floor, baseline × growth + 1) and the floor always dominates from below, so no baseline manipulation gets the threshold under it; and PERRY_GC_MAJOR_PACING_FLOOR_MB can't help because major_pacing_config is a process-wide OnceLock that only takes effect if this test happens to run first. That leaves a 32 MB live heap — exactly what major_pacing_escalation_threshold_for was factored out to avoid. Injection was the right escape.

the_floor_is_inclusive_and_below_it_declines also pins why the +1 lives on the growth boundary and not the floor, which is the kind of detail that gets "simplified" wrongly later.

Scope

Correctly stated as not closing #7737: item 1 landed in #7740, item 3 needs the pinned bench host, item 4 is a maintainer action. Leaving the issue open with two items outstanding is the honest bookkeeping.

Gates 21/21.

@proggeramlug
proggeramlug merged commit fb093cd into main Aug 10, 2026
1 of 16 checks passed
@proggeramlug
proggeramlug deleted the gc/7737-positive-pacing-test branch August 10, 2026 08:31
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.

1 participant