Skip to content

gc: ASYNC_STEP_GUARD.last_closure is a write-only stale closure address that is still rekeyed #8193

Closed

Description

@proggeramlug

ASYNC_STEP_GUARD.last_closure (crates/perry-runtime/src/promise/) is a raw closure address rekeyed in two places — scan_promise_roots_mut (scanners.rs:89) and its budgeted twin scan_async_step_guard_step (scanners.rs:500) — both via visit_metadata_usize_slot, i.e. rewritten if the closure moved, deliberately not marked.

It is written only at microtasks.rs:749 (last_closure: step_closure as usize) and zeroed on any non-error step (microtasks.rs:754-757). The same-closure check that used to read it was deleted (microtasks.rs:710-717), so on the error path it is now a write-only stale closure address that survives arbitrarily many collections with nothing marking it and nothing pruning it.

That is the #8040 shape: the arena recycles the address, the recycled bytes are read as a GcHeader, and a coincidental GC_FLAG_FORWARDED byte makes the rewrite pass follow a garbage forwarding pointer. #8174 narrowed what can be followed out of such a header; it did not remove the dead key.

Fix: either drop the field (nothing reads it any more — check first) or clear it when the closure is dead, wired into DEAD_KEY_PRUNES in crates/perry-runtime/src/gc/dead_owner.rs with the GC_TYPE_CLOSURE-narrowed predicate the closure tables already use.

Declared as open_gap in scripts/gc_rekeyed_key_tables.json (two sites); closing this must delete both entries, or the gate fails on the stale exemptions.

Refs #8174, #8168, #8040.

Activity

  1. proggeramlug commented on Aug 16, 2026

    @proggeramlug
    ContributorAuthor

    Fixed in #8196 — by deleting the field, not by adding a prune.

    AsyncStepGuard::last_closure held the address of the closure that took the last erroring async step, for a same-closure check that was deleted when #712/#921/#922 showed a runaway loop alternates between two closures (promise/microtasks.rs: "Drop the same-closure check"). Nothing has read it since.

    It was not inert, though: it was a raw heap address that scan_promise_roots_mut and its budgeted twin rekeyed via visit_metadata_usize_slot — rewritten if the closure moved, deliberately not marked — with nothing pruning it when the closure died. Writing a prune to maintain state nobody reads would be its own dead code, so the field goes, and with it the PROMISE_SCAN_ASYNC_STEP_GUARD budgeted scan phase whose only slot it was.

    Both of its scripts/gc_rekeyed_key_tables.json entries are deleted with it; the gate's MAX_OPEN_GAPS is 0.

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