Skip to content

gc: a loop-free program never drains its deferred nursery trigger, so it now performs zero collections (test_gap_specabi_reassign went inert on 9 matrix cells) #7059

Description

@proggeramlug

Found while landing #7024 (PR #7057), which made the alloc-point deferral reachable under an explicit PERRY_GC_HEAP_LIMIT. It is a coverage loss, not a correctness bug, and it is worth a number of its own because it is the one place the matrix got less live.

The observable

scripts/gc_repsel_matrix.sh --pressure 8, test_gap_specabi_reassign:

arm before #7024 after
default PASS (cycles=1 scavenged=0) UNVER (cycles=0)
verify_evac PASS (cycles=1) UNVER (cycles=0)
cons_scan_off PASS (cycles=1 scavenged=3568) UNVER (cycles=0)
gen_gc_off, wb_off, gen_off_verify, wb_off_force, all_four PASS UNVER

The row does not collect at all any more on any arm that used to take the allocation-point fallback. It stays byte-exact against the oracle; the harness correctly reports UNVER rather than green.

Mechanism

test-files/test_gap_specabi_reassign.ts is a 20-line program with no loop. Codegen only emits the back-edge poll (js_gc_loop_safepoint) for allocating loop bodies, so this program contains no poll at all, and it does not run long enough to reach the microtask-pump safepoint.

Before #7024 the deferral was unreachable under the pressure knob (the defer cap and the trigger ceiling shared a formula), so a due nursery trigger fell through to the alloc-point non-moving minor and the row collected once. Now the trigger is correctly deferred to the next precise-root safepoint — and the program exits before reaching one. The safety valve (one gc_moving_defer_slack_dyn_bytes() of further growth) never fires either, because the program never allocates that much.

Nothing leaks: growth is bounded by the slack, and the process is exiting.

Why file it

  1. One corpus row went inert, and the matrix's whole premise is that an inert arm is not a green one. Nine cells that used to be PASS are now UNVER for this reason.
  2. It is a real property of the shipped collector, not just of the corpus: a loop-free program that reaches its first nursery trigger near exit now performs no collection at all where it previously performed one. For a short-lived process that is free; for anything holding a finalizer-bearing resource it is a behavioural difference worth knowing about.

Candidate directions (not prescriptive)

  • Extend poll coverage so the deferral has a drain in more shapes — the emit_gc_loop_safepoint doc already lists the specialized/versioned for lowerings, for-of and for-in as not yet emitting it (Phase 2 codegen work). That does not help a genuinely loop-free program.
  • Drain a pending deferral at a function-return safepoint, not only at loop back-edges and the microtask pump.
  • Or accept it and give the corpus row a shape that reaches a safepoint, so the matrix keeps its liveness on this representation. That is the cheapest, and the least honest, so it should be a deliberate choice rather than a drive-by edit.

Environment: release build, macOS arm64, pinned Node 26.5.0, --pressure 8.

Related: #7024, #6993, #7019, #6950.

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