Skip to content

Fix observer cache poisoning in FrankenPHP worker mode (#63)#71

Draft
pronskiy wants to merge 3 commits into
mainfrom
fix/frankenphp-observer-cache
Draft

Fix observer cache poisoning in FrankenPHP worker mode (#63)#71
pronskiy wants to merge 3 commits into
mainfrom
fix/frankenphp-observer-cache

Conversation

@pronskiy

@pronskiy pronskiy commented May 3, 2026

Copy link
Copy Markdown
Member

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:

  1. xdebug_observer_init returns {NULL, NULL} when observer_active is 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.
  2. observer_active is never re-armed per request. PHP_RINIT runs only at worker startup; only sapi_activate runs per request. The worker's startup RINIT leaves observer_active=false (no IDE yet), and there is no later code path that flips it back.
  3. ZEND_COMPILE_EXTENDED_STMT and 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 no EXT_STMT opcodes — breakpoint_set then sees an empty "set of executable lines" and the breakpoint can never resolve.

Fix

  • src/base/base.c: xdebug_observer_init always 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): set observer_active per request based on trigger detection (1 if trigger or start_with_request=yes, else 0).
  • src/debugger/frankenphp.c (minit): once we've confirmed we're on the FrankenPHP SAPI, force CG(compiler_options) |= ZEND_COMPILE_EXTENDED_STMT and call xdebug_disable_opcache_optimizer(). Small per-statement overhead, acceptable for an interactive SAPI.

Reproduction

tests/frankenphp/app/lib.php defines workload(), called from index.php. tests/frankenphp/dbgp_listener.py is a minimal DBGp listener that sets a line breakpoint at lib.php:4 and reports whether it fires.

docker build -f tests/frankenphp/Dockerfile -t php-debugger-frankenphp .
python3 tests/frankenphp/dbgp_listener.py &
docker run --rm -p 8081:80 \
  -e XDEBUG_CLIENT_HOST=host.docker.internal \
  -e XDEBUG_CLIENT_PORT=9003 \
  php-debugger-frankenphp
# Poison: 200 no-trigger requests
for i in $(seq 1 200); do curl -s -o /dev/null http://localhost:8081/; done
# Trigger: 5 attempts
for i in 1 2 3 4 5; do curl -s -o /dev/null 'http://localhost:8081/?XDEBUG_TRIGGER=1'; done

Before fix: 0/5 hits, all return status="stopping".
After fix: 5/5 hits, all return status="break" at lib.php:4.
Control (clean trigger, no poison) still works: 1/1 hit.

Test plan

  • Manual: poison-then-trigger sequence — 5/5 breakpoints hit.
  • Manual: clean trigger (no poison) — still hits, no regression.
  • CI / existing PHPT suite — unchanged behavior expected for non-FrankenPHP SAPIs (CLI/FPM); the always-real-handlers change adds at most one extra fcall per function in non-debug runs and is otherwise inert.

pronskiy added 3 commits May 3, 2026 13:43
- 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.
@pronskiy pronskiy marked this pull request as draft May 3, 2026 12:53
Base automatically changed from feature/frankenphp to main May 4, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant