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
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)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), butfac's direct read ofcountsees 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), socountstays unboxed infac; the closure's ownmutable_capturesanalysis boxes its private copy.Two fix shapes considered and rejected as insufficient during the 2026-07-04 audit run:
collect_module_boxed_varswith class-container fragments (declaration-scoped analysis can't pair decl+closure across containers);fac's slot, but the field closure still receives the capture through the snapshot cap-param, not the box — verified still0 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
insert_class_capture_refresh_after_assignmentsconverges values, but only for same-body statement-level assignments — closure-side mutation is exactly the case it cannot cover).