GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Fix/frankenphp observer cache by carlos-granados · Pull Request #106 · php-debugger/php-debugger

Conversation

@carlos-granados

@carlos-granados carlos-granados commented Aug 14, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Collaborator

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 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.

All reactions
pronskiy and others added 2 commits August 13, 2026 19:38
@pronskiy @carlos-granados
Fix observer cache poisoning in FrankenPHP worker mode (php-debugger#63)
6461feb
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.
@carlos-granados
Last changes to finish the task
adf4f3b
@carlos-granados carlos-granados mentioned this pull request Aug 14, 2026
Fix observer cache poisoning in FrankenPHP worker mode (#63) #71
Closed
3 tasks done
@carlos-granados

carlos-granados commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

claude[bot] reacted with eyes emoji
All reactions
  • 👀 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@claude

claude Bot commented Aug 14, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

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

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@carlos-granados

carlos-granados commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

@claude I decided not to do #3, the gains are minuscule. Please review again the rest

claude[bot] reacted with eyes emoji
All reactions
  • 👀 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@claude

claude Bot commented Aug 14, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

Claude finished @carlos-granados's task in 4m 19s —— View job


Claude Code Re-Review (round 2)

  • Fetch latest changes and diff against previous review
  • Verify item Bump actions/checkout from 4 to 6 #1 (observer_can_activate_mid_call comment/logic)
  • Verify item Bump actions/download-artifact from 4 to 8 #2 (ZEND_INI_STAGE_STARTUP comment)
  • Verify item Phase 2: RINIT fast-path & deferred hooks — 630% → 10% overhead #4 (regression coverage / manual test doc)
  • Verify item Phase 3: Runtime Inspection API (JSON transport + stubs) #5 (FrankenPHP overhead trade-off docs)
  • Verify item Phase 2.5: Deferred activation — connect before enabling EXT_STMT #6 (dead-code nit)
  • Verify nit (shebang / executable bit)
  • Post updated review

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

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@carlos-granados
carlos-granados force-pushed the fix/frankenphp-observer-cache branch from 9c5d112 to 4e75ab3 Compare August 14, 2026 20:48
@carlos-granados
Changes after claude review
1ca81ec
@carlos-granados
carlos-granados force-pushed the fix/frankenphp-observer-cache branch from 4e75ab3 to 1ca81ec Compare August 14, 2026 20:48
@carlos-granados
carlos-granados requested a lite review from Copilot August 14, 2026 20:49
Copilot started reviewing on behalf of carlos-granados August 14, 2026 20:50
View session
Copilot AI reviewed Aug 14, 2026
View reviewed changes

Copilot AI left a comment

Copy link
Copy Markdown

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.

All reactions
Comment thread src/base/base.c
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};
pronskiy
pronskiy approved these changes Aug 18, 2026
View reviewed changes

@pronskiy pronskiy left a comment

Copy link
Copy Markdown
Member

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.

All reactions
@carlos-granados
carlos-granados merged commit 585efa0 into php-debugger:main Aug 18, 2026
34 checks passed

Uh oh!

There was an error while loading. Please reload this page.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

Copilot code review Copilot
Copilot review effort
Applies to this pull request for everyone.Learn more about Copilot code review.
Copilot left review comments
@pronskiy pronskiy pronskiy approved these changes

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

None yet

Projects

None yet

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.

3 participants

@carlos-granados @pronskiy
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.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.