Skip to content

Commit d71ee8f

Browse files
phpstan-botstaabmclaude
authored
Prevent nested foreach unrolling to fix exponential blowup (#5732)
Co-authored-by: staabm <120441+staabm@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 527689e commit d71ee8f

2 files changed

Lines changed: 92 additions & 5 deletions

File tree

src/Analyser/NodeScopeResolver.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ class NodeScopeResolver
193193
private const LOOP_SCOPE_ITERATIONS = 3;
194194
private const GENERALIZE_AFTER_ITERATION = 1;
195195
private const FOREACH_UNROLL_LIMIT = 16;
196-
private const FOREACH_UNROLL_NESTED_LIMIT = 16;
196+
private const FOREACH_UNROLL_NESTED_LIMIT = 8;
197197

198198
/** @var array<string, true> filePath(string) => bool(true) */
199199
private array $analysedFiles = [];
@@ -1450,6 +1450,7 @@ public function processStmtNode(
14501450

14511451
$originalStorage = $storage;
14521452
$unrolledEndScope = null;
1453+
$unrolledTotalKeys = null;
14531454
if ($context->isTopLevel()) {
14541455
$storage = $originalStorage->duplicate();
14551456

@@ -1458,6 +1459,7 @@ public function processStmtNode(
14581459
if ($unrolledResult !== null) {
14591460
$bodyScope = $unrolledResult['bodyScope'];
14601461
$unrolledEndScope = $unrolledResult['endScope'];
1462+
$unrolledTotalKeys = $unrolledResult['totalKeys'];
14611463
} else {
14621464
$bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $nodeCallback);
14631465
$count = 0;
@@ -1486,7 +1488,8 @@ public function processStmtNode(
14861488
$bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope);
14871489
$storage = $originalStorage;
14881490
$bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback);
1489-
$finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints();
1491+
$finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context;
1492+
$finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints();
14901493
$finalScope = $finalScopeResult->getScope();
14911494
$scopesWithIterableValueType = [];
14921495

@@ -4107,7 +4110,7 @@ public function processVarAnnotation(MutatingScope $scope, array $variableNames,
41074110
}
41084111

41094112
/**
4110-
* @return array{bodyScope: MutatingScope, endScope: MutatingScope}|null
4113+
* @return array{bodyScope: MutatingScope, endScope: MutatingScope, totalKeys: int}|null
41114114
*/
41124115
private function tryProcessUnrolledConstantArrayForeach(
41134116
Foreach_ $stmt,
@@ -4142,7 +4145,8 @@ private function tryProcessUnrolledConstantArrayForeach(
41424145
if ($totalKeys === 0 || $totalKeys > self::FOREACH_UNROLL_LIMIT) {
41434146
return null;
41444147
}
4145-
if ($context->getForeachUnrollFactor() * $totalKeys > self::FOREACH_UNROLL_NESTED_LIMIT) {
4148+
$foreachUnrollFactor = $context->getForeachUnrollFactor();
4149+
if ($foreachUnrollFactor > 1 && $foreachUnrollFactor * $totalKeys > self::FOREACH_UNROLL_NESTED_LIMIT) {
41464150
return null;
41474151
}
41484152

@@ -4270,7 +4274,7 @@ private function tryProcessUnrolledConstantArrayForeach(
42704274
$endScope = $endScope->mergeWith($breakScope);
42714275
}
42724276

4273-
return ['bodyScope' => $bodyScope, 'endScope' => $endScope];
4277+
return ['bodyScope' => $bodyScope, 'endScope' => $endScope, 'totalKeys' => $totalKeys];
42744278
}
42754279

42764280
private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee): ?InternalThrowPoint

tests/bench/data/bug-14674.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14674;
4+
5+
use PHPUnit\Framework\Attributes\DataProvider;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class PhpStanPerformanceTest extends TestCase {
9+
10+
/**
11+
* @param \Closure $closure
12+
*/
13+
#[DataProvider('performanceProvider')]
14+
public function testPerformance(\Closure $closure): void {
15+
$this->assertTrue($closure());
16+
}
17+
18+
/**
19+
* @return iterable<string, array{closure: \Closure}>
20+
*/
21+
public static function performanceProvider(): iterable {
22+
foreach(['0', '1'] as $level_1) {
23+
$keys = [$level_1];
24+
25+
foreach(['0', '1'] as $level_2) {
26+
$keys[] = $level_2;
27+
28+
foreach(['0', '1'] as $level_3) {
29+
$keys[] = $level_3;
30+
31+
foreach(['0', '1'] as $level_4) {
32+
$keys[] = $level_4;
33+
34+
foreach(['0', '1'] as $level_5) {
35+
$keys[] = $level_5;
36+
37+
foreach(['0', '1'] as $level_6) {
38+
$keys[] = $level_6;
39+
40+
foreach(['0', '1'] as $level_7) {
41+
$keys[] = $level_7;
42+
43+
foreach(['0', '1'] as $level_8) {
44+
$keys[] = $level_8;
45+
46+
foreach(['0', '1'] as $level_9) {
47+
$keys[] = $level_9;
48+
49+
foreach(['0', '1'] as $level_10) {
50+
$keys[] = $level_10;
51+
52+
foreach(['0', '1'] as $level_11) {
53+
$keys[] = $level_11;
54+
55+
foreach(['0', '1'] as $level_12) {
56+
$keys[] = $level_12;
57+
58+
foreach(['0', '1'] as $level_13) {
59+
$keys[] = $level_13;
60+
61+
$case = [
62+
'closure' => function () use ($level_1, $level_3, $level_5, $level_13) {
63+
return $level_1 === '1' && $level_3 === '1' && $level_5 === '1' && $level_13 === '1';
64+
},
65+
];
66+
67+
yield implode('-', $keys) => $case;
68+
}
69+
}
70+
}
71+
}
72+
}
73+
}
74+
}
75+
}
76+
}
77+
}
78+
}
79+
}
80+
}
81+
}
82+
83+
}

0 commit comments

Comments
 (0)