Skip to content

Commit e0a32b7

Browse files
committed
Replay the recorded fixpoint pass instead of repeating the final loop walk
Both convergence break paths guarantee the final walk's entry scope equals the last pass's entry, making the final walk an exact repetition of that pass. Convergence passes now record their (node, scope) emissions (RecordingNodeCallback); when the recorded pass consumed nothing (its recording is complete - convergence consumption skips subtree emissions) and its entry is the fixpoint, the final walk is replaced by replaying the recording through the real callback and adopting the pass's storage and statement result. The convergence mode becomes a stack of per-pass gap flags: a consumption is attributed to the innermost active pass only, so inner convergence (a by-ref closure inside a loop body) does not gap the outer recording, which received the inner final walk's emissions in full. Applies to While, Do-While, Foreach (non-unrolled finals) and the by-ref closure convergence, whose replay runs through the gathering callback on the pass's own entry scope - the gathering filter compares the anonymous-function reflection by identity. A body containing constructs that analyse differently at deep context than in the top-level final (nested loop/label fixpoints, statement-level classes) is not replayed; For keeps its real final walk (it applies inferForLoopExpressions there, which the passes do not).
1 parent 4a25082 commit e0a32b7

6 files changed

Lines changed: 334 additions & 60 deletions

File tree

src/Analyser/NodeScopeResolver.php

Lines changed: 138 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
use function array_last;
106106
use function array_map;
107107
use function array_merge;
108+
use function array_pop;
108109
use function array_slice;
109110
use function array_values;
110111
use function count;
@@ -144,12 +145,20 @@ class NodeScopeResolver
144145
private bool $consumeStoredExpressionResults = false;
145146

146147
/**
147-
* Convergence-pass mode (see enterConvergencePass()): a fixpoint re-walk
148-
* may consume the previous pass's stored result of an effect-free
149-
* expression whose read variables did not change - only the loop-carried
150-
* parts of the body re-walk.
148+
* The stack of active convergence passes (see enterConvergencePass()),
149+
* innermost last; a fixpoint re-walk may consume the previous pass's
150+
* stored result of an effect-free expression whose read variables did not
151+
* change - only the loop-carried parts of the body re-walk. Each entry
152+
* records whether that pass consumed anything: a consumed subtree's
153+
* emissions are missing from the pass's recording, so only a gap-free
154+
* pass's recording can replace the final walk. A consumption is
155+
* attributed to the innermost active pass only - inner convergence
156+
* (a by-ref closure inside a loop body) does not gap the outer recording,
157+
* which received the inner FINAL walk's emissions in full.
158+
*
159+
* @var array<int, bool>
151160
*/
152-
private bool $consumeEffectFreeStoredResults = false;
161+
private array $convergencePassGaps = [];
153162

