Skip to content
Draft
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
54 changes: 54 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Keep host build artifacts (with absolute host paths baked in) out of any
# Docker build context rooted at this repo. Without this, tests/frankenphp/
# would inherit a broken Makefile from a prior host `phpize`/`configure`.
.git
.github
.claude
.understand-anything
.idea
.vscode

# PHP build artifacts from running phpize/configure/make on the host.
# Docker .dockerignore patterns do not cross directory boundaries without
# explicit **/ — these live both at the root and nested under src/**.
Makefile
Makefile.*
Makefile.fragments
Makefile.global
Makefile.objects
config.status
config.cache
config.log
config.nice
configure
configure.ac
configure.in
acinclude.m4
aclocal.m4
autom4te.cache
build
libtool
ltmain.sh
install-sh
missing
mkinstalldirs
run-tests.php

# Compiled objects / deps (root + nested)
*.lo
*.la
*.o
*.dep
*.so
*.loT
**/*.lo
**/*.la
**/*.o
**/*.dep
**/*.so
**/*.loT
**/.libs
**/.deps
.libs
.deps
modules
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ phpt.*
!php_xdebug.stub.php
!run-xdebug-tests.php
!make-release.php
!tests/frankenphp/**/*.php
!tests/frankenphp/**/*.sh
!bench.php
!test-ini.php
!rector.php
Expand Down
2 changes: 1 addition & 1 deletion config.m4
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ if test "$PHP_PHP_DEBUGGER" != "no"; then
XDEBUG_LIB_SOURCES="src/lib/usefulstuff.c src/lib/cmd_parser.c src/lib/compat.c src/lib/crc32.c src/lib/file.c src/lib/hash.c src/lib/headers.c src/lib/lib.c src/lib/llist.c src/lib/log.c src/lib/normalize_path.c src/lib/set.c src/lib/str.c src/lib/timing.c src/lib/trim.c src/lib/var.c src/lib/var_export_html.c src/lib/var_export_line.c src/lib/var_export_text.c src/lib/var_export_xml.c src/lib/xdebug_strndup.c src/lib/xml.c src/lib/compat_stubs.c"
XDEBUG_LIB_MAPS_SOURCES="src/lib/maps/maps.c src/lib/maps/maps_private.c src/lib/maps/parser.c"

XDEBUG_DEBUGGER_SOURCES="src/debugger/com.c src/debugger/debugger.c src/debugger/handler_dbgp.c src/debugger/handlers.c src/debugger/ip_info.c"
XDEBUG_DEBUGGER_SOURCES="src/debugger/com.c src/debugger/debugger.c src/debugger/frankenphp.c src/debugger/handler_dbgp.c src/debugger/handlers.c src/debugger/ip_info.c"

