Skip to content

class field-init closure mutating a captured outer local gets a split cell (class captures are value-based; need boxed-capture mode) #5951

Description

@proggeramlug

Summary

A class field-initializer closure that MUTATES a captured outer local operates on its own cell, split from the declaring function's binding — both directions of visibility are broken.

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

function fac() {
  let count = 0;
  class T {
    bump = () => {
      count += 1;
      return count;
    };
  }
  const t = new T();
  t.bump();
  t.bump();
  console.log("count:", count, t.bump());
}
fac();

node: count: 2 3 — perry (v0.5.1229-era main): count: 0 3.

The closure's own view is internally consistent (bump() returns 1, 2, 3 — it boxed its copy), but fac's direct read of count sees the stale 0: two cells.

Mechanism

Classes are lifted out of their declaring function, and field-init closures capture outer locals through the class-capture machinery (__perry_cap_* stash/snapshot — the #5437/W6 system), which is value-based by design: the snapshot system converges values at registration/refresh points but cannot express a shared mutable cell. Meanwhile the boxing analyses are per-container: the declaring function's walk can't see the closure (it lives in the lifted class), so count stays unboxed in fac; the closure's own mutable_captures analysis boxes its private copy.

Two fix shapes considered and rejected as insufficient during the 2026-07-04 audit run:

  • extending collect_module_boxed_vars with class-container fragments (declaration-scoped analysis can't pair decl+closure across containers);
  • a cross-container union pass (boxes fac's slot, but the field closure still receives the capture through the snapshot cap-param, not the box — verified still 0 3).

The real fix needs the class-capture machinery to pass a box pointer through the cap param when the captured local is mutated on either side (i.e., a boxed-capture mode for __perry_cap_*), with the stash/rebind/refresh paths box-aware.

Related

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