Skip to content

gc: no reachable configuration exercises an evacuating minor with unpinned runtime locals — the #6655/#6935 bug class is untestable #6942

Description

@proggeramlug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions