Repository navigation
GC testing: PERRY_GC_FORCE_EVACUATE is inert for gc()-driven tests (full mark-sweep + forced conservative scan) — stress claims may be unsupported #6946
Description
Summary
PERRY_GC_FORCE_EVACUATE=1 has no effect on a test that drives collection with an explicit gc() call, and such a test additionally cannot expose raw-local rooting bugs. Both properties are structural, verified on origin/main. A test written that way looks like GC stress coverage and is not.
Mechanism (verified on origin/main)
js_gc_collect → manual_gc_collect_now (crates/perry-runtime/src/gc/policy.rs):
fn manual_gc_collect_now() {
let _scan = super::roots::ManualGcScanGuard::force_full_scan(); // (1)
...
gc_collect_full_mark_sweep_with_trigger(...) // (2)
}force_full_scan()pins raw locals. The conservative native-stack scan is forced on, so a raw NaN-boxed word in a Rust local or an unrooted slot is treated as a root. That is exactly the class of bug the rooting work (runtime: audit dynamic_arith operand rooting — raw NaN-boxed operands held across GC-capable to_numeric coercions (pre-existing, file-wide) #6655, runtime: audit ToPropertyKey receiver/value rooting — raw receiver and stored value held across GC-capable key coercions #6935/fix(runtime): root receivers and stored values across GC-capable property-key coercions (#6935) #6941) targets — agc()-driven test cannot observe it. Note production resolvesAuto → SkipDisabled(gc/roots.rs), so this is a test-only safety net: a greengc()-driven test can be a production-mode lie.- It runs a FULL mark-sweep, deliberately not the minor path (the in-code comment explains why: dead large/tenured objects must be reclaimed to match
--expose-gcsemantics). ButPERRY_GC_FORCE_EVACUATEis read only inside the minor cycle (gc/mod.rs:let force_evacuation = gc_force_evacuate_enabled();withinGcCollectionKind::Minor, plusgc/policy.rs/gc/oldgen.rsevacuation paths). A full mark-sweep never evacuates, so nothing moves, so a stale pointer is trivially valid.
Independently measured in #6942: across 10 collections in a 12M-allocation program, PERRY_GC_DIAG=1 printed zero [gc-evac-policy] / [gc-copy-minor] lines.
Blast radius (measured, not assumed)
- 9 gap files call
gc()—test_gc_array_layout_mask,test_gc_aggressive_forced,test_gc_class_object_forced,test_issue_1425_gc_unsafe_zones,test_issue_1830_gc_in_catch_after_deep_throw,test_issue_2656_weakref_finalization_gc,test_issue_4977_gc_toplevel_locals,test_issue_callable_with_properties,test_memory_class_object_churn. Any evacuation/rooting claim resting on these underFORCE_EVACUATEis unsupported. - The representation-selection corpus does NOT call
gc()(verified: 0 occurrences across alltest_gap_repsel_*/test_gap_specabi_*files). Those rely on allocation pressure, which can drive automatic minors — whereFORCE_EVACUATEdoes apply and the conservative scan is not forced. Whether their churn actually crosses the threshold is unverified and is the open question; gc: no reachable configuration exercises an evacuating minor with unpinned runtime locals — the #6655/#6935 bug class is untestable #6942 notes a common "20000 short-lived objects" prelude triggers no GC at all because non-escaping allocations are optimized away.
Why it matters
"Byte-exact under PERRY_GC_FORCE_EVACUATE=1" has been used as the standard validation ritual across a long series of GC and representation PRs. For gc()-driven tests that sentence asserts nothing about evacuation or rooting.
Suggested fixes
- Make the trap loud: if
PERRY_GC_FORCE_EVACUATE=1is set and a manualgc()runs, emit a diagnostic (or havePERRY_GC_DIAGstate plainly that force-evacuate was ignored on this path). - Give tests a real knob — a documented way to drive an evacuating minor deterministically (
perry/gc'sminor()evacuates, but per gc: no reachable configuration exercises an evacuating minor with unpinned runtime locals — the #6655/#6935 bug class is untestable #6942 it engagesforce_full_scan()and so re-pins raw locals; a variant without that pinning is what rooting tests actually need). - Require liveness proof in GC tests: assert via
PERRY_GC_DIAGthat a collection (and, for evacuating arms,moved_objects > 0) actually occurred, rather than trusting the env var.
Related: #6942 (stress arms measured inert, with three proposed harness routes), #6910 (mark/rewrite parity; its tests correctly pin ConservativeStackScanMode::Disabled — the pattern to copy).
Activity
fix(gc): a compiled program can now actually reach an evacuating minor (
docs: GC knob kill-policy -- an unexercised mode is an unverified con…
style: clear two of the lint backlog items blocking every merge (fmt …
docs: the four ways a CI gate can be unable to fail (#7049)
test(gc): corpus row for array truncate-then-append; PR-key the chang…
fix(lru-cache): npm-faithful option validation, subject-live GC test
fix(lru-cache): faithful JS-value keys/values, GC rooting, TTL + upda…
proggeramlug commentedon Aug 9, 2026
Half of this is now resolved, and the half that remains is the more interesting one. Updating rather than closing.
Resolved by #7657 (v0.5.1374): the second property — "such a test additionally cannot expose raw-local rooting bugs". The mechanism this issue quotes,
no longer exists.
gc()runs on precise roots; theConservativeScanSite::ManualCollectvariant was deleted rather than left unconstructible. So agc()-driven test now does expose raw-local rooting bugs, and the detector added alongside it (explicit_gc_collects_precisely_and_a_native_stack_plant_dies) asserts exactly that: an object reachable only from a pointer-shaped word in a live native frame is swept.Worth recording that the forced scan turned out to be a workaround for a precise-rooting hole (#4977 → #4998) that the rooting campaign had since closed from the other end — grandfathered, then quoted for two months as a requirement.
Still open: property one.
PERRY_GC_FORCE_EVACUATEis read on the minor path whilegc()runs a full mark-sweep, so the knob remains inert forgc()-driven tests. That is unchanged by #7657 and is what this issue should now be scoped to.The consequence is also unchanged and is the reason to keep it open: a test written as
gc(); assertFreed()underPERRY_GC_FORCE_EVACUATE=1looks like evacuation stress coverage and is not — CLAUDE.md's hazard 4, and one of the three worked examples in it.gc: the evacuation knobs stop lying about what they gate (#7611, #6946,
proggeramlug commentedon Aug 9, 2026
The remaining half is fixed. PR #7684.
An explicit
gc()underPERRY_GC_FORCE_EVACUATEnow runs an evacuating minor first, then the full mark-sweep. #7657 is what made this possible: this site used to takeManualGcScanGuard::force_full_scan, and a forced conservative scan makes the copying minor ineligible outright (CopiedMinorFallbackReason::ConservativeStack). With precise roots a copying minor here is exactly as sound as the full sweep that follows, which still runs and still reclaims dead large/tenured objects, so--expose-gcsemantics are unchanged.One subtlety worth recording, because it is this issue's own bug in a new place:
gc_collect_minor_with_triggercan escalate to a full mark-sweep via two throughput-pacing predicates (copied_minor_promotion_handoff_due,arena_growth_full_escalation_due), and a full sweep moves nothing. Left alone, the fix would have handed the caller a non-moving collection under a knob whose entire name is about relocation. There is now aFullEscalation::Refusedscoped to this one caller; every automatic path keepsAllowed, so the stub reclamation those predicates exist for is untouched.Shown able to fail.
explicit_gc_under_forced_evacuation_runs_a_moving_minorasserts both that a copying minor completed and that the survivor's address changed — the counter without the address cannot tell a copying minor that moved nothing from one that moved this object. Deleting the forced minor and re-running:Suggested fix 3 ("require liveness proof in GC tests") is also served:
crate::gc::copying_minor_cycles()andmoved_objects_total()are now public counters any test can assert on, added for #7604 in the same PR.CLAUDE.md's kill-policy keeps this as a precedent with both halves marked closed — the story is the argument for the policy.
gc: the evacuation knobs stop lying about what they gate (#7611, #6946,
gc: the evacuation knobs stop lying about what they gate (#7611, #6946,