Skip to content

async: a per-iteration binding that a closure WRITES and that survives an await still collapses #6354

Description

@proggeramlug

Summary

A per-iteration let that a closure writes and that is still read after an await in the same loop body collapses onto a single binding: every closure observes the last iteration's value.

This is the one residual left by #6345 / PR (per-iteration bindings across the async state machine). It is a pre-existing bug, unchanged by that PR — filed separately because it needs a different mechanism.

Repro

const fns: (() => void)[] = [];
const tick = () => new Promise<void>((r) => r());

async function main() {
  for (let i = 0; i < 9; i++) {
    let acc = i;
    const bump = () => { acc += 100; };   // closure WRITES acc
    bump();
    await tick();                          // acc is read after the suspend
    fns.push(() => console.log("acc =", acc));
  }
  fns.forEach((f) => f());
}
main();
output
node 26 acc = 100, 101, … 108
perry acc = 108 ×9

Trip count must exceed 8 — the static-loop unroller (MAX_TRIP_COUNT = 8) mints fresh ids per unrolled copy and masks it below that.

Why the #6345 fix does not cover it

#6345 handles per-iteration bindings two ways:

  1. If the binding's live range stays inside one state, its Stmt::Let is left in the loop and codegen re-boxes it per iteration (full fidelity, including write-sharing).
  2. If it is read after an await, it must keep a cross-state box. The step closure's capture slots are fixed for its lifetime, so the box pointer cannot be swapped per iteration. async: per-iteration let/const binding collapses for closures created in a loop body (every closure sees the last value) #6345 therefore hands the closure a value snapshot taken at creation time — correct precisely when nobody writes the binding afterwards, which is why it is gated to captures \ mutable_captures (HIR's own "read-only capture" classification).

acc above is in mutable_captures (a closure assigns it), so it is deliberately not snapshotted — a snapshot would break write-sharing, which is a worse bug. It keeps the shared box, and the shared box is what collapses.

What a real fix needs

A shared per-iteration cell that survives a suspend. The only per-activation storage that outlives a step re-entry is the set of boxes the step closure captured, and those slots are fixed — so a fresh box per iteration has to be reachable through one of them. That means a second level of indirection (a hoisted cell holding the current iteration's binding cell), which codegen's one-level boxed_vars contract cannot express today:

  • at step entry, the local slot for the binding would load through the persistent cell rather than being the cell;
  • the in-loop declaration would allocate a fresh inner cell and publish it into the persistent one;
  • closure captures and body reads/writes would then naturally target the current iteration's cell.

Reasonably contained, but it is a codegen/HIR contract change rather than a perry-transform scheduling change, so it wants its own PR.

Scope

Narrow: requires a loop binding that is (a) assigned by a closure or by the enclosing scope after capture, and (b) live across an await inside the loop body. The common async-loop shapes — const j = i, direct loop-var capture, const data = await load(x); cbs.push(() => data) — are all fixed by #6345.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions