Skip to content
Merged
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
17 changes: 14 additions & 3 deletions docs/opcache-binary.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,20 @@ $report->appliedMethods; // what actually happened, per entry

## Scope and limits (v1)

- **Platform.** The relocator targets the bundled 64-bit non-Windows build; it
asserts `PHP_INT_SIZE === 8` and a `/` path separator and throws
`OpCacheException::unsupportedPayload` otherwise. **Windows opcache support
- **Platform.** The relocator targets 64-bit POSIX builds - linux and macOS
(x64 and arm64) alike; it asserts `PHP_INT_SIZE === 8` and a `/` path
separator and throws `OpCacheException::unsupportedPayload` otherwise.
Darwin needs no per-opline walking of its own
([#119](https://github.com/lisachenko/z-engine/issues/119)): the
absolute-address opline branches of zend_file_cache.c
(`ZEND_USE_ABS_CONST_ADDR`/`ZEND_USE_ABS_JMP_ADDR`) are compiled in only
when `SIZEOF_SIZE_T == 4` (zend_compile.h), so every 64-bit build - darwin
included - stores IS_CONST operands as literal-table indexes and jumps as
opline-relative byte offsets, both position-independent and preserved
verbatim. `OpcodeAddressingModelTest` proves that on a real payload and
fails loudly if a build ever diverges; the 32-bit builds that do use
absolute addressing are refused by the `PHP_INT_SIZE` predicate.
**Windows opcache support
is an intentional non-goal**, not pending work: the relocator (and
`opcache.preload`-based features) keep rejecting Windows loudly, and the
Windows half of the original platform ticket was retired when
Expand Down
12 changes: 9 additions & 3 deletions src/OpCache/PayloadRelocator.php
Original file line number Diff line number Diff line change
Expand Up @@ -765,9 +765,15 @@ private function unserializeOpArray(object $opArray): void
$this->unserializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize));
}
}
// opcodes: only the array pointer is relocated; per-opline operands and
// handlers are literal indexes/relative jumps on this platform and are
// preserved verbatim (see class docblock)
// opcodes: only the array pointer is relocated. Per-opline operands are
// preserved verbatim because every 64-bit build uses relative addressing:
// ZEND_USE_ABS_CONST_ADDR / ZEND_USE_ABS_JMP_ADDR are 1 only when
// SIZEOF_SIZE_T == 4 (zend_compile.h), so in these payloads IS_CONST
// operands are literal-table indexes and jump operands are opline-relative
// byte offsets - position-independent on linux and darwin alike. The
// absolute-address per-opline walk of zend_file_cache.c is a 32-bit-only
// shape, excluded with the 32-bit refusal (issue #119;
// OpcodeAddressingModelTest is the tripwire should a build ever diverge).
$this->unPtr($opArray, 'opcodes');
$this->unPtr($opArray, 'scope');
$this->unserializeArgInfo($opArray);
Expand Down
147 changes: 147 additions & 0 deletions tests/OpCache/OpcodeAddressingModelTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

/**
* Z-Engine framework
*
* @copyright Copyright 2026, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* This source file is subject to the license that is bundled
* with this source code in the file LICENSE.
*
*/
declare(strict_types=1);

namespace ZEngine\OpCache;

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use ZEngine\Core;
use ZEngine\Generated\zend_op;
use ZEngine\Generated\zend_persistent_script;
use ZEngine\System\OpCode;
use ZEngine\Type\OpLine;

/**
* Tripwire for the relocator's opline-opaque contract (issue #119).
*
* PayloadRelocator never rewrites per-opline operands because every 64-bit
* build compiles with relative addressing (ZEND_USE_ABS_CONST_ADDR /
* ZEND_USE_ABS_JMP_ADDR are 1 only when SIZEOF_SIZE_T == 4, zend_compile.h) -
* darwin x64/arm64 included, they are not special. In such payloads IS_CONST
* operands are literal-table indexes and jump operands are opline-relative
* byte offsets, both position-independent. This test proves that on a real
* compiled payload and FAILS - it does not skip - if a supported build ever
* produced absolute (buffer-offset) operands, which would need the
* per-opline walk of zend_file_cache.c that only 32-bit builds compile in.
*/
#[Group('opcache')]
#[Group('opcache-relocator')]
final class OpcodeAddressingModelTest extends TestCase
{
use FileCacheFixture;

/** Opcodes whose op1/op2 is a jump target in zend_file_cache.c's ABS-addr switch (CATCH/FE_FETCH/SWITCH use extended_value and are not probed) */
private const array OP1_JUMPS = [OpCode::JMP];
private const array OP2_JUMPS = [
OpCode::JMPZ, OpCode::JMPNZ, OpCode::JMPZ_EX, OpCode::JMPNZ_EX,
OpCode::JMP_SET, OpCode::COALESCE, OpCode::FE_RESET_R, OpCode::FE_RESET_RW,
OpCode::ASSERT_CHECK, OpCode::JMP_NULL, OpCode::BIND_INIT_STATIC_OR_JMP,
OpCode::JMP_FRAMELESS,
];

protected function setUp(): void
{
if (!PayloadRelocator::isSupported()) {
self::markTestSkipped(
'The file-cache relocator supports 64-bit POSIX payloads only'
. ' (Windows opcache support is an intentional non-goal, issue #119)',
);
}
}

protected function tearDown(): void
{
self::removeCacheDir();
}

public function testPayloadOplinesUseRelativeAddressing(): void
{
$binPath = self::compileFixture(self::probeFixturePath());
$payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize());
$meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath);

