From 67f72d8f0ffc2372a419035ded146d11d82191b2 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:14:21 +0000 Subject: [PATCH 1/2] Keep empty constant arrays out of the oversized merge in `TypeCombinator::optimizeConstantArrays()` - optimizeConstantArrays() generalizes every non-empty constant array of an oversized union to `non-empty-array&oversized-array` and, when every member got generalized, merges them into a single oversized array. An `array{}` member is left alone by the generalization, so it set $eachIsOversized to false and the merge was skipped. The generalized members were then returned as separate union members. - Since e606ca20e (unions of constant arrays with many keys no longer degrade early), fetching an unknown offset of a big constant map, where some entries are `[]` (bug-12671.php bench), hit this path. The union then compared hundreds of generalized arrays pairwise. Their item types are large unions of `list{...}&oversized-array`, so every comparison is itself a union-vs-union check, which made the fetch quadratic (bench 0.3s -> 26s). - Set empty constant arrays aside before generalizing, merge the remaining members as before, and put the empty arrays back next to the result. A union with an `array{}` member now gives `array{}` plus exactly what the same union without it gives. - Ported the same change to the native mirror in turbo-ext/src/TypeCombinator.cpp. - Probed the other paths that feed optimizeConstantArrays() (the general-array branch of processArrayTypes() reuses the same function, so it is covered). The inner value traversal already left empty arrays alone. - Non-constant array members also prevent the merge. That is intended (their item types are not generalized), so it is unchanged. --- src/Type/TypeCombinator.php | 12 +- tests/PHPStan/Analyser/nsrt/bug-15293.php | 215 ++++++++++++++++++++++ turbo-ext/src/TypeCombinator.cpp | 24 ++- 3 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15293.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 56e9d939d3e..4a6aba92444 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1207,8 +1207,14 @@ private static function optimizeConstantArrays(array $types): array } $results = []; + $emptyArrays = []; $eachIsOversized = true; foreach ($types as $type) { + if ($type->isConstantArray()->yes() && $type->isIterableAtLeastOnce()->no()) { + $emptyArrays[] = $type; + continue; + } + $isOversized = false; $result = TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$isOversized): Type { if (!$type instanceof ConstantArrayType) { @@ -1290,7 +1296,7 @@ private static function optimizeConstantArrays(array $types): array $results[] = $result; } - if ($eachIsOversized) { + if ($eachIsOversized && $results !== []) { $eachIsList = true; $keyTypes = []; $valueTypes = []; @@ -1317,10 +1323,10 @@ private static function optimizeConstantArrays(array $types): array $accessories[] = new NonEmptyArrayType(); $accessories[] = new OversizedArrayType(); - return [self::intersect(new ArrayType($keyType, $valueType), ...$accessories)]; + return [...$emptyArrays, self::intersect(new ArrayType($keyType, $valueType), ...$accessories)]; } - return $results; + return [...$emptyArrays, ...$results]; } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-15293.php b/tests/PHPStan/Analyser/nsrt/bug-15293.php new file mode 100644 index 00000000000..f970f4dac0a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15293.php @@ -0,0 +1,215 @@ + [ + 'a0' => [0, 0, 0], + 'a1' => [1, 1, 1], + 'a2' => [2, 2, 2], + 'a3' => [3, 3, 3], + 'a4' => [4, 4, 4], + 'a5' => [5, 5, 5], + 'a6' => [6, 6, 6], + 'a7' => [7, 7, 7], + 'a8' => [8, 8, 8], + 'a9' => [9, 9, 9], + 'a10' => [10, 10, 10], + 'a11' => [11, 11, 11], + 'a12' => [12, 12, 12], + 'a13' => [13, 13, 13], + 'a14' => [14, 14, 14], + 'a15' => [15, 15, 15], + 'a16' => [16, 16, 16], + 'a17' => [17, 17, 17], + 'a18' => [18, 18, 18], + 'a19' => [19, 19, 19], + 'a20' => [20, 20, 20], + 'a21' => [21, 21, 21], + 'a22' => [22, 22, 22], + 'a23' => [23, 23, 23], + 'a24' => [24, 24, 24], + 'a25' => [25, 25, 25], + 'a26' => [26, 26, 26], + 'a27' => [27, 27, 27], + 'a28' => [28, 28, 28], + 'a29' => [29, 29, 29], + ], + 'b' => [ + 'b0' => [30, 30, 30], + 'b1' => [31, 31, 31], + 'b2' => [32, 32, 32], + 'b3' => [33, 33, 33], + 'b4' => [34, 34, 34], + 'b5' => [35, 35, 35], + 'b6' => [36, 36, 36], + 'b7' => [37, 37, 37], + 'b8' => [38, 38, 38], + 'b9' => [39, 39, 39], + 'b10' => [40, 40, 40], + 'b11' => [41, 41, 41], + 'b12' => [42, 42, 42], + 'b13' => [43, 43, 43], + 'b14' => [44, 44, 44], + 'b15' => [45, 45, 45], + 'b16' => [46, 46, 46], + 'b17' => [47, 47, 47], + 'b18' => [48, 48, 48], + 'b19' => [49, 49, 49], + 'b20' => [50, 50, 50], + 'b21' => [51, 51, 51], + 'b22' => [52, 52, 52], + 'b23' => [53, 53, 53], + 'b24' => [54, 54, 54], + 'b25' => [55, 55, 55], + 'b26' => [56, 56, 56], + 'b27' => [57, 57, 57], + 'b28' => [58, 58, 58], + 'b29' => [59, 59, 59], + ], + 'c' => [ + 'c0' => [60, 60, 60], + 'c1' => [61, 61, 61], + 'c2' => [62, 62, 62], + 'c3' => [63, 63, 63], + 'c4' => [64, 64, 64], + 'c5' => [65, 65, 65], + 'c6' => [66, 66, 66], + 'c7' => [67, 67, 67], + 'c8' => [68, 68, 68], + 'c9' => [69, 69, 69], + 'c10' => [70, 70, 70], + 'c11' => [71, 71, 71], + 'c12' => [72, 72, 72], + 'c13' => [73, 73, 73], + 'c14' => [74, 74, 74], + 'c15' => [75, 75, 75], + 'c16' => [76, 76, 76], + 'c17' => [77, 77, 77], + 'c18' => [78, 78, 78], + 'c19' => [79, 79, 79], + 'c20' => [80, 80, 80], + 'c21' => [81, 81, 81], + 'c22' => [82, 82, 82], + 'c23' => [83, 83, 83], + 'c24' => [84, 84, 84], + 'c25' => [85, 85, 85], + 'c26' => [86, 86, 86], + 'c27' => [87, 87, 87], + 'c28' => [88, 88, 88], + 'c29' => [89, 89, 89], + ], + 'd' => [], + ]; + + public const MAP_WITHOUT_EMPTY = [ + 'a' => [ + 'a0' => [0, 0, 0], + 'a1' => [1, 1, 1], + 'a2' => [2, 2, 2], + 'a3' => [3, 3, 3], + 'a4' => [4, 4, 4], + 'a5' => [5, 5, 5], + 'a6' => [6, 6, 6], + 'a7' => [7, 7, 7], + 'a8' => [8, 8, 8], + 'a9' => [9, 9, 9], + 'a10' => [10, 10, 10], + 'a11' => [11, 11, 11], + 'a12' => [12, 12, 12], + 'a13' => [13, 13, 13], + 'a14' => [14, 14, 14], + 'a15' => [15, 15, 15], + 'a16' => [16, 16, 16], + 'a17' => [17, 17, 17], + 'a18' => [18, 18, 18], + 'a19' => [19, 19, 19], + 'a20' => [20, 20, 20], + 'a21' => [21, 21, 21], + 'a22' => [22, 22, 22], + 'a23' => [23, 23, 23], + 'a24' => [24, 24, 24], + 'a25' => [25, 25, 25], + 'a26' => [26, 26, 26], + 'a27' => [27, 27, 27], + 'a28' => [28, 28, 28], + 'a29' => [29, 29, 29], + ], + 'b' => [ + 'b0' => [30, 30, 30], + 'b1' => [31, 31, 31], + 'b2' => [32, 32, 32], + 'b3' => [33, 33, 33], + 'b4' => [34, 34, 34], + 'b5' => [35, 35, 35], + 'b6' => [36, 36, 36], + 'b7' => [37, 37, 37], + 'b8' => [38, 38, 38], + 'b9' => [39, 39, 39], + 'b10' => [40, 40, 40], + 'b11' => [41, 41, 41], + 'b12' => [42, 42, 42], + 'b13' => [43, 43, 43], + 'b14' => [44, 44, 44], + 'b15' => [45, 45, 45], + 'b16' => [46, 46, 46], + 'b17' => [47, 47, 47], + 'b18' => [48, 48, 48], + 'b19' => [49, 49, 49], + 'b20' => [50, 50, 50], + 'b21' => [51, 51, 51], + 'b22' => [52, 52, 52], + 'b23' => [53, 53, 53], + 'b24' => [54, 54, 54], + 'b25' => [55, 55, 55], + 'b26' => [56, 56, 56], + 'b27' => [57, 57, 57], + 'b28' => [58, 58, 58], + 'b29' => [59, 59, 59], + ], + 'c' => [ + 'c0' => [60, 60, 60], + 'c1' => [61, 61, 61], + 'c2' => [62, 62, 62], + 'c3' => [63, 63, 63], + 'c4' => [64, 64, 64], + 'c5' => [65, 65, 65], + 'c6' => [66, 66, 66], + 'c7' => [67, 67, 67], + 'c8' => [68, 68, 68], + 'c9' => [69, 69, 69], + 'c10' => [70, 70, 70], + 'c11' => [71, 71, 71], + 'c12' => [72, 72, 72], + 'c13' => [73, 73, 73], + 'c14' => [74, 74, 74], + 'c15' => [75, 75, 75], + 'c16' => [76, 76, 76], + 'c17' => [77, 77, 77], + 'c18' => [78, 78, 78], + 'c19' => [79, 79, 79], + 'c20' => [80, 80, 80], + 'c21' => [81, 81, 81], + 'c22' => [82, 82, 82], + 'c23' => [83, 83, 83], + 'c24' => [84, 84, 84], + 'c25' => [85, 85, 85], + 'c26' => [86, 86, 86], + 'c27' => [87, 87, 87], + 'c28' => [88, 88, 88], + 'c29' => [89, 89, 89], + ], + ]; + + public function doFoo(string $key): void + { + assertType('array{}|(non-empty-array&oversized-array)', self::MAP[$key]); + assertType('non-empty-array&oversized-array', self::MAP_WITHOUT_EMPTY[$key]); + } + +} diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 1627571a566..0d60e95673b 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -2184,8 +2184,20 @@ class TypeCombinator } zv::Arr results = zv::Arr::create(countOf(types.raw())); + zv::Arr emptyArrays = zv::Arr::create(0); bool eachIsOversized = true; for (zv::ArrayEntry entry : zv::ArrRef(types.raw())) { + zend_long isConstantArray = callTrinary(entry.value().raw(), PT_LC("isconstantarray")); + PT_FAIL_IF_NEG(isConstantArray); + if (isConstantArray == PT_TRI_YES) { + zend_long atLeastOnce = callTrinary(entry.value().raw(), PT_LC("isiterableatleastonce")); + PT_FAIL_IF_NEG(atLeastOnce); + if (atLeastOnce == PT_TRI_NO) { + emptyArrays.push(zv::Ref(entry.value().raw())); + continue; + } + } + zval isOversized; ZVAL_FALSE(&isOversized); zv::Val callback = pt_type_native_callback(generalizeOversizedCallback, &isOversized, NULL); @@ -2200,7 +2212,7 @@ class TypeCombinator results.push(std::move(result)); } - if (eachIsOversized) { + if (eachIsOversized && zend_hash_num_elements(results.table()) > 0) { bool eachIsList = true; TypeList keyTypes; TypeList valueTypes; @@ -2236,12 +2248,14 @@ class TypeCombinator zv::Val oversized = oversizedArrayOf(keyType.raw(), valueType.raw(), eachIsList); PT_FAIL_IF_UNDEF(oversized); - zv::Arr single = zv::Arr::create(1); - single.push(std::move(oversized)); - return zv::Val(std::move(single)); + emptyArrays.push(std::move(oversized)); + return zv::Val(std::move(emptyArrays)); } - return zv::Val(std::move(results)); + for (zv::ArrayEntry entry : zv::ArrRef(results.raw())) { + emptyArrays.push(zv::Ref(entry.value().raw())); + } + return zv::Val(std::move(emptyArrays)); } /* the key signature of a ConstantArrayType the stage-1 grouping and the From b940151bf189325c5d0d1fd6677167f066b38d34 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 23 Sep 2026 12:30:01 +0200 Subject: [PATCH 2/2] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 3c064c284c2..a1a9f17e605 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '9cebb2a'; + public const EXPECTED_EXTENSION_VERSION = '67f72d8'; private static bool $active = false;