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: opcache poisoning between requests by carlos-granados · Pull Request #105 · php-debugger/php-debugger

Conversation

@carlos-granados

@carlos-granados carlos-granados commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

While testing the debugger in a PHP-FPM environment I found out that the code would not stop in several places and when step debugging, it would bypass some code and only stop in some other code. The reason is this: if we use opcache and a request is done without connecting to a debugging client, the opcodes saved in the opcache will not include the instrumentation needed to drive the debugger. If we later connect with a debugging client, these sections of code will not be able to be debugged as they lack this instrumentation and we will only be able to debug sections of code for which the opcache is newly built for the request.

There is also an inverse consequence of the problem: if you have been debugging some code and then disconnect the debugger, all the code saved in the opcache will have been instrumented and will be slower even if you don't continue debugging.

To fix this, what we do is that if we detect a debugging client, we disable reading from the opcache, so that all opcodes are new and will be instrumented. For a debugging run where we actually want to debug the code this should not be a problem. This also means that our instrumented code is not saved in the cache and will not affect future runs where the debugger is not used.

If we use the "on demand" debugging option then we need to disable the opcache if the debugger is active even if we don't connect at the start of the request as otherwise if we connect through xdebug_break or similar, some parts of the code will not have been instrumented and will not be debuggable. Another reason to avoid this mode if possible.

Note: this fix is related to the FrankenPHP issue being handled in this draft PR #71, so this solution might affect the work being done there

Sorry, something went wrong.

Uh oh!

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

All reactions
@carlos-granados
fix: opcache poisoning between requests
e1ab447
@carlos-granados

carlos-granados commented Aug 13, 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 13, 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 3m 5s —— View job


Claude PR Review

  • Gather context from PR
  • Read changed files (src/base/base.c, src/lib/lib.c, src/lib/lib.h, 3 new phpts + 1 inc)
  • Trace call sites (xdebug_base_rinit_if_enabled → only called on real debug/on_demand=1 paths)
  • Verify zend_alter_ini_entry semantics at STAGE_RUNTIME and interaction with opcache's OnUpdateEnable
  • Cross-check xdebug_break() / xdebug_connect_to_client() gating vs the new hook
  • Post review

Summary

The fix is well-scoped and correctly addresses a real correctness bug: opcache SHM being shared between instrumented and non-instrumented requests silently causes missed breakpoints (poisoning direction 1) and perpetual EXT_STMT overhead after a debug session ends (poisoning direction 2). The mechanism — flipping opcache.enable off at STAGE_RUNTIME — is the right lever, because it makes opcache's persistent_compile_file delegate straight to the original compiler, bypassing both SHM and the file cache, and PHP's INI modified-list restores the value at request shutdown so the toggle is truly per-request. The block comment in src/lib/lib.c:146-153 clearly documents both the why and the mechanism, which will age well.

The gating in xdebug.c:612-640 is also correct: xdebug_base_rinit_if_enabled() is only reached when (a) a debug session was requested and a client actually connected, or (b) on_demand_debugging_enabled=1. The near-zero-overhead path (start_with_request=no, on_demand=0, or a trigger present but no listener) still returns before the new call, so a "quiet" FPM worker keeps opcache enabled. Test 002 pins that behavior down.

Overall: correctness-focused fix, sensible tests. Approving in spirit — a couple of small notes below, none blocking.


Notes / questions

1. on_demand_debugging_enabled=1 in production FPM — worth calling out louder (src/base/base.c:1039)
When on_demand=1 is set to allow ad-hoc xdebug_break()/xdebug_connect_to_client(), every request in the worker now pays "no opcache" (and, implicitly, "no JIT") — not just requests that end up debugging. On a busy PHP-FPM server this is a meaningful throughput cliff. The PR body acknowledges this ("Another reason to avoid this mode if possible"), but nothing in the code, INI docs, or xdebug_info diagnosis output warns the operator. Worth at least a xdebug_log_ex(..., XLOG_INFO, ...) when we disable opcache due to on-demand mode with no client connected, so it shows up in the log and helps triage "why is my server suddenly slower".

