Skip to content

fix(transform): preserve hoisted-var slot across unrolled loop copies (#2308) - #2313

Merged
proggeramlug merged 2 commits into
mainfrom
worktree-fix-2308-unroll-var
May 28, 2026
Merged

fix(transform): preserve hoisted-var slot across unrolled loop copies (#2308)#2313
proggeramlug merged 2 commits into
mainfrom
worktree-fix-2308-unroll-var

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

Fixes #2308 — a var declared inside a constant-bound (unrolled) counted loop and read after the loop observed its initial value instead of the last iteration's.

function h(): number {
  let sum = 0;
  for (let i = 0; i < 3; i++) { var t = i * 2; sum += t; }
  return sum + t;   // node: 10 (t = 4)
}
h(); // perry before: 6 (t read as 0) — after: 10

Root cause

The static-loop unroller (crates/perry-transform/src/unroll.rs) clones a loop body N times and calls refresh_local_ids to rename every body-declared binding to a fresh id, so the copies don't alias — this is required for distinct per-iteration closure captures and to avoid duplicate module-init globals (#456).

But a var is function-scoped: a read after the loop references the original id. Renaming each copy's declaration meant the post-loop read bound to an id no copy ever wrote, so it saw the initial (undefined/0) value. It only fired with constant bounds (runtime-bound loops aren't unrolled and were already correct).

Fix

Before unrolling each function/method/init/constructor body, compute the set of loop-body-declared ids that are referenced outside their loop body (compute_loop_escaping_ids, a count-based analysis on the original HIR — total_uses > uses_inside_loop ⇒ escapes). These are exactly the hoisted, function-scoped vars.

refresh_local_ids then seeds its remap with identity entries for those ids, and alloc_fresh reuses an existing remap entry instead of minting a new id — so every unrolled copy keeps the var's original id. Since the unrolled body is straight-line, the last copy's write lands in the slot the post-loop read uses, matching JS.

Block-scoped let/const (which can never be referenced outside the loop body) are absent from the set and keep getting fresh per-copy ids, so per-iteration closure captures stay distinct.

Tests

  • Two unit tests in unroll.rs:
    • loop_escaping_var_keeps_original_id — every unrolled Let + the post-loop read keep the original id.
    • loop_local_let_still_refreshed_per_copy — a captured let still gets a distinct id per copy and each closure captures its own.
  • test-files/test_gap_2308_unrolled_loop_var.ts — byte-for-byte vs node --experimental-strip-types (var-after-loop, nested-loop var-after-outer, distinct closure captures).
  • cargo test -p perry-transform green (25 tests); cargo fmt --all --check clean; regression-swept several existing loop/closure/array parity files.

Related

A var assigned inside a conditional within an unrolled loop (for (...) { if (i===2) { var hit = ... } }, read after) additionally needs the per-branch var slot fix from #1803 — after unrolling it becomes the canonical #1803 shape (one Let per if-branch + a read past the merge). That's covered by #1803's PR, so this PR's test stays parity-clean independent of it.

Ralph Küpper added 2 commits May 28, 2026 23:25
…#2308)

The static-loop unroller (`unroll_static_loops`) clones a loop body N times
and calls `refresh_local_ids` to rename every body-declared binding to a
fresh id, so the copies don't alias (needed for distinct per-iteration
closure captures and to avoid duplicate module-init globals, #456).

But a `var` declared inside the loop body is function-scoped: a read AFTER
the loop references the original id. Renaming each copy's declaration to a
fresh id meant the post-loop read bound to an id that NO copy ever wrote,
so it observed the initial (undefined/0) value:

  for (let i = 0; i < 3; i++) { var t = i * 2; sum += t; }
  return sum + t;   // node: 10 (t = 4) — perry (before): 6 (t = 0)

Only fires when the loop bounds are constant (the loop is unrolled); with
a runtime bound the same code was already correct.

Fix: before unrolling each function/method/init/ctor body, compute the set
of loop-body-declared ids that are referenced outside their loop body
(`compute_loop_escaping_ids`, count-based on the original HIR). These are
exactly the hoisted, function-scoped `var`s. `refresh_local_ids` seeds its
remap with identity entries for them, and `alloc_fresh` reuses an existing
remap entry instead of minting a new id — so every copy keeps the var's
original id. Because the unrolled body is straight-line, the last copy's
write lands in the slot the post-loop read uses, matching JS.

Block-scoped `let`/`const` (which can never be referenced outside the loop
body) are absent from the set and keep getting fresh per-copy ids, so
per-iteration closure captures stay distinct (verified by a unit test and
the `distinctCaptures` e2e case).

Tests: two unit tests in unroll.rs (escaping var keeps id; captured let
still refreshed) + test-files/test_gap_2308_unrolled_loop_var.ts (byte-for-
byte vs node). A `var` assigned inside a *conditional* within an unrolled
loop additionally needs the per-branch slot fix from #1803 and is covered
there.
The #2308 fix pushed crates/perry-transform/src/unroll.rs to 2032 lines,
tripping the lint job's file-size gate (./scripts/check_file_size.sh, cap
2000). Move the file to unroll/mod.rs and extract the #2308 escaping-id
analysis (compute_loop_escaping_ids + its count/declared-id helpers) into a
sibling unroll/escape_analysis.rs, re-exported via an explicit named use.
No behavior change — mod.rs is now 1801 lines, escape_analysis.rs 245.
@proggeramlug
proggeramlug force-pushed the worktree-fix-2308-unroll-var branch from e99c70c to eb6f9a7 Compare May 28, 2026 21:37
@proggeramlug
proggeramlug merged commit 3dfc3e5 into main May 28, 2026
11 checks passed
@proggeramlug
proggeramlug deleted the worktree-fix-2308-unroll-var branch May 28, 2026 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(codegen): var declared in a constant-bound (unrolled) loop loses its value when read after the loop

1 participant