Skip to content

gc-matrix: the collect-arm liveness predicate (cycles > 0) cannot tell a mid-program collection from a teardown one that frees nothing #7017

Closed

Description

@proggeramlug

Split out of #6978 (fixed in #7015). Filed by the author of that fix, because the fix makes the weakness visible rather than removing it.

scripts/gc_repsel_matrix.sh scores a collect-requirement arm as PASS when cycles > 0, counting [gc] cycle markers on stderr. After #7015 the shipped configuration does complete cycles on the small corpus files, and 171 cells moved UNVER → PASS. On the small files that cycle lands at the event-loop boundary after the program's last output, and it reclaims nothing:

45:8                                    <- last program output
[gc-safepoint] finishing a cycle parked across 2 host safepoints
[gc-step] pre_in_use=479816 post_in_use=483472 sweep_freed=0 block_reclaim=0 pct=0%
[gc] cycle

Everything allocated after the cycle armed was born black, so the sweep frees nothing. Compare PERRY_GC_INCREMENTAL=0 on the same file, which collects twice, mid-program, interleaved with the test's output.

Both shapes score identically under cycles > 0. So a cell that traverses only the retained graph at rest, at teardown, is indistinguishable from one that collected while the test's representation-selected locals were live — and the latter is the property the matrix was built to assert.

This is not fixable in the collector. With two allocation-trigger opportunities in the whole process and seven resumable cycle phases, a mid-program completion under incremental mode would have to be synchronous at the arming point — which is exactly what PERRY_GC_INCREMENTAL=0 already is. See #6978 for the per-call measurements.

Suggested direction (not prescriptive): make the collect predicate demand a productive cycle rather than any cycle. Candidates, cheapest first:

  1. require sweep_freed > 0 or block_reclaim > 0 on at least one cycle (already in the PERRY_GC_DIAG [gc-step] line the harness captures);
  2. require at least one cycle to complete before the program's last stdout line, which needs the harness to interleave the two streams for the liveness read while keeping stdout pure for the parity compare;
  3. introduce a third verdict between PASS and UNVER for "collected, but only at teardown" so the distinction stays visible instead of being folded either way.

Whatever the choice, the point of #6942 / #6946 / #6950 stands: a green cell from a collection that could not have observed the test's live data licenses the same false confidence as a green cell from no collection at all.

Refs #6978, #7015, #6950, #6946, #6942.

Activity

  1. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Fixed in #7676. The predicate was at fault, and the chosen direction is your option 1 — a productive cycle — with one correction the measurement forced.

    collect now requires reclaimed > 0. Following #7657's widening of the gc-ratchet rule to copied + promoted > 0, the counter names a destination rather than a single number: it sums sweep_freed, block_reclaim, eden_dead_bytes, freed_bytes and dead_bytes.

    The correction: both spellings must be read, k=N and JSON "k": N. A predicate over sweep_freed=/block_reclaim= alone — the two counters this issue names — scores test_gap_gc_symbol_local_rooting as inert: it runs 86 malloc-count-triggered cycles that free 31.9 MB of symbols, and every arena-side counter reads 0 because the malloc sweep's bytes appear only in the JSON trace's sweep.freed_bytes. Which link mode you get is decided by auto-optimize, so a single-spelling predicate is inert on exactly the binaries CI builds.

    Option 3 (a third verdict) was not taken: the distinction is already carried by PASS vs UNVER, and the per-run liveness table now prints reclaimed beside collected so the gap is visible rather than folded — measured at collected 3/3, reclaimed 1/3 for gen_gc_off on the Phase 4a.3 slice. Option 2 (interleaving the streams) would need either a second run per cell or a runtime marker; the reclamation counters are already captured and cost nothing.

    The script states plainly that this is a conservative proxy: a mid-program cycle over an all-live heap reclaims nothing and reads UNVER. It under-claims, never over-claims — cycles over-claimed.

    Corpus-wide impact under gen_gc_off, 58 files: 12 never collect (already UNVER), 14 collect but reclaim nothing (PASS before, honestly UNVER now), 32 productive — enough to keep every collect arm live.

    Shown able to fail: reverting REQUIREMENTS["collect"] to ("cycles",) fails exactly the two new self-test cases and restoring it returns 24 checks / 0 failures.

    Found while validating and fixed in the same PR: all four arms registered known-inert in test-parity/gc_matrix_inert_arms.txt are live — CI's gc-stress on main (run 31240304595) had them at 41/58 cells with four STALE-REGISTRY lines, and the job was red for it.

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

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