Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions phpunit/code/eval-order-side-effects.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

function pair(int $a, int $b): string
{
return $a . ',' . $b;
}

function callArgOrder(): string
{
$j = 1;
return pair($j, $j = 5);
}

function concatOrder(): string
{
$m = 1;
return $m . ',' . ($m = 9);
}

function plainArithmeticUnchanged(): int
{
$k = 1;
return $k + ($k = 5);
}
88 changes: 88 additions & 0 deletions phpunit/src/EvalOrderSideEffectsCodegenTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

use TypePhp\CompilerTest;

/**
* PHP evaluates call arguments and concat operands left to right. When a
* later operand hoists captured statements (an assignment), earlier
* plain-variable reads must be snapshotted at their own position, or the
* hoisted side effect executes first: pair($j, $j = 5) must return "1,5"
* and $m . ',' . ($m = 9) must be "1,9". Plain arithmetic is exempt:
* Zend's ADD opcode reads the CV at op time, so $k + ($k = 5) is 10 in
* both worlds and must keep its existing codegen.
*/
final class EvalOrderSideEffectsCodegenTest extends \BaseTest
{
public function testCallArgumentReadIsSnapshottedBeforeLaterAssignment(): void
{
$code = $this->compileFixture();
$body = $this->extractFunctionBody($code, 'php_callargorder()');

self::assertMatchesRegularExpression(
'/(tmp_var_\d+) = j;\s*\n\s*(tmp_var_\d+) = j = 5L{1,2};/',
$body,
'the old value of $j must be captured before $j = 5 executes',
);
self::assertDoesNotMatchRegularExpression(
'/php_pair\(php::toIntArgExact\(j,/',
$body,
'$j must not be read directly after the hoisted assignment',
);
}

public function testConcatOperandReadIsSnapshottedBeforeLaterAssignment(): void
{
$code = $this->compileFixture();
$body = $this->extractFunctionBody($code, 'php_concatorder()');

self::assertMatchesRegularExpression(
'/(tmp_var_\d+) = m;\s*\n\s*(tmp_var_\d+) = m = 9L{1,2};/',
$body,
'the old value of $m must be captured before $m = 9 executes',
);
self::assertDoesNotMatchRegularExpression(
'/php::concat\(\{php::toString\(m\)/',
$body,
'$m must not be read directly after the hoisted assignment',
);
}

public function testPlainArithmeticKeepsZendCvReadSemantics(): void
{
$code = $this->compileFixture();
$body = $this->extractFunctionBody($code, 'php_plainarithmeticunchanged()');

// Zend reads the CV when the ADD executes, i.e. after the nested
// assignment; the direct read of k matches that and must stay.
self::assertMatchesRegularExpression(
'/(tmp_var_\d+) = k = 5L{1,2};\s*\n[^\n]*\(\(k\) \+ \(\1\)\)/',
$body,
);
self::assertStringNotContainsString('= k;', $body);
}

private function extractFunctionBody(string $code, string $marker): string
{
$start = strpos($code, $marker);
self::assertIsInt($start, "missing function: {$marker}");
$end = strpos($code, "\n}", $start);
self::assertIsInt($end);
return substr($code, $start, $end - $start);
}

private function compileFixture(): string
{
global $translator;

$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$translator = $compiler;
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/eval-order-side-effects.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$generated = $compiler->convertFile($source);
$code = file_get_contents($generated);

self::assertIsString($code);
return $code;
}
}
29 changes: 28 additions & 1 deletion src/Generator/CallArgumentGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,37 @@ protected function parseNativeCallArgs(
$variadicVar = null;
$callableName = $functionDef->displayName ?: $functionDef->getNamespacedName();

// PHP evaluates arguments left to right. A later argument that hoists
// captured statements while being lowered (an assignment, a call)
// would execute those side effects before an earlier plain-variable
// argument is read: `two($j, $j = 5)` must pass the old value of $j.
// Record the last such argument so every earlier by-value variable
// read can be snapshotted at its own argument position.
$lastHoistingSourceIndex = -1;
foreach ($sourceArgs as $sourceIndex => [, , $arg]) {
if ($arg instanceof Node\Arg && $this->shouldMaterializeOrderedOperand($arg->value)) {
$lastHoistingSourceIndex = $sourceIndex;
}
}

// Evaluate every supplied argument in PHP source order. The resulting
// expressions/temporaries may then be rearranged safely for the native
// C++ ABI without changing observable call order.
foreach ($sourceArgs as [$argIndex, $variadicName, $arg]) {
foreach ($sourceArgs as $sourceIndex => [$argIndex, $variadicName, $arg]) {
if ($sourceIndex < $lastHoistingSourceIndex
&& $arg instanceof Node\Arg
&& !$arg->unpack
&& $this->isSnapshotableVariableRead($arg->value)
) {
$paramInfo = $argIndex === $variadicArgIndex
? $functionDef->argInfoList[$variadicArgIndex]
: $this->getArgInfo($arg, $nativeFunc, $argIndex);
if ($paramInfo !== null && !$paramInfo->byRef) {
$snapshot = $this->parseOrderedOperand($arg->value, false, true);
$arg = clone $arg;
$arg->value = new Expr\Variable($snapshot, $arg->value->getAttributes());
}
}
if ($argIndex !== $variadicArgIndex) {
$argInfo = $this->getArgInfo($arg, $nativeFunc, $argIndex);
$resolvedArgs[$argIndex] = $this->getTypeConvertedArg(
Expand Down
80 changes: 71 additions & 9 deletions src/Parser/BinaryOpTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -759,29 +759,73 @@ protected function parseFlattenedConcat(NodeAbstract $expr, array $prefixExpress
$useTwoOperandOverload = $prefixExpressions === []
&& $this->canUseTwoOperandConcatOverload($items);

// Zend lowers the left-associated chain i0.i1.i2... into one CONCAT
// opcode per node and reads a CV operand when its opcode executes:
// i0 and i1 are both read at the first op (after the side effects of
// both), and every later item ik at the k-th op (after the side
// effects of i0..ik, before those of later items). The flattened
// braced list hoists all captured side effects ahead of the whole
// expression, so a plain-variable item that Zend reads before a later
// item's side effects (`$m . ',' . ($m = 9)` must yield "1,9") is
// snapshotted into a temporary at its Zend read position.
$lastHoistingIndex = -1;
foreach ($items as $index => $item) {
if ($this->shouldMaterializeOrderedOperand($item)
|| $this->isNativeObjectClass($this->detectClassOfExpr($item))
) {
$lastHoistingIndex = $index;
}
}

// The first item is read together with the second at the first op,
// i.e. after the second item's side effects. Its snapshot is deferred
// until the second item has been lowered.
$deferFirstItemSnapshot = $lastHoistingIndex >= 2
&& isset($items[1])
&& $this->isSnapshotableVariableRead($items[0])
&& !($this->isScalarString($items[1]) && $items[1]->value === '');

$argList = $prefixExpressions;
foreach ($items as $item) {
foreach ($items as $index => $item) {
if ($deferFirstItemSnapshot && $index === 0) {
continue;
}

// Keep one operand so concat still performs PHP string coercion.
// Prefix expressions are operands too (for example, the left-hand
// value of `.=`), so an empty RHS literal can be omitted there.
if ($argList !== [] && $this->isScalarString($item) && $item->value === '') {
continue;
}

$entryPosition = count($argList);
$itemClass = $this->detectClassOfExpr($item);
if ($this->isNativeObjectClass($itemClass)) {
$toString = new Expr\MethodCall($item, new Node\Identifier('toString'));
$argList[] = $this->parseOrderedOperand($toString, false);
continue;
} else {
$type = $this->detectTypeOfExpr($item);
// C++17 evaluates the braced-list elements in order. The
// temporary is still required because lowering a later operand
// may append captured beforeStmtLines ahead of the entire
// concat expression; without it, those statements could
// overtake an earlier Call.
$snapshotEarlierRead = $index >= 1
&& $index < $lastHoistingIndex
&& $this->isSnapshotableVariableRead($item);
$parsed = $this->parseOrderedOperand($item, false, $snapshotEarlierRead);
$argList[] = $this->prepareConcatOperand($parsed, $type);
}

$type = $this->detectTypeOfExpr($item);
// C++17 evaluates the braced-list elements in order. The temporary
// is still required because lowering a later operand may append
// captured beforeStmtLines ahead of the entire concat expression;
// without it, those statements could overtake an earlier Call.
$parsed = $this->parseOrderedOperand($item, false);
$argList[] = $this->prepareConcatOperand($parsed, $type);
if ($deferFirstItemSnapshot && $index === 1) {
// Snapshot the first item now, after the second item's side
// effects, and keep its leading position in the operand list.
$firstType = $this->detectTypeOfExpr($items[0]);
$firstParsed = $this->parseOrderedOperand($items[0], false, true);
array_splice($argList, $entryPosition, 0, [
$this->prepareConcatOperand($firstParsed, $firstType),
]);
}
}

if ($useTwoOperandOverload && count($argList) === 2) {
Expand All @@ -791,6 +835,24 @@ protected function parseFlattenedConcat(NodeAbstract $expr, array $prefixExpress
return Symbol::concat() . '({' . implode(', ', $argList) . '})';
}

/**
* Whether an operand is a plain local variable read whose value can be
* snapshotted into a temporary to preserve left-to-right evaluation when
* a later operand hoists side-effecting statements. `$this` cannot be
* reassigned and $GLOBALS has dedicated lowering; both are left alone.
*/
protected function isSnapshotableVariableRead(NodeAbstract $expr): bool
{
if (!$this->isVarExpr($expr) || !is_string($expr->name)) {
return false;
}
if ($expr->name === 'this' || $expr->name === 'GLOBALS') {
return false;
}
$var = (string) $this->parseIdentifier($expr);
return $this->hasVar($var) && !$this->isStdContainer($var);
}

protected function canUseTwoOperandConcatOverload(array $items): bool
{
if (count($items) !== 2) {
Expand Down
68 changes: 68 additions & 0 deletions tests/compiler/operator/eval-order-side-effects.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
--TEST--
Call arguments and concat operands follow Zend's operand read order around side effects
--FILE--
<?php
declare(strict_types=1);

function pair(int $a, int $b): string
{
return $a . ',' . $b;
}

function main(): void
{
// Call arguments are sent strictly left to right.
$j = 1;
var_dump(pair($j, $j = 5));
var_dump($j);

$n = 2;
var_dump(pair($n, $n *= 3));
var_dump($n);

// Concat chains: a variable is read when its concat op executes, so it
// sees the side effects of everything up to and including the operand it
// is combined with, but nothing later.
$m = 1;
var_dump($m . ',' . ($m = 9));
var_dump($m);

// The first two operands are read together at the first op, after the
// second operand's assignment.
$s = 'a';
var_dump($s . ($s = 'b') . $s);

$a = 'a';
var_dump($a . ($a = 'x') . $a . ($a = 'y'));

$t = 'p';
$u = 'a';
$t .= $u . ($u = 'b');
var_dump($t);

$t2 = 'p';
$u2 = 'a';
$t2 .= $u2 . ',' . ($u2 = 'b');
var_dump($t2);

$w = 'a';
var_dump('pre' . $w . ($w = 'z'));

// Zend reads the CV when the ADD executes, after the nested assignment.
$k = 1;
var_dump($k + ($k = 5));
}
?>
--EXPECT--
string(3) "1,5"
int(5)
string(3) "2,6"
int(6)
string(3) "1,9"
int(9)
string(3) "bbb"
string(4) "xxxy"
string(3) "pbb"
string(4) "pa,b"
string(5) "preaz"
int(10)
Loading