Skip to content

feat(hook): fire exception breakpoints from a THROW opcode handler - #11

Merged
lisachenko merged 3 commits into
mainfrom
claude/opus5-issue6-throw
Aug 7, 2026
Merged

feat(hook): fire exception breakpoints from a THROW opcode handler#11
lisachenko merged 3 commits into
mainfrom
claude/opus5-issue6-throw

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

Closes #6

What

breakpoint_set -t exception -x <class> was accepted, stored and matched, but nothing ever fired it. This wires it up on the only safe route: a user opcode handler on OpCode::THROW, which runs before EG(exception) is set (ext-ffi aborts the process on a PHP callback entered with a live exception, which rules out zend_throw_exception_hook).

  • src/Instrumentation/ThrowHook.php — installs the OpCode::THROW handler alongside the EXT_STMT one, modeled on StatementHook::onStatement() (latch guard → try/catch-all → always ZEND_USER_OPCODE_DISPATCH so the throw proceeds). Reads the thrown value from the opline's op1 in frame context (IS_VAR for throw new X(), IS_CV for re-throws; a by-reference re-throw needed an explicit deref through ReferenceEntry — feeding an IS_REFERENCE zval to getNativeValue() produced a corrupted object). Matches via BreakpointRegistry::forException() behind a new hasExceptionBreakpoints() hot-path gate, bumps hitCount, and suspends through the same enterBreak() path as line breakpoints.
  • src/Instrumentation/HookLatch.php — the reentrancy latch, now shared by both hooks (tryEnter()/leave()). Per-hook latches were a trap: while the statement hook sits suspended in the command loop, a throw from debugger code would sail through the THROW hook's own unheld latch and recurse. AGENTS.md invariant [Feature] ZDebug - Xdebug equivalent implementation #1 reworded accordingly.
  • src/Session/DebugSession.phpenterBreak(ExecutionData $top, ?ExceptionBreak $exception = null); the continuation response now says reason="exception" and <xdebug:message ... exception="DomainException">message</xdebug:message> for exception breaks. Line/step breaks stay byte-identical (reason="ok", self-closing element).
  • src/Debugger.php — installs/uninstalls the ThrowHook next to the StatementHook (LIFO on detach).

Documented coverage gap

Throws originating in internal/C functions and engine-generated errors (TypeError, DivisionByZeroError, …) never execute a userland THROW opcode and are invisible on this route — verified empirically (intdiv(1, 0) never reaches the hook) and spelled out in the ThrowHook docblock.

Tests

  • Integration (new throwing-app.php/throwing-entry.php fixture; existing fixtures untouched): breakpoint_set -t exception -x DomainException breaks before the throw with reason="exception" and the throwing line in stack_get; a LengthException thrown earlier in the fixture proves non-matching classes never suspend; after run the exception is caught and the child completes cleanly.
  • Unit: HookLatch, hasExceptionBreakpoints(), and the new ResponseBuilder::breakMessage() builder.

Verification

All four gates green on the branch merged with current main (PHP 8.4, z-engine 8.4.x-dev): composer test (94 tests / 324 assertions), composer test:integration (5 tests, --fail-on-skipped), composer phpstan (level max, no new ignores), composer cs:check.

Note: overlaps textually with PR #10 in tests/Integration/DbgpSessionTest.php and with the eval PR in DebugSession/StatementHook — whichever merges last will need a small conflict resolution.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JTWF9Kwszy1wDk5ur3jmiA


Generated by Claude Code

claude added 2 commits August 7, 2026 18:29
Exception breakpoints were accepted, stored and matched, but nothing ever
fired them. ThrowHook installs a user opcode handler on OpCode::THROW, which
runs *instead of* the VM handler - one instruction before the engine sets
EG(exception). That is the only safe window in the engine for a PHP callback
to see an exception at all: ext-ffi aborts the process when a callback is
entered while EG(exception) is live, which closes zend_throw_exception_hook
(and a CATCH handler) to userland for good.

The handler gates on the new BreakpointRegistry::hasExceptionBreakpoints(),
resolves the throwable from the opline's op1 in frame context (IS_VAR for
`throw new X()`, IS_CV for the re-throw of a local, dereferencing an
IS_REFERENCE slot on the way), matches it through forException(), bumps the
matched hit counts and suspends via DebugSession::enterBreak() - then always
returns ZEND_USER_OPCODE_DISPATCH so the throw proceeds untouched.

enterBreak() now takes an optional ExceptionBreak, which turns the pending
continuation response into reason="exception" with the throwable's class and
message on the <xdebug:message> element, the way Xdebug reports a first-chance
exception. Line and step breaks keep their previous shape.

Both hooks now share one process-wide HookLatch instead of a private static
per class: while the statement hook is suspended in the command loop, a throw
inside the debugger would otherwise reach the THROW handler and recurse
straight through the other hook's latch.

Coverage gap, documented on the class: throws raised inside internal/C
functions and engine-generated errors (TypeError, DivisionByZeroError, ...)
never execute a userland THROW opline and are invisible on this route by
design.

Closes #6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTWF9Kwszy1wDk5ur3jmiA
@lisachenko

Copy link
Copy Markdown
Owner Author

Rebase please

The exception-breakpoint tests now spawn and finish children through the
base class extracted in #10; the branch keeps only the two helpers the
base lacks (full <xdebug:message> element + attribute map).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTWF9Kwszy1wDk5ur3jmiA
@lisachenko
lisachenko marked this pull request as ready for review August 7, 2026 18:43
@lisachenko
lisachenko merged commit 5cc2325 into main Aug 7, 2026
5 checks passed
@lisachenko
lisachenko deleted the claude/opus5-issue6-throw branch August 7, 2026 18:43
lisachenko pushed a commit that referenced this pull request Aug 7, 2026
Debugger now constructs both the ContextProvider/ConditionEvaluator
statement-hook wiring from this branch and the ThrowHook merged in #11;
the integration suite carries both feature's tests side by side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTWF9Kwszy1wDk5ur3jmiA
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.

[M3] Exception breakpoints via the THROW opcode

2 participants