From 827a46f3f822edbb8d49578999f9adbd3772f6c8 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 11:50:21 +0900 Subject: [PATCH 1/2] Pass immutable array arguments non-refcounted through zv::Args zv::Args stored a HashTable argument with a bare ZVAL_ARR, which types an immutable table (the shared zend_empty_array of a PHP [] literal or zv::Arr::empty()) as refcounted. When the argument vector reached a PHP method, the addref in zend_call_function() wrote into read-only memory (SIGBUS on macOS arm64, SIGSEGV on Linux). ForeachHandler hit this for a constant array without keys: its conditional holder tables stay empty and are handed to a PHP override of MutatingScope::addConditionalExpressions(). Wrap immutable tables as plain IS_ARRAY, like zv::Arr::adoptTable() and copyOfTable() do. Co-authored-by: Claude Opus 5.5 --- .../ConditionalExpressionsRecordingScope.php | 43 +++++++++++++ .../StmtHandler/ForeachHandlerTest.php | 62 +++++++++++++++++++ turbo-ext/src/zv.h | 10 ++- 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/StmtHandler/ConditionalExpressionsRecordingScope.php create mode 100644 tests/PHPStan/Analyser/StmtHandler/ForeachHandlerTest.php diff --git a/tests/PHPStan/Analyser/StmtHandler/ConditionalExpressionsRecordingScope.php b/tests/PHPStan/Analyser/StmtHandler/ConditionalExpressionsRecordingScope.php new file mode 100644 index 00000000000..a1d71f8d6db --- /dev/null +++ b/tests/PHPStan/Analyser/StmtHandler/ConditionalExpressionsRecordingScope.php @@ -0,0 +1,43 @@ + */ + public array $added = []; + + public function enterForeach(MutatingScope $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $valueName, ?string $keyName, bool $valueByRef): MutatingScope + { + return $this; + } + + public function mergeWith(?MutatingScope $otherScope, bool $preserveVacuousConditionals = false): MutatingScope + { + return $this; + } + + /** + * @param ConditionalExpressionHolder[] $conditionalExpressionHolders + */ + public function addConditionalExpressions(string $exprString, array $conditionalExpressionHolders): MutatingScope + { + $this->added[] = [$exprString, count($conditionalExpressionHolders)]; + + return $this; + } + +} diff --git a/tests/PHPStan/Analyser/StmtHandler/ForeachHandlerTest.php b/tests/PHPStan/Analyser/StmtHandler/ForeachHandlerTest.php new file mode 100644 index 00000000000..d2ddab90b8a --- /dev/null +++ b/tests/PHPStan/Analyser/StmtHandler/ForeachHandlerTest.php @@ -0,0 +1,62 @@ +getService('typeSpecifier')) + ->create(ScopeContext::create(__FILE__)) + ->assignVariable('a', $iterateeType, $iterateeType, TrinaryLogic::createYes()); + // the scope factory only creates MutatingScope: rebuild the scope as + // the subclass from its constructor arguments + $args = []; + $reflection = new ReflectionClass(MutatingScope::class); + foreach ($reflection->getMethod('__construct')->getParameters() as $parameter) { + $args[$parameter->getName()] = $reflection->getProperty($parameter->getName())->getValue($scope); + } + $scope = new ConditionalExpressionsRecordingScope(...$args); + + $container->getByType(ForeachHandler::class)->processStmt( + $container->getByType(NodeScopeResolver::class), + new Foreach_(new Variable('a'), new Variable('v'), ['keyVar' => new Variable('k')]), + $scope, + new ExpressionResultStorage(), + new NoopNodeCallback(), + StatementContext::createDeep(), + ); + + $this->assertSame([ + ['$v', $expectedHolders], + ['$a[$k]', $expectedHolders], + ], $scope->added); + } + +} diff --git a/turbo-ext/src/zv.h b/turbo-ext/src/zv.h index 9bf834d7cc5..72ec560d599 100644 --- a/turbo-ext/src/zv.h +++ b/turbo-ext/src/zv.h @@ -589,7 +589,15 @@ class Args static zend_always_inline void set(zval *slot, const zval *value) { ZVAL_COPY_VALUE(slot, value); } static zend_always_inline void set(zval *slot, zend_object *value) { ZVAL_OBJ(slot, value); } static zend_always_inline void set(zval *slot, zend_string *value) { ZVAL_STR(slot, value); } - static zend_always_inline void set(zval *slot, HashTable *value) { ZVAL_ARR(slot, value); } + /* immutable tables (a PHP [] literal) are wrapped non-refcounted, like + * ZVAL_EMPTY_ARRAY: the call's addref of its arguments must not touch them */ + static zend_always_inline void set(zval *slot, HashTable *value) + { + ZVAL_ARR(slot, value); + if (GC_FLAGS(value) & IS_ARRAY_IMMUTABLE) { + Z_TYPE_INFO_P(slot) = IS_ARRAY; + } + } static zend_always_inline void set(zval *slot, bool value) { ZVAL_BOOL(slot, value); } static zend_always_inline void set(zval *slot, zend_long value) { ZVAL_LONG(slot, value); } static zend_always_inline void set(zval *slot, double value) { ZVAL_DOUBLE(slot, value); } From 6c1bc9d004d1b39c2cc1e961e8cd37e455169724 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 18:25:30 +0900 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 a10fdc3d858..bc3b9689bc9 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '56d1522'; + public const EXPECTED_EXTENSION_VERSION = '827a46f'; private static bool $active = false;