PHP_NEW_EXTENSION(php_debugger, xdebug.c $XDEBUG_BASE_SOURCES $XDEBUG_LIB_SOURCES $XDEBUG_LIB_MAPS_SOURCES $XDEBUG_DEBUGGER_SOURCES, $ext_shared,,$PHP_XDEBUG_CFLAGS,,yes)
PHP_ADD_BUILD_DIR(PHP_EXT_BUILDDIR(php_debugger)[/src/base])
Expand Down
2 changes: 1 addition & 1 deletion config.w32
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ if (PHP_DEBUGGER == 'yes') {
var XDEBUG_BASE_SOURCES="base.c ctrl_socket.c"
var XDEBUG_LIB_SOURCES="usefulstuff.c cmd_parser.c compat.c crc32.c file.c hash.c headers.c lib.c llist.c log.c normalize_path.c set.c str.c timing.c trim.c var.c var_export_html.c var_export_line.c var_export_text.c var_export_xml.c xdebug_strndup.c xml.c compat_stubs.c"
var XDEBUG_LIB_MAPS_SOURCES="maps.c maps_private.c parser.c"
var XDEBUG_DEBUGGER_SOURCES="com.c debugger.c handler_dbgp.c handlers.c ip_info.c"
var XDEBUG_DEBUGGER_SOURCES="com.c debugger.c frankenphp.c handler_dbgp.c handlers.c ip_info.c"

var files = "xdebug.c";

Expand Down
11 changes: 7 additions & 4 deletions src/base/base.c
Original file line number Diff line number Diff line change
Expand Up @@ -643,10 +643,13 @@ static void xdebug_execute_end(zend_execute_data *execute_data, zval *retval)

static zend_observer_fcall_handlers xdebug_observer_init(zend_execute_data *execute_data)
{
/* If observer is deactivated (no debugger connected), skip */
if (!XG_BASE(observer_active)) {
return (zend_observer_fcall_handlers){NULL, NULL};
}
/* Always install handlers. The engine caches the result of this
* function per zend_function, so returning NULL here would prevent
* our handlers from ever being invoked for this function — even if
* a debugger connects in a later request (e.g. FrankenPHP worker
* mode reuses op_arrays across requests). The handlers themselves
* fast-path on !observer_active for near-zero overhead when no
* debug session is active. See issue #63. */
return (zend_observer_fcall_handlers){xdebug_execute_begin, xdebug_execute_end};
}
/***************************************************************************/
Expand Down
4 changes: 4 additions & 0 deletions src/debugger/debugger.c
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include "zend_exceptions.h"

#include "debugger_private.h"
#include "frankenphp.h"
#include "lib/log.h"
#include "lib/var.h"

Expand Down Expand Up @@ -864,6 +865,9 @@ void xdebug_debugger_zend_shutdown(void)
void xdebug_debugger_minit(void)
{
XG_DBG(breakpoint_count) = 0;

/* Install FrankenPHP worker-mode SAPI hooks (no-op for other SAPIs). */
xdebug_frankenphp_minit();
}

void xdebug_debugger_minfo(void)
Expand Down
181 changes: 181 additions & 0 deletions src/debugger/frankenphp.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*
+----------------------------------------------------------------------+
| Xdebug |
+----------------------------------------------------------------------+
| Copyright (c) 2002-2025 Derick Rethans |
+----------------------------------------------------------------------+
| This source file is subject to version 1.01 of the Xdebug license, |
| that is bundled with this package in the file LICENSE, and is |
| available at through the world-wide-web at |
| https://xdebug.org/license.php |
| If you did not receive a copy of the Xdebug license and are unable |
| to obtain it through the world-wide-web, please send a note to |
| derick@xdebug.org so we can mail you a copy immediately. |
+----------------------------------------------------------------------+
*/

#include <string.h>

#include "lib/php-header.h"
#include "SAPI.h"

#include "php_xdebug.h"
#include "com.h"
#include "debugger.h"
#include "frankenphp.h"
#include "lib/lib.h"

ZEND_EXTERN_MODULE_GLOBALS(xdebug)

static int (*original_sapi_activate)(void) = NULL;
static int (*original_sapi_deactivate)(void) = NULL;
static int is_frankenphp = 0;

/* Scan a 'k=v' style string (cookies separated by ';', query string by '&')
* for one of the trigger variable names at a key boundary. */
static int has_trigger_in_string(const char *str, char delim)
{
static const char *triggers[] = {
"XDEBUG_SESSION", "XDEBUG_TRIGGER",
"PHP_DEBUGGER_SESSION", "PHP_DEBUGGER_TRIGGER",
NULL
};
const char **t;

if (!str) {
return 0;
}

for (t = triggers; *t; t++) {
size_t len = strlen(*t);
const char *p = str;

while ((p = strstr(p, *t)) != NULL) {
char prev = (p == str) ? delim : p[-1];
char next = p[len];

if ((prev == delim || prev == ' ') && next == '=') {
return 1;
}
p += len;
}
}
return 0;
}

static int has_debug_trigger(void)
{
return has_trigger_in_string(SG(request_info).cookie_data, ';') ||
has_trigger_in_string(SG(request_info).query_string, '&');
}

/* Per-request reset, called at the start of each FrankenPHP worker request. */
static int xdebug_frankenphp_sapi_activate(void)
{
int result = original_sapi_activate ? original_sapi_activate() : SUCCESS;

if (!XDEBUG_MODE_IS(XDEBUG_MODE_STEP_DEBUG)) {
return result;
}

/* Reset per-request debugger flags. The full RINIT path already ran once
* for the worker; here we only undo state that should not leak across
* requests inside the worker loop. */
XG_DBG(detached) = 0;
XG_DBG(no_exec) = 0;
XG_DBG(breakpoints_allowed) = 1;

XG_DBG(context).do_break = 0;
XG_DBG(context).do_step = 0;
XG_DBG(context).do_next = 0;
XG_DBG(context).do_finish = 0;
XG_DBG(context).do_connect_to_client = 0;

/* Trigger detection: superglobals are not yet populated this early in the
* SAPI lifecycle, so we read the raw request data. If a trigger is present
* (or start_with_request=yes), arm the connect-on-next-statement flag —
* the existing path in xdebug_debugger_statement_call() picks it up.
*
* Also re-arm the observer for the request: the worker's process-start
* RINIT may have left observer_active=false (no IDE at startup), and
* FrankenPHP does not run RINIT again per request — only sapi_activate.
* Without this, xdebug_execute_begin fast-paths out and never pushes a
* stack frame for the current function. xdebug_debugger_statement_call
* would then bail out on its empty-stack check, so breakpoints in the
* function that triggered the connection (and any function entered
* before observer activation) would be missed. See issue #63. */
if (xdebug_lib_start_with_request() || has_debug_trigger()) {
XG_DBG(context).do_connect_to_client = 1;
XG_BASE(observer_active) = 1;
} else {
XG_BASE(observer_active) = 0;
}

/* If a debug session is still alive (e.g. user kept it open across
* requests), drain any breakpoint_set / breakpoint_remove the IDE pushed
* while the worker was busy. */
if (xdebug_is_debug_connection_active() && XG_DBG(context).handler && XG_DBG(context).handler->remote_poll_pending) {
XG_DBG(context).handler->remote_poll_pending(&(XG_DBG(context)));
}

return result;
}

/* Per-request teardown, called at the end of each FrankenPHP worker request.
* Tears down the DBGp session so the next request starts fresh. */
static int xdebug_frankenphp_sapi_deactivate(void)
{
if (XDEBUG_MODE_IS(XDEBUG_MODE_STEP_DEBUG) && xdebug_is_debug_connection_active()) {
XG_DBG(context).handler->remote_deinit(&(XG_DBG(context)));
xdebug_mark_debug_connection_not_active();
}

return original_sapi_deactivate ? original_sapi_deactivate() : SUCCESS;
}

void xdebug_frankenphp_minit(void)
{
/* Note: at MINIT time on the FrankenPHP SAPI, sapi_module.name is set
* to "frankenphp" but sapi_module.activate may still be NULL — the SAPI
* fills the activate hook in later. We install our wrapper here, which
* the SAPI's per-request dispatch then invokes (FrankenPHP's worker loop
* calls activate/deactivate around each request even though it does not
* re-run RINIT/RSHUTDOWN). */
if (!sapi_module.name || strcmp(sapi_module.name, "frankenphp") != 0) {
return;
}

is_frankenphp = 1;

original_sapi_activate = sapi_module.activate;
sapi_module.activate = xdebug_frankenphp_sapi_activate;

original_sapi_deactivate = sapi_module.deactivate;
sapi_module.deactivate = xdebug_frankenphp_sapi_deactivate;

/* In worker mode the per-request decision to debug is made by
* sapi_activate, but PHP_RINIT runs only once at worker startup —
* before any request has set a trigger. The normal RINIT path skips
* setting ZEND_COMPILE_EXTENDED_STMT and disabling opcache's
* optimizer when no IDE is connected. With the worker flow, that
* means user files compiled by later trigger requests still have no
* EXT_STMT opcodes, so line breakpoints can never resolve. Force
* both on at MINIT — the small per-statement overhead is acceptable
* for a SAPI that exists to serve interactive workloads. See issue
* #63. */
CG(compiler_options) |= ZEND_COMPILE_EXTENDED_STMT;
xdebug_disable_opcache_optimizer();
}

void xdebug_frankenphp_mshutdown(void)
{
if (!is_frankenphp) {
return;
}

sapi_module.activate = original_sapi_activate;
sapi_module.deactivate = original_sapi_deactivate;
original_sapi_activate = NULL;
original_sapi_deactivate = NULL;
is_frankenphp = 0;
}
33 changes: 33 additions & 0 deletions src/debugger/frankenphp.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
+----------------------------------------------------------------------+
| Xdebug |
+----------------------------------------------------------------------+
| Copyright (c) 2002-2025 Derick Rethans |
+----------------------------------------------------------------------+
| This source file is subject to version 1.01 of the Xdebug license, |
| that is bundled with this package in the file LICENSE, and is |
| available at through the world-wide-web at |
| https://xdebug.org/license.php |
| If you did not receive a copy of the Xdebug license and are unable |
| to obtain it through the world-wide-web, please send a note to |
| derick@xdebug.org so we can mail you a copy immediately. |
+----------------------------------------------------------------------+
*/

#ifndef __XDEBUG_DEBUGGER_FRANKENPHP_H__
#define __XDEBUG_DEBUGGER_FRANKENPHP_H__

/*
* FrankenPHP worker mode support.
*
* In FrankenPHP worker mode, MINIT/RINIT/RSHUTDOWN/MSHUTDOWN run once per
* worker (not per request). Per-request setup happens through
* sapi_module.activate / deactivate. We hook those to drive a per-request
* debugger lifecycle so the debug session is reset between requests.
*
* If the SAPI is not "frankenphp", these functions are no-ops.
*/
void xdebug_frankenphp_minit(void);
void xdebug_frankenphp_mshutdown(void);

#endif /* __XDEBUG_DEBUGGER_FRANKENPHP_H__ */
Loading
Loading