Skip to content

captured variable reassigned inside a labeled block is not boxed — closure reads stale snapshot #5869

Closed

Description

@proggeramlug

Summary

A captured variable that is reassigned inside a labeled block is not boxed: closures capture the creation-time value and never observe the reassignment. Reads through the closure see a stale snapshot while direct reads in the same scope see the live value.

Repro (vs node --experimental-strip-types)

function factory() {
  let arr: any = [];
  const read = () => arr.length;
  outer: {
    arr = [1, 2, 3, 4];
    break outer;
  }
  console.log("len:", read());
  console.log("json:", JSON.stringify(arr));
}
factory();

node:

len: 4
json: [1,2,3,4]

perry (main):

len: 0
json: [1,2,3,4]

The closure reads .length === 0 on the stale empty array while JSON.stringify in the same scope prints all four elements.

Root cause

The boxing analysis in crates/perry-codegen/src/boxed_vars.rs is inconsistent on Stmt::Labeled: collect_write_ids_in_stmt (~1179) HAS a Labeled arm, but five sibling walkers do not —

  • collect_closure_refs_and_writes_in_stmt (_ => {} at ~737)
  • collect_outer_writes_in_stmt (~970)
  • collect_for_init_ids (~641)
  • collect_self_recursive_closure_ids (~596)
  • collect_let_types_in_stmts (~1362-1367)

A reassignment inside a labeled block is invisible to outer_writes (and a closure inside a labeled block contributes no closure_refs), so the captured+mutated variable is never boxed → the closure keeps a creation-time snapshot.

Why this matters

Minified bundles emit labeled blocks (e: { … break e }) pervasively; hand-written repros don't — which is exactly why this class of bug reproduces only in large bundles. The failure signature (a captured array reading .length === 0 while JSON.stringify in the same scope shows the elements, and a fresh local array "fixing" it) matches the long-standing unexplained misbehavior seen in a 13.8MB Next.js app-page bundle's webpack factory.

Suggested fix

Add Stmt::Labeled arms (recurse into the body) to all five walkers, mirroring the existing arm at ~1179.

Activity

  1. proggeramlug commented on Jul 2, 2026

    @proggeramlug
    ContributorAuthor

    PR #5871 fixes the codegen half (all five boxed_vars.rs walkers now descend into Stmt::Labeled); the write-inside-label repro passes.

    Residual (this issue stays open): the closure-created-INSIDE-the-label variant still misbehaves, one layer up:

    function factory2() {
      let n = 0;
      let get: any = null;
      tag: {
        get = () => n;
        break tag;
      }
      n = 42;
      console.log("n:", get());  // node: 42 — perry: 2.640280732206e-311 (a denormal)
    }

    --trace hir --focus factory2 shows perry-hir lowers the labeled block to Labeled { body: DoWhile { body: [LocalSet(1, Closure { captures: [0], mutable_captures: [], … }), LabeledBreak], condition: false } } — note mutable_captures: []: the HIR mutable-capture classifier doesn't see closures nested under the Labeled/DoWhile(false) wrapper, so it classifies n as a non-mutable capture. With #5871 the codegen creation site now stores the box pointer (boxing analysis sees the capture), but the closure body still reads the capture raw (HIR says non-mutable) — the call returns the box-pointer bits as a denormal number, the classic unboxed-capture signature.

    Fix direction: teach the perry-hir mutable-capture classification walk to descend Stmt::Labeled (and the synthesized DoWhile(false) wrapper) when pairing closures with later writes. A regression test is already in the tree, #[ignore]d: crates/perry/tests/issue_5869_labeled_block_capture_boxing.rs::closure_inside_labeled_block_counts_as_capture — un-ignore it when the HIR side lands.

  2. added 5 commits that reference this issue on Jul 2, 2026
  3. proggeramlug commented on Jul 13, 2026

    @proggeramlug
    ContributorAuthor

    Fixed by #5871 (boxing analysis descends into labeled blocks) and #5949 (the residual: boxed locals must not feed the typed-ABI closure specializations).

    Re-verified on current main: both the original repro (write inside the label) and the residual from the comment thread (closure created inside the label) are byte-identical to node — the latter previously printed a denormal (2.64e-311) instead of 42.

    Both regression tests in crates/perry/tests/issue_5869_labeled_block_capture_boxing.rs are live (no longer #[ignore]d).

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