Skip to content

Add FrankenPHP worker mode support - #51

Merged
pronskiy merged 2 commits into
mainfrom
feature/frankenphp
May 4, 2026
Merged

pronskiy merged 2 commits into
mainfrom
feature/frankenphp

Conversation

@pronskiy

Copy link
Copy Markdown
Member

Makes php-debugger work under FrankenPHP worker mode. In worker mode a single long-lived PHP process services many requests via
frankenphp_handle_request(), so MINIT/RINIT/RSHUTDOWN/MSHUTDOWN only fire once per worker — not per request. This means
the existing IDE handshake (done at RINIT), the trigger detection (also RINIT), and the per-request socket teardown
(POST_ZEND_DEACTIVATE) all only happen for the very first request the worker handles. Subsequent requests are not debuggable.

This PR ports the approach from upstream Xdebug PR into php-debugger:

  • Detects the FrankenPHP SAPI at MINIT (strcmp(sapi_module.name, "frankenphp") == 0) and wraps sapi_module.activate /
    deactivate to drive a per-request debugger lifecycle inside the worker loop.
  • On activate: resets XG_DBG(detached/no_exec/breakpoints_allowed) and context.do_* flags, scans raw
    SG(request_info).cookie_data and query_string for XDEBUG_SESSION / XDEBUG_TRIGGER / PHP_DEBUGGER_* (superglobals aren't
    built yet at this point), then arms do_connect_to_client so the existing init path picks up where it would on a normal CGI/CLI
    request.
  • On deactivate: tears down any live DBGp session via handler->remote_deinit so the next request gets a fresh one.
  • Adds xdebug_dbgp_poll_pending() — a non-blocking select() loop on the DBGp socket — and calls it from the activate hook so
    breakpoint_set / breakpoint_remove commands the IDE pushed while the worker was busy with another request actually get processed
    before the next request runs. Continuation commands (run/step_*) are logged and ignored — they're only meaningful at a stop.
  • MSHUTDOWN restores the original SAPI pointers via xdebug_frankenphp_mshutdown().

For non-FrankenPHP SAPIs the new code is a strict no-op; the existing RINIT/RSHUTDOWN lifecycle is unchanged.

Closes the equivalent of dunglas/symfony-docker#868 for php-debugger.

Test plan

Automated regression (already passing):

  • phpize && ./configure --enable-php-debugger && make -j — clean build, zero warnings on the new code (PHP 8.5 ZTS).
  • Full debugger test suite (run-xdebug-tests.php tests/debugger/): 240 passed, 0 new failures. The single failure is
    bug02090.phpt (FFI/__callStatic segfault), which is pre-existing on main and unrelated to these hooks.
  • Verified non-FrankenPHP SAPIs are unaffected: the hook is gated on strcmp(sapi_module.name, "frankenphp") == 0.

Manual FrankenPHP smoke test (see tests/frankenphp/README.md for full instructions):

docker build -f tests/frankenphp/Dockerfile -t php-debugger-frankenphp .
docker run --rm -p 8080:80 \                                                                                                         
  -v "$PWD/tests/frankenphp/app:/app" \                                                                                              
  php-debugger-frankenphp                                                                                                            
                                                                                                                                     
With PhpStorm listening on 9003 and a path mapping configured (tests/frankenphp/app → /app):                                         
                                                                
- No trigger ⇒ no pause: curl http://localhost:8080/ returns immediately. Log shows Trigger value ... not found, so not activating.  
- Trigger ⇒ pause: curl -b 'XDEBUG_SESSION=PHPSTORM' http://localhost:8080/ pauses on the breakpoint.
- Same worker, multiple debug requests: pid= stays constant across requests (proves worker reuse), but the IDE pauses on every       
request — proves the per-request sapi_module.activate reset.                                                                         
- Breakpoint added between requests (the xdebug_dbgp_poll_pending test): pause once, move the breakpoint to a different line while   
idle, send another debug request — the new breakpoint hits. Without the poll, the IDE's queued breakpoint_set is silently buffered   
and the new breakpoint is never honored.                        
- Clean shutdown: docker stop <container> exits cleanly — proves xdebug_frankenphp_mshutdown() correctly restored the original SAPI  
pointers.                                                                                                                            
                                             
Out of scope                                                                                                                         
                                                                
