Skip to content

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

Merged
lisachenko merged 2 commits into
8.4from
claude/fix-opline-operand-8.4
Aug 17, 2026
Merged

fix(type): read opline operands off the znode_op union instead of casting it#229
lisachenko merged 2 commits into
8.4from
claude/fix-opline-operand-8.4

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

What this changes

Ports #227 to the 8.4 line. OpLine::getValuePointer() reached the operand
fields by re-casting the znode_op it had already been handed:

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

$node is already a znode_op (a 4-byte union), not a pointer to one, so
Core::cast('znode_op *', …) asks FFI to widen a 4-byte value into an 8-byte
pointer and gets FFI\Exception: attempt to cast to larger type. Every read of
an IS_CV, IS_VAR, IS_TMP_VAR or IS_CONST operand from a user opcode
handler therefore threw. The fields are read directly now, and the same
already-typed-value mistake in the zend_op and zval casts alongside them is
removed.

Two regression tests cover the paths that were broken — reading a compiled
variable operand and reading a constant operand from inside an installed
handler. Both fail on 8.4 before this change with the cast exception above.

The PHPStan baseline drops the two OpLine entries the old casts produced and
gains a Binary operation "+" between FFI\CData and int entry, matching how
Compiler.php and ExecutionData.php already baseline the identical char* + int construct.

Found downstream: zdebug's main went red on both minors, with return-value
debugging and exception breakpoints failing because ReturnHook could no longer
read the RETURN opline's op1.

Note on the branch-flow rule below: this landed on master first (#227) and is
being cascaded down, which is the wrong direction. The bug is identical on both
lines and the diff cherry-picked cleanly, so the outcome is the same, but the
8.4 branch should have been the base.

Environment it was verified on

  • PHP version (full first line of php -v): PHP 8.4.19 (cli) (built: Mar 30 2026 19:28:35) (NTS)
  • Thread safety: NTS
  • OS / architecture: Debian linux-x64
  • Debug build (--enable-debug)? no

Verified in both directions, with opcache.enable_cli=0 so no stale bytecode
could mask the change:

  • tests/System/Hook/OpCodeHookTest (--group internal): 10/10 pass with the
    fix; the two new cases fail with FFI\Exception: attempt to cast to larger type without it.
  • zdebug's full suite against this working copy on PHP 8.4: 291/291 pass with
    the fix; without it exactly the four failures CI reports on zdebug main
    come back (testExceptionBreakpointFiresBeforeTheThrow,
    testSteppingOffAReturnStopsAgainWithTheValue, testContainerAndVoidReturns,
    testAReturnBreakpointCarriesTheValueWhenTheFeatureIsOn).

Checklist

  • Targets the minimum affected version branch — see the note above; this
    is the cascade of fix(type): read opline operands off the znode_op union instead of casting it #227 rather than the origin of the fix
  • composer test passes on the matching PHP minor
  • composer phpstan (level max) and composer cs:check are green — no
    PHPStan binary is installable in this environment (the egress proxy blocks
    api.github.com), so CI is the verifier for these two
  • Tests added or updated; no new struct is dereferenced, so
    layout_structs is unchanged
  • tools/generator/symbols.php unchanged
  • Conventional Commits used for the commit messages

Generated by Claude Code

claude added 2 commits August 17, 2026 04:25
…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
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:39
@lisachenko
lisachenko merged commit 8507ee0 into 8.4 Aug 17, 2026
20 checks passed
@lisachenko
lisachenko deleted the claude/fix-opline-operand-8.4 branch August 17, 2026 04:39
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