Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ The EXT_STMT / THROW / interrupt handlers run inside FFI callbacks. A `\Throwabl
escapes one is a **fatal engine abort** ("Throwing from FFI callbacks is not allowed"),
not a catchable error. Every handler entry point therefore:

1. checks the `static bool $inDebugger` reentrancy latch first and bails if set (the
debugger's own PHP re-enters its own hook otherwise — z-engine only auto-excludes
`ZEngine\*` classes, not `ZDebug\*`), and
1. checks the reentrancy latch first and bails if it is held — `HookLatch::tryEnter()`,
released in a `finally` (the debugger's own PHP re-enters its own hooks otherwise —
z-engine only auto-excludes `ZEngine\*` classes, not `ZDebug\*`). The latch is a
single process-wide flag **shared by every hook**: a per-hook latch would let the
handlers re-enter through each other (a `throw` inside the suspended statement hook
would reach the THROW handler), and
2. wraps its whole body in `try { ... } catch (\Throwable) { ...log... }`.

Frame inspection uses only the closure-safe API: `getFunctionEntry()` (not
Expand Down
12 changes: 12 additions & 0 deletions src/Breakpoint/BreakpointRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ public function hasLineBreakpoints(): bool
return $this->byLocation !== [];
}

/**
* Whether any exception breakpoint is registered (fast global gate for the THROW hook)
*
* Deliberately ignores the enabled flag, exactly like hasLineBreakpoints(): this is the
* cheap "is it worth resolving the thrown value at all" check, and forException() below
* still filters disabled breakpoints out.
*/
public function hasExceptionBreakpoints(): bool
{
return $this->exceptionBreakpoints !== [];
}

/**
* Returns the enabled line breakpoints registered at a (file, line), if any
*
Expand Down
10 changes: 9 additions & 1 deletion src/Debugger.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use ZDebug\Instrumentation\FileFilter;
use ZDebug\Instrumentation\OpArrayGate;
use ZDebug\Instrumentation\StatementHook;
use ZDebug\Instrumentation\ThrowHook;
use ZDebug\Protocol\DbgpConnection;
use ZDebug\Protocol\FileUri;
use ZDebug\Protocol\ResponseBuilder;
Expand Down Expand Up @@ -55,6 +56,7 @@ private function __construct(
private readonly StepController $stepper,
private readonly StackCollector $stackCollector,
private readonly StatementHook $statementHook,
private readonly ThrowHook $throwHook,
) {}

/**
Expand All @@ -81,8 +83,9 @@ public static function attach(Config|array|null $config = null): self
$stepper = new StepController();
$collector = new StackCollector($gate);
$hook = new StatementHook($gate, $breakpoints, $stepper, $log);
$throwHook = new ThrowHook($breakpoints, $log);

$debugger = new self($config, $log, $breakpoints, $stepper, $collector, $hook);
$debugger = new self($config, $log, $breakpoints, $stepper, $collector, $hook, $throwHook);
self::$instance = $debugger;

if ($config->isEnabled()) {
Expand Down Expand Up @@ -110,6 +113,8 @@ public function module(): ?ZDebugModule
*/
public function detach(): void
{
// LIFO, mirroring the installation order in boot()
$this->throwHook->uninstall();
$this->statementHook->uninstall();
$this->session = null;
$this->attached = false;
Expand Down Expand Up @@ -143,6 +148,9 @@ private function boot(): void
$compiler->setOptions($compiler->getOptions() | Compiler::COMPILE_EXTENDED_STMT);

$this->statementHook->install(fn(): ?DebugSession => $this->session);
// Exception breakpoints ride the THROW opcode: the only window where a PHP callback
// may look at an exception, since ext-ffi aborts once EG(exception) is set
$this->throwHook->install(fn(): ?DebugSession => $this->session);
$this->attached = true;

$connection = DbgpConnection::connect(
Expand Down
58 changes: 58 additions & 0 deletions src/Instrumentation/HookLatch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/**
* This file is part of the zdebug package.
*
* @copyright Copyright 2026, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* This source file is subject to the license that is bundled
* with this source code in the file LICENSE.
*/
declare(strict_types=1);

namespace ZDebug\Instrumentation;

/**
* The single process-wide "debugger code is running" latch shared by every engine hook
*
* z-engine only auto-excludes `ZEngine\*` classes from user opcode handlers, so the
* debugger's own PHP re-enters its handlers unless it says otherwise. One latch per hook
* would not be enough: while the statement hook is suspended in the command loop, any
* `throw` inside the debugger (or inside a value being inspected) would still reach the
* THROW handler and recurse. A single shared flag closes every hook at once.
*
* Usage is always `if (!HookLatch::tryEnter()) { return ...; }` first thing in the
* handler, with `HookLatch::leave()` in a `finally` block.
*/
final class HookLatch
{
private static bool $engaged = false;

/**
* Engages the latch, or reports false when it is already held (a reentrant call)
*/
public static function tryEnter(): bool
{
if (self::$engaged) {
return false;
}

return self::$engaged = true;
}

/**
* Releases the latch so the next engine callback can be serviced
*/
public static function leave(): void
{
self::$engaged = false;
}

/**
* Whether debugger code is currently executing inside an engine callback
*/
public static function isEngaged(): bool
{
return self::$engaged;
}
}
18 changes: 8 additions & 10 deletions src/Instrumentation/StatementHook.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,13 @@
*
* Compiling with COMPILE_EXTENDED_STMT emits an EXT_STMT opline before every statement;
* this handler runs on each. It is a raw FFI callback, so the two invariants from
* AGENTS.md are absolute: (1) a static reentrancy latch is checked first, because the
* debugger's own PHP would otherwise recurse into this very handler, and (2) nothing
* may throw - the whole body is wrapped, and only the closure-safe frame API is used.
* AGENTS.md are absolute: (1) the shared HookLatch is checked first, because the
* debugger's own PHP would otherwise recurse into this very handler (or into the THROW
* handler, which is why the latch is shared rather than per-hook), and (2) nothing may
* throw - the whole body is wrapped, and only the closure-safe frame API is used.
*/
final class StatementHook
{
private static bool $inDebugger = false;

private ?OpCodeHook $hook = null;

/** @var (callable(): ?DebugSession)|null */
Expand Down Expand Up @@ -69,13 +68,12 @@ public function uninstall(): void
*/
private function onStatement($scope): int
{
// (1) Reentrancy latch FIRST, and set BEFORE any zdebug code runs: resolving the
// session and every check below execute instrumented PHP that would otherwise
// (1) Reentrancy latch FIRST, and engaged BEFORE any zdebug code runs: resolving
// the session and every check below execute instrumented PHP that would otherwise
// re-enter this very handler (isLive(), evaluate(), the whole break loop).
if (self::$inDebugger) {
if (!HookLatch::tryEnter()) {
return Core::ZEND_USER_OPCODE_DISPATCH;
}
self::$inDebugger = true;
try {
if (!$scope instanceof ExecutionData) {
return Core::ZEND_USER_OPCODE_DISPATCH;
Expand All @@ -90,7 +88,7 @@ private function onStatement($scope): int
// (2) Nothing escapes the FFI callback
$this->log->exception($error);
} finally {
self::$inDebugger = false;
HookLatch::leave();
}

return Core::ZEND_USER_OPCODE_DISPATCH;
Expand Down
168 changes: 168 additions & 0 deletions src/Instrumentation/ThrowHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?php

/**
* This file is part of the zdebug package.
*
* @copyright Copyright 2026, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* This source file is subject to the license that is bundled
* with this source code in the file LICENSE.
*/
declare(strict_types=1);

namespace ZDebug\Instrumentation;

use ZDebug\Breakpoint\BreakpointRegistry;
use ZDebug\Log;
use ZDebug\Session\DebugSession;
use ZDebug\Session\ExceptionBreak;
use ZEngine\Core;
use ZEngine\Reflection\ReflectionValue;
use ZEngine\System\ExecutionData;
use ZEngine\System\Hook\OpCodeHook;
use ZEngine\System\OpCode;
use ZEngine\Type\OpLine;
use ZEngine\Type\ReferenceEntry;

/**
* The THROW opcode handler: first-chance exception breakpoints
*
* A user handler on OpCode::THROW runs *instead of* the VM handler, i.e. one instruction
* before the engine sets EG(exception). That is the only safe window in the whole engine
* for a PHP callback to see an exception: ext-ffi refuses to enter a PHP callback while
* the engine carries a live exception ("Throwing from FFI callbacks is not allowed"),
* which closes zend_throw_exception_hook and a CATCH handler to userland for good. The
* handler inspects the throwable in op1, suspends the session if a breakpoint matches,
* and always returns ZEND_USER_OPCODE_DISPATCH so the throw then proceeds untouched.
*
* Coverage gap, by design: only a *userland* `throw` compiles to a THROW opline. Throws
* raised inside internal/C functions and engine-generated errors (TypeError,
* DivisionByZeroError, ValueError, the ArgumentCountError of a bad call, ...) never
* execute one and are therefore invisible on this route - no amount of plumbing on the
* zdebug side can surface them. Same compile-order caveat as the statement hook: the
* throwing op_array must have been compiled after the handler was installed.
*
* The two AGENTS.md invariants apply exactly as in StatementHook: the shared HookLatch is
* checked first (a separate latch would let the two hooks re-enter through each other),
* and no \Throwable may escape the FFI callback.
*/
final class ThrowHook
{
private ?OpCodeHook $hook = null;

/** @var (callable(): ?DebugSession)|null */
private $sessionResolver;

public function __construct(
private readonly BreakpointRegistry $breakpoints,
private readonly Log $log,
) {}

/**
* Installs the handler. The session is resolved lazily so it can be attached later.
*
* @param callable(): ?DebugSession $sessionResolver
*/
public function install(callable $sessionResolver): void
{
$this->sessionResolver = $sessionResolver;
$this->hook = OpCode::setHandler(OpCode::THROW, fn($scope): int => $this->onThrow($scope));
}

public function uninstall(): void
{
$this->hook?->uninstall();
$this->hook = null;
}

/**
* @param mixed $scope The ExecutionData the engine passes (typed loosely for the handler contract)
*/
private function onThrow($scope): int
{
// (1) Reentrancy latch FIRST: everything below is instrumented PHP, and the break
// loop it may enter runs arbitrary debugger code that throws on its own.
if (!HookLatch::tryEnter()) {
return Core::ZEND_USER_OPCODE_DISPATCH;
}
try {
if (!$scope instanceof ExecutionData) {
return Core::ZEND_USER_OPCODE_DISPATCH;
}
if (!$this->breakpoints->hasExceptionBreakpoints()) {
// Fast path: the overwhelmingly common case, one array check per throw
return Core::ZEND_USER_OPCODE_DISPATCH;
}
$session = $this->sessionResolver !== null ? ($this->sessionResolver)() : null;
if ($session === null || !$session->isLive()) {
return Core::ZEND_USER_OPCODE_DISPATCH;
}
$this->evaluate($scope, $session);
} catch (\Throwable $error) {
// (2) Nothing escapes the FFI callback
$this->log->exception($error);
} finally {
HookLatch::leave();
}

return Core::ZEND_USER_OPCODE_DISPATCH;
}

private function evaluate(ExecutionData $frame, DebugSession $session): void
{
$thrown = self::thrownValue($frame);
if ($thrown === null) {
return;
}

$matching = $this->breakpoints->forException($thrown::class);
if ($matching === []) {
return;
}
foreach ($matching as $breakpoint) {
$breakpoint->hitCount++;
}

// Suspends exactly like a line breakpoint, on the frame that is about to throw:
// its opline is still the THROW, so the reported line is the `throw` statement
$session->enterBreak($frame, new ExceptionBreak($thrown::class, $thrown->getMessage()));
}

/**
* Materializes the throwable the THROW opline is about to raise, or null when the
* operand cannot be resolved into one
*
* op1 is IS_VAR for `throw new X()` and for a thrown call result, and IS_CV for the
* re-throw of a local (`catch (X $e) { throw $e; }`) - where the slot may additionally
* hold an IS_REFERENCE that has to be dereferenced before the object is visible. Every
* other shape is rejected rather than guessed at: this runs in an FFI callback where a
* bad read is not a recoverable error.
*/
private static function thrownValue(ExecutionData $frame): ?\Throwable
{
$opline = $frame->getOpline();
$type = $opline->getOp1Type();
if ($type !== OpLine::IS_VAR && $type !== OpLine::IS_CV && $type !== OpLine::IS_TMP_VAR && $type !== OpLine::IS_CONST) {
return null;
}

$operand = $opline->getOp1();
if ($operand === null) {
return null;
}
// The high byte of type_info carries the zval type flags; only the type is wanted
$valueType = $operand->getType() & 0xFF;
if ($valueType === ReflectionValue::IS_REFERENCE) {
$operand = ReferenceEntry::fromCData($operand->getRawReference())->getValue();
$valueType = $operand->getType() & 0xFF;
}
if ($valueType !== ReflectionValue::IS_OBJECT) {
return null;
}

$value = null;
$operand->getNativeValue($value);

return $value instanceof \Throwable ? $value : null;
}
}
22 changes: 22 additions & 0 deletions src/Protocol/ResponseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,28 @@ public function error(string $command, string $transactionId, int $code, string
return $this->response($command, $transactionId, [], $body);
}

/**
* Builds the <xdebug:message> element that tells the IDE where the debuggee stopped
*
* IDEs move their cursor off filename/lineno; for an exception breakpoint Xdebug also
* puts the throwable's class in an `exception` attribute and its message in the element
* text, which is how the "first chance exception" popup gets its wording. An empty
* message stays a self-closing element so line breaks keep their historic shape.
*/
public static function breakMessage(string $fileUri, int $line, ?string $exceptionClass = null, string $exceptionMessage = ''): string
{
$attributes = ['filename' => $fileUri, 'lineno' => (string) $line];
if ($exceptionClass !== null) {
$attributes['exception'] = $exceptionClass;
}
$element = '<xdebug:message ' . self::attributes($attributes);
if ($exceptionClass === null || $exceptionMessage === '') {
return $element . '/>';
}

return $element . '>' . self::escape($exceptionMessage) . '</xdebug:message>';
}

/**
* Renders an attribute string from a name => value map (values XML-escaped)
*
Expand Down
Loading
Loading