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 — Changes after claude review · php-debugger/php-debugger@1ca81ec

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
  • .github/workflows
    • frankenphp.yml
  • README.md
  • src
    • base
      • base.c
      • base_globals.h
    • debugger
      • frankenphp.c
    • lib
      • lib.c
  • tests/frankenphp
    • README.md
    • dbgp_listener.py
    • run-worker-tests.sh
  • xdebug.c
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎.github/workflows/frankenphp.yml‎

Copy file name to clipboard
+22Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: FrankenPHP
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
jobs:
10+
worker-mode:
11+
runs-on: ubuntu-latest
12+
name: "Worker mode"
13+
timeout-minutes: 20
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v7
17+
18+
- name: Worker-mode regression tests
19+
run: tests/frankenphp/run-worker-tests.sh
20+
env:
21+
HTTP_PORT: 8080
22+
DBGP_PORT: 9003
Collapse file

‎README.md‎

Copy file name to clipboardExpand all lines: README.md
+13Lines changed: 13 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,19 @@ incompatible with debugging anyway.
136136
`xdebug_info()` says `OPcache is bypassed for this request` when this applies, so you can tell this apart from OPcache being off
137137
for some other reason.
138138

139+
### FrankenPHP worker mode
140+
141+
The near-zero overhead described above relies on deciding per request whether to compile with debugging information. FrankenPHP
142+
worker mode cannot work that way: one worker serves many requests from code it compiled once, before it can know that a later
143+
request will ask to be debugged. PHP Debugger therefore compiles everything with debugging information for the whole worker
144+
process, and every request pays for the per-statement dispatch that comes with it — including requests with no debugging trigger.
145+
The dispatch bails out immediately when no client is connected, so the cost is small, but it is not the "you don't pay for what
146+
you don't use" behaviour you get on CLI and PHP-FPM.
147+
148+
This applies as soon as the extension is loaded with `mode=debug`, whether or not you ever attach an IDE. Setting the mode off
149+
(`php_debugger.mode=off`, `xdebug.mode=off`, or the `XDEBUG_MODE=off` environment variable) skips the instrumentation entirely, so
150+
a worker you are not planning to debug runs at full speed.
151+
139152
## IDE Setup
140153

141154
### PhpStorm
Collapse file

‎src/base/base.c‎

Copy file name to clipboardExpand all lines: src/base/base.c
+14-31Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -443,15 +443,18 @@ void xdebug_rebuild_stack(void)
443443
/** Function interceptors and dispatchers to modules ***********************/
444444

445445
/* The begin handlers do nothing when the observer is inactive at call entry,
446-
* but in some configurations the observer can be switched on part-way through
447-
* that call — see observer_can_activate_mid_call in xdebug_base_rinit(). The
448-
* end handler then runs for a call whose frame was never pushed, and popping
449-
* there would discard the caller's frame instead. Both begin handlers stamp
450-
* the frame with the calling execute_data, so the end handlers can tell
451-
* whether the frame on top of the stack is really theirs.
446+
* but the observer can be switched on part-way through that call: FrankenPHP
447+
* arms it per request inside the worker loop, xdebug_break() and
448+
* xdebug_connect_to_client() attach mid-request, and the control socket can
449+
* attach from outside at any point. The end handler then runs for a call
450+
* whose frame was never pushed, and popping there would discard the caller's
451+
* frame instead. Both begin handlers stamp the frame with the calling
452+
* execute_data, so the end handlers can tell whether the frame on top of the
453+
* stack is really theirs.
452454
*
453-
* Only called when that flag is set: this is the return path of every
454-
* function call, so the common configurations must not pay for it. */
455+
* This is the return path of every function call, but only of calls the
456+
* engine has installed our handlers on — which happens when a debug session
457+
* is active, or on FrankenPHP. Requests with no debugger never reach it. */
455458
static bool xdebug_frame_belongs_to_call(function_stack_entry *fse, zend_execute_data *execute_data)
456459
{
457460
if (XDEBUG_VECTOR_COUNT(XG_BASE(stack)) == 0 || !fse) {
@@ -520,7 +523,7 @@ static void xdebug_execute_user_code_end(zend_execute_data *execute_data, zval *
520523
function_stack_entry *fse = XDEBUG_VECTOR_TAIL(XG_BASE(stack));
521524
zval *return_value = NULL;
522525

523-
if (UNEXPECTED(XG_BASE(observer_can_activate_mid_call)) && !xdebug_frame_belongs_to_call(fse, execute_data)) {
526+
if (!xdebug_frame_belongs_to_call(fse, execute_data)) {
524527
return;
525528
}
526529

@@ -662,7 +665,7 @@ static void xdebug_execute_internal_end(zend_execute_data *execute_data, zval *r
662665
* nested calls might have reallocated the vector */
663666
fse = XDEBUG_VECTOR_TAIL(XG_BASE(stack));
664667

665-
if (UNEXPECTED(XG_BASE(observer_can_activate_mid_call)) && !xdebug_frame_belongs_to_call(fse, execute_data)) {
668+
if (!xdebug_frame_belongs_to_call(fse, execute_data)) {
666669
return;
667670
}
668671

@@ -730,7 +733,7 @@ static zend_observer_fcall_handlers xdebug_observer_init(zend_execute_data *exec
730733
* across requests within one process, so declining once would blacklist
731734
* the function for the rest of the worker's life — including for later
732735
* requests that do carry a trigger. Install unconditionally there and
733-
* let the handlers fast-path on !observer_active. See issue #63. */
736+
* let the handlers fast-path on !observer_active. */
734737
if (!XG_BASE(observer_active) && !xdebug_frankenphp_in_use()) {
735738
return (zend_observer_fcall_handlers){NULL, NULL};
736739
}
@@ -1014,26 +1017,6 @@ void xdebug_base_rinit(void)
10141017
XG_BASE(observer_active) = true;
10151018
XG_BASE(opcache_disabled_for_request) = false;
10161019

1017-
/* Can the observer go from inactive to active while a call is already
1018-
* running? Only then do the end handlers have to check that the frame
1019-
* they are about to pop is their own — see
1020-
* xdebug_frame_belongs_to_call(). Working this out once per request
1021-
* keeps that check off the return path of every function call in the
1022-
* configurations that cannot hit it. Two can:
1023-
*
1024-
* - FrankenPHP arms the observer per request inside the worker loop,
1025-
* and its handlers stay installed across requests;
1026-
* - on-demand debugging lets xdebug_break(), a late
1027-
* xdebug_connect_to_client(), start_upon_error or the control socket
1028-
* attach part-way through a request.
1029-
*
1030-
* Those last three cannot fire on their own: they all need code compiled
1031-
* with EXT_STMT or our error callback installed, and both only happen
1032-
* when a client was already attached at request start — in which case
1033-
* the observer was active for the whole request anyway. */
1034-
XG_BASE(observer_can_activate_mid_call) =
1035-
xdebug_frankenphp_in_use() || XINI_DBG(on_demand_debugging_enabled);
1036-
10371020
{
10381021
zend_string *fiber_key = create_key_for_fiber(EG(main_fiber_context));
10391022

Collapse file

‎src/base/base_globals.h‎

Copy file name to clipboardExpand all lines: src/base/base_globals.h
-1Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ typedef struct _xdebug_base_globals_t {
5555
zend_bool statement_handler_enabled;
5656
zend_bool early_connection;
5757
zend_bool opcache_disabled_for_request;
58-
zend_bool observer_can_activate_mid_call;
5958

6059
/* in-execution checking */
6160
zend_bool in_execution;
Collapse file

‎src/debugger/frankenphp.c‎

Copy file name to clipboardExpand all lines: src/debugger/frankenphp.c
+8-9Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ static int xdebug_frankenphp_sapi_activate(void)
117117
* stack frame for the current function. xdebug_debugger_statement_call
118118
* would then bail out on its empty-stack check, so breakpoints in the
119119
* function that triggered the connection (and any function entered
120-
* before observer activation) would be missed. See issue #63. */
120+
* before observer activation) would be missed. */
121121
if (xdebug_lib_start_with_request() || has_debug_trigger()) {
122122
XG_DBG(context).do_connect_to_client = 1;
123123
XG_BASE(observer_active) = 1;
@@ -149,12 +149,12 @@ static int xdebug_frankenphp_sapi_deactivate(void)
149149

150150
void xdebug_frankenphp_minit(void)
151151
{
152-
/* Note: at MINIT time on the FrankenPHP SAPI, sapi_module.name is set
153-
* to "frankenphp" but sapi_module.activate may still be NULL — the SAPI
154-
* fills the activate hook in later. We install our wrapper here, which
155-
* the SAPI's per-request dispatch then invokes (FrankenPHP's worker loop
156-
* calls activate/deactivate around each request even though it does not
157-
* re-run RINIT/RSHUTDOWN). */
152+
/* FrankenPHP's worker loop dispatches through sapi_module.activate and
153+
* .deactivate around every request, while RINIT and RSHUTDOWN run once
154+
* per worker — so these two pointers are the only per-request hook we
155+
* get. FrankenPHP itself installs no activate hook (the pointer is NULL
156+
* here, unlike deactivate), which is why the wrapper below calls the
157+
* saved original only when there is one. */
158158
if (!sapi_module.name || strcmp(sapi_module.name, "frankenphp") != 0) {
159159
return;
160160
}
@@ -177,8 +177,7 @@ void xdebug_frankenphp_minit(void)
177177
* per-statement overhead is acceptable for a SAPI that exists to
178178
* serve interactive workloads. Unlike an INI setting this survives
179179
* worker restarts, as the engine never resets compiler_options.
180-
* The matching optimizer disable lives in sapi_activate. See issue
181-
* #63. */
180+
* The matching optimizer disable lives in sapi_activate. */
182181
CG(compiler_options) |= ZEND_COMPILE_EXTENDED_STMT;
183182
}
184183

Collapse file

‎src/lib/lib.c‎

Copy file name to clipboardExpand all lines: src/lib/lib.c
+6-1Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,12 @@ void xdebug_disable_opcache_optimizer(void)
143143
zend_string *key = zend_string_init(ZEND_STRL("opcache.optimization_level"), 1);
144144
zend_string *value = zend_string_init(ZEND_STRL("0"), 1);
145145

146-
zend_alter_ini_entry(key, value, ZEND_INI_SYSTEM, ZEND_INI_STAGE_STARTUP);
146+
/* ZEND_INI_SYSTEM is the part that matters: the directive is
147+
* PHP_INI_SYSTEM, and zend_alter_ini_entry() refuses a change whose
148+
* modify type the directive does not allow. The stage is only handed to
149+
* the directive's handler, OnUpdateLong, which ignores it — so RUNTIME,
150+
* which is what we actually are, works as well as STARTUP would. */
151+
zend_alter_ini_entry(key, value, ZEND_INI_SYSTEM, ZEND_INI_STAGE_RUNTIME);
147152

148153
zend_string_release(key);
149154
zend_string_release(value);
Collapse file

‎tests/frankenphp/README.md‎

Copy file name to clipboardExpand all lines: tests/frankenphp/README.md
+40-38Lines changed: 40 additions & 38 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,34 @@
1+
# FrankenPHP worker-mode tests
2+
3+
`run-worker-tests.sh` is the automated check; the rest of this document is the
4+
manual procedure for working on the SAPI hooks by hand with an IDE.
5+
6+
## Automated (what CI runs)
7+
8+
```bash
9+
tests/frankenphp/run-worker-tests.sh
10+
```
11+
12+
Builds the image, runs a worker with `dbgp_listener.py` standing in for an
13+
IDE, and asserts that breakpoints still fire after the worker has served
14+
requests with no debugger attached — without that, a single un-debugged
15+
request stops every later request from breaking. It also checks that the
16+
reported stack includes the calling function, and repeats the trigger on a
17+
fresh worker as a control.
18+
19+
Overrides: `IMAGE`, `HTTP_PORT` (default 8081), `DBGP_PORT` (default 9004,
20+
because an IDE usually holds 9003), `POISON_REQUESTS`, `TRIGGER_REQUESTS`,
21+
`SKIP_BUILD=1` to reuse an image you already built.
22+
23+
The script exits non-zero with the listener transcript on failure. It is run
24+
by `.github/workflows/frankenphp.yml` on every push and pull request, and it
25+
was verified to fail (0/5 breakpoints) against the code from before the worker
26+
-mode fix, so a regression is caught rather than silently passing.
27+
28+
`dbgp_listener.py` can also be pointed at a different breakpoint for ad-hoc
29+
work — `DBGP_BREAKPOINT="-t call -m workload"` exercises the observer's begin
30+
handler instead of the statement handler that serves line breakpoints.
31+
132
# Manual FrankenPHP worker-mode test
233

334
Smoke-tests the SAPI activate/deactivate hooks added in `src/debugger/frankenphp.c`.
@@ -79,44 +110,16 @@ PhpStorm should pause on the opening `<?php` regardless of any path config.
79110
If even that doesn't fire, the connection isn't being made — see the
80111
**Troubleshooting** section.
81112

82-
## What to verify
83-
84-
Open `tests/frankenphp/app/index.php` in your IDE and put a breakpoint on
85-
the `$pid = getmypid();` line.
86-
87-
### 1. No trigger ⇒ no pause
88-
89-
```bash
90-
curl -s http://localhost:8080/
91-
```
92-
93-
Returns immediately. The IDE must not pause. `tail -f /tmp/xdebug.log`
94-
inside the container shows `Trigger value ... not found, so not activating`.
95-
96-
### 2. Trigger ⇒ pause
97-
98-
```bash
99-
curl -s -b 'XDEBUG_SESSION=PHPSTORM' http://localhost:8080/
100-
```
101-
102-
PhpStorm pauses on the breakpoint. Resume (F9) — request completes.
103-
104-
### 3. Same worker, multiple debug requests
105-
106-
```bash
107-
for i in 1 2 3; do
108-
curl -s -b 'XDEBUG_SESSION=PHPSTORM' http://localhost:8080/
109-
done
110-
```
113+
## What to verify manually
111114

112-
The `pid=` field in every response stays the same (one worker process), but
113-
the IDE pauses on **every** request. This proves the per-request
114-
`sapi_module.activate` reset is working — without it, only the first
115-
request would be debuggable.
115+
The script already covers the basics: a request without a trigger must not
116+
pause, a request with one must, and repeated debug requests against the same
117+
worker must all pause. What is left needs either a real IDE or a clean
118+
shutdown, so it stays a hand check.
116119

117-
### 4. Breakpoint added between requests (the `xdebug_dbgp_poll_pending` test)
120+
### 1. Breakpoint added between requests (the `xdebug_dbgp_poll_pending` test)
118121

119-
This is the scenario that caught the original bug:
122+
Open `tests/frankenphp/app/index.php` in your IDE, then:
120123

121124
1. Set a breakpoint on **line 4** (`$pid = getmypid();`).
122125
2. `curl -b 'XDEBUG_SESSION=PHPSTORM' http://localhost:8080/` → pauses on
@@ -131,10 +134,9 @@ This is the scenario that caught the original bug:
131134
PhpStorm pauses on line 5.
132135

133136
**Without the poll, the IDE's queued `breakpoint_set` is silently
134-
ignored and the new breakpoint is never honored** — exactly the
135-
symptom this PR fixes.
137+
ignored and the new breakpoint is never honored.**
136138

137-
### 5. Logs
139+
### 2. Logs
138140

139141
```bash
140142
docker exec $(docker ps -qf ancestor=php-debugger-frankenphp) \
Collapse file

‎tests/frankenphp/dbgp_listener.py‎

Copy file name to clipboard
100644100755
Expand all lines: tests/frankenphp/dbgp_listener.py
+17-3Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python3
2-
"""Minimal DBGp listener to reproduce php-debugger issue #63.
2+
"""Minimal DBGp listener, used by run-worker-tests.sh and for manual probing.
33
44
On accept:
55
1. Read init packet.
@@ -9,13 +9,18 @@
99
1010
DBGp framing: <length>\0<xml>\0
1111
"""
12+
import os
1213
import socket
1314
import sys
1415
import threading
1516
import time
1617

1718
HOST = "0.0.0.0"
18-
PORT = 9003
19+
PORT = int(os.environ.get("DBGP_PORT", "9003"))
20+
# What to break on. A line breakpoint is served by the statement handler; a
21+
# call breakpoint is served by the observer's begin handler, so the two cover
22+
# different halves of the plumbing.
23+
BREAKPOINT = os.environ.get("DBGP_BREAKPOINT", "-t line -f file:///app/lib.php -n 4")
1924

2025
def read_packet(sock):
2126
"""Read one DBGp packet: length\\0xml\\0."""
@@ -49,7 +54,7 @@ def handle_one(conn, addr, label):
4954
print(f"<<< init: {init}", flush=True)
5055

5156
txn = 1
52-
send(conn, f"breakpoint_set -i {txn} -t line -f file:///app/lib.php -n 4")
57+
send(conn, f"breakpoint_set -i {txn} {BREAKPOINT}")
5358
txn += 1
5459
resp = read_packet(conn)
5560
print(f"<<< {resp}", flush=True)
@@ -64,6 +69,15 @@ def handle_one(conn, addr, label):
6469
print(f"<<< {resp}", flush=True)
6570
if "status=\"break\"" in resp:
6671
print(f"!!! BREAKPOINT HIT in [{label}]", flush=True)
72+
# The stack is built by the observer handlers, which are a
73+
# separate mechanism from the statement handler that fires the
74+
# breakpoint. Ask for it so a regression in either shows up.
75+
send(conn, f"stack_get -i {txn}")
76+
txn += 1
77+
stack = read_packet(conn)
78+
print(f"<<< {stack}", flush=True)
79+
if stack and 'where="workload"' in stack:
80+
print(f"!!! STACK OK in [{label}]", flush=True)
6781
send(conn, f"run -i {txn}")
6882
txn += 1
6983
elif "status=\"stopping\"" in resp or "status=\"stopped\"" in resp:

0 commit comments

Comments
0 (0)
You are visitor 01402520 — 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.