feat(hook): fire exception breakpoints from a THROW opcode handler - #11
Merged
Conversation
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
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
marked this pull request as ready for review
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 onOpCode::THROW, which runs beforeEG(exception)is set (ext-ffi aborts the process on a PHP callback entered with a live exception, which rules outzend_throw_exception_hook).src/Instrumentation/ThrowHook.php— installs theOpCode::THROWhandler alongside theEXT_STMTone, modeled onStatementHook::onStatement()(latch guard → try/catch-all → alwaysZEND_USER_OPCODE_DISPATCHso the throw proceeds). Reads the thrown value from the opline's op1 in frame context (IS_VARforthrow new X(),IS_CVfor re-throws; a by-reference re-throw needed an explicit deref throughReferenceEntry— feeding anIS_REFERENCEzval togetNativeValue()produced a corrupted object). Matches viaBreakpointRegistry::forException()behind a newhasExceptionBreakpoints()hot-path gate, bumpshitCount, and suspends through the sameenterBreak()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, athrowfrom 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.php—enterBreak(ExecutionData $top, ?ExceptionBreak $exception = null); the continuation response now saysreason="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 theThrowHookdocblock.Tests
throwing-app.php/throwing-entry.phpfixture; existing fixtures untouched):breakpoint_set -t exception -x DomainExceptionbreaks before the throw withreason="exception"and the throwing line instack_get; aLengthExceptionthrown earlier in the fixture proves non-matching classes never suspend; afterrunthe exception is caught and the child completes cleanly.HookLatch,hasExceptionBreakpoints(), and the newResponseBuilder::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.phpand with the eval PR inDebugSession/StatementHook— whichever merges last will need a small conflict resolution.🤖 Generated with Claude Code
https://claude.ai/code/session_01JTWF9Kwszy1wDk5ur3jmiA
Generated by Claude Code