GeezerVison is showing github.com — Fix/frankenphp observer cache by carlos-granados · Pull Request #106 · php-debugger/php-debugger
Conversation
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_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.
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.
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 when FrankenPHP is active. 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.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
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.
claude[bot] reacted with eyes emoji
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
claude
Bot
commented
Aug 14, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
|
Claude finished @carlos-granados's task in 6m 46s —— View job
Claude Code Review
- Read the changed files
- Analyze the fix against the described root causes
- Check for correctness / edge cases
- Post review
Reviewed the PR against origin/main. The three coupled root causes are correctly identified, and the fix is coherent and well-commented. Nothing here blocks merging — a few notes and small suggestions below.
Correctness — looks good
Mid-call activation guard (src/base/base.c:445–523, 655–667). The new xdebug_frame_belongs_to_call correctly uses fse->execute_data == execute_data->prev_execute_data to detect whether the tail frame really belongs to this call. Both begin handlers stamp fse->execute_data = execute_data->prev_execute_data right after xdebug_add_stack_frame (base.c:502 and base.c:641), so the invariant holds. I walked through the interesting cases (top-level begin no-ops, then observer flips on before nested calls; nested begin no-ops with a legitimate outer frame on the stack) and the check discriminates them correctly. Empty-stack safety is handled by the XDEBUG_VECTOR_COUNT(...) == 0 || !fse early return.
Observer install decision (src/base/base.c:723–738). Returning real handlers on FrankenPHP regardless of observer_active is the right fix for cache poisoning across worker requests. The fast-path in xdebug_execute_begin/xdebug_execute_end (base.c:686, 706) keeps the no-debug overhead to a single load + branch per fcall.
FrankenPHP sapi_activate re-arming (src/debugger/frankenphp.c:121–126). Setting observer_active per request based on trigger detection matches the RINIT-only startup constraint you documented.
ZEND_COMPILE_EXTENDED_STMT at MINIT (src/debugger/frankenphp.c:182). Forcing this process-wide is the only way to make user code compiled by later trigger requests carry EXT_STMT opcodes — since xdebug_base_rinit_if_enabled (which normally sets the flag, base.c:1095) doesn't run per-request in the worker flow.
Suggestions
1. observer_can_activate_mid_call and on_demand_debugging_enabled (src/base/base.c:1017–1035). The comment argues that on-demand paths can't actually cause mid-call activation on their own ("both only happen when a client was already attached at request start — in which case the observer was active for the whole request anyway"). Yet the flag is set true for that case anyway. Either drop on_demand_debugging_enabled from the OR (simpler; the cost is one branch on every fcall return for those users), or expand the comment to say explicitly this is a defensive belt-and-suspenders — right now the code disagrees with its own justification.
2. xdebug_disable_opcache_optimizer — ZEND_INI_STAGE_STARTUP from a per-request path (src/lib/lib.c:141–150). ZEND_INI_STAGE_STARTUP is normally reserved for MINIT and bypasses ZEND_INI_SYSTEM modifiability restrictions. Using it from sapi_activate is unusual and load-bearing (needed because opcache.optimization_level is SYSTEM). Worth a one-line comment on zend_alter_ini_entry explaining why the STARTUP stage is required outside MINIT — a future reader will otherwise want to "correct" it to ZEND_INI_STAGE_RUNTIME and silently break the fix. Fix this →
3. Minor perf: cache the persistent zend_strings. xdebug_disable_opcache_optimizer allocates two persistent zend_strings on every request. In a FrankenPHP worker under load this is a lot of small allocations for a constant pair. Consider MINIT-time init + MSHUTDOWN release, or ZEND_STRL static literal wrappers. Not blocking.
4. No automated regression coverage. tests/frankenphp/dbgp_listener.py + the Dockerfile is a great manual repro, but the test suite (run-xdebug-tests.php) has no FrankenPHP path. Given how easy it is for a future refactor of the observer/statement plumbing to re-poison the cache silently, a follow-up CI job (or at least a documented "how to verify #63 manually" pointer in tests/frankenphp/README.md) would be worthwhile. Not blocking this PR.
5. Document the FrankenPHP overhead trade-off. Because EXT_STMT is now forced for the whole process, FrankenPHP requests without any debug trigger still pay per-statement dispatch through xdebug_statement_call (xdebug.c:695) — it bails cheaply on !xdebug_is_debug_connection_active(), but it's no longer "zero overhead" the way CLI/FPM is. The PR body acknowledges this; consider surfacing it in user docs too so FrankenPHP users know the trade-off applies as soon as the extension is loaded.
6. Dead-code nit (src/debugger/frankenphp.c:150–183). The header block comment about sapi_module.activate may still be NULL is helpful context, but the code doesn't actually protect against it — sapi_module.activate is dereferenced via original_sapi_activate inside xdebug_frankenphp_sapi_activate where the guard if (original_sapi_activate) already handles NULL. Minor: the comment might read as if there's something special being done here when the guard is elsewhere. Optional cleanup.
Nit
tests/frankenphp/dbgp_listener.py has a shebang but is unlikely to be marked executable in git (chmod +x before committing, or drop the shebang).
Nice work — the commentary in base.c:445, base.c:1017 and frankenphp.c:108 in particular makes the invariants easy to follow for future maintainers.
• Branch: fix/frankenphp-observer-cache
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
@claude I decided not to do #3, the gains are minuscule. Please review again the rest
|
claude[bot] reacted with eyes emoji
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
claude
Bot
commented
Aug 14, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
|
Claude finished @carlos-granados's task in 4m 19s —— View job
Claude Code Re-Review (round 2)
Reviewed commit 9c5d112 against origin/main. All the follow-ups landed cleanly. Nothing blocking — one minor cosmetic nit.
Follow-ups verified
#1 — observer_can_activate_mid_call (src/base/base.c, src/base/base_globals.h, xdebug.c)
You went further than I suggested and dropped the flag entirely, always running xdebug_frame_belongs_to_call inside the two end handlers. That's the cleaner call. The added cost is a couple of loads + a compare, and only on the return path of calls the engine has actually installed our handlers on (debug session active, or FrankenPHP) — never in the no-debug CLI/FPM path. The fast-path if (EXPECTED(!XG_BASE(observer_active))) at xdebug_execute_end (base.c:709) still short-circuits before reaching the frame check on FrankenPHP-without-trigger requests, so the "single load + branch per fcall" property holds. The rewritten comment at base.c:445 correctly enumerates the mid-call activation triggers and no longer contradicts itself.
#2 — opcache optimizer INI stage (src/lib/lib.c:141)
Nice — switching to ZEND_INI_STAGE_RUNTIME with the explanation of why the stage is inert here is more correct than the "comment on STARTUP" I suggested. The claim in the comment holds: zend_alter_ini_entry_ex() gates on modify_type & ini_entry->modifiable, and opcache.optimization_level's OnUpdateLong ignores stage. The one behavioural difference — RUNTIME saves orig_value for restoration at zend_ini_deactivate — is benign in FrankenPHP because RSHUTDOWN doesn't run per request in the worker loop (the value is re-set on every sapi_activate).
#4 — automated regression coverage (.github/workflows/frankenphp.yml, tests/frankenphp/run-worker-tests.sh, dbgp_listener.py)
Much better than a docs pointer. The script starts a fresh worker + fresh listener per scenario so state can't leak, the port-preflight prevents the classic "IDE on 9003 masks the failure" confusion, and the added STACK OK assertion (dbgp_listener.py:78) covers the observer path independently of the statement handler — good separation. stacks_ok will catch a regression where the breakpoint fires but the stack is empty because xdebug_frame_belongs_to_call starts returning false spuriously, so it doubles as a guard for the item-#1 simplification.
#5 — FrankenPHP overhead trade-off (README.md:139)
Clear and honest. The "Setting the mode off … runs at full speed" pointer is the right escape hatch to surface.
#6 — MINIT header comment (src/debugger/frankenphp.c:150)
Rewritten to explain why both hooks are wrapped and why only activate may be NULL. Reads correctly now.
Nit — executable bit
dbgp_listener.py is now 0755. Good.
One minor nit on this round
src/base/base.c:1019–1020 has a double blank line where the removed observer_can_activate_mid_call initialisation used to live. Purely cosmetic — collapse to a single blank line if you touch the file again. Fix this →
LGTM to merge as-is.
• Branch: fix/frankenphp-observer-cache
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Fixes a FrankenPHP worker-mode debugging regression where an initial no-trigger request could cause Zend’s observer decision to be cached as “no handlers”, preventing line breakpoints (and stack collection) from working on later triggered requests in the same long-lived worker process.
Changes:
- Adds a FrankenPHP worker-mode regression test harness (Docker-based) plus documentation for running it.
- Introduces a FrankenPHP SAPI activate hook to re-arm
observer_active per request based on trigger detection and to disable OPcache optimization for correct breakpoint resolution.
- Hardens observer end-handlers against mid-call activation by ensuring stack pop only occurs when the top frame belongs to the current returning call.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File |
Description |
src/base/base.c |
Prevents incorrect stack pops when observer becomes active mid-call; adjusts observer init behavior for FrankenPHP. |
src/debugger/frankenphp.c |
Installs FrankenPHP SAPI activate/deactivate hooks; re-arms observer per request; enforces EXT_STMT and disables OPcache optimizer. |
src/debugger/frankenphp.h |
Exposes a FrankenPHP-in-use query used by observer init logic. |
src/lib/lib.c |
Adds helper to disable OPcache optimizer level for worker-mode correctness. |
src/lib/lib.h |
Declares the new OPcache optimizer disable helper. |
tests/frankenphp/run-worker-tests.sh |
Automated poison-then-trigger regression test runner. |
tests/frankenphp/dbgp_listener.py |
Minimal DBGp listener used by the worker-mode regression test. |
tests/frankenphp/app/lib.php |
Minimal workload code used as a breakpoint/stack target. |
tests/frankenphp/app/index.php |
Calls the workload so line breakpoints and stack collection are exercised. |
tests/frankenphp/README.md |
Documents automated vs manual FrankenPHP worker-mode testing workflows. |
README.md |
Documents behavioral/performance implications of FrankenPHP worker mode. |
.github/workflows/frankenphp.yml |
Adds CI job to run the FrankenPHP worker-mode regression test. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Comment on lines
+737
to
741
|
|
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};
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Reviewers
pronskiy
pronskiy approved these changes
Uh oh!
There was an error while loading. Please reload this page.
Assignees
No one assigned
Milestone
No milestone
Development
Successfully merging this pull request may close these issues.
FrankenPHP and disabled observer
Uh oh!
There was an error while loading. Please reload this page.
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.
|