GeezerVison is showing github.com — Changes after claude review · php-debugger/php-debugger@1ca81ec
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboard +22Lines changed: 22 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 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 number | Diff line number | Diff line change |
|---|
@@ -136,6 +136,19 @@ incompatible with debugging anyway. | 136 | 136 | `xdebug_info()` says `OPcache is bypassed for this request` when this applies, so you can tell this apart from OPcache being off | 137 | 137 | for some other reason. | 138 | 138 |
| | 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 | + | 139 | 152 | ## IDE Setup | 140 | 153 |
| 141 | 154 | ### PhpStorm | |
Collapse file Copy file name to clipboardExpand all lines: src/base/base.c +14-31Lines changed: 14 additions & 31 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -443,15 +443,18 @@ void xdebug_rebuild_stack(void) | 443 | 443 | /** Function interceptors and dispatchers to modules ***********************/ | 444 | 444 |
| 445 | 445 | /* 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. | 452 | 454 | * | 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. */ | 455 | 458 | static bool xdebug_frame_belongs_to_call(function_stack_entry *fse, zend_execute_data *execute_data) | 456 | 459 | { | 457 | 460 | 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 * | 520 | 523 | function_stack_entry *fse = XDEBUG_VECTOR_TAIL(XG_BASE(stack)); | 521 | 524 | zval *return_value = NULL; | 522 | 525 |
| 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)) { | 524 | 527 | return; | 525 | 528 | } | 526 | 529 |
| @@ -662,7 +665,7 @@ static void xdebug_execute_internal_end(zend_execute_data *execute_data, zval *r | 662 | 665 | * nested calls might have reallocated the vector */ | 663 | 666 | fse = XDEBUG_VECTOR_TAIL(XG_BASE(stack)); | 664 | 667 |
| 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)) { | 666 | 669 | return; | 667 | 670 | } | 668 | 671 |
| @@ -730,7 +733,7 @@ static zend_observer_fcall_handlers xdebug_observer_init(zend_execute_data *exec | 730 | 733 | * across requests within one process, so declining once would blacklist | 731 | 734 | * the function for the rest of the worker's life — including for later | 732 | 735 | * 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. */ | 734 | 737 | if (!XG_BASE(observer_active) && !xdebug_frankenphp_in_use()) { | 735 | 738 | return (zend_observer_fcall_handlers){NULL, NULL}; | 736 | 739 | } | @@ -1014,26 +1017,6 @@ void xdebug_base_rinit(void) | 1014 | 1017 | XG_BASE(observer_active) = true; | 1015 | 1018 | XG_BASE(opcache_disabled_for_request) = false; | 1016 | 1019 |
| 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 | | - | 1037 | 1020 | { | 1038 | 1021 | zend_string *fiber_key = create_key_for_fiber(EG(main_fiber_context)); | 1039 | 1022 |
| |
Collapse file Copy file name to clipboardExpand all lines: src/base/base_globals.h -1Lines changed: 0 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -55,7 +55,6 @@ typedef struct _xdebug_base_globals_t { | 55 | 55 | zend_bool statement_handler_enabled; | 56 | 56 | zend_bool early_connection; | 57 | 57 | zend_bool opcache_disabled_for_request; | 58 | | - zend_bool observer_can_activate_mid_call; | 59 | 58 |
| 60 | 59 | /* in-execution checking */ | 61 | 60 | zend_bool in_execution; | |
Collapse file Copy file name to clipboardExpand all lines: src/debugger/frankenphp.c +8-9Lines changed: 8 additions & 9 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -117,7 +117,7 @@ static int xdebug_frankenphp_sapi_activate(void) | 117 | 117 | * stack frame for the current function. xdebug_debugger_statement_call | 118 | 118 | * would then bail out on its empty-stack check, so breakpoints in the | 119 | 119 | * 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. */ | 121 | 121 | if (xdebug_lib_start_with_request() || has_debug_trigger()) { | 122 | 122 | XG_DBG(context).do_connect_to_client = 1; | 123 | 123 | XG_BASE(observer_active) = 1; | @@ -149,12 +149,12 @@ static int xdebug_frankenphp_sapi_deactivate(void) | 149 | 149 |
| 150 | 150 | void xdebug_frankenphp_minit(void) | 151 | 151 | { | 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. */ | 158 | 158 | if (!sapi_module.name || strcmp(sapi_module.name, "frankenphp") != 0) { | 159 | 159 | return; | 160 | 160 | } | @@ -177,8 +177,7 @@ void xdebug_frankenphp_minit(void) | 177 | 177 | * per-statement overhead is acceptable for a SAPI that exists to | 178 | 178 | * serve interactive workloads. Unlike an INI setting this survives | 179 | 179 | * 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. */ | 182 | 181 | CG(compiler_options) |= ZEND_COMPILE_EXTENDED_STMT; | 183 | 182 | } | 184 | 183 |
| |
Collapse file Copy file name to clipboardExpand all lines: src/lib/lib.c +6-1Lines changed: 6 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -143,7 +143,12 @@ void xdebug_disable_opcache_optimizer(void) | 143 | 143 | zend_string *key = zend_string_init(ZEND_STRL("opcache.optimization_level"), 1); | 144 | 144 | zend_string *value = zend_string_init(ZEND_STRL("0"), 1); | 145 | 145 |
| 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); | 147 | 152 |
| 148 | 153 | zend_string_release(key); | 149 | 154 | zend_string_release(value); | |
Collapse file 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 number | Diff line number | Diff line change |
|---|
| | 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 | + | 1 | 32 | # Manual FrankenPHP worker-mode test | 2 | 33 |
| 3 | 34 | 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. | 79 | 110 | If even that doesn't fire, the connection isn't being made — see the | 80 | 111 | **Troubleshooting** section. | 81 | 112 |
| 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 | 111 | 114 |
| 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. | 116 | 119 |
| 117 | | -### 4. Breakpoint added between requests (the `xdebug_dbgp_poll_pending` test) | | 120 | +### 1. Breakpoint added between requests (the `xdebug_dbgp_poll_pending` test) | 118 | 121 |
| 119 | | -This is the scenario that caught the original bug: | | 122 | +Open `tests/frankenphp/app/index.php` in your IDE, then: | 120 | 123 |
| 121 | 124 | 1. Set a breakpoint on **line 4** (`$pid = getmypid();`). | 122 | 125 | 2. `curl -b 'XDEBUG_SESSION=PHPSTORM' http://localhost:8080/` → pauses on | @@ -131,10 +134,9 @@ This is the scenario that caught the original bug: | 131 | 134 | PhpStorm pauses on line 5. | 132 | 135 |
| 133 | 136 | **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.** | 136 | 138 |
| 137 | | -### 5. Logs | | 139 | +### 2. Logs | 138 | 140 |
| 139 | 141 | ```bash | 140 | 142 | docker exec $(docker ps -qf ancestor=php-debugger-frankenphp) \ | |
Collapse file Copy file name to clipboard100644100755 Expand all lines: tests/frankenphp/dbgp_listener.py +17-3Lines changed: 17 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | #!/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. | 3 | 3 |
| 4 | 4 | On accept: | 5 | 5 | 1. Read init packet. | | 9 | 9 |
| 10 | 10 | DBGp framing: <length>\0<xml>\0 | 11 | 11 | """ | | 12 | +import os | 12 | 13 | import socket | 13 | 14 | import sys | 14 | 15 | import threading | 15 | 16 | import time | 16 | 17 |
| 17 | 18 | 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") | 19 | 24 |
| 20 | 25 | def read_packet(sock): | 21 | 26 | """Read one DBGp packet: length\\0xml\\0.""" | @@ -49,7 +54,7 @@ def handle_one(conn, addr, label): | 49 | 54 | print(f"<<< init: {init}", flush=True) | 50 | 55 |
| 51 | 56 | 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}") | 53 | 58 | txn += 1 | 54 | 59 | resp = read_packet(conn) | 55 | 60 | print(f"<<< {resp}", flush=True) | @@ -64,6 +69,15 @@ def handle_one(conn, addr, label): | 64 | 69 | print(f"<<< {resp}", flush=True) | 65 | 70 | if "status=\"break\"" in resp: | 66 | 71 | 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) | 67 | 81 | send(conn, f"run -i {txn}") | 68 | 82 | txn += 1 | 69 | 83 | elif "status=\"stopping\"" in resp or "status=\"stopped\"" in resp: | |
|