Skip to content

steins-infer: an object reached through an unresolved alias is escaped, and a write through an unbound name sweeps the escaped objects (#1048) - #1056

Merged
zonuexe merged 8 commits into
masterfrom
claude/array-alias-heap-write
Oct 11, 2026
Merged

zonuexe merged 8 commits into
masterfrom
claude/array-alias-heap-write

Conversation

@zonuexe

@zonuexe zonuexe commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1048: an object reached through an unresolved alias kept a stale property fact (unsound).

Root cause

The store followed an object only through a plain $b = $a copy. apply_prop_assign did nothing when the receiver was unbound, and an array literal escaped resources but not objects. Further holes with the same root turned up:

  • A loop's fall-through was built from its write sets before the body was walked, so a sweep in the body never reached the code after the loop.
  • Opaque constructs did not sweep escaped objects.
  • Joins dropped names silently.
  • Values lowered to Other (match, (object), computed keys) hid the objects they stored.
  • $r = yield $o; kept $o's facts across the suspension.
  • byref($o->p) to a resolved function kept p.

Rule (crates/steins-infer/src/heap_alias.rs)

Reach marks the object escaped. Any of these reaches it:

  • an array literal, ternary, ??, a cast or +;
  • a nested call argument, or a foreach subject;
  • a join that drops the name, or a loop or opaque construct that forgets it;
  • any Other value (new Stmt::unspelled).

These routes reach through the ones above: foreach value and by-ref, list, array_map/array_walk, current/reset/end/array_pop/array_shift, $arr[$k], iterator_to_array, array_values/array_filter, and identity functions.

A write through an unbound name sweeps the escape set. That covers:

  • a property write;
  • an unresolved method call;
  • unset;
  • a by-ref pass of a property;
  • passing a possibly-object argument, or a spread, to a resolved callee.

A by-ref pass of a bound object's property forgets that property.

Constructs.

  • A loop applies its body's sweeps to the fall-through (WalkCx::heap_sweeps).
  • Opaque sweeps.
  • An expression statement containing yield lowers to a barrier.

The whole escape set is swept rather than filtered by class or property name. An unbound receiver has no class the walk can trust, and a write can run __set or a hook. An object that no route reached keeps its facts.

Tests

  • heap_alias.rs holds:
    • a 27 reach × 5 write matrix;
    • spread, bound by-ref, yield and loop-body-sweep cases;
    • the issue's witnesses (now unknown), with the related precise cases unchanged;
    • 3 controls that keep the fact;
    • a needInt($o->p) false mismatch that is now silent, with a twin that still fires.
  • Seven of these fail on base.
  • Three new lowering tests.
  • PHP 8.5.11 witnessed all 167 matrix rows: head leaves 0 stale answers, while base left 65 of the first 124.
  • Clippy and rustdoc pass with -D warnings, as does xtask changelog. The tests for syntax, infer, db and gen also pass.
  • An ADR-0036 amendment (dated, pending ratification) and a trace-IR note are included.

Measurement (ten public packages, base d543c7c)

  • Strict and default check, effect-diff and the five transforms: byte-identical.
  • Median user time across 7 alternating runs:
package base head
guzzle 0.77 s 0.83 s
phpunit 3.15 s 3.17 s
composer 1.98 s 2.03 s

RSS is unchanged; the new field is boxed. The cost is about 2% at most, within noise at load 15–40.

Open (recorded in the ADR amendment)

  • A resolved callee that writes an escaped object through a static property or a global it reaches itself stays stale.
  • A __toString that mutates $this under . stays stale.
  • yield inside a condition or a return is not covered.
  • The private half of the local fp-gate has not been run yet; the orchestrator runs it before merge.

Review outcome

Adversarial review, two rounds: approve.

  • Round 1: approved, but asked for S1 and S2 before merge: a property write inside an expression through an unbound receiver, and a yield outside an expression statement. The same rule also gave three more rows:
    • S3: a forgotten name escapes its object.
    • S4: a loop body's escapes reach the fall-through.
    • S6: a full local clear only in by-ref generators.
  • Round 2 (at 1f55418): every S1–S4 and S6 witness now answers unknown where PHP differs.
    • These new mechanisms were attacked: release vs unbind rebinding (18 shapes), the per-scope escape recorder (nesting, unwind, break/continue), writes_through coverage (35 shapes), and yield from / by-ref closures. None was stale.
    • Strict and default output is byte-identical on five public packages. User time is about +3%.
  • Pre-existing routes not covered are tracked in Heap aliasing routes not yet escaped: methods returning $this, get_defined_vars, magic writes, two entry objects that may be the same #1057: methods returning $this, get_defined_vars, magic or hook writes, __destruct, two aliased entry objects, an inline-called closure as an assignment's right side, and a destructure nested in an assignment.

…red to Other, and an expression statement that yields is a barrier (#1048)
…es, and a write through a name the store does not bind sweeps the escaped objects, so an alias read back from an array no longer leaves the original's property fact stale (#1048)
…so the common empty case costs one word, and the new helpers stay under the width line (#1048)
…pelled names and yield, and the changelog fragment (#1048)
… which receivers they write a property through, and a yielding statement keeps its kind; a generator records whether it yields by reference (#1048)
…pes reach its fall-through, a property written inside an expression sweeps like a statement-level one, and a yield sweeps the escaped objects while keeping the locals unless the generator yields by reference (#1048)
…ent take the review's rows: embedded writes, forgotten names, loop-body escapes and yields (#1048)
@zonuexe
zonuexe force-pushed the claude/array-alias-heap-write branch from 1f55418 to bf1cc70 Compare October 11, 2026 08:29
@zonuexe
zonuexe marked this pull request as ready for review October 11, 2026 08:35
@zonuexe
zonuexe merged commit 50f5130 into master Oct 11, 2026
7 checks passed
@zonuexe
zonuexe deleted the claude/array-alias-heap-write branch October 11, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A write through a foreach alias of an object stored in an array leaves the original binding's property fact stale

1 participant