Skip to content

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

Description

@proggeramlug

The invariant

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 four instances

issue state binding proof what it selected symptom
#7700 closed "local is Uint8Array/Buffer", and the key node is not a string literal fold every non-STRING key to Uint8ArrayGet typeof u8[Symbol.iterator] is number; a string key held in a local reads a byte
#7796 closed the value's function-ness, lost once stored in a local inlined truthiness fast path f ? … falsy while Boolean(f) is true
#7837 open declared string on a local that actually holds a number concat lowering for + s + 7"427"; t + "x""x" — an operand silently dropped
#7844 open 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:

  1. 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.
  2. 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.
  3. A declared TS type is never a representation proof on its own. A lying string-declared local silently drops an operand: t + "x" returns "x", and s + 7 concatenates instead of adding #7837 is the pure case; it needs a runtime check or a guarded fast path with a deopt, not trust.
  4. 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.

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