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
umbrella: a local binding's type/kind proof is not invalidated by assignment and is not validated against the runtime value (#7700, #7796, #7837, #7844) #7846
A type/kind proof attached to a local binding is usable only at sites that every write to that binding dominates. A proof derived from a declared TypeScript type is not a proof at all without a runtime check.
Codegen repeatedly attaches a kind/representation proof to a binding — from its declaration or its initializer — and then uses that proof to select a specialized lowering or constant-fold a predicate. The proof is sound where it was derived and unsound afterwards, because nothing invalidates it when the binding is written again, and nothing validates a declared type against the value actually present at runtime (CLAUDE.md, Known Limitations: "declared TS types aren't enforced at runtime").
Four independent bugs found within one day are all this one mistake. Filing an umbrella because patching one spelling at a time is precisely what let the same root ship four times.
the initializer's kind, never invalidated by assignment
constant-folded Array.isArray
answers from the initializer in both directions; a guard admits a number
The shared tell is one sentence: correct inline or at the definition site, wrong once the value lives in a local binding.#7700's recorded tell was already literally "wrong only when STORED in a local", and #7796's title says "only when stored in a local". Two of these were closed with the tell written down and the root not named.
Severity note: #7844's false-positive direction and #7837's dropped operand are the two that fail silently in the dangerous direction — a validation guard admitting data it exists to reject, and data vanishing from a string. Both exit 0 with no diagnostic.
Why this keeps happening
This is the structural twin of the GC rooting-dominance invariant in docs/src/internals/gc-rooting-invariant.md: a proof must dominate every subsequent site that can invalidate it. There the invalidating event is a collection; here it is an assignment. That invariant needed a static checker (scripts/gc_root_dominance_check.py) plus a CI gate plus an allowlist that shrinks, because review alone did not catch it — the same is likely true here.
It is also the predicted failure mode of the representation-selection direction rather than bad luck. #6377 already recorded that "more type visibility" PRs un-gate latent fast paths, and the memory of the PERRY_PTR_SHAPE_LOCALS=0 episode records a declared-type fallback firing whenever the Ptr<Shape> proof is missing. Every increase in type visibility widens the set of sites where an unsound binding proof gets used.
Proposed acceptance criterion
Not "fix these four". Rather:
Enumerate the binding proofs. Every place codegen/HIR consults a local's declared type or inferred initializer kind to pick a lowering or fold a predicate.
For each, establish dominance or drop it. A proof survives only if every write to the binding is dominated by it; a reassignment must kill it. Where that cannot be shown, fall back to the runtime path — note that in Symbol-keyed function value is falsy in if/! but true under Boolean() — only when stored in a local #7796 the runtime path (Boolean(f)) was correct and only the inlined one was wrong, so the fallback is known-good.
A detector, not four patches. The realistic instrument is a static pass over HIR (or --trace llvm) that flags a specialized lowering keyed on a binding proof where a write to that binding is not dominated — same shape as gc_root_dominance_check.py, including a --self-test that proves it can still fail and an allowlist whose entries must match something.
Without (4), the honest expectation is a fifth instance.
Reproducers
All four issues carry standalone reproducers; #7837 and #7844 reproduce on current main (b1edd2340, perry 0.5.1464) against Node 26.5.1, with and without PERRY_NO_AUTO_OPTIMIZE=1.
The invariant
Codegen repeatedly attaches a kind/representation proof to a binding — from its declaration or its initializer — and then uses that proof to select a specialized lowering or constant-fold a predicate. The proof is sound where it was derived and unsound afterwards, because nothing invalidates it when the binding is written again, and nothing validates a declared type against the value actually present at runtime (CLAUDE.md, Known Limitations: "declared TS types aren't enforced at runtime").
Four independent bugs found within one day are all this one mistake. Filing an umbrella because patching one spelling at a time is precisely what let the same root ship four times.
The four instances
Uint8Array/Buffer", and the key node is not a string literalUint8ArrayGettypeof u8[Symbol.iterator]isnumber; a string key held in a local reads a bytef ? …falsy whileBoolean(f)istruestringon a local that actually holds a number+s + 7→"427";t + "x"→"x"— an operand silently droppedArray.isArrayThe shared tell is one sentence: correct inline or at the definition site, wrong once the value lives in a local binding. #7700's recorded tell was already literally "wrong only when STORED in a local", and #7796's title says "only when stored in a local". Two of these were closed with the tell written down and the root not named.
Severity note: #7844's false-positive direction and #7837's dropped operand are the two that fail silently in the dangerous direction — a validation guard admitting data it exists to reject, and data vanishing from a string. Both exit 0 with no diagnostic.
Why this keeps happening
This is the structural twin of the GC rooting-dominance invariant in
docs/src/internals/gc-rooting-invariant.md: a proof must dominate every subsequent site that can invalidate it. There the invalidating event is a collection; here it is an assignment. That invariant needed a static checker (scripts/gc_root_dominance_check.py) plus a CI gate plus an allowlist that shrinks, because review alone did not catch it — the same is likely true here.It is also the predicted failure mode of the representation-selection direction rather than bad luck. #6377 already recorded that "more type visibility" PRs un-gate latent fast paths, and the memory of the
PERRY_PTR_SHAPE_LOCALS=0episode records a declared-type fallback firing whenever thePtr<Shape>proof is missing. Every increase in type visibility widens the set of sites where an unsound binding proof gets used.Proposed acceptance criterion
Not "fix these four". Rather:
if/!but true under Boolean() — only when stored in a local #7796 the runtime path (Boolean(f)) was correct and only the inlined one was wrong, so the fallback is known-good.string-declared local silently drops an operand:t + "x"returns "x", ands + 7concatenates instead of adding #7837 is the pure case; it needs a runtime check or a guarded fast path with a deopt, not trust.--trace llvm) that flags a specialized lowering keyed on a binding proof where a write to that binding is not dominated — same shape asgc_root_dominance_check.py, including a--self-testthat proves it can still fail and an allowlist whose entries must match something.Without (4), the honest expectation is a fifth instance.
Reproducers
All four issues carry standalone reproducers; #7837 and #7844 reproduce on current
main(b1edd2340, perry 0.5.1464) against Node 26.5.1, with and withoutPERRY_NO_AUTO_OPTIMIZE=1.