Skip to content

fix(type): read opline operands off the znode_op union instead of casting it - #227

Merged
lisachenko merged 2 commits into
masterfrom
claude/fix-opline-operand-and-hook-recursion
Aug 17, 2026
Merged

fix(type): read opline operands off the znode_op union instead of casting it#227
lisachenko merged 2 commits into
masterfrom
claude/fix-opline-operand-and-hook-recursion

Conversation

@lisachenko

@lisachenko lisachenko commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Reading an opline operand from a user opcode handler has been impossible since the struct-stub migration (#190). Found by ZDebug, whose exception breakpoints and return-value debugging both went silently dead against current master.

The bug

OpLine::getValuePointer() reached the operand through:

$this->context->getCallVariable(Core::cast('znode_op *', $node)->var)

$node is the znode_op union already. Casting it to znode_op * asks FFI to reinterpret a 4-byte union value as an 8-byte pointer, and FFI answers:

FFI\Exception: attempt to cast to larger type

for every IS_CV / IS_VAR / IS_TMP_VAR operand, and the same in getRuntimeConstant() for every runtime constant. The fields are now read straight off the union.

Why it presents as silence. A user opcode handler runs inside an FFI callback, where an escaping throw is a fatal engine abort — so every serious consumer wraps its handler in a catch-all. The exception disappears there, and the symptom is an instruction that never seems to arrive. Downstream this looked like THROW and RETURN hooks that had simply stopped firing, with nothing in any log.

The rest of the diff

Only what sharper types then exposed:

  • the redundant Core::cast('zend_op *', …) on an already-typed opline is gone;
  • the two operand params carry their generated stub views (znode_op), so the field reads type as int;
  • the zval cast uses the stub-class form Core::cast(zval::class, …), which types the handle for analysis;
  • two phpstan-baseline.neon entries are removed — the mixed they recorded came from the casts this deletes.

Verification

Two new tests on OpCodeHookTest, both of which fail on master:

test on master
testHandlerCanReadCompiledVariableOperands FFI\Exception: attempt to cast to larger type
testHandlerCanReadConstantOperands same
WITH the fix:     OK (10 tests, 46 assertions)
WITHOUT the fix:  Tests: 10, Assertions: 46, Failures: 2

Downstream, ZDebug's suite goes from 4 failures to green against this branch: 291 unit + 41 integration, 0 failures, including the paths that read operands from inside THROW and RETURN handlers. php-cs-fixer --dry-run clean; PHPStan clean for src/ (the tests/ leg needs phpstan-phpunit, which is not installable in my environment — CI covers it).

Separately: a recursion hazard I could not pin down

While investigating I hit a second, unrelated failure — hooking RETURN in a cold process stack-overflowed during install:

PHP Fatal error: Maximum call stack size … reached during compilation
 in include/8.5/linux-x64-nts/constants.php on line 6
#5  ZEngine\System\ExecutionData->getScopeClass()
#6  ZEngine\System\Hook\OpCodeHook->handle()
#7  include/8.5/linux-x64-nts/constants.php(6): require('...')
#9  ZEngine\Core::engineConstant()
… repeating to frame #64909

The mechanism looks clear — handle()'s scope check reaches Core::engineConstant(), which lazily requires the constants artifact; that require's own top-level RETURN re-enters a guard that cannot recognise a scope-less frame as z-engine's, and Core::$engineConstants is only assigned once the require completes.

I have deliberately left the fix out of this PR. I could not build a test that reproduces it — every scenario I wrote boots through vendor/autoload.php, which warms the artifact before a hook can be installed — and I am not willing to ship a change to install() I cannot demonstrate. Recording it here so it is not lost; happy to pursue it separately if you can suggest a reliably cold entry point.

…ting it

OpLine::getValuePointer() reached the operand through Core::cast('znode_op *',
$node). $node IS the znode_op union already, so that asks FFI to reinterpret a
4-byte union VALUE as an 8-byte pointer, and FFI answers "attempt to cast to
larger type" - for every IS_CV/IS_VAR/IS_TMP_VAR operand and, through
getRuntimeConstant(), for every runtime constant. Reading an opline operand
from a user opcode handler has been impossible since the struct-stub migration
(#190). The fields are now read straight off the union.

Consumers meet that failure as silence rather than as an error, which is what
made it expensive to find: a user opcode handler runs inside an FFI callback,
where an escaping throw is a fatal engine abort, so every serious consumer
wraps its handler in a catch-all. The exception disappears there and the
symptom is an instruction that never seems to arrive. Downstream in ZDebug it
looked like exception breakpoints and return-value debugging had silently
stopped firing, with nothing in any log.

The rest of the change is what sharper types then exposed. The redundant
'zend_op *' cast on an already-typed opline is gone, the two operand params
carry their generated stub views, and the zval cast uses the stub-class form
that types the handle for analysis. Two baseline entries went with it: the
'mixed' they recorded came from the casts this removes.

Covered by two tests on OpCodeHookTest - reading a compiled-variable operand
and reading a constant one - both of which fail on master with the cast error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCNB8eDgS6NEnfCFw7tPjU
@lisachenko
lisachenko force-pushed the claude/fix-opline-operand-and-hook-recursion branch from 00dcdd4 to e640f53 Compare August 16, 2026 20:21
@lisachenko lisachenko changed the title fix(system): restore operand reads and make hooking RETURN survive install fix(type): read opline operands off the znode_op union instead of casting it Aug 16, 2026
Reading the operand off the union types $constantOffset as int, which turns
OpLine's pointer arithmetic into the same "Binary operation + between FFI\CData
and int" that Compiler.php and ExecutionData.php already carry for the identical
Core::cast('char *', ...) + int construct. Recorded the same way they are, and
the "and mixed" entry it replaces is dropped.

Not verifiable outside CI: the analyser available in this environment infers
Core::cast() differently and reports neither message, so CI's PHPStan leg is the
only thing that can confirm the entry matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCNB8eDgS6NEnfCFw7tPjU
@lisachenko
lisachenko marked this pull request as ready for review August 17, 2026 04:21
@lisachenko
lisachenko merged commit e19b1a0 into master Aug 17, 2026
20 checks passed
@lisachenko
lisachenko deleted the claude/fix-opline-operand-and-hook-recursion branch August 17, 2026 04:21
@lisachenko

Copy link
Copy Markdown
Owner Author

What about php8.4 branch - do we need to backport a fix there as well?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants