Skip to content

fix(core): make CastObjectHook fall through to default engine behaviour safely - #156

Merged
lisachenko merged 2 commits into
8.4from
claude/cast-hook-fallthrough
Aug 7, 2026
Merged

fix(core): make CastObjectHook fall through to default engine behaviour safely#156
lisachenko merged 2 commits into
8.4from
claude/cast-hook-fallthrough

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

Fixes #153.

The bugs

Two defects made the documented fall-through pattern — $hook->proceed(); return $hook->getResult(); — unusable for cast types the default handler cannot satisfy:

  1. getResult() read uninitialized memory. The engine hands cast_object an uninitialized retval slot, and zend_std_cast_object_tostring returns FAILURE for numeric casts without writing it (the engine caller is what warns and substitutes 1). getResult() dereferenced the slot regardless, running the by-ref ReferenceEntry machinery over garbage — observably unsetting local variables in the calling frames ($result in handle() itself became undefined after being assigned) and emitting spurious "Undefined variable" warnings.
  2. handle() always reported Core::SUCCESS, so a FAILURE from the original handler could never propagate. Whatever the user handler returned — including an accidental null — was silently installed as the cast result, and the engine caller's default behaviour (warning + substitute value for numeric casts, engine Error for string casts) was unreachable.

The fix

  • proceed() records its status.
  • getResult() refuses to read the slot unless the last proceed() succeeded, returning null instead of dereferencing scratch memory.
  • handle() propagates FAILURE to the engine when the user handler fell through (returned null after a failed proceed()). The failure is surfaced by the engine's own caller after the FFI callback has returned, so even the throwing default paths (string casts) stay safe.

Together this makes the naive fall-through behave exactly like an uninstalled handler for every cast type.

The regression test

testCastObjectHandlerFallsThroughToEngineDefault installs the naive fall-through handler and asserts:

  • boolean conversion yields the default handler's true (via getResult() after a successful proceed);
  • (int) yields 1 with the engine's own diagnostic Object of class ... could not be converted to int and — by capturing every PHP diagnostic into a strict assertSame — no "Undefined variable" corruption noise.

Against the unfixed hook the test fails with exactly the reported symptom (captured warning is Undefined variable $result instead of the engine diagnostic); with the fix it passes.

Verified: full suite green on PHP 8.4.19 NTS (409 tests), PHPStan level max clean, PER-CS2.0 clean.

Note: this PR touches the same file as #155 in non-overlapping regions; both are independent of each other and either merge order works.


🤖 Generated with Claude Code

https://claude.ai/code/session_01JsbdqisRfGuujN3QD9n8En


Generated by Claude Code

claude added 2 commits August 7, 2026 21:56
…ur safely

Two defects made the documented fall-through pattern - proceed() then
getResult() - unusable for cast types the default handler cannot
satisfy:

- The engine hands cast_object an UNINITIALIZED retval slot, and
  zend_std_cast_object_tostring returns FAILURE for numeric casts
  without writing it (the engine caller is the one that warns and
  substitutes 1). getResult() dereferenced that slot regardless,
  running the by-ref ReferenceEntry machinery over garbage - which
  observably unsets local variables in the calling frames and emits
  spurious "Undefined variable" warnings.
- handle() unconditionally reported Core::SUCCESS, so a FAILURE from
  the original handler could never propagate: whatever the user handler
  returned - including an accidental null - was silently installed as
  the cast result, and the engine caller's default behaviour (warning
  plus substitute value for numeric casts, engine Error for string
  casts) was unreachable.

proceed() now records its status. getResult() refuses to read the slot
unless the last proceed() succeeded and returns null instead, and
handle() propagates FAILURE to the engine when the user handler fell
through (returned null after a failed proceed). Together this makes the
naive fall-through behave exactly like an uninstalled handler for every
cast type; the regression test asserts the engine's own diagnostic and
substitute value come back and that no corruption noise is emitted.

Fixes #153

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsbdqisRfGuujN3QD9n8En
@lisachenko
lisachenko marked this pull request as ready for review August 7, 2026 22:06
@lisachenko
lisachenko merged commit 097219a into 8.4 Aug 7, 2026
7 checks passed
@lisachenko
lisachenko deleted the claude/cast-hook-fallthrough branch August 7, 2026 22:06
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.

bug(hooks): CastObjectHook cannot fall through to default engine behaviour — getResult() after a failed proceed() corrupts the calling VM frame

2 participants