Split out of #6935 (and it applies equally to #6655 / #6934).
Both of those PRs fixed real rooting gaps by audit, and both shipped a forced-evacuation regression suite that passes on the pre-fix runtime. That is not a weak test — there is currently no reachable configuration in which a compiled Perry program performs a minor cycle that evacuates while a runtime helper's raw Rust locals are unpinned. That is the exact state the whole bug class needs, and nothing can produce it today.
Measured on main (0bb03e8), macOS host, perry-dev profile, with PERRY_GC_DIAG=1:
1. The gc() hook runs a full mark-sweep, and evacuation is minor-only.
A program doing ~12M escaping allocations plus explicit gc() calls produced ten [gc] blocks: … sweep lines and zero [gc-evac-policy] / [gc-copy-minor] lines. maybe_print_evacuation_policy_diag is only called from the self.minor.is_some() arm in gc/cycle.rs, so every one of those cycles was a full collection. Nothing moved, so a stale pointer is trivially still valid — which is exactly why the #6655 suite could not fail pre-fix.
Note also that the churn has to escape (pushed into a live array) before any collection happens at all; a loop allocating short-lived object literals is optimized away, so the "20000 short-lived objects" prelude both #6655 and #6935 use never triggers a GC on its own.
2. perry/gc's minor() evacuates, but pins the operands the bug is about.
Switching the churn to import { minor } from "perry/gc" does reach the evacuator:
[gc-copy-minor] eligible=false fallback=conservative_stack
[gc-evac-policy] enabled=true reason=force … moved_objects=8467 released_original_objects=8467 cons_pinned=1488
Thousands of objects genuinely move. But js_gc_module_minor engages ManualGcScanGuard::force_full_scan() (deliberately, per #4977: "the callsite may hold live locals only on the native stack"). The conservative stack scan then pins everything reachable from the native stack — including the raw receiver / stored-value locals held inside the runtime helper. The bug is masked by construction.
3. Disabling the conservative scan makes the configuration independently unsound.
PERRY_CONSERVATIVE_STACK_SCAN=off wins over the guard (gc/roots.rs::conservative_stack_scan_mode), and evacuation then does move the unpinned operands. But the resulting state is not interpretable, because plain JS values are lost too. Minimal repro, no property-key coercion anywhere:
import { minor } from "perry/gc";
const obj: any = {
n: "a-reasonably-long-heap-string-value",
tag: 4242,
m(): string { churn(50000); minor(); return this.n; },
t(): number { churn(50000); minor(); return this.tag; },
};
console.log("method this.n :", obj.m());
console.log("method this.tag:", obj.t());
console.log("direct n :", obj.n);
Default flags print all three correctly. With PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1:
undefined
undefined
direct n : a-reasonably-long-heap-string-value
Both method results are undefined — and the console.log string literal arguments are gone too (the labels vanish). The direct field read, which does not cross a collection, is fine. This reproduces identically with and without the #6935 fix, so it is not a property-key issue: with the conservative scan off, shadow-stack roots alone are not sufficient to keep a method's this (or an argument temporary) alive across a manual minor.
Why this matters
Until one of these is resolved, every fix in this family is audit-only and unverifiable, and a regression in it would be silent. Concretely, some combination of:
Referenced: #6655, #6934, #6941, #4977.
Split out of #6935 (and it applies equally to #6655 / #6934).
Both of those PRs fixed real rooting gaps by audit, and both shipped a forced-evacuation regression suite that passes on the pre-fix runtime. That is not a weak test — there is currently no reachable configuration in which a compiled Perry program performs a minor cycle that evacuates while a runtime helper's raw Rust locals are unpinned. That is the exact state the whole bug class needs, and nothing can produce it today.
Measured on
main(0bb03e8), macOS host,perry-devprofile, withPERRY_GC_DIAG=1:1. The
gc()hook runs a full mark-sweep, and evacuation is minor-only.A program doing ~12M escaping allocations plus explicit
gc()calls produced ten[gc] blocks: …sweep lines and zero[gc-evac-policy]/[gc-copy-minor]lines.maybe_print_evacuation_policy_diagis only called from theself.minor.is_some()arm ingc/cycle.rs, so every one of those cycles was a full collection. Nothing moved, so a stale pointer is trivially still valid — which is exactly why the #6655 suite could not fail pre-fix.Note also that the churn has to escape (pushed into a live array) before any collection happens at all; a loop allocating short-lived object literals is optimized away, so the "20000 short-lived objects" prelude both #6655 and #6935 use never triggers a GC on its own.
2.
perry/gc'sminor()evacuates, but pins the operands the bug is about.Switching the churn to
import { minor } from "perry/gc"does reach the evacuator:Thousands of objects genuinely move. But
js_gc_module_minorengagesManualGcScanGuard::force_full_scan()(deliberately, per #4977: "the callsite may hold live locals only on the native stack"). The conservative stack scan then pins everything reachable from the native stack — including the raw receiver / stored-value locals held inside the runtime helper. The bug is masked by construction.3. Disabling the conservative scan makes the configuration independently unsound.
PERRY_CONSERVATIVE_STACK_SCAN=offwins over the guard (gc/roots.rs::conservative_stack_scan_mode), and evacuation then does move the unpinned operands. But the resulting state is not interpretable, because plain JS values are lost too. Minimal repro, no property-key coercion anywhere:Default flags print all three correctly. With
PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1:Both method results are
undefined— and theconsole.logstring literal arguments are gone too (the labels vanish). The direct field read, which does not cross a collection, is fine. This reproduces identically with and without the #6935 fix, so it is not a property-key issue: with the conservative scan off, shadow-stack roots alone are not sufficient to keep a method'sthis(or an argument temporary) alive across a manual minor.Why this matters
Until one of these is resolved, every fix in this family is audit-only and unverifiable, and a regression in it would be silent. Concretely, some combination of:
js_gc_module_minor's guard conditional on the same soundness property GC: explicit gc() (full collect, default auto stack-scan) reclaims live top-level locals — string fields read back as garbage #4977 was working around — if shadow-stack coverage is now complete enough, the forced scan may be over-conservative; if it is not, item 3 above is a real soundness bug in its own right and should be triaged as one.copied_minor_promotion_handoff_due, the 64 MB threshold cap) would give the suites a natural way to exercise evacuation.perry-runtimethat drivejs_object_set_property_key/js_dynamic_mul/ … directly with a native closure whosetoStringforces an evacuating minor, using the existinggc/tests/evacuation.rsscaffolding (which does exercise the mechanism in-process). This is the most direct route to a deterministic pre/post-fix A/B and would let runtime: audit dynamic_arith operand rooting — raw NaN-boxed operands held across GC-capable to_numeric coercions (pre-existing, file-wide) #6655 and runtime: audit ToPropertyKey receiver/value rooting — raw receiver and stored value held across GC-capable key coercions #6935 both gain a test that actually fails before the fix.Referenced: #6655, #6934, #6941, #4977.