154163
/** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */
155164
public static bool $guardNewWorld = false;
@@ -290,24 +299,24 @@ public function processNodes(
290299
* flags - on-demand stored-result reuse and convergence-pass consumption
291300
* describe the walk that was interrupted, not the nested one.
292301
*
293-
* @return array{bool, bool, bool}
302+
* @return array{bool, bool, array<int, bool>}
294303
*/
295304
private function suspendWalkModes(): array
296305
{
297-
$modes = [$this->returnStoredExpressionResults, $this->consumeStoredExpressionResults, $this->consumeEffectFreeStoredResults];
306+
$modes = [$this->returnStoredExpressionResults, $this->consumeStoredExpressionResults, $this->convergencePassGaps];
298307
$this->returnStoredExpressionResults = false;
299308
$this->consumeStoredExpressionResults = false;
300-
$this->consumeEffectFreeStoredResults = false;
309+
$this->convergencePassGaps = [];
301310

302311
return $modes;
303312
}
304313

305314
/**
306-
* @param array{bool, bool, bool} $modes
315+
* @param array{bool, bool, array<int, bool>} $modes
307316
*/
308317
private function restoreWalkModes(array $modes): void
309318
{
310-
[$this->returnStoredExpressionResults, $this->consumeStoredExpressionResults, $this->consumeEffectFreeStoredResults] = $modes;
319+
[$this->returnStoredExpressionResults, $this->consumeStoredExpressionResults, $this->convergencePassGaps] = $modes;
311320
}
312321

313322
/**
@@ -479,7 +488,7 @@ private function resolveBackwardGotoScope(
479488
}
480489
$prevEntryScope = $bodyScope;
481490
$tempStorage = ($previousPassStorage ?? $storage)->duplicate();
482-
$previousConvergencePass = $this->enterConvergencePass();
491+
$this->enterConvergencePass();
483492
try {
484493
$bodyScopeResult = $this->processStmtNodesInternal(
485494
$parentNode,
@@ -490,7 +499,7 @@ private function resolveBackwardGotoScope(
490499
$context,
491500
);
492501
} finally {
493-
$this->exitConvergencePass($previousConvergencePass);
502+
$this->exitConvergencePass();
494503
}
495504
$previousPassStorage = $tempStorage;
496505

@@ -1006,20 +1015,23 @@ public function processExprNodeConsumingStored(Node\Stmt $stmt, Expr $expr, Muta
10061015
* whose stored result is effect-free (ExpressionResult::isEffectFree())
10071016
* and whose read variables did not change since the pass that stored it
10081017
* are consumed instead of re-walked. The caller chains the previous pass's
1009-
* storage as the new pass storage's fallback and restores the returned
1010-
* previous mode in a finally block via exitConvergencePass().
1018+
* storage as the new pass storage's fallback and pops the pass in a
1019+
* finally block via exitConvergencePass().
10111020
*/
1012-
public function enterConvergencePass(): bool
1021+
public function enterConvergencePass(): void
10131022
{
1014-
$previous = $this->consumeEffectFreeStoredResults;
1015-
$this->consumeEffectFreeStoredResults = true;
1016-
1017-
return $previous;
1023+
$this->convergencePassGaps[] = false;
10181024
}
10191025

1020-
public function exitConvergencePass(bool $previous): void
1026+
/** Returns whether the exited pass consumed anything (a recording gap). */
1027+
public function exitConvergencePass(): bool
10211028
{
1022-
$this->consumeEffectFreeStoredResults = $previous;
1029+
$gapped = array_pop($this->convergencePassGaps);
1030+
if ($gapped === null) {
1031+
throw new ShouldNotHappenException();
1032+
}
1033+
1034+
return $gapped;
10231035
}
10241036

10251037
public function processExprOnDemand(Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage): ExpressionResult
@@ -1233,7 +1245,7 @@ public function processExprNode(
12331245

12341246
return $reanchored;
12351247
}
1236-
} elseif ($this->consumeEffectFreeStoredResults) {
1248+
} elseif ($this->convergencePassGaps !== []) {
12371249
// a convergence pass: the previous pass's result answers exactly
12381250
// when the walk had no scope effects and nothing this expression
12391251
// reads changed between the passes - the loop-carried parts of the
@@ -1244,6 +1256,7 @@ public function processExprNode(
12441256
&& $storedResult->isEffectFree()
12451257
&& $storedResult->askScopeVariableStateMatches($scope, $scope->nativeTypesPromoted)
12461258
) {
1259+
$this->convergencePassGaps[count($this->convergencePassGaps) - 1] = true;
12471260
if ($storedResult->getBeforeScope() === $scope) {
12481261
return $storedResult;
12491262
}
@@ -1255,6 +1268,71 @@ public function processExprNode(
12551268
return $this->processExprNodeInternal($stmt, $expr, $scope, $storage, $nodeCallback, $context);
12561269
}
12571270

1271+
private const REPLAYABLE_BODY_ATTRIBUTE = 'convergenceReplayableBody';
1272+
1273+
/**
1274+
* Whether a recorded convergence pass over the loop body can replace the
1275+
* final walk. A pass runs at deep statement context, the final walk at top
1276+
* level - constructs that analyse differently between the two (nested
1277+
* loop/label fixpoints run only at top level, statement-level classes are
1278+
* skipped at deep context) disqualify the body. Closure bodies process
1279+
* context-independently and are not traversed.
1280+
*
1281+
* @param Node\Stmt[] $bodyStmts
1282+
*/
1283+
public function isReplayableConvergenceBody(Node $loopNode, array $bodyStmts): bool
1284+
{
1285+
$cached = $loopNode->getAttribute(self::REPLAYABLE_BODY_ATTRIBUTE);
1286+
if ($cached !== null) {
1287+
return $cached;
1288+
}
1289+
1290+
$replayable = true;
1291+
foreach ($bodyStmts as $bodyStmt) {
1292+
if ($this->hasContextSensitiveConstruct($bodyStmt)) {
1293+
$replayable = false;
1294+
break;
1295+
}
1296+
}
1297+
$loopNode->setAttribute(self::REPLAYABLE_BODY_ATTRIBUTE, $replayable);
1298+
1299+
return $replayable;
1300+
}
1301+
1302+
private function hasContextSensitiveConstruct(Node $node): bool
1303+
{
1304+
if ($node instanceof Expr\Closure) {
1305+
return false;
1306+
}
1307+
if (
1308+
$node instanceof Node\Stmt\While_
1309+
|| $node instanceof Node\Stmt\Do_
1310+
|| $node instanceof Node\Stmt\For_
1311+
|| $node instanceof Foreach_
1312+
|| $node instanceof Node\Stmt\Label
1313+
|| $node instanceof Node\Stmt\ClassLike
1314+
) {
1315+
return true;
1316+
}
1317+
1318+
foreach ($node->getSubNodeNames() as $subNodeName) {
1319+
$subNode = $node->$subNodeName;
1320+
if ($subNode instanceof Node) {
1321+
if ($this->hasContextSensitiveConstruct($subNode)) {
1322+
return true;
1323+
}
1324+
} elseif (is_array($subNode)) {
1325+
foreach ($subNode as $item) {
1326+
if ($item instanceof Node && $this->hasContextSensitiveConstruct($item)) {
1327+
return true;
1328+
}
1329+
}
1330+
}
1331+
}
1332+
1333+
return false;
1334+
}
1335+
12581336
/**
12591337
* @param callable(Node $node, Scope $scope): void $nodeCallback
12601338
*/
@@ -1577,6 +1655,11 @@ private function processClosureNodeInternal(
15771655

15781656
$count = 0;
15791657
$closureResultScope = null;
1658+
$replayBodyRecording = null;
1659+
$replayPassStorage = null;
1660+
$replayPassResult = null;
1661+
$replayEntryScope = null;
1662+
$bodyIsReplayable = $this->isReplayableConvergenceBody($expr, $expr->stmts);
15801663
// each pass chains the previous pass's storage as its fallback, so
15811664
// the convergence-pass mode consumes unchanged effect-free subtrees
15821665
// instead of re-walking the whole body every round
@@ -1585,17 +1668,33 @@ private function processClosureNodeInternal(
15851668
$prevScope = $closureScope;
15861669

15871670
$storage = ($previousPassStorage ?? $originalStorage)->duplicate();
1588-
$previousConvergencePass = $this->enterConvergencePass();
1671+
$this->enterConvergencePass();
1672+
$passGapped = false;
1673+
$bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback();
15891674
try {
15901675
// deep context, like the loop handlers' own convergence passes: inner
15911676
// loops walk single-pass here and only the final walk below (top-level)
15921677
// runs their full convergence - otherwise every closure-convergence
15931678
// pass would re-converge every inner loop from scratch
1594-
$intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, new NoopNodeCallback(), StatementContext::createDeep());
1679+
$intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $bodyRecording, StatementContext::createDeep());
15951680
} finally {
1596-
$this->exitConvergencePass($previousConvergencePass);
1681+
$passGapped = $this->exitConvergencePass();
15971682
}
15981683
$previousPassStorage = $storage;
1684+
// a pass that consumed nothing has a complete recording - the
1685+
// candidate to replace the final walk when its entry turns out
1686+
// to be the fixpoint
1687+
if ($bodyRecording instanceof RecordingNodeCallback && !$passGapped) {
1688+
$replayBodyRecording = $bodyRecording;
1689+
$replayPassStorage = $storage;
1690+
$replayPassResult = $intermediaryClosureScopeResult;
1691+
$replayEntryScope = $prevScope;
1692+
} else {
1693+
$replayBodyRecording = null;
1694+
$replayPassStorage = null;
1695+
$replayPassResult = null;
1696+
$replayEntryScope = null;
1697+
}
15991698
$intermediaryClosureScope = $intermediaryClosureScopeResult->getScope();
16001699
foreach ($intermediaryClosureScopeResult->getExitPoints() as $exitPoint) {
16011700
$intermediaryClosureScope = $intermediaryClosureScope->mergeWith($exitPoint->getScope());
@@ -1623,7 +1722,20 @@ private function processClosureNodeInternal(
16231722
}
16241723

16251724
$storage = $originalStorage;
1626-
$statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel());
1725+
if ($replayBodyRecording !== null && $closureScope->equals($replayEntryScope)) {
1726+
// the final walk would repeat the recorded fixpoint pass exactly
1727+
// (same entry scope, deterministic walk) - adopt the pass's result
1728+
// and replay its emissions through the gathering callback instead.
1729+
// The pass's own entry scope takes over: the recorded pairs carry
1730+
// its anonymous-function reflection, which the gathering filter
1731+
// compares by identity (the state is equals-identical anyway).
1732+
$closureScope = $replayEntryScope;
1733+
$originalStorage->mergeResults($replayPassStorage);
1734+
$replayBodyRecording->replayThrough($closureStmtsCallback);
1735+
$statementResult = $replayPassResult;
1736+
} else {
1737+
$statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel());
1738+
}
16271739
$publicStatementResult = $statementResult->toPublic();
16281740
$closureReturnStatementsNodeScope = $this->refineClosureNodeScope($closureScope, $scope, $expr, $gatheredReturnStatementsWithScope, $gatheredYieldStatementsWithScope, $executionEnds, $statementResult->getThrowPoints(), array_merge($closureImpurePoints, $statementResult->getImpurePoints()), $invalidateExpressions, $storage);
16291741
$this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode(
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser;
4+
5+
use PhpParser\Node;
6+
7+
/**
8+
* Records every (node, scope) emission of a convergence pass in order. When
9+
* the pass turns out to be the fixpoint (the final entry scope equals the
10+
* pass's entry and the pass consumed nothing), the final walk is replaced by
11+
* replaying the recording through the real node callback - the scopes are
12+
* state-equal to what the repeated walk would emit.
13+
*/
14+
final class RecordingNodeCallback
15+
{
16+
17+
/** @var list<array{Node, Scope}> */
18+
private array $pairs = [];
19+
20+
public function __invoke(Node $node, Scope $scope): void
21+
{
22+
$this->pairs[] = [$node, $scope];
23+
}
24+
25+
/**
26+
* @param callable(Node $node, Scope $scope): void $nodeCallback
27+
*/
28+
public function replayThrough(callable $nodeCallback): void
29+
{
30+
foreach ($this->pairs as [$node, $scope]) {
31+
$nodeCallback($node, $scope);
32+
}
33+
}
34+
35+
}

0 commit comments

Comments
 (0)