- Process-wide function-pointer overrides in src/base/base.c (set once in MINIT, safe in single-process worker).                     
- pcntl_fork PID-tracking path in src/base/base.c — FrankenPHP uses threads, not forks.
- Persistent (across-request) debug connections — we close per-request to match upstream Xdebug PR #1063 behavior. Can be revisited  
later if IDE UX warrants it.   

@carlos-granados

Copy link
Copy Markdown
Collaborator

@pronskiy thanks for this. Took a look and the current implementation looks good for the issues that it tries to solve. However, I think we need to check a couple more things that may be affected by our implementation:

  • If frankenphp loads our debugger and no IDE is connected, in our RINIT we will set observer_active to false and we will not enable the compiler flag that allows calling the statement handler.
  • If observer_active is set to false, then for any called function we will set the observer handlers to null, which means that if we later on activate the debugger the php-debugger handlers for these functions will not be called which means that things like breakpoints in them or stack values might not work. Is there any way to reset this handler list if we connect to a debugger in a frankenphp "request"?
  • If the compiler flag is not set, this means that the code to call the statement handler will not be added to any code compiled into opcodes. If we later on connect the debugger, there might be code in the opcode cache which we will not be able to debug. Would there be a way to invalidate the opcode cache if we connect to a debugger in a frankenphp "request"?

@carlos-granados carlos-granados left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking more about this, I think that the two issues that I raised can be tackled separately. The current implementation in this PR would cover 99% of the use cases. I have created two issues to follow these items:

#63
#64

pronskiy added 2 commits May 3, 2026 13:43
- Introduced `frankenphp.c` and `frankenphp.h` to hook into FrankenPHP's per-request lifecycle using `sapi_module.activate` and `sapi_module.deactivate`.
- Integrated per-request debugger resets, trigger detection, and breakpoint polling mechanism for FrankenPHP workers.
- Updated existing modules (e.g., `handler_dbgp`, `debugger.c`) to support queued command handling and lifecycle management.
- Adjusted build files to include new FrankenPHP-specific sources.
Provides a self-contained reproducer for the new sapi_module.activate /
deactivate hooks added in src/debugger/frankenphp.c.

- tests/frankenphp/Dockerfile: multi-stage build that compiles
  php_debugger.so against FrankenPHP's bundled ZTS PHP and installs it
  as a Zend extension with xdebug.mode=debug and start_with_request=trigger.
- tests/frankenphp/Caddyfile: minimal config registering /app/worker.php
  as the FrankenPHP worker, with explicit http:// scheme to avoid the
  default auto-https redirect.
- tests/frankenphp/app/{worker.php,index.php}: long-lived
  frankenphp_handle_request() loop and a sample request script with an
  obvious breakpoint location.
- tests/frankenphp/entrypoint.sh: rewrites xdebug.client_host /
  xdebug.client_port from XDEBUG_CLIENT_HOST / XDEBUG_CLIENT_PORT env
  vars before exec'ing FrankenPHP (PHP INI has no native fallback
  syntax for env-var defaults).
- tests/frankenphp/README.md: build/run instructions, one-time PhpStorm
  Server + path-mapping setup, the four verification scenarios
  (no-trigger skip, trigger pause, multi-request worker reuse, and the
  xdebug_dbgp_poll_pending breakpoint-between-requests test), and a
  troubleshooting table.
- .dockerignore: keeps host build artifacts (Makefile, *.dep, *.lo,
  .libs, ...) out of the build context — without this, absolute host
  paths baked into the local Makefile break the in-container build.
- .gitignore: adds tests/frankenphp/**/*.{php,sh} allowlist exceptions
  so the test files aren't swallowed by the blanket *.php / *.sh rules
  used to ignore stray test artifacts.
@pronskiy
pronskiy force-pushed the feature/frankenphp branch from 475822c to 5920eab Compare May 3, 2026 11:44
@pronskiy
pronskiy merged commit d1e9e60 into main May 4, 2026
16 checks passed
@carlos-granados
carlos-granados deleted the feature/frankenphp branch May 4, 2026 11:25
@xavierleune

Copy link
Copy Markdown

Hi @pronskiy sorry I missed your initial comment in dunglas/symfony-docker#868
Great to see you've liked my idea to resolve this issue, that should help some people. Some attribution would have been great too, in release note or somewhere.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants