Skip to content

Inline forwarding: a class double runs the real bodies on itself - #157

Merged
rasuvaeff merged 4 commits into
masterfrom
feat/inline-forwarding
Oct 13, 2026
Merged

rasuvaeff merged 4 commits into
masterfrom
feat/inline-forwarding

Conversation

@rasuvaeff

@rasuvaeff rasuvaeff commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Fixes #150

A forwarding double of a class now runs the real bodies on itself. The state the instance held is copied onto the double when forwarding() is called, and an unmatched call runs the doubled class's own body with $this still the double — so a self-call inside real code re-enters the double, meets stubs, an armed protocol and the call log, and is recorded like the boundary call. An interface double has no bodies to reach and proxies exactly as before. The README limitation paragraph is deleted, which was the acceptance criterion.

Mechanism: reflection, not a parent:: closure

The issue proposed what jasonmccreary/double does — a generated __td_real_*() sibling calling parent::. Golden rule 3 closes that: every member added to a double is a name the doubled type can no longer use.

The next candidate, a closure rendered into each generated method and handed to the dispatcher, was built and measured. It cost +10% per call on every class double, forwarding or not, because the argument is evaluated before dispatch can decide it is not needed. perf/ doubles only interfaces, so make perf cannot see this; the figure comes from a loop over a loose class double (1.217 → 1.337 µs/call, three paired runs, no overlap).

Runtime::runInline() therefore enters the body through ReflectionMethod::invokeArgs() on a reflection of the doubled class: the same non-virtual entry parent:: gives, while $this stays the double so the body's self-calls dispatch virtually and land back in the override. The generated code is byte-identical to master. spikes/08-inline-forwarding pins the language claim (non-virtual entry, virtual self-calls, protected bodies, by-reference parameters, $this identity) on 8.3, 8.4 and 8.5.

Decisions open to review

Decision Why
Inline is the default for class doubles An opt-in mode would leave the limitation as the default behaviour, and the acceptance asks for the paragraph deleted, not reworded
Inline requires the instance to be exactly the doubled class A subclass instance carries overrides the doubled class's bodies do not contain; running those instead would silently change which code executes, so such a double keeps the proxy form
callOriginal() follows the double's own form After the copy, the donor's state is no longer the current one — an inline double's escape hatch has to run where the state is
A clone made while a body runs inherits that double's forwarding — but only when it IS a copy of that double Without the inheritance a : self wither, or a body computing over an internal copy, would hold a default-answering double in the middle of real code. Without the second half, a body handed another double of its own class and cloning that one — merge(self $other) — would adopt it with no state copy to run bodies over. Runtime::isCopyOf() compares both property tables through get_mangled_object_vars(); expectations are still not copied
The frames a clone can see stop at the test's own closure An answers()/returns() closure runs inside dispatch, which a real body can reach, and clone $double written there is the test's clone. Runtime::$inlineFloor hides what is underneath for exactly its span, so only what the closure itself enters (callOriginal()) stays visible
A new static is refused, by registration rather than return type Its constructor built its own state, so it cannot be adopted, and an instance no context knows is broken for every caller
Re-pointing an inline double is refused A readonly property cannot be written twice, and a second copy would discard what the real code has written since

Behaviour changes for a suite that already forwards class doubles

Documented as **Changed.** in CHANGELOG.md, one test each:

  • a self-call counts toward expect(...)->times(n) and can fill a step of an armed protocol;
  • callOriginal() runs on the double, over the state the double holds;
  • static::class inside a real body names the generated class;
  • a real body handing out a new static raises OriginalReturnTypeViolation;
  • the instance stops receiving the calls — it holds the state it had when forwarding() ran, and the copy is shallow, so writes through an object-valued property still land on the donor's collaborators.

The one thing PHP leaves open

Two doubles of one class in the same state are indistinguishable: __clone()
is handed no reference to its source, so a copy of the other double is
adopted and answers for real, over the state it already holds. It is stated as
a limitation in both READMEs, the forwarding guide and the CHANGELOG, rather
than called harmless — proxied, that copy answered defaults.

Verification

