Fix observer cache poisoning in FrankenPHP worker mode (#63)#71
Draft
pronskiy wants to merge 3 commits into
Draft
Fix observer cache poisoning in FrankenPHP worker mode (#63)#71pronskiy wants to merge 3 commits into
pronskiy wants to merge 3 commits into
Conversation
- Introduced `frankenphp.c` and `frankenphp.h` to hook into FrankenPHP's per-request lifecycle using `sapi_module.activate` and `sapi_module.deactivate`. - Integrated per-request debugger resets, trigger detection, and breakpoint polling mechanism for FrankenPHP workers. - Updated existing modules (e.g., `handler_dbgp`, `debugger.c`) to support queued command handling and lifecycle management. - Adjusted build files to include new FrankenPHP-specific sources.
Provides a self-contained reproducer for the new sapi_module.activate /
deactivate hooks added in src/debugger/frankenphp.c.
- tests/frankenphp/Dockerfile: multi-stage build that compiles
php_debugger.so against FrankenPHP's bundled ZTS PHP and installs it
as a Zend extension with xdebug.mode=debug and start_with_request=trigger.
- tests/frankenphp/Caddyfile: minimal config registering /app/worker.php
as the FrankenPHP worker, with explicit http:// scheme to avoid the
default auto-https redirect.
- tests/frankenphp/app/{worker.php,index.php}: long-lived
frankenphp_handle_request() loop and a sample request script with an
obvious breakpoint location.
- tests/frankenphp/entrypoint.sh: rewrites xdebug.client_host /
xdebug.client_port from XDEBUG_CLIENT_HOST / XDEBUG_CLIENT_PORT env
vars before exec'ing FrankenPHP (PHP INI has no native fallback
syntax for env-var defaults).
- tests/frankenphp/README.md: build/run instructions, one-time PhpStorm
Server + path-mapping setup, the four verification scenarios
(no-trigger skip, trigger pause, multi-request worker reuse, and the
xdebug_dbgp_poll_pending breakpoint-between-requests test), and a
troubleshooting table.
- .dockerignore: keeps host build artifacts (Makefile, *.dep, *.lo,
.libs, ...) out of the build context — without this, absolute host
paths baked into the local Makefile break the in-container build.
- .gitignore: adds tests/frankenphp/**/*.{php,sh} allowlist exceptions
so the test files aren't swallowed by the blanket *.php / *.sh rules
used to ignore stray test artifacts.
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.
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.
Fixes #63.
Problem
In FrankenPHP worker mode, after any request runs without an active IDE connection, line breakpoints stop firing for the rest of the worker's life — including on subsequent requests that do carry a trigger.
Three coupled root causes:
xdebug_observer_initreturns{NULL, NULL}whenobserver_activeis false. Zend caches the per-function result the first time it asks, so a no-trigger request that observes a function permanently blacklists it. FrankenPHP reuses op_arrays across requests, so the blacklist survives.observer_activeis never re-armed per request.PHP_RINITruns only at worker startup; onlysapi_activateruns per request. The worker's startup RINIT leavesobserver_active=false(no IDE yet), and there is no later code path that flips it back.ZEND_COMPILE_EXTENDED_STMTand opcache-optimizer-disable are only applied on the connected RINIT path. In worker mode that path doesn't run at startup, so user files compiled later by trigger requests have noEXT_STMTopcodes —breakpoint_setthen sees an empty "set of executable lines" and the breakpoint can never resolve.Fix
src/base/base.c:xdebug_observer_initalways returns real handlers. The handlers themselves already fast-path on!observer_active, so the no-debug overhead stays at a single load + branch per fcall.src/debugger/frankenphp.c(sapi_activate): setobserver_activeper request based on trigger detection (1if trigger orstart_with_request=yes, else0).src/debugger/frankenphp.c(minit): once we've confirmed we're on the FrankenPHP SAPI, forceCG(compiler_options) |= ZEND_COMPILE_EXTENDED_STMTand callxdebug_disable_opcache_optimizer(). Small per-statement overhead, acceptable for an interactive SAPI.Reproduction
tests/frankenphp/app/lib.phpdefinesworkload(), called fromindex.php.tests/frankenphp/dbgp_listener.pyis a minimal DBGp listener that sets a line breakpoint atlib.php:4and reports whether it fires.Before fix: 0/5 hits, all return
status="stopping".After fix: 5/5 hits, all return
status="break"atlib.php:4.Control (clean trigger, no poison) still works: 1/1 hit.
Test plan