You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A per-iteration let that a closure writesand 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
constfns: (()=>void)[]=[];consttick=()=>newPromise<void>((r)=>r());asyncfunctionmain(){for(leti=0;i<9;i++){letacc=i;constbump=()=>{acc+=100;};// closure WRITES accbump();awaittick();// acc is read after the suspendfns.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.
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).
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 awaitinside 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.
Summary
A per-iteration
letthat a closure writes and that is still read after anawaitin 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
acc = 100,101, …108acc = 108×9Trip 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:
Stmt::Letis left in the loop and codegen re-boxes it per iteration (full fidelity, including write-sharing).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-iterationlet/constbinding 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 tocaptures \ mutable_captures(HIR's own "read-only capture" classification).accabove is inmutable_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_varscontract cannot express today:Reasonably contained, but it is a codegen/HIR contract change rather than a
perry-transformscheduling 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
awaitinside 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.