2. Ordering of the two opcache disables (src/base/base.c:1038-1039)
xdebug_disable_opcache_optimizer() runs first (writing opcache.optimization_level=0 with STAGE_STARTUP), then xdebug_disable_opcache_for_request() turns opcache off entirely. Once opcache.enable=0, the optimizer level is moot for this request. Not a bug (writing an INI entry that's about to become irrelevant is cheap), but functionally the optimizer disable is dead code on the debugging path — only the non-debugging path (where xdebug_base_rinit_if_enabled is skipped entirely) benefits from it. If that's intentional (belt-and-braces), a one-line comment would help future readers.

3. zend_alter_ini_entry return value ignored (src/lib/lib.c:159)
If opcache isn't loaded, zend_alter_ini_entry silently returns FAILURE. That's fine — no-op is the right behavior — but consider a xdebug_log_ex(XLOG_CHAN_CONFIG, XLOG_DEBUG, ...) when the call fails, so someone diagnosing "why is my breakpoint not firing" has a breadcrumb. Same latent issue exists in xdebug_disable_opcache_optimizer, so this is pre-existing and low priority.

4. Documentation surface (docs / changelog / xdebug_info)
This is a visible behavior change (opcache toggling on debug requests, potentially JIT off too). Users have no way to discover it except by reading the source. Worth a note in the changelog and, ideally, a line in the debug-mode section of the docs. xdebug_info() diagnostics could also mention "opcache disabled for this request" when applicable — makes the "why is opcache off?" question self-answering.

5. Test coverage gap: mid-request xdebug_break()/xdebug_connect_to_client() under on_demand=1
Tests 001–003 cover the three main static paths well. There's no test that exercises "on_demand=1, request starts with opcache disabled (per this fix), then connects mid-request via xdebug_break() and successfully hits a breakpoint in a file that opcache would have cached from a prior request." That's exactly the scenario the fix protects against. A test that pre-warms opcache with a helper request (or uses opcache_compile_file() in one process, then debugs in another) would nail down the intended guarantee. Might not be feasible within the current phpt harness — worth a quick think, not a hard requirement.

6. Nit: test 001 uses --EXPECTF-- with no format specifiers
--EXPECT-- would be equivalent and slightly stricter. Consistent with other DBGp tests in the repo, so this is only worth changing if you want to tighten it.


Correctness of the mechanism (verified against opcache source semantics)

  • opcache.enable is PHP_INI_ALL, so ZEND_INI_USER/STAGE_RUNTIME is accepted by opcache's OnUpdateEnable, which sets ZCG(enabled) = 0 and returns SUCCESS.
  • With ZCG(enabled) = 0, persistent_compile_file short-circuits to accelerator_orig_compile_file, so SHM lookups, SHM stores, and file-cache reads/writes are all bypassed for the rest of the request.
  • JIT is implicitly disabled too (JIT operates on cached scripts), which is what we want — a JIT-compiled trampoline would skip our EXT_STMT observer.
  • PHP's INI modified-list restores the prior value at request deactivation, so the next request on the same FPM worker is independent. ✓

Nothing scary in the ZTS story either — the modified list is per-request/per-thread.


Nice fix. The core logic is right; the notes above are polish, not blockers.
• Branch: fix/opcache-poisoning

All reactions

Sorry, something went wrong.

Uh oh!

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

@carlos-granados
Changes after claude review
7cc8e99
@carlos-granados

carlos-granados commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

@claude do another 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 13, 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 3m 57s —— View job


Claude PR Review (round 2)

  • Pull latest branch state, diff vs origin/main
  • Re-read changed C files (src/base/base.c, src/lib/lib.c/h, src/lib/log.c, src/debugger/com.c, xdebug.c, src/base/base_globals.h)
  • Read new tests (opcache-bypass-request-001..008) + README changes
  • Verify the fix still holds, evaluate delta since round 1, look for anything new
  • Post review

Round-1 follow-up: all five points addressed

Round 1 note This round
Loud warning for on-demand mode ✅ xdebug_log_ex(XLOG_CHAN_CONFIG, XLOG_WARN, "OPCACHE-OD", …) at xdebug.c:643-646, covered by test 004
Optimizer disable was dead code once opcache was off ✅ Collapsed into a single xdebug_disable_opcache_for_request(), comment explains why disabling opcache also covers the optimizer
Ignored zend_alter_ini_entry return ✅ Captured, propagated as XG_BASE(opcache_disabled), and surfaced
No user-visible documentation ✅ README gains an "OPcache" section plus a note in the on-demand section
Missing test for "opcache-cached file, then debug" ✅ Tests 007 (on-demand + xdebug_connect_to_client()) and 008 (normal on-connect path) prime opcache.file_cache in a first process, then debug in a second and land the breakpoint

Also removed warn_if_opcache_is_loaded_after_xdebug() and is_opcache_enabled() from src/debugger/com.c — that warning existed because opcache caching did need to happen after Xdebug had installed its extended-info opcode; now that we simply bypass opcache for instrumented requests, load order no longer matters, so the diagnostic is correctly retired.

The core comment in src/lib/lib.c:135-146 is worth calling out — it captures the why (per-request instrumentation vs. cross-request opcache), the mechanism (OnUpdateEnable at STAGE_RUNTIME turns the accelerator off including the file cache; INI system restores on shutdown), and the fact that this incidentally covers the optimizer too. And the ZEND_INI_SYSTEM-vs-ZEND_INI_USER comment at src/lib/lib.c:154-157 is a genuine subtle-config gotcha — php_admin_value[opcache.enable] in an FPM pool would silently defeat a USER-level change. Good catch to encode.

Verified mechanism once more:

  • opcache.enable is PHP_INI_ALL, so ZEND_INI_SYSTEM at STAGE_RUNTIME passes the permission check; OnUpdateEnable sets ZCG(enabled) = 0 and persistent_compile_file short-circuits to accelerator_orig_compile_file — SHM lookup/store and file-cache lookup/store are all bypassed for the rest of the request. JIT is implicitly disabled (JIT is part of opcache).
  • XG_BASE(opcache_disabled) is reset in xdebug_base_rinit() (always called for non-off modes) and set in xdebug_base_rinit_if_enabled() (instrumented paths only), so xdebug_info() reflects the state that actually applied to this request.

New / remaining notes (all polish, none blocking)

1. OPCACHE-OD WARN fires on every request — xdebug.c:643-646
Because on-demand mode reaches this branch on every non-debugged request, a busy FPM worker with xdebug.log set gets one WARN per request forever, not one per process. That's a lot of log noise for what is really a static configuration statement. Consider a per-process logged_once flag so the message appears at the top of the log and once per worker restart. Also, XLOG_INFO would arguably fit better than XLOG_WARN — nothing is broken, this is informational advice about a config choice the user already made — but WARN is defensible if the intent is "make sure it's visible at default log levels" (default is XLOG_INFO=7, so both are visible; changing to INFO wouldn't hide it).

2. Test 004 is fragile against unrelated INFO lines — tests/base/opcache-bypass-request-004.phpt
The test asserts exactly two lines with --EXPECTF--:

[%d] Log opened at %s
[%d] [Config] WARN: OPcache is bypassed …

log_level=7 (INFO) also emits [Config] INFO: PRIVTMP … when systemd private-tmp is detected (src/base/base.c:1032), and [Config] INFO: CTRL-OK … when the control socket is set up (src/base/ctrl_socket.c:478) — both fire during RINIT before your on-demand branch runs. Neither happens in this specific CLI test env today, but a future run in a systemd/container CI could break the test. Two low-effort fixes:

  • Drop to xdebug.log_level=3 (WARN) — the WARN line is the only one you actually care about matching.
  • Or use %A between the two expected lines to allow arbitrary intervening output.

3. Test 007/008 shell redirection isn't portable — tests/base/opcache-bypass-request-007.phpt:22, opcache-bypass-request-008.phpt:22
... > /dev/null 2>&1 is Unix-only; on Windows the redirect + /dev/null both fail. Existing tests use substr(PHP_OS, 0, 3) == 'WIN' ? 'NUL' : '/dev/null' (see tests/base/bug00665.phpt:16). Either follow that pattern or add --SKIPIF-- linux|osx gating; otherwise the tests will fail on Windows once they get exercised there.

4. xdebug_info() HTML row misses a docs link — src/lib/log.c:633
Every other row uses <td class="d"><a href="…"> for the docs icon; the new OPcache row uses &nbsp;. Fine if there's no docs anchor for this yet; if you add one to the "OPcache" section of the docs, linking here (or reusing #step_debug) would be consistent.

5. xdebug_disable_opcache_for_request() returns int for a boolean — src/lib/lib.h:227, src/lib/lib.c:145
Assigned into zend_bool XG_BASE(opcache_disabled) — the implicit int → zend_bool narrowing works but a bool return would document the contract and match the field. One-liner.

6. Nit: field naming — src/base/base_globals.h:57
opcache_disabled reads like "opcache is (permanently) disabled". opcache_bypassed_for_request matches both the user-facing string and the actual scope (per-request bypass, INI-modified-list restores it). Cosmetic.


Verdict

Round 1's items are all resolved cleanly and the mechanism is provably correct. The two new tests (007, 008) are the most important addition — they close the loop by actually round-tripping through a warm file cache. Everything above is polish and can go in a follow-up if you'd rather ship this now.
• Branch: fix/opcache-poisoning

All reactions

Sorry, something went wrong.

Uh oh!

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

@carlos-granados
changes after new Claude review
786320c
@carlos-granados

carlos-granados commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

@claude I decided not to do number 4, check all the other changes

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 13, 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 2m 30s —— View job


Claude PR Review (round 3)

  • Pull latest branch state, verify what's changed since round 2
  • Re-check items 1, 2, 3, 5, 6 from round-2 review (skipping Phase 2: RINIT fast-path & deferred hooks — 630% → 10% overhead #4 per author's request)
  • Look for anything new introduced since round 2
  • Post review

Round-2 follow-ups (excluding #4)

Round-2 item This round
1. OPCACHE-OD too loud / per-request repetition ✅ Changed XLOG_WARN → XLOG_INFO at xdebug.c:644, README updated to match. See note below on the "one-per-request" side of the original point.
2. Test 004 fragile against unrelated INFO lines ✅ Wrapped the expected line with %A on both sides (tests/base/opcache-bypass-request-004.phpt:22) — future systemd/CTRL-OK INFO lines can appear before/after without breaking the match. Exactly right fix.
3. Tests 007/008 Windows portability ✅ $null = substr(PHP_OS,0,3) == 'WIN' ? 'NUL' : '/dev/null' + escapeshellarg around the cache dir. Bonus: quoting the cache path was worth doing regardless.
4. Docs link on xdebug_info OPcache row ⏭️ Intentionally skipped by author.
5. int → bool return ✅ src/lib/lib.h:227 and src/lib/lib.c:147 both bool; local disabled is bool too.
6. Field renamed ✅ opcache_disabled → opcache_disabled_for_request — all six references updated (base_globals.h, base.c x2, lib/log.c x2, xdebug.c). Not my exact suggestion (opcache_bypassed_for_request) but captures the "per-request" scope, which was the point.

Verification of the delta

Grepped for the old field name — no stragglers:

src/base/base_globals.h:57:    zend_bool     opcache_disabled_for_request;
src/lib/log.c:633:            if (XG_BASE(opcache_disabled_for_request)) {
src/lib/log.c:703:            if (XG_BASE(opcache_disabled_for_request)) {
src/base/base.c:978:          XG_BASE(opcache_disabled_for_request) = false;
src/base/base.c:1039:         XG_BASE(opcache_disabled_for_request) = xdebug_disable_opcache_for_request();
xdebug.c:364:                 xg->opcache_disabled_for_request = false;

Default xdebug.log_level is XLOG_DEFAULT = "7" (src/lib/log.h:36), and XLOG_INFO = 7, so INFO is visible at the default level — the user-facing signal is preserved by the WARN→INFO change; it just stops using WARN for something that isn't broken. Good call.

The mechanism itself is unchanged and still correct: opcache.enable set to 0 at STAGE_RUNTIME with ZEND_INI_SYSTEM flips ZCG(enabled) off (bypassing SHM + file cache + JIT), and PHP's INI modified-list restores it at request shutdown.


One small note on item 1

The severity is now right, but the volume concern from round 2 is unchanged: on a busy FPM worker with xdebug.log pointed at a file, this INFO line still fires once per request forever, not once per worker. If you want to close that loop later, a per-process logged_once flag around the xdebug_log_ex call (reset in MINIT, set on first emit) would give you "log at worker start, silent thereafter". Not a blocker — happy either way, just flagging that the deduplication half of the note wasn't picked up. Fix this →


Verdict

All non-skipped round-2 items are addressed cleanly. Nothing new introduced. Ship it.
• fix/opcache-poisoning

All reactions

Sorry, something went wrong.

Uh oh!

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

@carlos-granados
carlos-granados requested a lite review from Copilot August 13, 2026 08:58
Copilot started reviewing on behalf of carlos-granados August 13, 2026 08:58
View session
Copilot AI reviewed Aug 13, 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

This PR addresses OPcache “poisoning” across requests by ensuring that requests compiled with debugging instrumentation do not read from (or write to) OPcache, preventing both “missing instrumentation” (breakpoints/stepping silently failing) and “cached instrumentation” (non-debug requests paying EXT_STMT overhead).

Changes:

  • Disable OPcache for instrumented requests by flipping opcache.enable=0 at runtime, and track this per-request state (opcache_disabled_for_request).
  • Surface OPcache bypass status in xdebug_info() and add an on-demand debugging log line explaining the throughput impact.
  • Add a focused test suite covering OPcache bypass behavior and breakpoint binding with previously cached scripts.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
xdebug.c Adds per-request state init and logs an INFO message when on-demand debugging implies OPcache bypass for every request.
src/base/base.c Disables OPcache for instrumented requests during RINIT enablement and records bypass state.
src/base/base_globals.h Adds opcache_disabled_for_request to base globals.
src/lib/lib.c Implements xdebug_disable_opcache_for_request() by setting opcache.enable=0 at runtime.
src/lib/lib.h Updates public API to xdebug_disable_opcache_for_request() returning bool.
src/lib/log.c Reports OPcache bypass status in xdebug_info() (HTML + text output).
src/debugger/com.c Removes the “OPcache loaded after Xdebug” warning logic (now less relevant with per-request bypass).
README.md Documents OPcache bypass behavior and its interaction with on-demand debugging.
tests/base/opcache-bypass-request-001.phpt Verifies an attached debug session disables OPcache for that request.
tests/base/opcache-bypass-request-001.inc Script used by test 001.
tests/base/opcache-bypass-request-002.phpt Verifies non-instrumented “near-zero overhead” request keeps OPcache enabled.
tests/base/opcache-bypass-request-003.phpt Verifies instrumented request (on-demand enabled) disables OPcache.
tests/base/opcache-bypass-request-004.phpt Verifies on-demand mode logs that OPcache is bypassed.
tests/base/opcache-bypass-request-005.phpt Verifies xdebug_info() reports OPcache bypass when active.
tests/base/opcache-bypass-request-006.phpt Verifies xdebug_info() does not mention OPcache bypass when not instrumented.
tests/base/opcache-bypass-request-007.phpt Ensures breakpoints bind when a file was cached during a prior non-debugged run (trigger + on-demand).
tests/base/opcache-bypass-request-007.inc Script used by test 007 (late connect via xdebug_connect_to_client()).
tests/base/opcache-bypass-request-008.phpt Ensures breakpoints bind in an OPcache-cached file when client connects at request start (production path).
tests/base/opcache-bypass-request-008.inc Script used by test 008.

💡 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 README.md Resolved

Uh oh!

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

@carlos-granados
Fix after copilot review
69fe11b
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
@carlos-granados
carlos-granados requested a review from pronskiy August 13, 2026 10:44
pronskiy
pronskiy approved these changes Aug 13, 2026
View reviewed changes
@carlos-granados
carlos-granados merged commit 4cff6f2 into php-debugger:main Aug 13, 2026
34 checks passed

Uh oh!

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

@carlos-granados
carlos-granados deleted the fix/opcache-poisoning branch August 13, 2026 17:33
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.

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.