From fbf40c7e8cff0d741709dc21fc5ed8067cbd3fea Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 22 Oct 2026 21:46:03 +0200 Subject: [PATCH 1/2] Price op-assigns as binary ops when deriving conditional expressions Propagating conditional expressions through `$var op= value` re-priced the AssignOp itself under every evaluated holder. That walks the write again, which derives conditional expressions again on the evaluation scope - nested, so analysis time grew exponentially with the number of conditionally described variables the right-hand side reads (several int|float accumulators concatenated with .= in a loop). The value the op-assign writes is `$var op value`, so the equivalent BinaryOp is priced instead, without the write. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L1wYnCXeqv8tgY2Eq1UuZA --- src/Analyser/ExprHandler/AssignHandler.php | 35 +++++++++++- tests/PHPStan/Analyser/nsrt/bug-15498.php | 48 ++++++++++++++++ tests/bench/data/bug-15498.php | 22 ++++++++ turbo-ext/src/AssignHandler.cpp | 66 +++++++++++++++++++++- 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15498.php create mode 100644 tests/bench/data/bug-15498.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 0a1f5f83e84..62ee67ed27b 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -123,6 +123,22 @@ final class AssignHandler implements ExprHandler private const DERIVED_CONDITIONAL_EXPRESSIONS_LIMIT = 16; + private const ASSIGN_OP_BINARY_OPS = [ + Expr\AssignOp\BitwiseAnd::class => Expr\BinaryOp\BitwiseAnd::class, + Expr\AssignOp\BitwiseOr::class => Expr\BinaryOp\BitwiseOr::class, + Expr\AssignOp\BitwiseXor::class => Expr\BinaryOp\BitwiseXor::class, + Expr\AssignOp\Coalesce::class => Expr\BinaryOp\Coalesce::class, + Expr\AssignOp\Concat::class => Expr\BinaryOp\Concat::class, + Expr\AssignOp\Div::class => Expr\BinaryOp\Div::class, + Expr\AssignOp\Minus::class => Expr\BinaryOp\Minus::class, + Expr\AssignOp\Mod::class => Expr\BinaryOp\Mod::class, + Expr\AssignOp\Mul::class => Expr\BinaryOp\Mul::class, + Expr\AssignOp\Plus::class => Expr\BinaryOp\Plus::class, + Expr\AssignOp\Pow::class => Expr\BinaryOp\Pow::class, + Expr\AssignOp\ShiftLeft::class => Expr\BinaryOp\ShiftLeft::class, + Expr\AssignOp\ShiftRight::class => Expr\BinaryOp\ShiftRight::class, + ]; + public function __construct( private TemplateArgumentObserver $templateArgumentObserver, private VarAnnotationProcessor $varAnnotationProcessor, @@ -2338,6 +2354,23 @@ private function processDerivedConditionalExpressionsAfterAssign( return $conditionalExpressions; } + // `$var op= value` writes `$var op value` - pricing the AssignOp itself + // would walk its write again, re-deriving conditional expressions under + // every evaluation (exponential in the number of described reads) + $pricedExpr = $assignedExpr; + if ($assignedExpr instanceof Expr\AssignOp) { + $pricedExpr = null; + foreach (self::ASSIGN_OP_BINARY_OPS as $assignOpClass => $binaryOpClass) { + if ($assignedExpr instanceof $assignOpClass) { + $pricedExpr = new $binaryOpClass($assignedExpr->var, $assignedExpr->expr); + break; + } + } + if ($pricedExpr === null) { + throw new ShouldNotHappenException(); + } + } + $targetExprString = '$' . $variableName; $evaluations = 0; $seenReadExprStrings = []; @@ -2374,7 +2407,7 @@ private function processDerivedConditionalExpressionsAfterAssign( } $evalScope = $evalScope->assignExpression($consequent->getExpr(), $consequent->getType(), $consequent->getType()); - $derivedType = $nodeScopeResolver->readScopeStateOrSyntheticType($assignedExpr, $evalScope); + $derivedType = $nodeScopeResolver->readScopeStateOrSyntheticType($pricedExpr, $evalScope); if ($derivedType->equals($assignedType)) { continue; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15498.php b/tests/PHPStan/Analyser/nsrt/bug-15498.php new file mode 100644 index 00000000000..9a6ce7bacab --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15498.php @@ -0,0 +1,48 @@ + 'X', 'b' => 'YY'] as $k => $v) { + $s = 'p'; + $s .= $v; + $n = 1; + $n += strlen($v); + $c = null; + $c ??= $v; + if ($k === 'a') { + assertType("'pX'", $s); + assertType('2', $n); + assertType("'X'", $c); + } else { + assertType("'pYY'", $s); + assertType('3', $n); + assertType("'YY'", $c); + } + } +} + +/** @param list>> $pl */ +function accumulators(array $pl): string +{ + $out = ''; + foreach ($pl as $rows) { + $a = 0; $b = 0; $c = 0; $d = 0; $e = 0; $f = 0; $g = 0; + foreach ($rows as $r) { + $a = $a + (float) $r['a']; + $b = $b + (float) $r['b']; + $c = $c + (float) $r['c']; + $d = $d + (float) $r['d']; + $e = $e + (float) $r['e']; + $f = $f + (float) $r['f']; + $g = $g + (float) $r['g']; + } + $out .= "$a $b $c $d $e $f $g"; + assertType('non-falsy-string&uppercase-string', $out); + } + assertType('uppercase-string', $out); + return $out; +} diff --git a/tests/bench/data/bug-15498.php b/tests/bench/data/bug-15498.php new file mode 100644 index 00000000000..5017d2bc1ad --- /dev/null +++ b/tests/bench/data/bug-15498.php @@ -0,0 +1,22 @@ +>> $pl */ +function t(array $pl): string { + $out = ''; + foreach ($pl as $rows) { + $a = 0; $b = 0; $c = 0; $d = 0; $e = 0; $f = 0; $g = 0; + foreach ($rows as $r) { + $a = $a + (float) $r['a']; + $b = $b + (float) $r['b']; + $c = $c + (float) $r['c']; + $d = $d + (float) $r['d']; + $e = $e + (float) $r['e']; + $f = $f + (float) $r['f']; + $g = $g + (float) $r['g']; + } + $out .= "$a $b $c $d $e $f $g"; + } + return $out; +} diff --git a/turbo-ext/src/AssignHandler.cpp b/turbo-ext/src/AssignHandler.cpp index f5a81e3b4bc..e249818c5d4 100644 --- a/turbo-ext/src/AssignHandler.cpp +++ b/turbo-ext/src/AssignHandler.cpp @@ -42,6 +42,49 @@ zend_class_entry *pt_ce_assign_handler = nullptr; #define PT_AH_DERIVED_CONDITIONAL_EXPRESSIONS_LIMIT 16 #define PT_AH_ARRAY_DIM_FETCH_WRITE_DEPTH_LIMIT 5 +/* the twin's ASSIGN_OP_BINARY_OPS: each AssignOp class with the BinaryOp + * computing the value it writes */ +struct pt_ah_assign_op_binary_op { + int assignOpClass; + int binaryOpClass; + const char *assignOpName; + const char *binaryOpName; +}; + +static const pt_ah_assign_op_binary_op pt_ah_assign_op_binary_ops[] = { + { PT_CLASS_ASSIGN_OP_BITWISE_AND, PT_CLASS_BITWISE_AND_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\BitwiseAnd", "PhpParser\\Node\\Expr\\BinaryOp\\BitwiseAnd" }, + { PT_CLASS_ASSIGN_OP_BITWISE_OR, PT_CLASS_BITWISE_OR_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\BitwiseOr", "PhpParser\\Node\\Expr\\BinaryOp\\BitwiseOr" }, + { PT_CLASS_ASSIGN_OP_BITWISE_XOR, PT_CLASS_BITWISE_XOR_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\BitwiseXor", "PhpParser\\Node\\Expr\\BinaryOp\\BitwiseXor" }, + { PT_CLASS_COALESCE_ASSIGN_OP_EXPR, PT_CLASS_COALESCE_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\Coalesce", "PhpParser\\Node\\Expr\\BinaryOp\\Coalesce" }, + { PT_CLASS_ASSIGN_OP_CONCAT, PT_CLASS_CONCAT_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\Concat", "PhpParser\\Node\\Expr\\BinaryOp\\Concat" }, + { PT_CLASS_ASSIGN_OP_DIV, PT_CLASS_DIV_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\Div", "PhpParser\\Node\\Expr\\BinaryOp\\Div" }, + { PT_CLASS_ASSIGN_OP_MINUS, PT_CLASS_BINARY_OP_MINUS, "PhpParser\\Node\\Expr\\AssignOp\\Minus", "PhpParser\\Node\\Expr\\BinaryOp\\Minus" }, + { PT_CLASS_ASSIGN_OP_MOD, PT_CLASS_MOD_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\Mod", "PhpParser\\Node\\Expr\\BinaryOp\\Mod" }, + { PT_CLASS_ASSIGN_OP_MUL, PT_CLASS_MUL_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\Mul", "PhpParser\\Node\\Expr\\BinaryOp\\Mul" }, + { PT_CLASS_ASSIGN_OP_PLUS, PT_CLASS_BINARY_OP_PLUS, "PhpParser\\Node\\Expr\\AssignOp\\Plus", "PhpParser\\Node\\Expr\\BinaryOp\\Plus" }, + { PT_CLASS_ASSIGN_OP_POW, PT_CLASS_POW_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\Pow", "PhpParser\\Node\\Expr\\BinaryOp\\Pow" }, + { PT_CLASS_ASSIGN_OP_SHIFT_LEFT, PT_CLASS_SHIFT_LEFT_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\ShiftLeft", "PhpParser\\Node\\Expr\\BinaryOp\\ShiftLeft" }, + { PT_CLASS_ASSIGN_OP_SHIFT_RIGHT, PT_CLASS_SHIFT_RIGHT_EXPR, "PhpParser\\Node\\Expr\\AssignOp\\ShiftRight", "PhpParser\\Node\\Expr\\BinaryOp\\ShiftRight" }, +}; + +/* the constant's value: a persistent immutable map of interned strings, as + * the engine references a class constant for the process lifetime */ +static void pt_ah_assign_op_binary_ops_constant(zval *out) +{ + size_t count = sizeof(pt_ah_assign_op_binary_ops) / sizeof(pt_ah_assign_op_binary_ops[0]); + HashTable *map = (HashTable *) pemalloc(sizeof(HashTable), 1); + zend_hash_init(map, (uint32_t) count, NULL, NULL, 1); + for (size_t i = 0; i < count; i++) { + zval value; + ZVAL_INTERNED_STR(&value, zend_string_init_interned(pt_ah_assign_op_binary_ops[i].binaryOpName, strlen(pt_ah_assign_op_binary_ops[i].binaryOpName), 1)); + zend_hash_add_new(map, zend_string_init_interned(pt_ah_assign_op_binary_ops[i].assignOpName, strlen(pt_ah_assign_op_binary_ops[i].assignOpName), 1), &value); + } + GC_ADD_FLAGS(map, IS_ARRAY_IMMUTABLE); + GC_SET_REFCOUNT(map, 2); + ZVAL_ARR(out, map); + Z_TYPE_INFO_P(out) = IS_ARRAY; +} + /* VariableWrite::KIND_ASSIGN / KIND_READ_MODIFY_WRITE */ #define PT_AH_KIND_ASSIGN 1 #define PT_AH_KIND_READ_MODIFY_WRITE 2 @@ -2630,6 +2673,26 @@ class AssignHandler AH_VALB(scopeConditionalExpressions, pt_mutating_scope_get_conditional_expressions(Z_OBJ_P(scope))); if (zend_hash_num_elements(Z_ARRVAL_P(scopeConditionalExpressions.raw())) == 0) return true; + // `$var op= value` writes `$var op value` - pricing the AssignOp itself + // would walk its write again, re-deriving conditional expressions under + // every evaluation (exponential in the number of described reads) + zv::Val pricedExpr = zv::Val::copyOf(zv::Ref(assignedExpr)); + if (ahIs(assignedExpr, PT_CLASS_ASSIGN_OP_EXPR)) { + int binaryOpClass = -1; + for (const pt_ah_assign_op_binary_op &entry : pt_ah_assign_op_binary_ops) { + if (ahIs(assignedExpr, entry.assignOpClass)) { + binaryOpClass = entry.binaryOpClass; + break; + } + } + if (binaryOpClass < 0) { + pt_throw_should_not_happen(); + return false; + } + zv::Args argv{AH_PROP(assignedExpr, var), AH_PROP(assignedExpr, expr)}; + AH_SETB(pricedExpr, pt_type_new(binaryOpClass, 2, argv)); + } + zv::Str targetExprString = zv::Str::adopt(zend_string_concat2(PT_LC("$"), ZSTR_VAL(variableName), ZSTR_LEN(variableName))); zend_long evaluations = 0; zv::Val seenReadExprStrings = ahEmptyArray(); @@ -2673,7 +2736,7 @@ class AssignHandler zval *consequentType = OBJ_PROP_NUM(Z_OBJ_P(consequent), PT_ETH_PROP_TYPE); AH_SETB(evalScope, pt_mutating_scope_assign_expression(Z_OBJ_P(evalScope.raw()), Z_OBJ_P(OBJ_PROP_NUM(Z_OBJ_P(consequent), PT_ETH_PROP_EXPR)), consequentType, consequentType)); - AH_VALB(derivedType, nsrReadScopeStateOrSyntheticType(nsr, assignedExpr, evalScope.raw())); + AH_VALB(derivedType, nsrReadScopeStateOrSyntheticType(nsr, pricedExpr.raw(), evalScope.raw())); bool equal; AH_OKB(ahEquals(derivedType.raw(), assignedType, equal)); if (equal) continue; @@ -4802,6 +4865,7 @@ PT_MINIT_REGISTRATION(pt_register_assign_handler) ptdecl::AssignHandler::declareProperties(cls); cls.privateClassConstantLong("TERNARY_ARM_EXCLUDED_VALUES_LIMIT", PT_AH_TERNARY_ARM_EXCLUDED_VALUES_LIMIT); cls.privateClassConstantLong("DERIVED_CONDITIONAL_EXPRESSIONS_LIMIT", PT_AH_DERIVED_CONDITIONAL_EXPRESSIONS_LIMIT); + cls.privateClassConstantValue("ASSIGN_OP_BINARY_OPS", pt_ah_assign_op_binary_ops_constant); cls.privateClassConstantLong("ARRAY_DIM_FETCH_WRITE_DEPTH_LIMIT", PT_AH_ARRAY_DIM_FETCH_WRITE_DEPTH_LIMIT); /* the real parameter class names: the DI container autowires the From 3c2d2d6bd3955124cbe4994c42a7fbbd3eb28598 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 22 Oct 2026 21:46:04 +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 c9740cb393d..9a01b4248f0 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -34,7 +34,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '04247a5'; + public const EXPECTED_EXTENSION_VERSION = 'fbf40c7'; private static bool $active = false;