Gate Result
composer build PASS — Unit 1173, Integration 27, examples 8
PHP 8.3 / 8.4 / 8.5 1173 tests green on each; spikes 7/7 on 8.3 and 8.4
composer mutation 3586 mutants, 249 escaped, MSI 93% (gate 92) — master: 3481 / 234 / 93%. On the changed lines the survivors are equivalents, and the clone-adoption fix added none: cache-key permutations that stay unique per Class::method, ??= → =, ?-> where null is unreachable, >= 80400 vs > 80400
make perf / perf-memory ours −3.9…+3.5% against competitors' −5.7…+0.6% in the same runs; memory per double unchanged (586 / 571 bytes). Class-double dispatch loop: −3.4%, −4.8%, +3.4%. re-taken for the clone-adoption fix on 2026-10-13, which puts two integer writes and a try/finally on every matched-with-action call: six runs of the full harness in one sitting, alternating b8a8f8e and 8befa94, medians of three a side. Ours moved −2.6%, +1.6%, −18.3%, −18.5%, 0.0%, 0.0% across the six tables while Mockery, Prophecy and PHPUnit moved −18.4% to +3.0% in the same runs — the two −18% tables moved for everybody, so the machine moved, not the library, and the two per-call dispatch tables (narrow, wide) are 0.0% for us against ±3.0% for them. RStDev ran 6.5–20%, so the ratios are the reading, not the microseconds. perf-memory identical to the byte (586 / 571 B)
make release-check rector clean; roave reports only the pre-existing verifyAll(): void → int from #154
bin/package-audit 0 errors, 0 warnings
docs docs-api, docs-build, docs-claims, docs-cookbook, docs-links green; MIGRATION.md regenerated; the @throws completeness budget lowered 8 → 7. make docs-vale needs a vale binary the authoring host has not got — the docs job lints the prose

