Skip to content

Emit more node callbacks after their expressions' results are stored - #6278

Merged
ondrejmirtes merged 9 commits into
2.2.xfrom
callback-emission-order-2
Aug 26, 2026
Merged

ondrejmirtes merged 9 commits into
2.2.xfrom
callback-emission-order-2

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Aug 26, 2026

Copy link
Copy Markdown
Member

Extracted from resolve-type-rewrite-2 (extraction series, follows #6274/#6275). Continues the post-order callback emission that #6248 established (ae03ab3ec1, 1dc598762f, 8bb4d4d352): each reorder makes rule-side getType() asks answer from the storage instead of re-walking yet-unstored sub-expressions on demand.

Six reorders, each gated individually with the full test suite — zero expectation churn at every step:

  • Closure and arrow-function argument callbacks fire after their results are stored (re-authored on mainline's processArgs arms, emission scope unchanged).
  • Intermediate assign-target links ($a[$x][$y] = …) emit after their dimension was processed, with the link's entry scope — defer-only on mainline, matching the whole-target idiom from 1dc598762f.
  • The AssignOp result is stored before applyWrite() emits assignment nodes, so PropertyAssignNode rules asking about the whole $lvalue OP= value expression answer from the storage. The stored entry is the same before-scope-anchored result the engine stores after the handler returns, just visible earlier.
  • Array item callbacks fire after their key and value were processed.
  • Unset_, ClassConst, Const_ and While_ statement callbacks are deferred past their expressions, joining Return_/Expression/Echo_/If_/Switch_/Foreach_. In WhileHandler the deferred emission sits in both arms of the Replay the recorded fixpoint pass instead of repeating the final loop walk #6249 replay split: after the condition's replay, or after its real walk, before the body either way.
  • Class attributes are processed before the InClassNode emission, so rules firing on it (e.g. ClassAttributesRule) read the attribute arguments from the storage.

Gates: full suite 21155 tests / 96002 assertions green after each commit, make phpstan clean, make cs clean. None of the touched files have turbo mirrors; only existing ExpressionResultStorage API calls are added.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R7pqAkCx4xP6WYxBo2nMJE


Round 2 — miss census. A temporary census guard in NodeCallbackScope (log every stored-result miss with the emitting callback node, the asked expression, and the asking rule) over the full test suite + self-analysis found three more real emission-order gaps, fixed in the follow-up commits:

  • Switch_ fired before its case conditions were walkedMatchingTypeInSwitchCaseConditionRule (strict-rules) re-priced every case condition on demand (~250 walk-scope re-pricings on self-analysis). The deferred emission now sits after the cases.
  • Do_ was not deferred at all, and DoWhileLoopConditionNode fired before the final condition walk — both now emit after the condition's results are stored.
  • Inc/dec expressions lacked the AssignOp treatment: PropertyAssignNode rules asking about the whole $x++ missed the storage; all four handlers now store a before-scope-anchored result before the virtual assign runs.

Everything else the census surfaced is classified non-actionable: rule-fabricated synthetic nodes (including DataProviderDataRule's simulated test-method calls, which carry a real startLine but are TypeExpr-based fabrications), aggregate-node forward asks (ClassPropertiesNode scanning bodies), O(1) variable state reads, and a handful of ArgumentsNormalizer clones.

Gates re-run after the instrumentation was removed: full suite 21155/96002 green, make phpstan clean, make cs clean — zero churn.

…essed

The dimensions loop in doPrepareTarget() fired an intermediate chain
link's node callback before evaluating its dimension and before the
link's write-flavoured result existed, so callback-side consumers
(NonexistentOffsetInArrayDimFetchRule, DependencyResolver) re-walked the
yet-unstored sub-expressions. The callback now fires at the end of the
iteration, with the link's entry scope, after its result is stored.
InvalidKeyInArrayItemRule asks about the item's key at the ArrayItem
callback - firing the callback after the key and value walks lets it
answer from the storage instead of re-walking them.
…their expressions

These statements' rules (UnsetRule, ValueAssignedToClassConstantRule,
ValueAssignedToGlobalConstantRule, WhileLoopAlwaysFalseConditionRule)
read the statement's child expressions, so the statement callback now
fires inside the handler after those expressions are processed - with
the entry scope, joining the existing deferred set (Return_, Expression,
Echo_, If_, Switch_, Foreach_). The per-constant Const node callbacks
move after their value walks for the same reason.
ClassAttributesRule fires on InClassNode and reads the attribute
constructor arguments - walking the attribute groups first lets it answer
from the storage.
@ondrejmirtes
ondrejmirtes merged commit 3db8662 into 2.2.x Aug 26, 2026
1 of 5 checks passed
@ondrejmirtes
ondrejmirtes deleted the callback-emission-order-2 branch August 26, 2026 15:13
@SanderMuller

Copy link
Copy Markdown
Contributor

Verified - this fixes phpstan/phpstan#15112, and the mechanism it closes is exactly the one I measured there. All numbers below: M-series mac, PHP 8.5.8, no turbo extension, interleaved A/B of e6aee4db5 (the commit before the series) vs 3db8662d0, 3 rounds each.

1. The regression is gone. bug-7901.php:

round 1 round 2 round 3
before 434.2 ms 440.0 ms 438.4 ms
after 341.0 ms 341.8 ms 344.6 ms

-21.7%, rstdev <=1.5%. In a separate interleaved run earlier today 8bb4d4d35 (the parent of the fiber removal) measured 358-364 ms, so this lands at or slightly below the pre-regression level.

2. It closes the specific gap, not just the symptom. Counters on that file:

before after 8bb4d4d35 (pre-regression)
MutatingScope::getType() calls 90,000 60,002 60,002
ArrayHandler::resolveType() runs 6 4 4

The two extra full re-prices of the 15,000-element literal are gone - the ClassConst deferral is what does it, since ValueAssignedToClassConstantRule now answers from the storage instead of re-pricing on a cold scope.

3. No cost elsewhere. Ten subjects measured; bug-14972, bug-14996, bug-14624, nullsafe-chain-walk first looked 1-3% slower, but that was drift - interleaved, bug-14996 is 837/836/846 ms before vs 834/838/842 ms after.

4. Independent census, on the bench corpus rather than the test suite. 185,350 stored-ask hits against 1,338 misses (0.7%), and I classified each miss by whether the same node object is stored later in the run:

  • zero asked-before-stored cases - no ordering gaps left on this corpus
  • all 1,338 are nodes never stored at all: RuleLevelHelper on PropertyFetch (735), StaticMethodCallCheck on ClassConstFetch (223), RuleLevelHelper on MethodCall / ArrayDimFetch (120 each), ImpossibleCheckTypeHelper on ArrayDimFetch (119), tail of <10

That matches your own classification of the leftovers as fabrications, clones and forward asks. Worth keeping in mind that a never-stored miss still re-prices the expression, so it is cheap only because these shapes are cheap - a literal the size of bug-7901's reached this way would cost the same 30% again.

5. The riskiest hunk holds up. For "store the AssignOp result before applyWrite()" and the four inc/dec handlers I checked both things that could go wrong:

  • The fabricated throwPoints: [] / impurePoints: [] / hasYield: false never escape: storeExpressionResult() keeps only getBeforeScope().
  • The early store and the final store use the same before-scope object - a re-store detector reports AssignOp\Concat 1676, AssignOp\Plus 178, PostInc 324, all SAME-SCOPE. The DIFFERENT-SCOPE re-stores it also reports (ArrayDimFetch 8830, Variable 1778) are loop-convergence passes and pre-existing: 8852 and 1784 before the series.

One suggestion, not a defect: fabricating a partial ExpressionResult only to hand its before-scope to the storage depends on storeExpressionResult() discarding the rest. If the storage ever keeps more than the before-scope, those empty throw/impure points become silently wrong. A before-scope-only entry point would make that safe by construction.

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.

2 participants