Summary
Our FrankenPHP worker-mode support decides whether to start a debug session in
sapi_activate, using a raw substring scan of the request data instead of the
normal request-activation path. As a result several documented activation rules
are not applied to worker requests: xdebug.trigger_value,
xdebug.start_with_request=no, triggers arriving via POST/env, and
XDEBUG_SESSION_START / XDEBUG_SESSION_STOP.
Xdebug has since grown its own worker-mode support in
b4052057
("Fixed issue #2403: Reinit debugger for requests in FrankenPHP worker mode"),
built on a different mechanism that does not have these gaps. That commit was
deliberately not transplanted during the Xdebug sync (#121), because taking
it as-is would replace our implementation and drop the overhead optimisation
this fork exists for. This issue records the gaps and proposes how to close them.
Related: #63 (closed) and #64 cover the observer/statement-handler mechanics,
which our implementation already addresses. This issue is about the activation
checks, which it does not.
Root cause
xdebug_init_auto_globals() is called only from PHP_RINIT (xdebug.c:577),
and RINIT does not run per request in a FrankenPHP worker — only
sapi_module.activate does. So superglobals are never force-built for a worker
request, and anything that reads them is unreliable there.
That is why src/debugger/frankenphp.c falls back to scanning
SG(request_info).cookie_data and .query_string directly
(has_trigger_in_string() / has_debug_trigger()), and decides up front:
/* src/debugger/frankenphp.c:122 */
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;
}
This bypasses xdebug_debug_init_if_requested_at_startup() (src/debugger/com.c:971),
which is what every non-worker request goes through.
Gaps
| Gap |
Code we bypass |
Consequence in worker mode |
xdebug.trigger_value / shared secret not checked |
trigger_enabled() → xdebug_lib_has_shared_secret(), does_shared_secret_match() (src/lib/lib.c:569-580) |
Security-relevant: with a shared secret configured, a bare XDEBUG_TRIGGER= activates a session |
start_with_request=no not honoured |
xdebug_lib_never_start_with_request() (src/lib/lib.c:314) |
We only test == YES, so a stray trigger cookie activates despite no |
| Trigger sources limited to raw cookie header + query string |
xdebug_lib_find_in_globals() checks _GET, _POST, _COOKIE, real superglobals, then getenv() (src/lib/lib.c:393+) |
POST-body, $_ENV, $_SERVER and env-var triggers never fire |
XDEBUG_SESSION_START side effects missing |
xdebug_handle_start_session() (src/debugger/com.c:869) — sets the session cookie, calls xdebug_update_ide_key() |
No session cookie set; IDE key not taken from the trigger value |
XDEBUG_SESSION_STOP not handled |
xdebug_handle_stop_session() (src/debugger/com.c) |
A session cannot be stopped in worker mode |
start_session_result cache never reset |
reset only in xdebug_debugger_rinit() (src/debugger/debugger.c:895) |
Not a bug today (we never call xdebug_handle_start_session()), but it becomes one the moment we do: the worker's first answer would stick for its whole life |
XDEBUG_IGNORE is a partial case: it is reached, via do_connect_to_client →
xdebug_debug_init_if_requested_on_connect_to_client() → xdebug_should_ignore()
(src/debugger/debugger.c:354). But should_ignore() reads superglobals that
nothing forces here, so it may see the previous request's data or none. Unreliable
rather than absent.
Why this is not a small patch
The correct checks need superglobals. Superglobals are not available in
sapi_activate — which is exactly why upstream defers the decision to the first
user code executed in the request:
sapi_activate resets per-request flags and sets context.do_request_reinit = 1; it decides nothing
xdebug_execute_user_code_begin() checks xdebug_frankenphp_reinit_pending() and calls xdebug_frankenphp_request_reinit()
- that forces five superglobals with
zend_is_auto_global_str(), then calls xdebug_debug_init_if_requested_at_startup() — the same function a normal request uses
rinit clears the flag, so it doubles as a reliable "this is a worker request" detector
Upstream can do this because its observer is always installed, so the hook point
is always reached. Reaching it requires observer_active == true, and our
optimisation's whole point is to set observer_active before the request runs,
from the cheap scan. Adopting the deferred path naively means the observer runs
for every worker request, and we lose the near-zero-overhead property for
untriggered ones.
Things our implementation has that upstream's does not, and that must survive any
change here:
CG(compiler_options) |= ZEND_COMPILE_EXTENDED_STMT forced process-wide at MINIT (the fix for FrankenPHP and disabled statement handler #64)
xdebug_disable_opcache_optimizer() per sapi_activate (INI values are restored when a worker generation ends, so MINIT alone is not enough)
xdebug_frankenphp_in_use() making xdebug_observer_init() install handlers unconditionally, so the engine's per-zend_function cache does not blacklist them for the worker's life (the fix for FrankenPHP and disabled observer #63)
- draining
remote_poll_pending so breakpoints pushed while the worker was busy are picked up
Proposed approach
Keep the cheap scan, but demote it to a conservative negative filter, and use
upstream's deferred mechanism for everything else:
- In
sapi_activate: if start_with_request=yes → arm. Else if the raw scan finds no candidate trigger name in the cookie header or query string, getenv() has none, and the request method is not POST → leave observer_active = 0 and return. Zero cost, and this is the common case.
- Otherwise arm
observer_active and a do_request_reinit flag, and let the real xdebug_debug_init_if_requested_at_startup() decide inside the request, as upstream does.
- Reset
XG_DBG(start_session_result) = -1 in sapi_activate.
- Keep all four items listed above.
Open question: POST-body triggers either force the slow path for every POST
request, or stay unsupported. Worth deciding explicitly rather than by omission.
Testing
tests/frankenphp/run-worker-tests.sh + tests/frankenphp/dbgp_listener.py already
drive a real worker in Docker and assert that breakpoints still fire after
un-debugged requests, but exercise none of the gaps above. Cases to add:
- trigger present but
xdebug.trigger_value mismatch → must not activate
start_with_request=no plus a trigger → must not activate
XDEBUG_IGNORE present → must not activate
XDEBUG_SESSION_START → session cookie set; XDEBUG_SESSION_STOP → both cookies cleared
- trigger in a POST body (whatever we decide above)
tests/frankenphp/Dockerfile currently hardcodes dunglas/frankenphp:1-php8.4, so
testing across versions needs the PHP_VERSION plumbing that upstream's
1aea1ae7
("Add 'make test-frankenphp' which also sets the current PHP version") was adding.
That commit was skipped in #121 for the same reason, and is worth folding in here.