The @throws budget move and the version strings across docs/src/api/** are the ordinary product of make docs-api on this branch.

Review follow-up (b91073a)

A review of the branch found that adoption decided on the clone's class,
and one generated class serves every double of a type. A body handed another
double of its own class and cloning that one adopted it on the running
double's behalf, with bodies to run and no state copy to run them over: PHP's
own must not be accessed before initialization where the class declares a
typed property, and a quiet answer computed from the declaration's default
where it has one. Both were regressions — proxied, the copy answered defaults.
A clone $double in the test's own answers() closure was adopted too,
against the documented rule for it.

The fix is the two table rows above. Seven tests came with it; five fail or
error on the commit before it, and two pin mechanisms that did not exist
there — the floor coming back after a throwing stub, and a hook that counts
its own calls to show that neither the state copy nor the comparison runs one.

A forwarding double of a class no longer proxies the real bodies to the
instance it was given: the state that instance held is copied onto the
double, and an unmatched call runs the doubled class's own body with `$this`
still the double. A self-call inside real code therefore re-enters the
double — it meets stubs, an armed protocol and the call log — which is what a
`makePartial()` port needs and what the standing limitation ("understudy
proxies an object; it does not instrument one") described. An interface
double has no bodies to reach and proxies exactly as before.

The body is entered through `ReflectionMethod::invokeArgs()` on a reflection
of the doubled class, not through a generated `parent::` closure. The closure
variant was built and measured first: handed to the dispatcher as an argument
of every generated method, it cost +10% per call on every class double,
forwarding or not, because the argument is evaluated before dispatch can
decide it is not needed — and `perf/` doubles only interfaces, so the harness
cannot see it. With the reflection the generated code is byte-identical to
master. A generated `__understudyReal_*()` sibling is the third way, and it
is closed by golden rule 3. The language claim — non-virtual entry, virtual
self-calls, protected bodies, by-reference parameters, `$this` identity — is
pinned by spikes/08-inline-forwarding on 8.3, 8.4 and 8.5.

Decisions behind the shape:

- Inline needs the instance to be exactly the doubled class. A donor that is
  a subclass carries overrides the doubled class's bodies do not contain, and
  running those instead would silently change which code executes, so it
  keeps the proxy form.
- `callOriginal()` follows the double's own form, because after the copy the
  donor's state is no longer the current one.
- A clone made while a real body runs inherits that double's forwarding:
  without it a `: self` wither, or a body merely computing over an internal
  copy, would hold a double answering defaults in the middle of real code.
  Expectations are still not copied, and a clone the test makes is untouched.
- A `new static` cannot be adopted — its constructor built its own state — so
  it is refused by registration rather than by return type: an instance no
  context knows is broken for every caller.
- Re-pointing an inline double at another instance is refused. A `readonly`
  property cannot be written twice, and a second copy would discard what the
  real code has written since.

Measured: mutation 3573 mutants / 93% MSI (gate 92), against 3481 / 93% on
master; the class-double dispatch loop moves -3.4%, -4.8%, +3.4% across three
paired runs, and `perf`/`perf-memory` are unchanged. Suites green on 8.3, 8.4
and 8.5.
@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a5fdd3e8-9bf5-4a4d-949b-db4d12d0ecd4
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…on 8.3

`hasHooks()`, `isVirtual()`, `getRawValue()` and `setRawValue()` exist from
PHP 8.4, and the state copy reaches them behind a `PHP_VERSION_ID` guard — but
Psalm reflects the real `ReflectionProperty` of the PHP it runs on, so naming
them directly is an `UndefinedMethod` under 8.3. `composer build` was green on
a machine with 8.5 and red in the `PHP 8.3` and `Prefer lowest` matrix jobs,
which is where this was caught.

They are now asked through two private helpers that take the member name as a
parameter, so nothing can resolve it — the idiom
`Codegen\PropertyDefaults::isWritableFromOutside()` already uses for the same
three members. Verified by running psalm under php:8.3-cli and php:8.4-cli,
not only under the 8.5 the build image carries.
Inline forwarding adopted any clone of the running double's class, and one
generated class serves every double of a type: a real body handed another
double of its own class — `merge(self $other)` — and cloning that one gave
the copy bodies to run with no state copy to run them over. Where the class
declares a typed property that is PHP's own "must not be accessed before
initialization" out of the clone; where every property has a declared
default it is a quiet wrong answer computed from the declaration instead of
the donor. Both were a regression: proxied, the copy answered defaults.

`Runtime::isCopyOf()` now compares the two property tables with
`get_mangled_object_vars()` — the only reading that reaches a parent's
private properties, answers a hooked property with its backing store rather
than running the doubled class's `get` in the middle of `__clone()`, has no
entry for a virtual one, and omits what nobody initialized. A copy is
property-identical to its source at this moment, because a generated
`__clone()` does nothing but call `adoptClone()`.

Two doubles of one class in the same state stay indistinguishable, because
`__clone()` is handed no reference to its source: such a copy is still
adopted and answers for real, over the state it already holds. That
residual is documented where the rule is stated — both READMEs, the
forwarding guide, the CHANGELOG — rather than called harmless.

A `clone $double` written in the test's own `answers()` closure was adopted
too, although the documented rule makes it a fresh double: the closure runs
inside dispatch, which a real body can reach. `Runtime::$inlineFloor` hides
the frames underneath for exactly the closure's span, so only what the
closure itself enters — `callOriginal()`, which pushes a frame of its own —
is visible to a clone made there.

Five of the seven new tests fail or error on the previous commit: both
forms of the foreign clone, the test closure under a real body, the
comparison reading a hooked property through its backing store, and a plain
property declared after a hooked and a virtual one deciding the verdict.
The other two pin mechanisms that did not exist before it — the floor
coming back after a throwing stub, and the hook that counts its own calls
to show neither the state copy nor the comparison runs one.

composer build green (Unit 1173, Integration 27, examples 8), full mutation
3586 mutants / 249 escaped / MSI 93% against the gate of 92, rector clean,
roave reports only the pre-existing verifyAll(): void -> int from #154,
bin/package-audit 0 errors, docs build and claims green. `make docs-vale`
needs a vale binary this host has not got; the docs job lints the new prose.
Perf was not re-measured.
…below 8.5

The fixtures of the previous commit declared `mergeWith(self $other)`, and
the suite died on PHP 8.3 and 8.4 before reaching a single test:

    Declaration of …\Generated\Understudy_c28436a2f78b6ee1::mergeWith(
      Understudy_c28436a2f78b6ee1|ArgumentMatcher|Absent $other = …): string
    must be compatible with …\Fixture\Fwd\Pipeline::mergeWith(
      Pipeline $other): string

The generated override renders the parameter as `self`, which inside the
generated class is the generated class — a subclass of the doubled one, so
the widened union excludes the very type the contract accepts. PHP 8.5 lets
it through, which is why the authoring host and the 8.5 job were green while
every other job was not.

That is a codegen gap of its own, not something this branch introduced: no
fixture had a `self`-typed parameter before. It is filed separately, and the
regression fixtures here name their class explicitly, which is what a user's
class usually does anyway.

Unit suite 1173 green on 8.3, 8.4 and 8.5 (the first two in `php:8.N-cli`
directly, since `composer:2` is 8.5 and cannot see this); `composer build`
and `bin/package-audit` green.
@rasuvaeff
rasuvaeff merged commit 5f3c4c3 into master Oct 13, 2026
25 checks passed
@rasuvaeff
rasuvaeff deleted the feat/inline-forwarding branch October 14, 2026 09:15
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.

Forwarding doubles cannot intercept self-calls — consider parent::-based inline forwarding (as double PR #15)

1 participant