From 4cff6f2903e605f8863ba53ca59bb78001e6b016 Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Thu, 13 Aug 2026 19:33:22 +0200 Subject: [PATCH 1/3] fix: opcache poisoning between requests (#105) --- README.md | 19 ++++++ src/base/base.c | 3 +- src/base/base_globals.h | 1 + src/debugger/com.c | 55 ---------------- src/lib/lib.c | 27 ++++++-- src/lib/lib.h | 2 +- src/lib/log.c | 6 ++ tests/base/opcache-bypass-request-001.inc | 3 + tests/base/opcache-bypass-request-001.phpt | 43 ++++++++++++ tests/base/opcache-bypass-request-002.phpt | 19 ++++++ tests/base/opcache-bypass-request-003.phpt | 19 ++++++ tests/base/opcache-bypass-request-004.phpt | 22 +++++++ tests/base/opcache-bypass-request-005.phpt | 26 ++++++++ tests/base/opcache-bypass-request-006.phpt | 20 ++++++ tests/base/opcache-bypass-request-007.inc | 14 ++++ tests/base/opcache-bypass-request-007.phpt | 76 ++++++++++++++++++++++ tests/base/opcache-bypass-request-008.inc | 10 +++ tests/base/opcache-bypass-request-008.phpt | 71 ++++++++++++++++++++ xdebug.c | 9 +++ 19 files changed, 384 insertions(+), 61 deletions(-) create mode 100644 tests/base/opcache-bypass-request-001.inc create mode 100644 tests/base/opcache-bypass-request-001.phpt create mode 100644 tests/base/opcache-bypass-request-002.phpt create mode 100644 tests/base/opcache-bypass-request-003.phpt create mode 100644 tests/base/opcache-bypass-request-004.phpt create mode 100644 tests/base/opcache-bypass-request-005.phpt create mode 100644 tests/base/opcache-bypass-request-006.phpt create mode 100644 tests/base/opcache-bypass-request-007.inc create mode 100644 tests/base/opcache-bypass-request-007.phpt create mode 100644 tests/base/opcache-bypass-request-008.inc create mode 100644 tests/base/opcache-bypass-request-008.phpt diff --git a/README.md b/README.md index 40227d68..7b7b1fc2 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,11 @@ INI setting: `php_debugger.on_demand_debugging_enabled` (default: false) When this setting is enabled, on-demand debugging features remain active even if no client is connected at startup. Note that this has a significant performance impact: instead of achieving up to a 97% performance improvement, the average improvement drops to around 60%. +On top of that, every request must be compiled with debugging instrumentation, so OPcache is bypassed for *all* requests in the +process — not just the ones that end up being debugged. On a busy server (PHP-FPM in particular) that recompilation is a +substantial throughput cost. The debugger logs a `[Config] INFO` line about this at request start, and `xdebug_info()` reports the +bypass in its Step Debugging section. + For this reason, we recommend enabling this setting only if you specifically require on-demand debugging. ## Installation @@ -117,6 +122,20 @@ xdebug.client_port = 9003 xdebug.start_with_request = trigger ``` +### OPcache + +Debugging needs every file compiled with debugging information, and OPcache is shared between requests, so PHP Debugger switches +OPcache off for the requests it instruments — those with a debugging client attached, and all requests when +`php_debugger.on_demand_debugging_enabled` (aka `xdebug.on_demand_debugging_enabled`) is on. Other requests keep OPcache exactly as you configured it. + +Without this, a file first compiled by a non-debugged request stays cached without debugging information: breakpoints in it never +fire and stepping walks straight past its functions. It also keeps instrumented code from being cached and slowing down requests +that are not being debugged. As a side effect, JIT does not run for debugged requests either — it is part of OPcache, and it is +incompatible with debugging anyway. + +`xdebug_info()` says `OPcache is bypassed for this request` when this applies, so you can tell this apart from OPcache being off +for some other reason. + ## IDE Setup ### PhpStorm diff --git a/src/base/base.c b/src/base/base.c index 9de5f241..32d2ccc4 100644 --- a/src/base/base.c +++ b/src/base/base.c @@ -975,6 +975,7 @@ void xdebug_base_rinit(void) { XG_BASE(statement_handler_enabled) = true; XG_BASE(observer_active) = true; + XG_BASE(opcache_disabled_for_request) = false; { zend_string *fiber_key = create_key_for_fiber(EG(main_fiber_context)); @@ -1035,7 +1036,7 @@ void xdebug_base_rinit(void) void xdebug_base_rinit_if_enabled(void) { CG(compiler_options) = CG(compiler_options) | ZEND_COMPILE_EXTENDED_STMT; - xdebug_disable_opcache_optimizer(); + XG_BASE(opcache_disabled_for_request) = xdebug_disable_opcache_for_request(); zend_execute_ex = xdebug_execute_ex; /* Hack: We check for a soap header here, if that's existing, we don't use diff --git a/src/base/base_globals.h b/src/base/base_globals.h index 7b1322e7..42d5e3fa 100644 --- a/src/base/base_globals.h +++ b/src/base/base_globals.h @@ -54,6 +54,7 @@ typedef struct _xdebug_base_globals_t { char *last_exception_trace; zend_bool statement_handler_enabled; zend_bool early_connection; + zend_bool opcache_disabled_for_request; /* in-execution checking */ zend_bool in_execution; diff --git a/src/debugger/com.c b/src/debugger/com.c index 00fa006e..36d58f84 100644 --- a/src/debugger/com.c +++ b/src/debugger/com.c @@ -560,57 +560,6 @@ static int ide_key_is_cloud_id(void) return 1; } -static bool is_opcache_enabled(void) -{ - zend_string *opcache_enable = ZSTR_INIT_LITERAL("opcache.enable", 0); - zend_string *opcache_enable_cli = ZSTR_INIT_LITERAL("opcache.enable_cli", 0); - zend_string *opcache_optimization_level = ZSTR_INIT_LITERAL("opcache.optimization_level", 0); - - zend_string *opcache_enable_v = zend_ini_get_value(opcache_enable); - zend_string *opcache_enable_cli_v = zend_ini_get_value(opcache_enable_cli); - zend_string *opcache_optimization_level_v = zend_ini_get_value(opcache_optimization_level); - - zend_string_release(opcache_enable); - zend_string_release(opcache_enable_cli); - zend_string_release(opcache_optimization_level); - - if (!opcache_enable_v || zend_string_equals_literal(opcache_enable_v, "0")) { - return false; - } - if (!opcache_enable_cli_v || zend_string_equals_literal(opcache_enable_cli_v, "0")) { - return false; - } - if (!opcache_optimization_level_v || zend_string_equals_literal(opcache_optimization_level_v, "0")) { - return false; - } - - return true; -} - -static void warn_if_opcache_is_loaded_after_xdebug(void) -{ - bool xdebug_loaded = false; - zend_llist_element *ext_ptr = zend_extensions.head; - - do - { - zend_extension *zext = (zend_extension *)ext_ptr->data; - - if (strcmp(zext->name, "Xdebug") == 0) { - xdebug_loaded = true; - } - - if (strcmp(zext->name, "Zend OPcache") == 0 && is_opcache_enabled()) { - if (xdebug_loaded) { - xdebug_log_ex(XLOG_CHAN_DEBUG, XLOG_WARN, "OPCACHE", "Debugger is not working optimally, as Xdebug is loaded before Zend OPcache"); - } - return; - } - - ext_ptr = ext_ptr->next; - } while (ext_ptr != NULL); -} - static void xdebug_init_debugger(void) { xdebug_str *connection_attempts = xdebug_str_new(); @@ -618,8 +567,6 @@ static void xdebug_init_debugger(void) /* Get handler from mode */ XG_DBG(context).handler = &xdebug_handler_dbgp; - warn_if_opcache_is_loaded_after_xdebug(); - /* If socket was already established by early connect at RINIT, * skip straight to protocol initialization */ if (XG_BASE(early_connection)) { @@ -678,8 +625,6 @@ int xdebug_early_connect_to_client(void) XG_DBG(context).handler = &xdebug_handler_dbgp; - warn_if_opcache_is_loaded_after_xdebug(); - /* Cloud connections can't be probed early */ if (strcmp(XINI_DBG(cloud_id), "") != 0) { xdebug_str_free(connection_attempts); diff --git a/src/lib/lib.c b/src/lib/lib.c index 6eeb4175..3a4aadad 100644 --- a/src/lib/lib.c +++ b/src/lib/lib.c @@ -132,15 +132,34 @@ void xdebug_library_post_deactivate(void) } } -void xdebug_disable_opcache_optimizer(void) -{ - zend_string *key = zend_string_init(ZEND_STRL("opcache.optimization_level"), 1); +/* Instrumentation is decided per request, but opcache is shared across + * requests: reusing an op_array cached by a non-instrumented request means + * breakpoints silently don't bind and stepping skips those functions, while + * storing an instrumented op_array makes every later request pay the + * EXT_STMT overhead. So instrumented requests must not read from or write + * to opcache at all. opcache.enable's handler turns the accelerator off + * immediately (including the file cache) when set to 0 at runtime, and the + * INI system restores the value at request shutdown. + * + * This also covers the optimizer, which only ever runs while opcache caches a + * script: with the accelerator off, no optimization pass can shift or remove + * the statements breakpoints are set on. */ +bool xdebug_disable_opcache_for_request(void) +{ + zend_string *key = zend_string_init(ZEND_STRL("opcache.enable"), 1); zend_string *value = zend_string_init(ZEND_STRL("0"), 1); + bool disabled; - zend_alter_ini_entry(key, value, ZEND_INI_SYSTEM, ZEND_INI_STAGE_STARTUP); + /* ZEND_INI_SYSTEM, not ZEND_INI_USER: php_admin_value[opcache.enable] in an + * FPM pool narrows the directive to system-modifiable, which would make a + * user-level change fail for the whole worker. Failure here means opcache + * isn't loaded, which needs no handling — there is nothing to bypass. */ + disabled = (zend_alter_ini_entry(key, value, ZEND_INI_SYSTEM, ZEND_INI_STAGE_RUNTIME) == SUCCESS); zend_string_release(key); zend_string_release(value); + + return disabled; } static int xdebug_lib_set_mode_item(const char *mode, int len) diff --git a/src/lib/lib.h b/src/lib/lib.h index 3b7263ad..7c48adcb 100644 --- a/src/lib/lib.h +++ b/src/lib/lib.h @@ -224,7 +224,7 @@ void xdebug_library_mshutdown(void); void xdebug_library_rinit(void); void xdebug_library_post_deactivate(void); -void xdebug_disable_opcache_optimizer(void); +bool xdebug_disable_opcache_for_request(void); #define XDEBUG_MODE_OFF 0 #define XDEBUG_MODE_STEP_DEBUG 1<<2 diff --git a/src/lib/log.c b/src/lib/log.c index 2e7ad9f5..0654ac07 100644 --- a/src/lib/log.c +++ b/src/lib/log.c @@ -630,6 +630,9 @@ static void print_step_debug_information(void) "Debugger%s" "" DOCS_LINK_ICON "\n", is_active ? "Active" : (XG_DBG(detached) ? "Detached" : "Not Active"), xdebug_lib_docs_base()); + if (XG_BASE(opcache_disabled_for_request)) { + PUTS("OPcacheBypassed for this request, so that every file is compiled with debugging information \n"); + } if (XG_DBG(context).connected_hostname) { if (strcmp(XINI_DBG(cloud_id), "") == 0) { xdebug_info_printf( @@ -697,6 +700,9 @@ static void print_step_debug_information(void) } else { PUTS("Debugger is not active\n"); } + if (XG_BASE(opcache_disabled_for_request)) { + PUTS("OPcache is bypassed for this request, so that every file is compiled with debugging information\n"); + } if (XG_DBG(context).connected_hostname) { if (strcmp(XINI_DBG(cloud_id), "") == 0) { xdebug_info_printf("Connected Client => %s:%d\n", diff --git a/tests/base/opcache-bypass-request-001.inc b/tests/base/opcache-bypass-request-001.inc new file mode 100644 index 00000000..16117861 --- /dev/null +++ b/tests/base/opcache-bypass-request-001.inc @@ -0,0 +1,3 @@ + +--FILE-- + 1, + 'opcache.enable_cli' => 1, + 'xdebug.on_demand_debugging_enabled' => 0, +) ); +?> +--EXPECT-- + + + +-> step_into -i 1 + + + +-> eval -i 2 -- aW5pX2dldCgib3BjYWNoZS5lbmFibGUiKSAuICI6IiAuIChpbnQpIG9wY2FjaGVfZ2V0X3N0YXR1cyhmYWxzZSlbIm9wY2FjaGVfZW5hYmxlZCJd + + + +-> detach -i 3 + + diff --git a/tests/base/opcache-bypass-request-002.phpt b/tests/base/opcache-bypass-request-002.phpt new file mode 100644 index 00000000..20544661 --- /dev/null +++ b/tests/base/opcache-bypass-request-002.phpt @@ -0,0 +1,19 @@ +--TEST-- +Non-instrumented request keeps opcache enabled (near-zero overhead path) +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +xdebug.mode=debug +xdebug.start_with_request=no +xdebug.on_demand_debugging_enabled=0 +--FILE-- + +--EXPECT-- +bool(true) +string(1) "1" diff --git a/tests/base/opcache-bypass-request-003.phpt b/tests/base/opcache-bypass-request-003.phpt new file mode 100644 index 00000000..768edd2c --- /dev/null +++ b/tests/base/opcache-bypass-request-003.phpt @@ -0,0 +1,19 @@ +--TEST-- +Instrumented request disables opcache (on_demand_debugging_enabled=1) +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +xdebug.mode=debug +xdebug.start_with_request=no +xdebug.on_demand_debugging_enabled=1 +--FILE-- + +--EXPECT-- +bool(false) +string(1) "0" diff --git a/tests/base/opcache-bypass-request-004.phpt b/tests/base/opcache-bypass-request-004.phpt new file mode 100644 index 00000000..4df6e98f --- /dev/null +++ b/tests/base/opcache-bypass-request-004.phpt @@ -0,0 +1,22 @@ +--TEST-- +On-demand debugging without a client warns that opcache is bypassed +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +xdebug.mode=debug +xdebug.start_with_request=no +xdebug.on_demand_debugging_enabled=1 +xdebug.log={TMPFILE:opcache-bypass-request-004.txt} +xdebug.log_level=7 +--FILE-- + +--EXPECTF-- +%A[Config] INFO: OPcache is bypassed for every request because xdebug.on_demand_debugging_enabled=1 needs each request compiled with debugging instrumentation. Set it to 0 to keep OPcache active when no debugging client is connected.%A diff --git a/tests/base/opcache-bypass-request-005.phpt b/tests/base/opcache-bypass-request-005.phpt new file mode 100644 index 00000000..0133aece --- /dev/null +++ b/tests/base/opcache-bypass-request-005.phpt @@ -0,0 +1,26 @@ +--TEST-- +xdebug_info() reports that opcache is bypassed for an instrumented request +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +xdebug.mode=debug +xdebug.start_with_request=no +xdebug.on_demand_debugging_enabled=1 +--FILE-- + +--EXPECT-- +OPcache is bypassed for this request, so that every file is compiled with debugging information diff --git a/tests/base/opcache-bypass-request-006.phpt b/tests/base/opcache-bypass-request-006.phpt new file mode 100644 index 00000000..373e604b --- /dev/null +++ b/tests/base/opcache-bypass-request-006.phpt @@ -0,0 +1,20 @@ +--TEST-- +xdebug_info() does not mention opcache when the request is not instrumented +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +xdebug.mode=debug +xdebug.start_with_request=no +xdebug.on_demand_debugging_enabled=0 +--FILE-- + +--EXPECT-- +bool(false) diff --git a/tests/base/opcache-bypass-request-007.inc b/tests/base/opcache-bypass-request-007.inc new file mode 100644 index 00000000..c6ba4cd6 --- /dev/null +++ b/tests/base/opcache-bypass-request-007.inc @@ -0,0 +1,14 @@ + +--FILE-- + {$null} 2>&1"; +exec( $primeCommand ); + +$cached = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $cacheDir, FilesystemIterator::SKIP_DOTS ) ); +echo 'cached by the non-debugged run: ', iterator_count( $cached ) > 0 ? "yes" : "no", "\n\n"; + +/* Now debug it. The breakpoint can only bind if this request recompiled the + * file instead of reusing the cached, non-instrumented copy. */ +$commands = array( + 'breakpoint_set -t line -n 8 -- ' . base64_encode( '$i == 4' ), + 'run', + 'property_get -n $i', + 'detach', +); + +dbgpRunFile( $filename, $commands, array( + 'opcache.enable' => 1, + 'opcache.enable_cli' => 1, + 'opcache.file_cache' => $cacheDir, + 'xdebug.mode' => 'debug', + 'xdebug.start_with_request' => 'trigger', + 'xdebug.on_demand_debugging_enabled' => 1, +) ); + +foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $cacheDir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ) as $entry ) +{ + $entry->isDir() ? @rmdir( $entry->getPathname() ) : @unlink( $entry->getPathname() ); +} +@rmdir( $cacheDir ); +?> +--EXPECT-- +cached by the non-debugged run: yes + + + + +-> breakpoint_set -i 1 -t line -n 8 -- JGkgPT0gNA== + + + +-> run -i 2 + + + +-> property_get -i 3 -n $i + + + +-> detach -i 4 + + diff --git a/tests/base/opcache-bypass-request-008.inc b/tests/base/opcache-bypass-request-008.inc new file mode 100644 index 00000000..fe163c1c --- /dev/null +++ b/tests/base/opcache-bypass-request-008.inc @@ -0,0 +1,10 @@ + +--FILE-- + {$null} 2>&1"; +exec( $primeCommand ); + +$cached = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $cacheDir, FilesystemIterator::SKIP_DOTS ) ); +echo 'cached by the non-debugged run: ', iterator_count( $cached ) > 0 ? "yes" : "no", "\n\n"; + +$commands = array( + "breakpoint_set -t line -f file://{$filename} -n 4", + 'run', + 'property_get -n $i', + 'detach', +); + +dbgpRunFile( $filename, $commands, array( + 'opcache.enable' => 1, + 'opcache.enable_cli' => 1, + 'opcache.file_cache' => $cacheDir, + 'xdebug.on_demand_debugging_enabled' => 0, +) ); + +foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $cacheDir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ) as $entry ) +{ + $entry->isDir() ? @rmdir( $entry->getPathname() ) : @unlink( $entry->getPathname() ); +} +@rmdir( $cacheDir ); +?> +--EXPECT-- +cached by the non-debugged run: yes + + + + +-> breakpoint_set -i 1 -t line -f file://opcache-bypass-request-008.inc -n 4 + + + +-> run -i 2 + + + +-> property_get -i 3 -n $i + + + +-> detach -i 4 + + diff --git a/xdebug.c b/xdebug.c index 4c4c8dfa..50662de8 100644 --- a/xdebug.c +++ b/xdebug.c @@ -361,6 +361,7 @@ static void xdebug_init_base_globals(xdebug_base_globals_t *xg) xg->error_reporting_overridden = 0; xg->statement_handler_enabled = false; xg->early_connection = false; + xg->opcache_disabled_for_request = false; xg->php_version_compile_time = PHP_VERSION; xg->php_version_run_time = zend_get_module_version("standard"); @@ -635,6 +636,14 @@ PHP_RINIT_FUNCTION(xdebug) XG_BASE(observer_active) = false; return SUCCESS; } + /* On-demand debugging compiles with instrumentation even without a + * client, which means OPcache is bypassed for every request in this + * process, not just the debugged ones. Say so: on a busy server this + * is a throughput cliff that is otherwise hard to attribute. */ + xdebug_log_ex( + XLOG_CHAN_CONFIG, XLOG_INFO, "OPCACHE-OD", + "OPcache is bypassed for every request because xdebug.on_demand_debugging_enabled=1 needs each request compiled with debugging instrumentation. Set it to 0 to keep OPcache active when no debugging client is connected." + ); XG_BASE(statement_handler_enabled) = false; } xdebug_base_rinit_if_enabled(); From 585efa051ca6e2772be0d0f6ed45e8b3189d2eaf Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Tue, 18 Aug 2026 23:21:28 +0200 Subject: [PATCH 2/3] Fix/frankenphp observer cache (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix observer cache poisoning in FrankenPHP worker mode (#63) Three coupled changes are needed for line breakpoints to fire on functions called by trigger requests after non-trigger requests have run in the same worker: 1. xdebug_observer_init: always return real handlers. The Zend engine caches the result per zend_function the first time the function is observed, so returning {NULL, NULL} when observer_active=false permanently blacklists that function — even after a debugger connects in a later request that reuses the cached op_array. The handlers themselves already fast-path on \!observer_active, so the no-debug overhead remains a single load+branch. 2. xdebug_frankenphp_sapi_activate: re-arm observer_active per request. FrankenPHP runs PHP_RINIT only once at worker startup; per-request only sapi_activate fires. Without this, a worker that started with no IDE leaves observer_active=false for the rest of its life, so xdebug_execute_begin fast-paths out, no fse is pushed, and xdebug_debugger_statement_call bails on its empty-stack check. 3. xdebug_frankenphp_minit: force ZEND_COMPILE_EXTENDED_STMT and disable the opcache optimizer at MINIT. The normal RINIT path skips this when no IDE is connected at startup, but in the worker flow user files are compiled later — by trigger requests that need EXT_STMT opcodes already present. Without this, breakpoint_set sees an empty "set of executable lines" for the target file. The `tests/frankenphp/app` workload (`workload()` defined in `lib.php`, called from `index.php`) plus `dbgp_listener.py` reproduce the failure deterministically: 200 no-trigger requests followed by 5 trigger requests previously yielded 0/5 breakpoint hits; with the fix all 5 hit lib.php:4. * Last changes to finish the task * Changes after claude review --------- Co-authored-by: Roman Pronskiy --- .github/workflows/frankenphp.yml | 22 +++++ README.md | 13 +++ src/base/base.c | 46 ++++++++- src/debugger/frankenphp.c | 47 ++++++++- src/debugger/frankenphp.h | 5 + src/lib/lib.c | 22 +++++ src/lib/lib.h | 1 + tests/frankenphp/README.md | 78 +++++++-------- tests/frankenphp/app/index.php | 7 +- tests/frankenphp/app/lib.php | 7 ++ tests/frankenphp/dbgp_listener.py | 103 +++++++++++++++++++ tests/frankenphp/run-worker-tests.sh | 141 +++++++++++++++++++++++++++ 12 files changed, 448 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/frankenphp.yml create mode 100644 tests/frankenphp/app/lib.php create mode 100755 tests/frankenphp/dbgp_listener.py create mode 100755 tests/frankenphp/run-worker-tests.sh diff --git a/.github/workflows/frankenphp.yml b/.github/workflows/frankenphp.yml new file mode 100644 index 00000000..4617d942 --- /dev/null +++ b/.github/workflows/frankenphp.yml @@ -0,0 +1,22 @@ +name: FrankenPHP + +on: + push: + branches: + - main + pull_request: + +jobs: + worker-mode: + runs-on: ubuntu-latest + name: "Worker mode" + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Worker-mode regression tests + run: tests/frankenphp/run-worker-tests.sh + env: + HTTP_PORT: 8080 + DBGP_PORT: 9003 diff --git a/README.md b/README.md index 7b7b1fc2..62499592 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,19 @@ incompatible with debugging anyway. `xdebug_info()` says `OPcache is bypassed for this request` when this applies, so you can tell this apart from OPcache being off for some other reason. +### FrankenPHP worker mode + +The near-zero overhead described above relies on deciding per request whether to compile with debugging information. FrankenPHP +worker mode cannot work that way: one worker serves many requests from code it compiled once, before it can know that a later +request will ask to be debugged. PHP Debugger therefore compiles everything with debugging information for the whole worker +process, and every request pays for the per-statement dispatch that comes with it — including requests with no debugging trigger. +The dispatch bails out immediately when no client is connected, so the cost is small, but it is not the "you don't pay for what +you don't use" behaviour you get on CLI and PHP-FPM. + +This applies as soon as the extension is loaded with `mode=debug`, whether or not you ever attach an IDE. Setting the mode off +(`php_debugger.mode=off`, `xdebug.mode=off`, or the `XDEBUG_MODE=off` environment variable) skips the instrumentation entirely, so +a worker you are not planning to debug runs at full speed. + ## IDE Setup ### PhpStorm diff --git a/src/base/base.c b/src/base/base.c index 32d2ccc4..04518204 100644 --- a/src/base/base.c +++ b/src/base/base.c @@ -34,6 +34,7 @@ #if HAVE_XDEBUG_CONTROL_SOCKET_SUPPORT # include "ctrl_socket.h" #endif +#include "debugger/frankenphp.h" #include "lib/lib_private.h" #include "lib/log.h" #include "lib/var.h" @@ -441,6 +442,28 @@ void xdebug_rebuild_stack(void) /** Function interceptors and dispatchers to modules ***********************/ +/* The begin handlers do nothing when the observer is inactive at call entry, + * but the observer can be switched on part-way through that call: FrankenPHP + * arms it per request inside the worker loop, xdebug_break() and + * xdebug_connect_to_client() attach mid-request, and the control socket can + * attach from outside at any point. The end handler then runs for a call + * whose frame was never pushed, and popping there would discard the caller's + * frame instead. Both begin handlers stamp the frame with the calling + * execute_data, so the end handlers can tell whether the frame on top of the + * stack is really theirs. + * + * This is the return path of every function call, but only of calls the + * engine has installed our handlers on — which happens when a debug session + * is active, or on FrankenPHP. Requests with no debugger never reach it. */ +static bool xdebug_frame_belongs_to_call(function_stack_entry *fse, zend_execute_data *execute_data) +{ + if (XDEBUG_VECTOR_COUNT(XG_BASE(stack)) == 0 || !fse) { + return false; + } + + return fse->execute_data == execute_data->prev_execute_data; +} + static void xdebug_execute_user_code_begin(zend_execute_data *execute_data) { zend_op_array *op_array = &(execute_data->func->op_array); @@ -500,6 +523,10 @@ static void xdebug_execute_user_code_end(zend_execute_data *execute_data, zval * function_stack_entry *fse = XDEBUG_VECTOR_TAIL(XG_BASE(stack)); zval *return_value = NULL; + if (!xdebug_frame_belongs_to_call(fse, execute_data)) { + return; + } + if (!fse->is_trampoline && retval && !(op_array->fn_flags & ZEND_ACC_GENERATOR)) { return_value = execute_data->return_value; } @@ -638,6 +665,10 @@ static void xdebug_execute_internal_end(zend_execute_data *execute_data, zval *r * nested calls might have reallocated the vector */ fse = XDEBUG_VECTOR_TAIL(XG_BASE(stack)); + if (!xdebug_frame_belongs_to_call(fse, execute_data)) { + return; + } + /* Restore SOAP situation if needed */ if (fse->soap_error_cb) { zend_error_cb = fse->soap_error_cb; @@ -694,10 +725,19 @@ 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}; + /* The engine caches this decision per zend_function, so declining here + * keeps our handlers away from that function for as long as the + * function lives. That is what we want everywhere except FrankenPHP: + * a request without a debug session costs nothing at all, and the next + * request recompiles anyway. FrankenPHP worker mode reuses op_arrays + * across requests within one process, so declining once would blacklist + * the function for the rest of the worker's life — including for later + * requests that do carry a trigger. Install unconditionally there and + * let the handlers fast-path on !observer_active. */ + if (!XG_BASE(observer_active) && !xdebug_frankenphp_in_use()) { + return (zend_observer_fcall_handlers){NULL, NULL}; } + return (zend_observer_fcall_handlers){xdebug_execute_begin, xdebug_execute_end}; } /***************************************************************************/ diff --git a/src/debugger/frankenphp.c b/src/debugger/frankenphp.c index 6be80dee..a8e36665 100644 --- a/src/debugger/frankenphp.c +++ b/src/debugger/frankenphp.c @@ -69,6 +69,11 @@ static int has_debug_trigger(void) has_trigger_in_string(SG(request_info).query_string, '&'); } +int xdebug_frankenphp_in_use(void) +{ + return is_frankenphp; +} + /* Per-request reset, called at the start of each FrankenPHP worker request. */ static int xdebug_frankenphp_sapi_activate(void) { @@ -78,6 +83,15 @@ static int xdebug_frankenphp_sapi_activate(void) return result; } + /* Keep opcache's optimizer off. MINIT forces EXT_STMT for the whole + * process, but opcache stays enabled here, and the optimizer would move + * or drop the statements breakpoints resolve against. This has to happen + * per request rather than once at MINIT: a worker generation is a single + * PHP request, so the engine restores every INI value it altered when + * that generation ends, and MINIT does not run again when FrankenPHP + * starts the next one (max_requests, a watcher restart, a crash). */ + xdebug_disable_opcache_optimizer(); + /* 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. */ @@ -94,9 +108,21 @@ static int xdebug_frankenphp_sapi_activate(void) /* 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. */ + * 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. */ 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 @@ -123,6 +149,12 @@ static int xdebug_frankenphp_sapi_deactivate(void) void xdebug_frankenphp_minit(void) { + /* FrankenPHP's worker loop dispatches through sapi_module.activate and + * .deactivate around every request, while RINIT and RSHUTDOWN run once + * per worker — so these two pointers are the only per-request hook we + * get. FrankenPHP itself installs no activate hook (the pointer is NULL + * here, unlike deactivate), which is why the wrapper below calls the + * saved original only when there is one. */ if (!sapi_module.name || strcmp(sapi_module.name, "frankenphp") != 0) { return; } @@ -134,6 +166,19 @@ void xdebug_frankenphp_minit(void) 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 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 it on for the whole process — the small + * per-statement overhead is acceptable for a SAPI that exists to + * serve interactive workloads. Unlike an INI setting this survives + * worker restarts, as the engine never resets compiler_options. + * The matching optimizer disable lives in sapi_activate. */ + CG(compiler_options) |= ZEND_COMPILE_EXTENDED_STMT; } void xdebug_frankenphp_mshutdown(void) diff --git a/src/debugger/frankenphp.h b/src/debugger/frankenphp.h index 2d8146e4..1c2e5fda 100644 --- a/src/debugger/frankenphp.h +++ b/src/debugger/frankenphp.h @@ -30,4 +30,9 @@ void xdebug_frankenphp_minit(void); void xdebug_frankenphp_mshutdown(void); +/* True once MINIT has confirmed we are running on the FrankenPHP SAPI, which + * needs the observer installed for every function regardless of whether a + * debug session is active right now. */ +int xdebug_frankenphp_in_use(void); + #endif /* __XDEBUG_DEBUGGER_FRANKENPHP_H__ */ diff --git a/src/lib/lib.c b/src/lib/lib.c index 3a4aadad..bed81360 100644 --- a/src/lib/lib.c +++ b/src/lib/lib.c @@ -132,6 +132,28 @@ void xdebug_library_post_deactivate(void) } } +/* Keeps opcache from optimising away or moving the statements that + * breakpoints are set on. Only needed where instrumented code is compiled + * while opcache stays enabled — that is the FrankenPHP worker SAPI, which + * forces EXT_STMT for the whole process instead of per request. Everywhere + * else xdebug_disable_opcache_for_request() below switches the accelerator + * off entirely, which stops the optimizer too. */ +void xdebug_disable_opcache_optimizer(void) +{ + zend_string *key = zend_string_init(ZEND_STRL("opcache.optimization_level"), 1); + zend_string *value = zend_string_init(ZEND_STRL("0"), 1); + + /* ZEND_INI_SYSTEM is the part that matters: the directive is + * PHP_INI_SYSTEM, and zend_alter_ini_entry() refuses a change whose + * modify type the directive does not allow. The stage is only handed to + * the directive's handler, OnUpdateLong, which ignores it — so RUNTIME, + * which is what we actually are, works as well as STARTUP would. */ + zend_alter_ini_entry(key, value, ZEND_INI_SYSTEM, ZEND_INI_STAGE_RUNTIME); + + zend_string_release(key); + zend_string_release(value); +} + /* Instrumentation is decided per request, but opcache is shared across * requests: reusing an op_array cached by a non-instrumented request means * breakpoints silently don't bind and stepping skips those functions, while diff --git a/src/lib/lib.h b/src/lib/lib.h index 7c48adcb..5523ec8a 100644 --- a/src/lib/lib.h +++ b/src/lib/lib.h @@ -224,6 +224,7 @@ void xdebug_library_mshutdown(void); void xdebug_library_rinit(void); void xdebug_library_post_deactivate(void); +void xdebug_disable_opcache_optimizer(void); bool xdebug_disable_opcache_for_request(void); #define XDEBUG_MODE_OFF 0 diff --git a/tests/frankenphp/README.md b/tests/frankenphp/README.md index bc1e23e9..46f290a3 100644 --- a/tests/frankenphp/README.md +++ b/tests/frankenphp/README.md @@ -1,3 +1,34 @@ +# FrankenPHP worker-mode tests + +`run-worker-tests.sh` is the automated check; the rest of this document is the +manual procedure for working on the SAPI hooks by hand with an IDE. + +## Automated (what CI runs) + +```bash +tests/frankenphp/run-worker-tests.sh +``` + +Builds the image, runs a worker with `dbgp_listener.py` standing in for an +IDE, and asserts that breakpoints still fire after the worker has served +requests with no debugger attached — without that, a single un-debugged +request stops every later request from breaking. It also checks that the +reported stack includes the calling function, and repeats the trigger on a +fresh worker as a control. + +Overrides: `IMAGE`, `HTTP_PORT` (default 8081), `DBGP_PORT` (default 9004, +because an IDE usually holds 9003), `POISON_REQUESTS`, `TRIGGER_REQUESTS`, +`SKIP_BUILD=1` to reuse an image you already built. + +The script exits non-zero with the listener transcript on failure. It is run +by `.github/workflows/frankenphp.yml` on every push and pull request, and it +was verified to fail (0/5 breakpoints) against the code from before the worker +-mode fix, so a regression is caught rather than silently passing. + +`dbgp_listener.py` can also be pointed at a different breakpoint for ad-hoc +work — `DBGP_BREAKPOINT="-t call -m workload"` exercises the observer's begin +handler instead of the statement handler that serves line breakpoints. + # Manual FrankenPHP worker-mode test Smoke-tests the SAPI activate/deactivate hooks added in `src/debugger/frankenphp.c`. @@ -79,44 +110,16 @@ PhpStorm should pause on the opening `format(DATE_ATOM); $worker_iteration = $GLOBALS['counter'] ?? 'n/a'; +$result = workload((int)$worker_iteration); + header('Content-Type: text/plain'); echo "pid={$pid} time={$now} iter={$worker_iteration}\n"; +echo "result={$result}\n"; echo "cookie: " . ($_COOKIE['XDEBUG_SESSION'] ?? '(none)') . "\n"; diff --git a/tests/frankenphp/app/lib.php b/tests/frankenphp/app/lib.php new file mode 100644 index 00000000..aa05d55a --- /dev/null +++ b/tests/frankenphp/app/lib.php @@ -0,0 +1,7 @@ + packets until disconnect. + +DBGp framing: \0\0 +""" +import os +import socket +import sys +import threading +import time + +HOST = "0.0.0.0" +PORT = int(os.environ.get("DBGP_PORT", "9003")) +# What to break on. A line breakpoint is served by the statement handler; a +# call breakpoint is served by the observer's begin handler, so the two cover +# different halves of the plumbing. +BREAKPOINT = os.environ.get("DBGP_BREAKPOINT", "-t line -f file:///app/lib.php -n 4") + +def read_packet(sock): + """Read one DBGp packet: length\\0xml\\0.""" + length_buf = b"" + while True: + ch = sock.recv(1) + if not ch: + return None + if ch == b"\x00": + break + length_buf += ch + length = int(length_buf) + data = b"" + while len(data) < length: + chunk = sock.recv(length - len(data)) + if not chunk: + return None + data += chunk + trailing = sock.recv(1) + if trailing != b"\x00": + print(f"!! expected null terminator got {trailing!r}", flush=True) + return data.decode("utf-8", errors="replace") + +def send(sock, cmd): + sock.sendall(cmd.encode("utf-8") + b"\x00") + print(f">>> {cmd}", flush=True) + +def handle_one(conn, addr, label): + print(f"[{label}] connected from {addr}", flush=True) + init = read_packet(conn) + print(f"<<< init: {init}", flush=True) + + txn = 1 + send(conn, f"breakpoint_set -i {txn} {BREAKPOINT}") + txn += 1 + resp = read_packet(conn) + print(f"<<< {resp}", flush=True) + + send(conn, f"run -i {txn}") + txn += 1 + while True: + resp = read_packet(conn) + if resp is None: + print(f"[{label}] disconnected", flush=True) + return + print(f"<<< {resp}", flush=True) + if "status=\"break\"" in resp: + print(f"!!! BREAKPOINT HIT in [{label}]", flush=True) + # The stack is built by the observer handlers, which are a + # separate mechanism from the statement handler that fires the + # breakpoint. Ask for it so a regression in either shows up. + send(conn, f"stack_get -i {txn}") + txn += 1 + stack = read_packet(conn) + print(f"<<< {stack}", flush=True) + if stack and 'where="workload"' in stack: + print(f"!!! STACK OK in [{label}]", flush=True) + send(conn, f"run -i {txn}") + txn += 1 + elif "status=\"stopping\"" in resp or "status=\"stopped\"" in resp: + send(conn, f"stop -i {txn}") + txn += 1 + return + +def main(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((HOST, PORT)) + s.listen(8) + print(f"listening on {HOST}:{PORT}", flush=True) + n = 0 + while True: + conn, addr = s.accept() + n += 1 + label = f"conn#{n}" + t = threading.Thread(target=handle_one, args=(conn, addr, label), daemon=True) + t.start() + +if __name__ == "__main__": + main() diff --git a/tests/frankenphp/run-worker-tests.sh b/tests/frankenphp/run-worker-tests.sh new file mode 100755 index 00000000..ed4df718 --- /dev/null +++ b/tests/frankenphp/run-worker-tests.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# Automated regression test: in FrankenPHP worker mode, requests that run +# without a debug client must not stop later requests from hitting +# breakpoints. The engine caches the observer decision per function and the +# worker reuses op_arrays across requests, so a single un-debugged request used +# to blacklist every function it touched for the rest of the worker's life. +# +# Two scenarios, each against a fresh worker: +# +# poisoned N requests with no trigger, then requests with one — must break +# control a trigger request against an untouched worker — must break +# +# Run from the repo root: +# +# tests/frankenphp/run-worker-tests.sh +# +# Environment overrides: IMAGE, HTTP_PORT, DBGP_PORT, POISON_REQUESTS, +# TRIGGER_REQUESTS, SKIP_BUILD=1 (reuse an already built image). + +set -euo pipefail + +IMAGE="${IMAGE:-php-debugger-frankenphp}" +HTTP_PORT="${HTTP_PORT:-8081}" +DBGP_PORT="${DBGP_PORT:-9004}" +POISON_REQUESTS="${POISON_REQUESTS:-200}" +TRIGGER_REQUESTS="${TRIGGER_REQUESTS:-5}" +CONTAINER="php-debugger-frankenphp-test" + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$repo_root" + +workdir="$(mktemp -d)" +listener_pid="" + +cleanup() { + [ -n "$listener_pid" ] && kill "$listener_pid" 2>/dev/null || true + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + rm -rf "$workdir" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +python3 - "$DBGP_PORT" <<'EOF' || fail "port $DBGP_PORT is already in use — set DBGP_PORT to a free port (an IDE listening on 9003 is the usual culprit)" +import socket, sys +s = socket.socket() +try: + s.bind(("0.0.0.0", int(sys.argv[1]))) +except OSError: + sys.exit(1) +finally: + s.close() +EOF + +if [ "${SKIP_BUILD:-0}" != "1" ]; then + echo "==> building $IMAGE" + docker build -q -f tests/frankenphp/Dockerfile -t "$IMAGE" . >/dev/null +fi + +# Starts a fresh worker plus a fresh DBGp listener, so no state leaks between +# scenarios. Echoes the listener log path. +start_worker() { + local log="$1" + + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + [ -n "$listener_pid" ] && kill "$listener_pid" 2>/dev/null || true + + DBGP_PORT="$DBGP_PORT" python3 tests/frankenphp/dbgp_listener.py >"$log" 2>&1 & + listener_pid=$! + disown "$listener_pid" 2>/dev/null || true + + docker run -d --name "$CONTAINER" \ + -p "$HTTP_PORT:80" \ + --add-host=host.docker.internal:host-gateway \ + -e XDEBUG_CLIENT_HOST=host.docker.internal \ + -e XDEBUG_CLIENT_PORT="$DBGP_PORT" \ + "$IMAGE" >/dev/null + + local i + for i in $(seq 1 60); do + if curl -fsS -o /dev/null "http://localhost:$HTTP_PORT/" 2>/dev/null; then + return 0 + fi + sleep 1 + done + + docker logs "$CONTAINER" >&2 || true + fail "worker did not answer on port $HTTP_PORT within 60s" +} + +hits() { + grep -c 'BREAKPOINT HIT' "$1" || true +} + +stacks_ok() { + grep -c 'STACK OK' "$1" || true +} + +echo "==> scenario 1: $POISON_REQUESTS requests without a trigger, then $TRIGGER_REQUESTS with one" +start_worker "$workdir/poisoned.log" +for _ in $(seq 1 "$POISON_REQUESTS"); do + curl -fsS -o /dev/null "http://localhost:$HTTP_PORT/" +done +for _ in $(seq 1 "$TRIGGER_REQUESTS"); do + curl -fsS -o /dev/null "http://localhost:$HTTP_PORT/?XDEBUG_TRIGGER=1" + sleep 0.3 +done +sleep 2 +poisoned_hits="$(hits "$workdir/poisoned.log")" +echo " breakpoints hit: $poisoned_hits/$TRIGGER_REQUESTS" +[ "$poisoned_hits" -eq "$TRIGGER_REQUESTS" ] || { + cat "$workdir/poisoned.log" >&2 + fail "breakpoints stopped firing after un-debugged requests" +} +poisoned_stacks="$(stacks_ok "$workdir/poisoned.log")" +echo " stacks containing the caller: $poisoned_stacks/$TRIGGER_REQUESTS" +[ "$poisoned_stacks" -eq "$TRIGGER_REQUESTS" ] || { + cat "$workdir/poisoned.log" >&2 + fail "the debugger stopped but could not see the enclosing function — the observer was blacklisted by the un-debugged requests" +} + +echo "==> scenario 2: a trigger request against a fresh worker" +start_worker "$workdir/control.log" +curl -fsS -o /dev/null "http://localhost:$HTTP_PORT/?XDEBUG_TRIGGER=1" +sleep 2 +control_hits="$(hits "$workdir/control.log")" +echo " breakpoints hit: $control_hits/1" +[ "$control_hits" -eq 1 ] || { + cat "$workdir/control.log" >&2 + fail "a plain trigger request did not hit the breakpoint" +} +[ "$(stacks_ok "$workdir/control.log")" -eq 1 ] || { + cat "$workdir/control.log" >&2 + fail "a plain trigger request produced an incomplete stack" +} + +echo "PASS: FrankenPHP worker-mode breakpoints survive un-debugged requests" From 9dfb60af61dea4c1274ea0a2fd54fdc1fae1b8d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 21 Aug 2026 14:44:53 +0000 Subject: [PATCH 3/3] chore: update version number to 0.3.0 --- php_xdebug.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php_xdebug.h b/php_xdebug.h index 2614274f..f9e2a6c3 100644 --- a/php_xdebug.h +++ b/php_xdebug.h @@ -18,7 +18,7 @@ #define PHP_XDEBUG_H #define XDEBUG_NAME "PHP Debugger" -#define XDEBUG_VERSION "0.2.3" +#define XDEBUG_VERSION "0.3.0" #define XDEBUG_AUTHOR "Derick Rethans" #define XDEBUG_COPYRIGHT "Copyright (c) 2002-2026 by Derick Rethans" #define XDEBUG_COPYRIGHT_SHORT "Copyright (c) 2002-2026"