$length = strlen($payload);
$buffer = Core::new("char[{$length}]", false);
Core::memcpy($buffer, $payload, $length);
$relocator = new PayloadRelocator($buffer, $meta);
$relocator->relocate();

$base = Core::addressOf(Core::addr($buffer));
$script = Core::pointerAtAddress(zend_persistent_script::class, $base + $meta->scriptOffset());
$mainOpArray = $script->script->main_op_array;
$lastLiteral = $mainOpArray->last_literal;
$lastOpline = $mainOpArray->last;
$opSize = Core::sizeOfType(zend_op::class);
$opcodes = $mainOpArray->opcodes;
self::assertNotNull($opcodes);
$opcodesAddr = Core::addressOf($opcodes);
$opcodesBytes = $lastOpline * $opSize;

$constOperands = 0;
$jumpOperands = 0;
for ($i = 0; $i < $lastOpline; $i++) {
$opline = Core::pointerAtAddress(zend_op::class, $opcodesAddr + $i * $opSize);
foreach ([
'op1' => [$opline->op1_type, $opline->op1->constant],
'op2' => [$opline->op2_type, $opline->op2->constant],
] as $node => [$type, $index]) {
if ($type !== OpLine::IS_CONST) {
continue;
}
$constOperands++;
self::assertLessThan(
$lastLiteral,
$index,
"Opline #{$i} {$node} IS_CONST operand is not a literal-table index - "
. 'this build stores absolute constant addresses (ZEND_USE_ABS_CONST_ADDR), '
. 'which the relocator does not walk (issue #119)',
);
}
$rawOffset = null;
$jumpNode = '';
if (\in_array($opline->opcode, self::OP1_JUMPS, true)) {
[$rawOffset, $jumpNode] = [$opline->op1->jmp_offset, 'op1'];
} elseif (\in_array($opline->opcode, self::OP2_JUMPS, true)) {
[$rawOffset, $jumpNode] = [$opline->op2->jmp_offset, 'op2'];
}
if ($rawOffset !== null) {
$jumpOperands++;
$unpacked = unpack('l', pack('V', $rawOffset));
self::assertIsArray($unpacked);
$signedOffset = $unpacked[1];
self::assertIsInt($signedOffset);
$targetPosition = $i * $opSize + $signedOffset;
self::assertTrue(
$targetPosition >= 0 && $targetPosition < $opcodesBytes && $targetPosition % $opSize === 0,
"Opline #{$i} {$jumpNode} jump does not land on an opline of this op_array - "
. 'this build stores absolute jump addresses (ZEND_USE_ABS_JMP_ADDR), '
. 'which the relocator does not walk (issue #119)',
);
}
}

self::assertGreaterThan(0, $constOperands, 'The probe fixture must yield at least one IS_CONST operand');
self::assertGreaterThan(0, $jumpOperands, 'The probe fixture must yield at least one conditional jump');

// And the invariant holds through the writer: untouched round trip stays exact
self::assertSame($payload, $relocator->derelocate());
}

private static function probeFixturePath(): string
{
$path = realpath(__DIR__ . '/fixtures/addressing-probe.php');
self::assertIsString($path);

return $path;
}
}
20 changes: 20 additions & 0 deletions tests/OpCache/fixtures/addressing-probe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/**
* Fixture compiled into the opcache file cache by the opcode-addressing tests.
*
* The top-level code is built so its main op_array is guaranteed to carry both
* IS_CONST operands and conditional jumps after optimization: getenv() is
* opaque to SCCP, so neither the ?: nor the !== branch can be folded away.
*/
declare(strict_types=1);

$zengineProbeSeed = getenv('ZENGINE_PROBE_SEED') ?: 'seed';
if ($zengineProbeSeed !== 'expected-marker') {
$zengineProbeSeed .= ':fallback-branch';
}

function zengine_bin_probe(): string
{
return 'probe-ok';
}
Loading