Skip to content

[ php-wasm ] Add xdebug dynamic extension to @php-wasm/node JSPI #2248

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: trunk
Choose a base branch
from

Conversation

mho22
Copy link
Collaborator

@mho22 mho22 commented Jun 9, 2025

Motivation for the change, related issues

Based on @brandonpayton's original pull request and his briliant contribution on this.

This is a work in progress to dynamically enable xdebug in @php-wasm.

Implementation details

The first step is to compile PHP as a main module, with -s MAIN_MODULE. This addition resulted in numerous wasm-ld errors, which necessitated a comprehensive reformatting of the library load and skip sections within the Dockerfile. A significant amount of further understanding, analysis, and testing is required to ensure that no critical components were inadvertently removed. However, the current setup is functional, primarily for testing Xdebug.

The second step is to populate the Xdebug extensions for each PHP version. A dedicated Dockerfile file is created and first builds PHP wasm in its minimal form. Then, XDebug is built with PHP wasm's phpize and --disable-static --enable-shared options.

The last step is the dynamic loading of the previously created xdebug.so file populated by the second step. To achieve this, an option is added during the runtime loading in loadNodeRuntime. Adding withXdebug : true will execute multiple tasks from function withXdebug in with-xdebug.ts :

  1. Load the .so file from the Node filesystem.
  2. write that loaded file into a dedicated directory named /internal/shared/extensions.
  3. create a dedicated ini files with default entries.
  4. mount the current working directory.

A PHP_INI_SCAN_DIR environment variable with value /internal/shared/extensions is necessary to allow the use of multiple .ini files. This is why any dynamically loaded extension should add that environment.

Xdebug and the debugger he communicates with have to use the same work environment, the files used to step debug need to have the same paths. This is why we have to mount the current working directory.

Adding the option will run Xdebug. It will automatically try to connect to a socket at default port 9003 and open a communication tunnel between them. Nothing happens if no port is detected. Adding a breakpoint to the file ran by php-wasm will trigger it if that tunnel is set.

Testing Instructions

Two files are needed. A PHP file to test the step debugger. A JS file to run PHP 8.4 node JSPI.

php/xdebug.php

<?php

$test = 42; // Set a breakpoint on this line

echo "Hello Xdebug World\n";

scripts/node.js

import { PHP } from '@php-wasm/universal';
import { loadNodeRuntime } from '@php-wasm/node';


const php = new PHP( await loadNodeRuntime( '8.4', { withXdebug : true } ) );

await php.runStream( { scriptPath : `php/xdebug.php` } );

To achieve the test, you first need to start debugging [ with F5 or Run > Start debugging in VSCode ], then run :

node --experimental-wasm-stack-switching scripts/node.js
Capture d’écran 2025-06-09 à 14 19 24

@mho22 mho22 changed the title [ php-wasm ] Add xdebug dynamic extension to @php wasm node JSPI [ php-wasm ] Add xdebug dynamic extension to @php-wasm/node JSPI Jun 9, 2025
@mho22
Copy link
Collaborator Author

mho22 commented Jun 9, 2025

Here is a mega-comment containing all our comments and investigation while working in a private repo.

Original PR Description

Motivation for the change, related issues

It would be great to offer XDebug support with Studio, and in general, developers using @php-wasm/node could benefit from more debugging tools.

Implementation details

TBD

Testing Instructions (or ideally a Blueprint)

TBD


Comments from Pull Request #60 in Automattic/wordpress-playground-private

Comment by brandonpayton at 2025-02-26

This is a work-in-progress. Currently, building XDebug succeeds, but it is producing the a static build rather than an .so file. And XDebug has to be a dynamically loaded module.

Here is a clue from the build output:

#81 14.49 *** Warning: libtool could not satisfy all declared inter-library                                                                                  
#81 14.49 *** dependencies of module xdebug.  Therefore, libtool will create                                                                                 
#81 14.49 *** a static module, that should work as long as the dlopening                                                                                     
#81 14.49 *** application is linked with the -dlopen flag. 

Comment by adamziel at 2025-02-26

AFAIR you need to use -sSIDE_MODULE=2 to build a dynamic library. The regular ./configure shared option may or may not apply.


Comment by brandonpayton at 2025-02-26

Thanks for the tips, @adamziel.

I updated the configure invocation to include --disable-static --enable-shared which nicely says what we want, but the build still produces a static library.

So far, using SIDE_MODULE=2 does not seem to affect whether the result is static or shared. Based on the docs, I think that option controls dead code elimination, and I'm not sure whether that aspect is related to this issue. Regardless, the current version of the Dockerfile uses SIDE_MODULE=2 just in case it is a factor.

I'll keep digging into this.


Comment by adamziel at 2025-02-27

Check the emmake and emcc overrides in the main Dockerfile we use to build php. You need to pass -sSIDE_MODULE to every atomic emcc call triggered by emmake


Comment by adamziel at 2025-02-27

Ah you already do it, nevermind them :D


Comment by adamziel at 2025-02-27

I vaguely remember I had to actually edit the xdebug makefile FWIW


Comment by brandonpayton at 2025-02-27

I vaguely remember I had to actually edit the xdebug makefile FWIW

Ah, that's a good clue. Thank you!


Comment by brandonpayton at 2025-03-04

I vaguely remember I had to actually edit the xdebug makefile FWIW

Ah, yep. The issue is that the Makefile tries to link against libm, but Emscripten apparently links to an implementation implicitly.

If I edit the Makefile and remove the -lm argument to the linker, the build produces a shared lib. 🙌


Comment by brandonpayton at 2025-03-04

It was so handy to run a shell within that image:
docker run -it php-wasm /bin/bash

Then I could just install vim, look at the actual source files, and re-run emscripten build commands manually.


Comment by brandonpayton at 2025-03-05

I am testing loading the extension like this:

  • Make an updated PHP build with npx nx recompile-php:asyncify php-wasm-node -- --PHP_VERSION=8.3
  • Try running and loading the XDebug lib with:
    • export PHP=8.3
    • npx nx start php-wasm-cli -- -d zend_extension=/Users/brandon/src/playground/packages/php-wasm/node/asyncify/8_3_0/xdebug.so -r 'print_r(get_loaded_extensions());'
      There is this error:
      Failed loading /Users/brandon/src/playground/packages/php-wasm/node/asyncify/8_3_0/xdebug.so: dynamic linking not enabled

I have @adamziel's old XDebug draft PR for reference, and there are some differences between our builds. I plan to look at those.

But my understanding is that dynamic linking should already be enabled based on the current options in the Dockerfile. Dynamic linking is enabled here in Emscripten based on the RELOCATABLE flag, and according to the docs, the RELOCATABLE flag should be set because of us setting MAIN_MODULE and SIDE_MODULE during build:

Automatically set for SIDE_MODULE or MAIN_MODULE.


Comment by adamziel at 2025-03-05

I documented my dynamic linking discoveries in #673


Comment by brandonpayton at 2025-03-05

I documented my dynamic linking discoveries in #673

Thank you for kindly pointing to that again. You mentioned it elsewhere, but I hadn't reviewed it yet. And it is covering the same load failure territory. 🙇


Comment by brandonpayton at 2025-03-05

I documented my dynamic linking discoveries in #673

It's also been helpful to see some of the individual commits from that PR to see how you approached solving similar issues.

I was testing with an asyncify build, but I'm exploring with a JSPI build because I think it will likely involve fewer hurdles to get something working and maybe fewer issues to troubleshoot. Then, once we have something working with JSPI, we can fix issues related to asyncify. (We'll see. The previous PR was just with asyncify, so if I'm struggling with the JSPI end, I'll probably revisit this)


Comment by brandonpayton at 2025-03-15

I spent more time on this this evening. The XDebug build is producing a shared library, and I am working on getting it loading.

There were problems with duplicate symbols when enabling -sMAIN_MODULE for the main linking step. Some look like they are caused by our static libs already containing symbols for things from libz and libxml2. If I temporarily hack the Dockerfile to remove where we explicitly link to those libs, those errors go away. If I'm right about why this is an issue, we should be able to fix it by rebuilding the pre-built static libs so they no longer include the duplicate symbols.

After that, there was one more symbol conflict around HARDCODED_INI which is declared as static const in both php_embed.c and php_cli.c. Our build incorporates code from both of those when building for node. Fortunately, the const only appears to be used in the file where it is declared, so renaming the const in one file avoids this issue and appears completely safe.

Now, I'm running into errors like this when trying to load the xdebug.so extension:

RuntimeError: Unreachable code should not be executed (evaluating 'original(...args)')

It appears that this is something to do with dlopen(), and I should be able to instrument the emscripten-generated JS code to get a better idea. That's the next thing.

Hopefully, we can get back to the place where we can load the extension and start troubleshooting debugger connection and step debugging soon.


Comment by brandonpayton at 2025-03-15

Does the error go away with jspi? Or do the dlopen have to be declared async? Also, the JS code updates from the other PR may be relevant, especially those sections that mark the act of loading the library as async


Comment by brandonpayton at 2025-03-17

Does the error go away with jspi? Or do the dlopen have to be declared async? Also, the JS code updates from the other PR may be relevant, especially those sections that mark the act of loading the library as async

I hadn't tried yet with JSPI, but taking the JS code from your previous PR which actually marks _dlopen_js as synchronous appears to have done the trick. Then I started running into what appears to be a BigInt related error. Temporarily disabling WASM_BIGINT in the build allows php-wasm CLI to load the XDebug extension without error, and extra XDebug notes appear in the phpinfo() output.

...
xdebug.auto_trace => (setting renamed in Xdebug 3) => (setting renamed in Xdebug 3)
xdebug.cli_color => 0 => 0
xdebug.client_discovery_header => HTTP_X_FORWARDED_FOR,REMOTE_ADDR => HTTP_X_FORWARDED_FOR,REMOTE_ADDR
xdebug.client_host => localhost => localhost
xdebug.client_port => 9003 => 9003
...

Now I am running into an issue with sleep() callbacks in JS.

call_indirect to a null table entry (evaluating 'original(...args)') 

This may be some kind of linking-related error. Planning to push the current config shortly...


Comment by brandonpayton at 2025-03-17

I was testing with an asyncify build, but I'm exploring with a JSPI build because I think it will likely involve fewer hurdles to get something working and maybe fewer issues to troubleshoot.

I said this a while ago but apparently forgot to test with JSPI when getting back to this PR.

It's too bad that bun doesn't support JSPI yet because it's much faster to test with bun than to rebuild and test via node.


Comment by brandonpayton at 2025-03-18

UPDATE: This was due to an accidental 64-bit architecture declaration left in place for the XDebug compilation. There seem to be some ASSERTION-related issues with the recent 64-bit changes, so I am temporarily reverting those in this PR to remove distraction.

I spent some time fixing the build and code to run with ASSERTIONS and ERROR_ON_UNDEFINED_SYMBOLS enabled to hopefully track down the issue. I don't know what meaningfully could have changed, but now I'm seeing a different set of errors.

When synchronous dlopen() is used, there is an memory access out-of-bounds error like:

  WASM ERROR
  Out of bounds memory access (evaluating 'original(...args)') 

16172 |         for (let [x, original] of Object.entries(exports)) {
16173 |           if (typeof original == 'function') {
16174 |             ret[x] = (...args) => {
16175 |               Asyncify.exportCallStack.push(x);
16176 |               try {
16177 |                 return original(...args);
                               ^
RuntimeError: Out of bounds memory access (evaluating 'original(...args)')
      at <anonymous> (/Users/brandon/src/playground/packages/php-wasm/node/asyncify/php_8_3.js:16177:24)
      at invoke_v (/Users/brandon/src/playground/packages/php-wasm/node/asyncify/php_8_3.js:31105:12)

When async dlopen() is used, there is an "unreachable code should not be executed" error:

WASM ERROR
Unreachable code should not be executed (evaluating 'original(...args)') 

RuntimeError: Unreachable code should not be executed (evaluating 'original(...args)')
31298 | PHPLoader.debug = 'debug' in PHPLoader ? PHPLoader.debug : true;
31299 | if (PHPLoader.debug && typeof Asyncify !== "undefined") {
31300 |     const originalHandleSleep = Asyncify.handleSleep;
31301 |     Asyncify.handleSleep = function (startAsync) {
31302 |         if (!ABORT) {
31303 |             Module["lastAsyncifyStackSource"] = new Error();
													  ^
Error: 
	at /Users/brandon/src/playground/packages/php-wasm/node/asyncify/php_8_3.js:31303:49
16173 |         for (let [x, original] of Object.entries(exports)) {
16174 |           if (typeof original == 'function') {
16175 |             ret[x] = (...args) => {
16176 |               Asyncify.exportCallStack.push(x);
16177 |               try {
16178 |                 return original(...args);
							 ^
RuntimeError: Unreachable code should not be executed (evaluating 'original(...args)')
	at <anonymous> (/Users/brandon/src/playground/packages/php-wasm/node/asyncify/php_8_3.js:16178:24)
	at <anonymous> (/Users/brandon/src/playground/packages/php-wasm/universal/src/lib/wasm-error-reporting.ts:43:13)
	at <anonymous> (/Users/brandon/src/playground/packages/php-wasm/node/asyncify/php_8_3.js:16313:47)
31298 | PHPLoader.debug = 'debug' in PHPLoader ? PHPLoader.debug : true;
31299 | if (PHPLoader.debug && typeof Asyncify !== "undefined") {
31300 |     const originalHandleSleep = Asyncify.handleSleep;
31301 |     Asyncify.handleSleep = function (startAsync) {
31302 |         if (!ABORT) {
31303 |             Module["lastAsyncifyStackSource"] = new Error();
													  ^
Error: 
	at /Users/brandon/src/playground/packages/php-wasm/node/asyncify/php_8_3.js:31303:49

I've read some Emscripten issues today involving asyncify, dlopen(), and possible stack corruption but don't have the links handy. Planning to look them up and share later.


Comment by brandonpayton at 2025-03-18

The weird memory-related errors were due to a 64-bit architecture declaration that was accidentally left in place.

Now, I am back to the "Unreachable code should not be executed" error and am looking into that with ASSERTIONS enabled. Also there is some suggestion to expand the asyncify stack size, so I will try that. But this does not feel like that kind of issue...


Comment by brandonpayton at 2025-03-18

A JSPI build appears to show the same issue, but I'm not 100% it is the JSPI build that is running in my test. Planning to resume in the morning.


Comment by brandonpayton at 2025-03-18

It looks like there are some conflicts with 64-bit support. I thought that I had temporarily reverted the 64-bit support changes but had not reverted them on my main working/testing branch. Without 64-bit support, the xdebug.so module is loading well with both JSPI and Asyncify.

Instead of pushing a bunch of changes due to the temporary 64-bit revert, let's rebase this branch on the commit immediately preceding the 64-bit support and work from there. That will make it easier to see the meaningful changes in this PR.


Comment by brandonpayton at 2025-03-18

Correction: The JSPI build has not been properly tested yet. It only appeared that it had. But it's promising that the Asyncify version is loading.


Comment by brandonpayton at 2025-03-18

Correction: The JSPI build has not been properly tested yet. It only appeared that it had. But it's promising that the Asyncify version is loading.

This is because Node.js v22, the version I was testing with, has the old JSPI WebAssembly.Suspender rather than the new JSPI WebAssembly.Suspending member. Our JSPI test is for Suspending, and that is what the Emscripten JSPI builds target as well.


Comment by brandonpayton at 2025-03-18

Node v23 with the --experimental-wasm-jspi flag does support WebAssembly.Suspending, and I've used that to confirm the XDebug extension is loading in the JSPI build as well (specifically PHP 8.3).


Comment by brandonpayton at 2025-03-19

We could consider whether it is valuable to merge an xdebug extension even before enabling step debugging. But it isn't working after applying the 64-bit int changes.

 WASM ERROR
  Invalid argument type in ToBigInt operation 

16710 |         for (let [x, original] of Object.entries(exports)) {
16711 |           if (typeof original == 'function') {
16712 |             ret[x] = (...args) => {
16713 |               Asyncify.exportCallStack.push(x);
16714 |               try {
16715 |                 return original(...args);
                               ^
TypeError: Invalid argument type in ToBigInt operation
      at <anonymous> (/Users/brandon/src/playground/packages/php-wasm/node/asyncify/php_8_3.js:16715:24)
      at <anonymous> (/Users/brandon/src/playground/packages/php-wasm/node/asyncify/php_8_3.js:16715:24)

IIRC, when I looked into this error recently, args was an empty array. The reason why is not clear yet.

I'm also wondering whether it would be cleaner or less problematic to define __LP64__ instead of __x86_64__. The former makes a statement about integers while the latter makes a statement about CPU which required some hacks in PHP compilation to avoid use of x86_64 assembler by PHP.

Google's summary of __LP64__ is:

LP64 is a preprocessor macro in C and C++ that indicates compilation for a 64-bit architecture where long integers and pointers are 64 bits wide, while int remains 32 bits wide.

And PHP enables 64-bit integers when that is defined:

/* This is the heart of the whole int64 enablement in zval. */
#if defined(__x86_64__) || defined(__LP64__) || defined(_LP64) || defined(_WIN64)
# define ZEND_ENABLE_ZVAL_LONG64 1
#endif

There are few other mentions of __LP64__ in the PHP codebase, so it seems like it might be a more surgical option than __x86_64__.

I may try switching to __LP64__ to see if it changes anything with the ToBigInt error. cc @adamziel in case you have any thoughts or experience to share here.


Comment by brandonpayton at 2025-03-19

We could consider whether it is valuable to merge an xdebug extension even before enabling step debugging. But it isn't working after applying the 64-bit int changes.

A couple notes:

  • The error "Invalid argument type in ToBigInt operation" is present regardless of whether ASSERTIONS are enabled in the linking step.
  • Rebuilding all our static libraries with __LP64__ instead of __x86_64__ fails, but I haven't yet looked into why.

Comment by adamziel at 2025-03-19

__LP64__ sounds reasonable to me, good thinking! Switching to it would require a round of review of all the places in the PHP codebase where a decision is made based on that macro or on __x86_64__. There aren't many of them, but we'll likely need to adjust a few inline patches in our Dockerfile.


Comment by adamziel at 2025-03-20

Invalid argument type in ToBigInt operation

This sounds related to -sWASM_BIGINT https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-pass-int64-t-and-uint64-t-values-from-js-into-wasm-functions


Comment by adamziel at 2025-03-20

I couldn't find that in the PR – are you building XDebug with 64bit integers, too? Could it be a mismatch between the dynamically loaded library and the base program?


Comment by adamziel at 2025-03-20

Also, what would be a JSPI stack trace of the error? Asyncify loses a lot of that contextual information and I wonder if the full trace would give us more hints.


Comment by brandonpayton at 2025-03-20

__LP64__ sounds reasonable to me, good thinking! Switching to it would require a round of review of all the places in the PHP codebase where a decision is made based on that macro or on x86_64. There aren't many of them, but we'll likely need to adjust a few inline patches in our Dockerfile.

Cool!

I couldn't find that in the PR – are you building XDebug with 64bit integers, too? Could it be a mismatch between the dynamically loaded library and the base program?

I was originally building xdebug with 64-bit integers (using the same -D__x86_64__ define), but the latest in this PR was rebased on the commit immediately preceding the WASM_BIGINT commit. The reason was to get the PHP extension loading and tested a bit without the ToBigInt error.

Also, what would be a JSPI stack trace of the error? Asyncify loses a lot of that contextual information and I wonder if the full trace would give us more hints.

Good idea. Here's a stacktrace from a JSPI build of PHP 8.3:

TypeError: Cannot convert 1 to a BigInt
    at ret.<computed> (file:///Users/brandon/src/playground/dist/packages/php-wasm/node/index.js:21186:20)
    at wasm://wasm/000f0ece:wasm-function[773]:0x3121f
    at wasm://wasm/000f0ece:wasm-function[521]:0x1e3d4
    at wasm://wasm/000f0ece:wasm-function[206]:0x9b4e
    at php.wasm.zend_startup_module_ex (wasm://wasm/php.wasm-06309a5e:wasm-function[15268]:0x81f919)
    at php.wasm.zend_startup_module (wasm://wasm/php.wasm-06309a5e:wasm-function[15284]:0x821f47)
    at ret.<computed> (file:///Users/brandon/src/playground/dist/packages/php-wasm/node/index.js:21186:20)
    at wasm://wasm/000f0ece:wasm-function[219]:0xa2c9
    at php.wasm.zend_extension_startup (wasm://wasm/php.wasm-06309a5e:wasm-function[15388]:0x8276ef)
    at php.wasm.zend_llist_apply_with_del (wasm://wasm/php.wasm-06309a5e:wasm-function[14951]:0x8065d7)
Node.js v23.10.0

There is some mention of an error like this here and the related Emscripten bug here.

It's possible I'm not building xdebug with the right ASYNCIFY/JSPI flags (and will try to share the latest build config tomorrow).


Comment by brandonpayton at 2025-03-20

I'm also curious if upgrading to the latest Emscripten version would have any impact here.


Comment by brandonpayton at 2025-03-20

I disabled optimizations and built xdebug with debug symbols, and call stack with JSPI is more informative:

file:///Users/brandon/src/playground/dist/packages/php-wasm/node/index.js:21186
            return original(...args2);
                   ^
TypeError: Cannot convert 1 to a BigInt
    at ret.<computed> (file:///Users/brandon/src/playground/dist/packages/php-wasm/node/index.js:21186:20)
    at xdebug.so.legalfunc$zend_register_long_constant (wasm://wasm/xdebug.so-006ca806:wasm-function[1014]:0xbb91e)
    at xdebug.so.xdebug_coverage_register_constants (wasm://wasm/xdebug.so-006ca806:wasm-function[630]:0x656e6)
    at xdebug.so.zm_startup_xdebug (wasm://wasm/xdebug.so-006ca806:wasm-function[204]:0x9f3b)
    at php.wasm.zend_startup_module_ex (wasm://wasm/php.wasm-0fb20ac6:wasm-function[17279]:0x8b4929)
    at php.wasm.zend_startup_module (wasm://wasm/php.wasm-0fb20ac6:wasm-function[17295]:0x8b71f6)
    at ret.<computed> (file:///Users/brandon/src/playground/dist/packages/php-wasm/node/index.js:21186:20)
    at xdebug.so.xdebug_zend_startup (wasm://wasm/xdebug.so-006ca806:wasm-function[222]:0xca4c)
    at php.wasm.zend_extension_startup (wasm://wasm/php.wasm-0fb20ac6:wasm-function[17401]:0x8bcd67)
    at php.wasm.zend_llist_apply_with_del (wasm://wasm/php.wasm-0fb20ac6:wasm-function[16934]:0x89a9f2)
Node.js v23.10.0

Comment by brandonpayton at 2025-03-20

Ok! @adamziel, I was able to get the xdebug extension loading with both JSPI and ASYNCIFY with WASM_BIGINT=1 set for the xdebug compilation. I had thought it was just a link-time option for the main module, but I guess it makes sense that the shared library would need to be built with that.

This is wonderful news. I'll clean up the build portion of this PR and start looking into step debugging.


Comment by brandonpayton at 2025-03-21

I pushed JSPI and ASYNCIFY builds for PHP 8.3.

To see the extension loading, you can run:
PHP=8.3 npx nx dev php-wasm-cli -- -d zend_extension="$(pwd)/packages/php-wasm/node/asyncify/8_3_0/xdebug.so" -r '"phpinfo();"'


Comment by brandonpayton at 2025-03-21

export default async function runExecutor(options: BuiltScriptExecutorSchema) {
	const args = [
+		...(options.nodeArg || []),

brandonpayton [on Mar 21]
This change should drop off once https://github.com/Automattic/wordpress-playground-private/pull/92 is merged. And if we decide not to merge it, I plan to remove these changes from the PR.


Comment by brandonpayton at 2025-03-21

async function run() {
	// @ts-ignore
-	const defaultPhpIniPath = await import('./php.ini');
+	const defaultPhpIniPath = (await import('./php.ini')).default;

brandonpayton[on Mar 21]
This is an unrelated fix for a crash that occurs when ASSERTIONS=1 is set for the Emscripten build. Without this, we are passing a module exports object instead of a string for the php.ini path. I wonder if the php.ini is even being respected without this change...


Comment by brandonpayton at 2025-03-21

	then \
		set -euxo pipefail;\
		echo -n ' --enable-libxml --with-libxml --with-libxml-dir=/root/lib --enable-dom --enable-xml --enable-simplexml --enable-xmlreader --enable-xmlwriter' >> /root/.php-configure-flags && \
-		echo -n ' -I /root/lib -I /root/lib/include/libxml2 -lxml2' >> /root/.emcc-php-wasm-flags && \
+		# TODO: Look at restoring -lxml2 here, probably by eliminating it from library pre-build

brandonpayton [on Mar 21]
There are currently symbol conflicts if -lxml2 is left here. Probably we need to remove it from a static library build.

There are other places here where the same is true for -lz.

I'm planning to check our static lib builds and see if adjustments there can fix this.

brandonpayton[on Mar 21]
Actually... it looks as if maybe the EMCC_SKIP var is not being applied as expected.

If I change the commands from

EMCC_SKIP="..."
emmake ...
to

export EMCC_SKIP="...";
emmake ...
The specified libs appear to be skipped. I'm not yet certain what is happening there.

brandonpayton [on Mar 21]

Actually... it looks as if maybe the EMCC_SKIP var is not being applied as expected.

Nah, that seems fine.

It looks like the real issue is that, in the MAIN_MODULE compilation, we are specifying both -lz and libz.a, and we do the same for -lxml2 and libxml2.a

I think we need to pick one way or the other to link to those libs. If we use the -l arg, it looks like the linker is finding the right library versions because the duplicate symbol messages call out the same library as the source for both:

wasm-ld: error: duplicate symbol: initxmlDefaultSAXHandler                                                                                                                                                  
>>> defined in /root/lib/lib/libxml2.a(SAX.o)                                                                                                                                                               
>>> defined in /root/lib/lib/libxml2.a(SAX.o)

brandonpayton [on Mar 21]
If I save that emcc command, start a shell in the php-wasm image, and remove either the -l flags or mention of the specific .a files, compilation succeeds.

I'm not sure why we use both the -l flags and explicit mention of the static libraries, but it seems safe to fix this.

brandonpayton [on Mar 22]
This ended up being much more of a headache than I expected, but I think I have it fixed now. Other linkers I've used have been able to ignore duplicate -l<lib> args, but duplicating those args led to issues with wasm-ld. To deal with this, I updated our emcc override script to also deduplicate lib args, preserving order and keeping the last instance of each lib arg because dependencies are supposed to come later than the things that depend on them.

adamziel [on Mar 22]
I'm sorry this turned out to be such a pain, but also thank you so much for cleaning this up!

brandonpayton [on Mar 23]
I'm sorry this turned out to be such a pain, but also thank you so much for cleaning this up!

Aw, thanks, Adam!


Comment by adamziel at 2025-03-24

So far so good here. Great work and great documentation. I wish I left more writing from my ancient explorations on this. This is looking really optimistic.


Comment by brandonpayton at 2025-03-25

So far so good here. Great work and great documentation. I wish I left more writing from my ancient explorations on this. This is looking really optimistic.

:) Agreed. I am hopeful as well.

Some additional notes:

  • We have a couple of unit test failures that may be due to detecting support for socketpair() that throws an "unimplemented" error. It looks like Emscripten might provide an implementation for this depending on which network mode we're building in. Will need to look at this more closely.
  • There is trouble with PHP 7.2 and 7.3 builds failing due to symbol conflicts with Emscripten built-ins and libraries like sqlite3. Those PHP earlier versions bundle versions of some libraries.
    @bgrgicak For PHP 7.2 and 7.3, there is a build failure due to a conflict between the built-in libonig and Emscripten built-ins. I'm not getting the same linker failures for other PHP versions when we build with our separate Onigurama library. Do you know if there is a reason we shouldn't configure PHP 7.2 and 7.3 to use the separate lib version (if that works around the build failure)? I believe you may have worked on this a year or so ago.

For the moment, we can configure the build to ignore these older PHP versions and focus on new PHP versions.


Comment by adamziel at 2025-03-25

Do you know if there is a reason we shouldn't configure PHP 7.2 and 7.3 to use the separate lib version

No blockers from my end, AFAIR that solved some built-time error. Let's see what @bgrgicak says.


Comment by brandonpayton at 2025-03-25

I'm temporarily setting aside the PHP 7.2 and 7.3 issues, but here are more notes from my exploration due to those issues:

To potentially avoid the PHP 7.2 and 7.3 symbol conflicts, I explored switching from MAIN_MODULE=1 to MAIN_MODULE=2 which stops the build from using --whole-archive option and telling the linker to preserve every symbol from libphp.a. With MAIN_MODULE=2, Emscripten no longer preserves all symbols for potential use by xdebug.so, and we have to explicitly list which symbols need to be preserved for use by dynamic libraries.

It would be annoying to have to maintain a list of symbols like we do for ASYNCIFY. Fortunately, we may be able to detect which symbols xdebug.so needs.

xdebug.so is just another wasm module, and we parse its imports. I asked Cursor to generate a small, straightforward C program to parse wasm imports and exports and print the results as JSON, and the result only required a few tweaks by hand. The beauty of the C program is that it doesn't require anything but a basic C compiler and standard libs.

There are naming conventions Emscripten uses to segment imports, and once we have the list from xdebug.so imports, I think those give us a shot of deducing the list it needs from libphp.a. In addition, as a safety net, we could parse the symbols exported by libphp.a and ask emcc to preserve the intersection of names from xdebug.so and libphp.a.


Comment by brandonpayton at 2025-03-25

xdebug.so is just another wasm module, and we parse its imports. I asked Cursor to generate a small, straightforward C program to parse wasm imports and exports and print the results as JSON, and the result only required a few tweaks by hand. The beauty of the C program is that it doesn't require anything but a basic C compiler and standard libs.

As a semi-related aside:
I wonder if there is any analysis possible of libphp.a that could give us an automatic list of functions that need ASYNCIFY treatment. It seems likely analysis couldn't detect all cases, but if it could detect a large percentage, maybe it would be worth exploring, since there is some disagreement about standardizing JSPI.


Comment by adamziel at 2025-03-25

Emscripten even has an option to autogenerate such a list. Unfortunately, it seems way too eager. It lists the majority of the functions which slows down the final binary by a lot. If we could do better than that, it would be great, but first I'd like to explore that WASM-land JSPI polyfill. Maybe the performance wouldn't be that terrible?


Comment by brandonpayton at 2025-03-26

Emscripten even has an option to autogenerate such a list. Unfortunately, it seems way too eager. It lists the majority of the functions which slows down the final binary by a lot. If we could do better than that, it would be great,

Interesting! Looking at their implementation could be helpful as well.

but first I'd like to explore that WASM-land JSPI polyfill. Maybe the performance wouldn't be that terrible?

Nice! I was wondering about this as well. Just noticed today that you had commented last month on theJSPI-skeptical comment from the WebKit dev and mentioned his JSPI polyfill.


Comment by brandonpayton at 2025-04-07

My next step here is to examine XDebug attempting to connect with a local debugger and figure out whether that is still failing and how we might fix it. My guess is that it will fail due to our current Emscripten networking mode. Will see.


Comment by adamziel at 2025-04-07

Looking at their implementation could be helpful as well.

Yes! It was one of the ASYNCIFY_ options. AFAIR the dynamic calls were the largest problems there - you need to assume a set of possible function calls which, if you go by signature, generates a large cartesian product.


Comment by brandonpayton at 2025-04-15

I think this is in a place where it would be good to debug the XDebug Wasm, see where it is creating its socket and reading from it, and see how Emscripten is handling that. It's what made me work on getting set up for faster, unbundled Node.js debugging in VSCode.

Hopefully I can merge that debugging PR yet today. There is just one more thing I want to check first.


Comment by brandonpayton at 2025-04-15

There is a VSCode extension for debugging WebAssembly with DWARF debug info:
https://marketplace.visualstudio.com/items?itemName=ms-vscode.wasm-dwarf-debugging

Hopefully we will be able to use it here.


Comment by mho22 at 2025-04-23

I found out this line was creating the socket in Xdebug :

src/debugger/com.c line 250 :

if ((sockfd = socket(ptr->ai_family, ptr->ai_socktype, ptr->ai_protocol)) == SOCK_ERR) {

I decided to add a bunch of printf-styled logs in this file. Like this log :

src/debugger/com.c line 267 :

xdebug_log_ex( XLOG_CHAN_DEBUG, XLOG_COM, "SOCKET", "PHP-WASM: socket() succeeded: sockfd=%d", sockfd );

I got these informations in the xdebug.log file :

[42] Log opened at 2025-04-23 09:28:18.002925
[42] [Step Debug] INFO: xdebug_init_normal_debugger- Connecting to configured address/port: 127.0.0.1:9003.
[42] [Step Debug] PHP-WASM: Calling socket() with ai_family=2, ai_socktype=1, ai_protocol=6
[42] [Step Debug] PHP-WASM: socket() succeeded: sockfd=10
[42] [Step Debug] PHP-WASM: connect() status: status=0
[42] [Step Debug] PHP-WASM: Calling setsockopt(fd=10, IPPROTO_TCP, TCP_NODELAY, val=1, len=4)
[42] [Step Debug] PHP-WASM: Calling setsockopt(fd=10, SOL_SOCKET, SO_KEEPALIVE, val=1, len=4)
[42] [Step Debug] WARN: Could not set SO_KEEPALIVE: No error information.
[42] [Step Debug] INFO: Connected to debugging client: 127.0.0.1:4 (through xdebug.client_host/xdebug.client_port).
[42] [Step Debug] -> <init xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" fileuri="file:///internal/shared/auto_prepend_file.php" language="PHP" xdebug:language_version="8.3.0-dev" protocol_version="1.0" appid="42" idekey="VSCODE"><engine version="3.4.3-dev"><![CDATA[Xdebug]]></engine><author><![CDATA[Derick Rethans]]></author><url><![CDATA[https://xdebug.org]]></url><copyright><![CDATA[Copyright (c) 2002-2025 by Derick Rethans]]></copyright></init>

[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" status="stopping" reason="ok"></response>

[42] Log closed at 2025-04-23 09:28:18.033858

I console.log in the wasm_setsockopt function from php.js called by Xdebug setsockopt and I got this :

method : _wasm_setsockopt(socketd, level, optionName, optionValuePtr, optionLen) | parameters : 10 6 1 12783140 4
call : PHPWASM.getAllWebSockets(socketd)[0] | result :  undefined
method : _wasm_setsockopt(socketd, level, optionName, optionValuePtr, optionLen) | parameters : 10 1 9 12782812 4
call : PHPWASM.getAllWebSockets(socketd)[0] | result :  undefined

As you can see, the socket options are supported but unfortunately getAllWebSockets(10) is an empty array.

My assumption is that xdebug is correctly creating a socket and file descriptor but this one is undefined in wasm. I suspect the issue is related to how the socketd is located. Keep investigating.

@brandonpayton You did a fantastic job here, I regret not contributing sooner. Thank you very much. Below are the steps I followed to obtain the logs mentioned above.

php/Dockerfile

+ COPY ./php/xdebug-com.c /root/debug/xdebug-com.c


RUN set -euxo pipefail; \
	if ( \
		# [ "$EMSCRIPTEN_ENVIRONMENT" = "node" ] && \
		# TODO: Fix XDebug build and runtime problems with PHP 7.2 and 7.3 or drop support.
		([[ "${PHP_VERSION:0:1}" -ge 8 ]] || [[ "${PHP_VERSION:0:3}" == "7.4" ]]) \
	); then \
		export PHP_VERSION_ESCAPED="${PHP_VERSION//./_}"; \
		if [[ "${PHP_VERSION:0:1}" -ge 8 ]]; then \
			export XDEBUG_BRANCH="xdebug_3_4"; \
		else \
			export XDEBUG_BRANCH="xdebug_3_1"; \
		fi; \
		cd /root; \
		git clone https://github.com/xdebug/xdebug.git xdebug \
			--branch "${XDEBUG_BRANCH}" \
			--single-branch \
			--depth 1; \

		cd xdebug; \
		( \
			cd /root/php-src; \
			# Install php build scripts like phpize
			make install; \
		); \

+		cp /root/debug/xdebug-com.c /root/xdebug/src/debugger/com.c; \

...

- 		export EMCC_FLAGS="-sSIDE_MODULE -D__x86_64__ -sWASM_BIGINT=1; \
+ 		export EMCC_FLAGS="-sSIDE_MODULE -D__x86_64__ -sWASM_BIGINT=1 -Dsetsockopt=wasm_setsockopt"; \

The complete xdebug-com.c file is in this gist

Secondarily, I quickly made it "work" on web as well :

By adding this in compile/php/phpwasm-emscripten-library-dynamic-linking.js :

       _dlopen_js__deps: ['$dlopenInternal'],
+     _dlopen_js__async: false

Here is the code I use to run xdebug in node :

scripts/node.js

import { PHP, setPhpIniEntries } from '@php-wasm/universal';
import { loadNodeRuntime } from '@php-wasm/node';
import fs from 'fs';



const php = new PHP( await loadNodeRuntime( '8.3' ) );



const data = fs.readFileSync( 'node_modules/@php-wasm/node/jspi/8_3_0/xdebug.so' );

php.mkdir( '/extensions' );

php.writeFile( '/extensions/xdebug.so', new Uint8Array( data ) );



setPhpIniEntries( php, {

    'zend_extension' : '/extensions/xdebug.so',
    'html_errors' : 'Off',
    'xdebug.mode' : 'debug',
    'xdebug.start_with_request' : 'yes',
    'xdebug.client_host' : '127.0.0.1',
    'xdebug.client_port' : '9003',
    'xdebug.idekey' : 'VSCODE',
    'xdebug.log' : '/xdebug.log'

} );



await php.run( { code : "<?php echo xdebug_info();" } );

console.log( php.readFileAsText( '/xdebug.log' ) );

JSPI command :

node --experimental-wasm-stack-switching scripts/node.js 

Comment by adamziel at 2025-04-23

I just can't overstate how happy I am about the work happening here. Thank you both ❤️


Comment by adamziel at 2025-04-23

As you can see, the socket options are supported but unfortunately getAllWebSockets(10) is an empty array.

Weird! How is it different from what fsockopen() is doing? Since it works. Any chance this is created as a non-websocket socket?


Comment by adamziel at 2025-04-23

Also, look into the TLS -> fetch() handler. It wraps emscripten websocket creation, maybe the execution flow gets there, finds no handler, and closes the socket?


Comment by adamziel at 2025-04-23

Wait, is this an outbound socket or a listening socket? For the latter, we need a server handler. I've dine one here:

https://github.com/adamziel/mysql-sqlite-network-proxy/blob/7037c4ea9b4b6c9c07a458489a0c75c83da7b426/php-implementation/post-message-server.ts#L90


Comment by mho22 at 2025-04-23

@adamziel I followed your advice, corrected the errors and noticed a break in the Xdebug process logs :

node --experimental-wasm-stack-switching scripts/node.js

[42] Log opened at 2025-04-23 15:14:06.249361
[42] [Step Debug] INFO: xdebug_init_normal_debugger - Connecting to configured address/port: 127.0.0.1:9003.
[42] [Step Debug] INFO: Connected to super debugging client: 127.0.0.1:9003 (through xdebug.client_host/xdebug.client_port).
[42] [Step Debug] -> <init xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" fileuri="file:///internal/shared/auto_prepend_file.php" language="PHP" xdebug:language_version="8.3.0-dev" protocol_version="1.0" appid="42"><engine version="3.4.3-dev"><![CDATA[Xdebug]]></engine><author><![CDATA[Derick Rethans]]></author><url><![CDATA[https://xdebug.org]]></url><copyright><![CDATA[Copyright (c) 2002-2025 by Derick Rethans]]></copyright></init>

[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" status="break" reason="ok"><xdebug:message filename="file:///xdebug.php" lineno="7"></xdebug:message></response>

[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" status="stopping" reason="ok"></response>

[42] Log closed at 2025-04-23 15:14:06.268183

My script :

scripts/node.js

import { PHP, __private__dont__use, setPhpIniEntries } from '@php-wasm/universal';
import { loadNodeRuntime } from '@php-wasm/node';
import fs from 'fs';



const php = new PHP( await loadNodeRuntime( '8.3' ) );



const data = fs.readFileSync( 'node_modules/@php-wasm/node/jspi/8_3_0/xdebug.so' );

php.mkdir( '/extensions' );

php.writeFile( '/extensions/xdebug.so', new Uint8Array( data ) );

setPhpIniEntries( php, {

    'zend_extension' : '/extensions/xdebug.so',
    'html_errors' : 'Off',
    'xdebug.mode' : 'debug',
    'xdebug.start_with_request' : 'yes',
    'xdebug.client_host' : '127.0.0.1',
    'xdebug.client_port' : '9003',
    'xdebug.log' : '/xdebug.log'

} );




const script = `<?php

echo "Hello Xdebug World";

xdebug_break();

echo xdebug_info();
`


php.writeFile( '/xdebug.php', script );





await php.run( { scriptPath : '/xdebug.php' } );

console.log( php.readFileAsText( '/xdebug.log' ) );

But when I lsof -i -n the different open ports I have these :

Code\x20H 88429  mho   21u  IPv4 0x800a0d60f47e6516      0t0    TCP 127.0.0.1:9003 (LISTEN)
node      88862  mho   11u  IPv4 0xe0ffd77971715b7e      0t0    TCP 127.0.0.1:61110 (LISTEN)

The first one is PHP DEBUG, the second one is php-wasm/xdebug. I think the second one shouldn't be listening. And when I log the resulting websocket._url from PHPWASM.getAllWebSockets(socketd)[0] with my corrected code it returns this :

ws://127.0.0.1:61110/?host=127.0.0.1&port=9003

@brandonpayton I modified phpwasm-emscripten-library.js :

getAllWebSockets: function (sock) {

+    const socket = FS.getStream(sock).node.sock;

	const webSockets = /* @__PURE__ */ new Set();
-	if (sock.server) {
+	if (socket.server) {
-		sock.server.clients.forEach((ws) => {
+		socket.server.clients.forEach((ws) => {
			webSockets.add(ws);
		});
	}
	for (const peer of PHPWASM.getAllPeers(sock)) {
		webSockets.add(peer.socket);
	}
	return Array.from(webSockets);
},

getAllPeers: function (sock) {

+	const socket = FS.getStream(sock).node.sock;

	const peers = new Set();
-	if (sock.server) {
+	if (socket.server) {
-               sock.pending
+		socket.pending
			.filter((pending) => pending.peers)
			.forEach((pending) => {
				for (const peer of Object.values(pending.peers)) {
					peers.add(peer);
				}
			});
	}
-	if (sock.peers) {
+	if (socket.peers) {
-		for (const peer of Object.values(sock.peers)) {
+		for (const peer of Object.values(socket.peers)) {
			peers.add(peer);
		}
	}
	return Array.from(peers);
},

...

   const SOL_SOCKET = 1;
   const SO_KEEPALIVE = 9;
   const IPPROTO_TCP = 6;
   const TCP_NODELAY = 1;
+ const TCP_KEEPIDLE = 4;
+ const TCP_KEEPINTVL = 5;
+ const TCP_KEEPCNT = 6;
   const isSupported =
	(level === SOL_SOCKET && optionName === SO_KEEPALIVE) ||
- 	(level === IPPROTO_TCP && optionName === TCP_NODELAY);
+	(level === IPPROTO_TCP && optionName === TCP_NODELAY) ||
+	(level === IPPROTO_TCP && optionName === TCP_KEEPIDLE) ||
+	(level === IPPROTO_TCP && optionName === TCP_KEEPCNT) ||
+	(level === IPPROTO_TCP && optionName === TCP_KEEPINTVL);

My code should be taken with caution. Especially the modifications here as I expect sock to be an integer.

I also added a tiny line in php/Dockerfile :

+  /root/replace.sh 's/, XINI_DBG\(client_port\)/, (long int) XINI_DBG(client_port)/g' /root/xdebug/src/debugger/com.c; \

# Prepare the XDebug module for build
phpize . ; \

To display the correct port in the Xdebug logs, without that it was confusing to be connected to 127.0.01:4


Comment by mho22 at 2025-04-24

I made a lot of mistakes here. I am discovering things as well as I go forward in the issues I face.

What I am certain of is that the websocket created by xdebug is not connecting to the websocketServer.

PHP-WASM : WebSocket error: Error: WebSocket was closed before the connection was established
PHP-WASM : WebSocket connection closed

Xdebug should call 5 times setSocketOpt. When calling PHPWasmWebSocketConstructor.setsocketOpt, it calls sendCommand :

sendCommand(commandType, chunk, callback) {
    return WebSocketConstructor.prototype.send.call(
         this,
         prependByte(chunk, commandType),
         callback
    );
}

and returns this :

Error: WebSocket is not open: readyState 0 (CONNECTING)

There seems to be a problem when connecting to the webserver. Even if :

PHP-WASM : Webserver listening to 127.0.0.1:51293.
PHP-WASM : WebSocket server is now accepting connections.
PHP-WASM : Creating WebSocket with args: [ 'ws://127.0.0.1:51293/?host=127.0.0.1&port=9003', [ 'binary' ] ]
PHP-WASM : Websocket Header : 

'GET /?host=127.0.0.1&port=9003 HTTP/1.1\r\n' +
      'Sec-WebSocket-Version: 13\r\n' +
      'Sec-WebSocket-Key: qJ/JY9gA1I3s07IX03W6Gw==\r\n' +
      'Connection: Upgrade\r\n' +
      'Upgrade: websocket\r\n' +
      'Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n' +
      'Sec-WebSocket-Protocol: binary\r\n' +
      'Host: 127.0.0.1:51293\r\n' +
      '\r\n'

Something is perhaps happening during handshake. So I should look for tcp-over-fetch-websocket.ts file.

Or maybe things are processing too quickly, and the connection can't be established before Xdebug stops?


Comment by mho22 at 2025-04-24

It was indeed a matter of time. The connection couldn't be established quickly enough. To correct that I modified the outbound-ws-to-tcp-proxy.ts file and my php script. It currently needs to sleep for 1 second in order to allow the process to complete the task :

<?php

sleep(1);

echo "Hello Xdebug World\n";

xdebug_break();

echo "Bye Xdebug World\n";

running the script returns this when VSCode extension PHP DEBUG is running on port 9003 :

Hello Xdebug World
Bye Xdebug World

[42] Log opened at 2025-04-24 13:56:22.570371
[42] [Config] DEBUG: Checking if trigger 'XDEBUG_TRIGGER' is enabled for mode 'debug'
[42] [Config] INFO: No shared secret: Activating
[42] [Step Debug] INFO: xdebug_init_normal_debugger - Connecting to configured address/port: 127.0.0.1:9003.
[42] [Step Debug] INFO: Connected to super debugging client: 127.0.0.1:9003 (through xdebug.client_host/xdebug.client_port).
[42] [Step Debug] -> <init xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" fileuri="file:///internal/shared/auto_prepend_file.php" language="PHP" xdebug:language_version="8.3.0-dev" protocol_version="1.0" appid="42" idekey="vscode"><engine version="3.4.3-dev"><![CDATA[Xdebug]]></engine><author><![CDATA[Derick Rethans]]></author><url><![CDATA[https://xdebug.org]]></url><copyright><![CDATA[Copyright (c) 2002-2025 by Derick Rethans]]></copyright></init>

[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" status="break" reason="ok"><xdebug:message filename="file:///Users/mho/Work/Projects/Development/Web/Professional/xdebug/template/php/xdebug.php" lineno="9"></xdebug:message></response>

[42] [Step Debug] <- feature_set -i 1 -n resolved_breakpoints -v 1
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="1" feature="resolved_breakpoints" success="1"></response>

[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="1" status="stopping" reason="ok"></response>

[42] Log closed at 2025-04-24 13:56:23.598414

else it returns this when VSCode extension PHP DEBUG port 9003 is closed :

Hello Xdebug World
Bye Xdebug World

[42] Log opened at 2025-04-24 13:54:14.419169
[42] [Config] DEBUG: Checking if trigger 'XDEBUG_TRIGGER' is enabled for mode 'debug'
[42] [Config] INFO: No shared secret: Activating
[42] [Step Debug] INFO: xdebug_init_normal_debugger - Connecting to configured address/port: 127.0.0.1:9003.
[42] [Step Debug] INFO: Connected to super debugging client: 127.0.0.1:9003 (through xdebug.client_host/xdebug.client_port).
[42] [Step Debug] -> <init xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" fileuri="file:///internal/shared/auto_prepend_file.php" language="PHP" xdebug:language_version="8.3.0-dev" protocol_version="1.0" appid="42" idekey="vscode"><engine version="3.4.3-dev"><![CDATA[Xdebug]]></engine><author><![CDATA[Derick Rethans]]></author><url><![CDATA[https://xdebug.org]]></url><copyright><![CDATA[Copyright (c) 2002-2025 by Derick Rethans]]></copyright></init>

[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" status="break" reason="ok"><xdebug:message filename="file:///Users/mho/Work/Projects/Development/Web/Professional/xdebug/template/php/xdebug.php" lineno="9"></xdebug:message></response>

[42] [Step Debug] WARN: There was a problem sending 323 bytes on socket 10: Socket not connected (error: 53).
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" status="stopping" reason="ok"></response>

[42] [Step Debug] WARN: There was a problem sending 179 bytes on socket 10: Socket not connected (error: 53).
[42] Log closed at 2025-04-24 13:54:15.445956

However, nothing more happens for now. But it's a start!

@brandonpayton This is what I added :

node/src/lib/networking/outbound-ws-to-tcp-proxy.ts :

export function addSocketOptionsSupportToWebSocketClass(
	WebSocketConstructor: typeof WebSocket
) {
	return class PHPWasmWebSocketConstructor extends WebSocketConstructor {

+		public sendQueue: {commandType: number,chunk: string | ArrayBuffer | ArrayLike<number>, callback: any}[] = [];

+		constructor(url: string | URL, protocols?: string | string[]) {
+			super(url, protocols);

+			this.addEventListener( 'open', () => {
+			  	this.sendQueue.forEach( ( { commandType, chunk, callback } ) => {
+					return (WebSocketConstructor.prototype.send as any).call(
+						this,
+						prependByte(chunk, commandType),
+						callback
+					);
+			  	});
+			});
+		}

		// @ts-ignore
		send(chunk: any, callback: any) {
			return this.sendCommand(COMMAND_CHUNK, chunk, callback);
		}

		setSocketOpt(
			optionClass: number,
			optionName: number,
			optionValue: number
		) {
			return this.sendCommand(
				COMMAND_SET_SOCKETOPT,
				new Uint8Array([optionClass, optionName, optionValue]).buffer,
				() => undefined
			);
		}
		sendCommand(
			commandType: number,
			chunk: string | ArrayBuffer | ArrayLike<number>,
			callback: any
		) {
+			if (this.readyState !== WebSocket.OPEN) {
+				this.sendQueue.push({ commandType, chunk, callback });
+				return;
+			}

			return (WebSocketConstructor.prototype.send as any).call(
				this,
				prependByte(chunk, commandType),
				callback
			);
		}
	};
}

And I slightly modified php-wasm/compile/php/phpwasm-emscripten-library.js :

getAllWebSockets: function (sock) {
-    const socket = FS.getStream(sock).node.sock;
+    const socket = getSocketFromFD(sock);

getAllPeers: function (sock) {
-    const socket = FS.getStream(sock).node.sock;
+    const socket = getSocketFromFD(sock);

Comment by mho22 at 2025-04-29

@adamziel @brandonpayton I made the step debugger work!

It was indeed an issue with execution and sleep. Xdebug's handler_dbgp.c file has a loop called xdebug_fd_read_line_delim calling recv(socketfd, buffer, READ_BUFFER_SIZE, 0) on line 2258.

Under normal circumstances, recv will pause the PHP process waiting for data to be received but not with emscripten. I had to pause the process differently.

Adding a while loop with emscripten_sleep(10) made the trick :

 if (type == FD_RL_FILE) {
    newl = read(socketfd, buffer, READ_BUFFER_SIZE);
 } else {
-    newl = recv(socketfd, buffer, READ_BUFFER_SIZE, 0);

+    while( true )
+    {
+        newl = recv(socketfd, buffer, READ_BUFFER_SIZE, 0);
+
+        if (newl > 0)
+        {
+            break;
+        }
+
+        emscripten_sleep(10);
+    }
 }

I'm not sure if this is the right approach, but it works, and it's definitely surprising to debug a PHP file using Node!

I added these two lines in php/Dockerfile to modify the handler_dbgp.c file :

/root/replace.sh 's/, XINI_DBG\(client_port\)/, (long int) XINI_DBG(client_port)/g' /root/xdebug/src/debugger/com.c; \

+ /root/replace.sh 's/#include <fcntl.h>/#include <fcntl.h>\n#include <emscripten.h>/g' /root/xdebug/src/debugger/handler_dbgp.c; \

+ /root/replace.sh 's/newl = recv\(socketfd, buffer, READ_BUFFER_SIZE, 0\);/while ((newl = recv(socketfd, buffer, READ_BUFFER_SIZE, 0)) <= 0) emscripten_sleep(10);/g' /root/xdebug/src/debugger/handler_dbgp.c; \

# Prepare the XDebug module for build
phpize . ; \

I also found out that previous corrections were not working correctly when other functions were called so I kind of improved my previous corrections in phpwasm-emscripten-library.js :

getAllWebSockets: function (sock) {
-    const socket = getSocketFromFD(sock);

getAllPeers: function (sock) {
-    const socket = getSocketFromFD(sock);

...

wasm_setsockopt: function (
	socketd,
	level,
	optionName,
	optionValuePtr,
	optionLen
) {
...
+	const sock = getSocketFromFD(socketd);
-	const ws = PHPWASM.getAllWebSockets(socketd)[0]; 
+	const ws = PHPWASM.getAllWebSockets(sock)[0];damziel @brandonpayton I made the step debugger work!

Comment by adamziel at 2025-04-30

NICE! This is such a profound milestone @mho22!

As for the approach. I think waiting with a timeout is as good as we can do. Typically, the waiting would be done by the recv() call – let's do it that way to avoid modifying xdebug. It will help us with all other servers that also rely on recv(). It will involve a custom syscall handler.

Here's a few pointers:

  • Overriding a syscall – @brandonpayton done that in the fcntl PR.
  • Alternatively, we can add -Drecv=wasm_recv compilation option), but that's less elegant than @brandonpayton's solution.
  • Waiting in a syscall – see wasm_poll_socket. You'll need a JSPI signature and an Asyncify signature. From there, it takes either returning a promise or calling Asyncify.handleSleep.

Comment by brandonpayton at 2025-04-30

I made the step debugger work!

This is awesome, @mho22!


Comment by mho22 at 2025-05-06

This is what I learned so far :

How Xdebug runs in php-wasm/node with VSCode :

With 'xdebug.mode' : 'debug' active : running php.run() will call 5 times setsockopt. Establishing a connection between Websocket and a TCP socket at port 9003. Default one from xdebug.
To open the port 9003 on VSCode, a .vscode/launch.json configuration has to be added :

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Listen for XDebug",
            "type": "php",
            "request": "launch",
            "port": 9003
        }
    ]
}

When running Run/Start debugging in VSCode, it opens up the port 9300 :

> lsof -i :9300


COMMAND     PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
Code\x20H 85044  mho   21u  IPv6 0x751cf93969dbabda      0t0  TCP *:9003 (LISTEN)

Now that we have a connection and the socket options are correctly bound between Wasm Websocket and VSCode TCP socket, it needs to understand the communication. With the help of the while loop in xdebug handler_dbgp.c C file [ and soon the suggested syscall ] xdebug discusses with vscode under the DBGp protocol and sleeps until it has an answer. Communication is ok.

A last error occurs. VSCode needs to recognize the file based on current working directory :

const result = await php.run( { scriptPath : '/php/xdebug.php' } );

And because it needs an absolute path. We will have to mount it :

php.mkdir( 'Users/mho/wordpress-playground/php' );

php.writeFile( '/Users/mho/wordpress-playground/php/xdebug.php', fs.readFileSync( `${process.cwd()}/php/xdebug.php` ) );


const result = await php.run( { scriptPath : 'Users/mho/wordpress-playground/php/xdebug.php' } );

A transaction between xdebug and vscode will now look like this :

[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="run" transaction_id="7" status="break" reason="ok"><xdebug:message filename="file:///Users/mho/wordpress-playground/php/xdebug.php" lineno="3"></xdebug:message></response>

And step debugging will occur. Pretty simple in the end.

how Xdebug works in php-wasm/web with VSCode :

It is pretty similar of course, but the main difference is the absence of a WebsocketServer on the browser. I roughly rewrote the send method in tcp-over-fetch-websocket.ts to achieve to connect with a local WebSocketServer I implemented.

tcp-over-fetch-websocket.ts

async send(data: ArrayBuffer) {
	await this.fetchOverTCP( data );
}



setSocketOpt(optionClass : number, optionName : number, optionValue : number) {
}

async fetchOverTCP( message : string | ArrayBufferLike | Blob | ArrayBufferView )
{
	const socket = new WebSocket( 'ws://127.0.0.1:22028', 'binary' );

	await new Promise( resolve => socket.addEventListener( 'open', resolve ) );

	socket.send( message );

	socket.onmessage = async event =>
	{
		this.emit( 'message', { data : await event.data.arrayBuffer() } )

		socket.close();
	}
}
  • setSocketOpt was called but it is not needed in my test.
  • I added an arbitrary 22028 port here.

websocketserver.js

import { WebSocketServer } from 'ws';
import net from 'net';

const wss = new WebSocketServer( { port : 22028 } );

console.log( 'Running WebSocketServer on port 22028.' );

let tcpClient = null;

let connected = false;

const queue = [];

let current = null;

wss.on( 'connection', ws =>
{
    current = ws;

    console.log( 'WebSocket connected on port 22028.' );

    ws.on( 'message', message =>
    {
        console.log( 'Received from WebSocket:', message.toString() );

        if( message.toString().includes( '<init' ) )
        {
            tcpClient = connectTcpAndFlush( tcpClient, queue, ws );
        }

        if( connected )
        {
            tcpClient.write( message );
        }
        else
        {
            queue.push( message );
        }
    });

    ws.on( 'error', error => console.error( 'WebSocket Error:', error.message ) );

    ws.on( 'close', () => console.log( 'WebSocket closed' ) );
} );




function connectTcpAndFlush( tcpClient, queue )
{
    if( tcpClient )
    {
        console.log( 'Closing existing TCP connection...' );

        tcpClient.destroy();
    }

    tcpClient = new net.Socket();

    tcpClient.connect( 9003, '127.0.0.1', ()  =>
    {
        console.log( 'TCP connected' );

        connected = true;

        while( queue.length > 0 )
        {
            tcpClient.write( queue.shift() );
        }
    } );

    tcpClient.on( 'data', data =>
    {
        console.log( 'Received from TCP:', data.toString() );

        current.send( data );
    } );

    tcpClient.on( 'error', error =>
    {
        console.error( 'TCP Client Error:', error.message );

        current.close();
    } );

    return tcpClient;
}

The Xdebug websockets will connect with the websocketserver and with the TCP socket from VSCode.

The file paths in PHP-WASM FS must be the same as on your local project to allow VSCode to break correctly.

And step debugging will occur.

how Xdebug works in php-wasm/web with Google Chrome Devtools :

I think it won't. For now. For several reasons that we will maybe handle in the future but I wanted to list them and separate it from this PR. :

  • Chrome Devtools Debugger can only parse javascript and wasm files.
  • Chrome Devtools Protocol doesn't understand Xdebug DBGp protocol.

I will create a new Draft in which I write my findings about php-wasm/web and Google Chrome Devtools so we can focus on Xdebug in php-wasm/node in this PR.


Comment by adamziel at 2025-05-06

Fantastic work @mho22! Let's focus on the node.js version and then follow up with the web version.

As for running this with Chrome devtools in the future:

  • You can debug C++ in Chrome devtools with the right browser extension, which tells me we can likely add PHP support as well.
  • At least for JS, VS Code can connect to Chrome and "forward" the debug session, which tells me we can borrow that code fragment to translate between the two protocols.

Comment by bgrgicak at 2025-05-16

@bgrgicak For PHP 7.2 and 7.3, there is a build failure due to a conflict between the built-in libonig and Emscripten built-ins. I'm not getting the same linker failures for other PHP versions when we build with our separate Onigurama library. Do you know if there is a reason we shouldn't configure PHP 7.2 and 7.3 to use the separate lib version (if that works around the build failure)? I believe you may have worked on this a year or so ago.

Sorry @brandonpayton, I somehow missed this ping and found it today while researching dynamic library loading.

TBH I don't know more about this, I just fixed the PHP 7.4 issue, but from the docs it looks like we should be able to use a separate lib version by passing --with-onig[=DIR].

From PHP docs

Oniguruma is necessary for the regular expression functions with multibyte character support. As of PHP 7.4.0, pkg-config is used to detect the libonig library. Prior to PHP 7.4.0, Oniguruma was bundled with mbstring, but it was possible to build against an already installed libonig by passing --with-onig[=DIR].


Comment by bgrgicak at 2025-05-21

Sorry @brandonpayton, I somehow missed this ping and found it today while researching dynamic library loading.

TBH I don't know more about this, I just fixed the PHP 7.4 issue, but from the docs it looks like we should be able to use a separate lib version by passing --with-onig[=DIR].

🤦 😅 @bgrgicak, I saw this and also forgot to reply immediately.

It's no problem. Thanks for your feedback here. It would be cool if we could just use the separate onig lib for the older PHP version builds. @mho22, I don't recall whether I tried that or not already, but it sounds like a good idea if we want to support those older versions.


Comment by adamziel at 2025-05-28

Any updates here @mho22 ?


Comment by mho22 at 2025-05-29

I am still in the middle of my experiments, but here's a brief summary so far.

I started testing with syscall overriding as you suggested. Thanks to @brandonpayton, I had access to a lot of helpful documentation in the fnctl64 pull request so it didn't take long before I got some results. Though nothing particularly conclusive.

I found out a way to show verbosity in socket processes :

-s SOCKET_DEBUG=1 \

This gave me key insights. I noticed that the values expected by Xdebug’s recv function were not being returned correctly, it always received 0.

I decided to investigate this anomaly further, so I temporarily set aside syscall overriding and attempted to return hardcoded values to Xdebug’s recv. Every attempt failed until I realized the issue stemmed from the synchronous behavior of recv conflicting with Xdebug’s dynamically loaded extension.

Since any changes I make in php-wasm don’t affect Xdebug directly, I realized that I needed to translate Xdebug’s recv into an asynchronous function. Here's the best approach I’ve found so far:

Add -Drecv=wasm_recv to the EMCC_FLAGS of Xdebug build
Add -sJSPI_IMPORTS=wasm_recv -sJSPI_EXPORTS= wasm_recv to the EMCC_FLAGS of Xdebug build
Add -sJSPI_IMPORTS= wasm_recv -sJSPI_EXPORTS= wasm_recv to the EMCC_FLAGS of PHP-WASM build

phpwasm-emscripten-library.js

wasm_recv : function (fd, buf, len, flags) {
	return Asyncify.handleSleep( async (wakeUp) => {
      		await _emscripten_sleep(20);
		let newl = ___syscall_recvfrom(fd, buf, len, flags, null, null);

		if( newl > 0 ) {
			wakeUp( newl );
		} else {
			if( newl != -6 )
				throw new Error("Socket connection error");
		}
    	});
},

And, it works. 🎉

I confirmed that including wasm_recv in both IMPORTS and EXPORTS in both PHP-WASM and Xdebug is necessary for the components to communicate properly. Very interesting finding.

This setup doesn’t yet qualify as full syscall overriding, but based on my current understanding, it seems that building Xdebug with -Drecv=wasm_recv is necessary to ensure async behavior. I suspect I could override the syscall within wasm_recv itself, but since wasm_recv needs to return a Promise, I’m unsure how to reconcile that with syscall semantics.

Questions :

What if __syscall_recvfrom has the Asyncify handleSleep in its own js-library [ like fcntl ] and wasm_recv returns this like :

wasm_recv : function (fd, buf, len, flags) {
	return ___syscall_recvfrom(fd, buf, len, flags, null, null);
}

And, this may be too far-fetched, but if the above approach works, would building with -Drecv=__syscall_recvfrom be possible?

What do you think is the better approach: continuing with -Drecv=wasm_recv, or trying to implement full syscall overriding?


Comment by mho22 at 2025-05-29

@brandonpayton, the only reference to versioning appears in the Xdebug branch when cloning the Git repository. But I’ll definitely try the --with-onig= option once I’m able to test a PHP 7.* version!

if ([[ "${PHP_VERSION:0:1}" -ge 8 ]] || [[ "${PHP_VERSION:0:3}" == "7.4" ]]) ); then \
	export PHP_VERSION_ESCAPED="${PHP_VERSION//./_}"; \
	if [[ "${PHP_VERSION:0:1}" -ge 8 ]]; then \
		export XDEBUG_BRANCH="xdebug_3_4"; \
	else \
		export XDEBUG_BRANCH="xdebug_3_1"; \
	fi; \

Comment by adamziel at 2025-05-29

What do you think is the better approach: continuing with -Drecv=wasm_recv, or trying to implement full syscall overriding?

-Drecv=wasm_recv sounds absolutely fine 👍


Comment by mho22 at 2025-05-29

While I was testing PHP-WASM NODE JSPI version 7.4 with Xdebug enabled, I faced a particular issue :

WASM ERROR
  null function or function signature mismatch 

wasm://wasm/php.wasm-05bc73fa:1


RuntimeError: null function or function signature mismatch
    at php.wasm.zend_extension_startup (wasm://wasm/php.wasm-05bc73fa:wasm-function[18639]:0x9c259c)
    at php.wasm.byn$fpcast-emu$zend_extension_startup (wasm://wasm/php.wasm-05bc73fa:wasm-function[32959]:0xb6133c)
    at php.wasm.zend_llist_apply_with_del (wasm://wasm/php.wasm-05bc73fa:wasm-function[18258]:0x9a388e)
    at php.wasm.php_module_startup (wasm://wasm/php.wasm-05bc73fa:wasm-function[17521]:0x946d8c)
    at php.wasm.php_embed_startup (wasm://wasm/php.wasm-05bc73fa:wasm-function[20348]:0xa668ba)
    at php.wasm.byn$fpcast-emu$php_embed_startup (wasm://wasm/php.wasm-05bc73fa:wasm-function[34098]:0xb6472a)
    at php.wasm.php_wasm_init (wasm://wasm/php.wasm-05bc73fa:wasm-function[20401]:0xa69848)

Strange, when it comes to JSPI.

I found out that part was adding an option in php/Dockerfile :

RUN if [ "${PHP_VERSION:0:1}" -lt "8" ]; then \
	echo -n ' -s EMULATE_FUNCTION_POINTER_CASTS=1 ' >> /root/.emcc-php-wasm-flags; \
fi

Commenting it solved the issue.


Comment by mho22 at 2025-05-29

@brandonpayton I have temporarily resolved the issue related to PHP 7.2 and 7.3 builds by commenting out the following section in :

php/Dockerfile at line 282 :

# if [[ "${PHP_VERSION:0:1}" -ge "8" ]] || [ "${PHP_VERSION:0:3}" == "7.4" ]; then \
    echo -n ' --with-onig=/root/lib' >> /root/.php-configure-flags; \
    echo -n ' -I /root/lib -I /root/install -lonig' >> /root/.emcc-php-wasm-flags; \
# fi; \

Currently, I have successful step debugging for PHP versions 7.2, 7.3, 7.4, and 8.4. I assume other PHP 8 versions should also work, but I will need to test them.

Next steps:

  • Test Asyncify versions
  • Create a dedicated Dockerfile for Xdebug

Comment by mho22 at 2025-06-02

I managed to run step debugging with PHP 8.4 Asyncify! It required a lot of self control to find the complete list of ASYNCIFY_ONLY functions, both for php and for xdebug.

For php:

+ zend_extension_statement_handler
+ zend_llist_apply_with_argument
+ ZEND_EXT_STMT_SPEC_HANDLER
+ zend_post_deactivate_modules
+ call
+ zend_observer_fcall_begin_prechecked
+ zend_observer_fcall_begin

For Xdebug :

+ zm_post_zend_deactivate_xdebug
+ xdebug_debugger_post_deactivate
+ xdebug_dbgp_deinit
+ xdebug_execute_begin
+ xdebug_execute_user_code_begin
+ xdebug_init_debugger
+ xdebug_debug_init_if_requested_at_startup
+ xdebug_dbgp_init
+ xdebug_execute_ex
+ xdebug_statement_call
+ xdebug_debugger_statement_call
+ xdebug_dbgp_breakpoint
+ xdebug_dbgp_cmdloop
+ xdebug_fd_read_line_delim

We must keep in mind that these lists are certainly incomplete, since I just tested the step debugging of my simple $test = 42; breakpoint example. But its a start.

And wasm_recv had to be improved :

phpwasm-emscripten-library.js

wasm_recv : function (
	sockfd,
	buffer,
	size,
	flags
) {
	return Asyncify.handleSleep((wakeUp) => {
		const poll = function() {
			let newl = ___syscall_recvfrom(sockfd, buffer, size, flags, null, null);
			if(newl > 0) {
				wakeUp(newl);
			} else if ( newl === -6 ) {
				setTimeout(poll, 20);
			} else {
				throw new Error("Socket connection error");
			}
		};
		poll();
    	});
},

It follows the same logic as js_waitpid.

Let's test the other PHP versions.


Comment by adamziel at 2025-06-02

Amazing work! Yay! For other asyncify functions you could set up a console.trace() statement in handleAsync() in the JSPI build. You'd then use xdebug manually, step through some code, set up watchers etc., and log all the unique C function names that come up in the process. You'll need a -O0 -g2 build for that to retain the symbols names.

These might also be good code paths to write unit tests for - that way we could easily re-run it on JSPI for PHP 7.2+ to source those symbols as well. Then we could reuse the same test suite to confirm it all works on asyncify builds.

In fact, I wonder if there's a way we could run some existing XDebug test suite with our wasm build - either unit tests or some tests that send mock packets over the network.


Comment by adamziel at 2025-06-02

Also, what would it take to ship XDebug with JSPI before we ship asyncify support? If it creates a significant overhead, a ton of if jspi else etc. then let's not worry about it. But perhaps it wouldn't be too difficult to ship a point release with XDebug support in the JSPI build?


Comment by mho22 at 2025-06-03

For other asyncify functions you could set up a console.trace() statement in handleAsync() in the JSPI build. You'd then use xdebug manually, step through some code, set up watchers etc., and log all the unique C function names that come up in the process. You'll need a -O0 -g2 build for that to retain the symbols names.

Ow, I should have thought about this sooner. Thank you!

In fact, I wonder if there's a way we could run some existing XDebug test suite with our wasm build - either unit tests or some tests that send mock packets over the network

I was thinking about this yesterday. I'm not sure if this will be straightforward, but, yeah, if we can retrieve the tests, run them and return results, that would be great. I found this file in the Xdebug repository. Let's dig into it.

Also, what would it take to ship XDebug with JSPI before we ship asyncify support? If it creates a significant overhead, a ton of if jspi else etc. then let's not worry about it. But perhaps it wouldn't be too difficult to ship a point release with XDebug support in the JSPI build?

I can't answer precisely since I am still experimenting. For now, I am building xdebug.so files from within the php/Dockerfile file but this can't be the definitive approach. So my next goal will be to :

  • Isolate the extension build in a separate Dockerfile.
  • Set the different commands to build Xdebug for one specific php version and all php versions.
  • Copy .so files into a dedicated directory inside the php version directory : node/jspi/8_4_0/extensions ?
  • Implement a specific method to load the extension like withICU ? withXdebug would be too specific. Maybe withExtensions : [ 'xdebug' ] ?
  • Set the minimum php.ini entries
  • Mount the current working directory

But I suppose it won't be complicated to enable these for JSPI only. I am on it.

Also, now that the public repository has reopened, should I start a new pull request with my findings?


Comment by adamziel at 2025-06-03

Fresh public pr -> yes. I'll answer the rest later

@mho22
Copy link
Collaborator Author

mho22 commented Jun 10, 2025

The next main steps are :

  • - Compile xdebug extension for a given PHP version and add it in the php version directory inside a new extensions directory. This means creating a JS script like php's build.js.
  • - Add a withXdebug parameter in loadNodeRuntime, that will load the extension in NodeFS.
  • - Mount the current working directory and add the necessary php ini entries.
  • - Compile every xdebug version for Node JSPI by creating a xdebug:all target in php-wasm/compile/project.json.
  • - Add tests :
    - Does not load dynamically by default
    - Supports Dynamic Loading when enabled
    - Has its own ini file and entries
    - Mounts current working directory
    - Communicates with default DBGP port
  • - Mark as Ready for review
  • - Correct Errors and make improvements

Side steps :

  • - Copy / paste the comments from the original PR into this first comment.
  • - Create a TS version of supported-php-functions.mjs file.
  • - Prevent Xdebug to be loaded in Asyncify mode.
  • - Verify if php-wasm new Dockerfile build is not breaking things.
  • - Determine the minimal php ini entries needed for xdebug.
  • - Complete the Pull request description.

Next follow-up PRs suggestion :

  • Transform withXdebug into a more generic option : withExtensions and manage those extensions individually.
  • Transform setPHPIniEntries to indicate which php ini file we want to create, update
  • Figure out path mapping

@mho22
Copy link
Collaborator Author

mho22 commented Jun 10, 2025

This command will compile a given version of xdebug as a dynamic extension :

nx run php-wasm-compile:xdebug --PHP_VERSION=8.4

And copy/paste it in the correct node jspi directory.

@mho22
Copy link
Collaborator Author

mho22 commented Jun 10, 2025

  • Add a withXdebug parameter in loadNodeRuntime, that will load the extension in NodeFS, mount the current working directory and add the necessary php ini entries.

While currently on this task, I found out I couldn't mount the current working directory and set some php ini entries without having access to php. I only have access to phpRuntime in my with-xdebug.ts file in the onRuntimeInitialized method.

And even if I find a way to set the php ini entries inside the onRuntimeInitialized method, this will break the creation of the right php ini content in the initializeRuntime.

@adamziel Where would you add the code relative to mounting the current working directory and setting additional php ini entries relative to Xdebug ?

BTW, it works when I separate it in a script like this :

import { PHP, setPhpIniEntries } from '@php-wasm/universal';
import { createNodeFsMountHandler, loadNodeRuntime } from '@php-wasm/node';


const php = new PHP( await loadNodeRuntime( '8.4', { withXdebug : true } ) );


php.mkdir( process.cwd() );
php.mount( process.cwd(), createNodeFsMountHandler( process.cwd() ) );
php.chdir( process.cwd() );


setPhpIniEntries( php, {
    'zend_extension' : '/extensions/xdebug.so',
    'html_errors' : 'Off',
    'xdebug.mode' : 'debug',
    'xdebug.start_with_request' : 'yes',
    'xdebug.log' : '/xdebug.log'
} );


const result = await php.run( { scriptPath : `php/xdebug.php` } );

console.log( result.text );

@adamziel
Copy link
Collaborator

adamziel commented Jun 10, 2025

We might need to add a new semantic to define initial php.ini entries during the runtime initialization. That could mean either actually creating the php.ini file or just storing a key/value record for the PHP class to handle.

@adamziel
Copy link
Collaborator

PHP supports multiple php.ini files, right? That could be the way to go here, every add-on would create a dedicated partial config file

@mho22
Copy link
Collaborator Author

mho22 commented Jun 11, 2025

I followed your suggestion and used the PHP_INI_SCAN_DIR ENVvariable. This variable should probably be present in each with-[extension].ts file since this will determine where its own ini file will be located.

 PHP_INI_SCAN_DIR: '/internal/shared/extensions',

Additionally, when onRuntimeInitialized is called, we can write down the entries in the file :

phpRuntime.FS.writeFile( '/internal/shared/extensions/xdebug.ini', [
    'zend_extension=/internal/shared/extensions/xdebug.so',
    'html_errors=Off',
    'xdebug.mode=debug',
    'xdebug.start_with_request=yes',
    'xdebug.log=/xdebug.log'
].join('\n') );

The next step is related to the current working directory mount. And while I thought this would be difficult to implement since we don't have access to PHP yet in onRuntimeInitialized, it was actually easy to set :

phpRuntime.FS.mkdirTree(process.cwd());
phpRuntime.FS.mount(phpRuntime.FS.filesystems["NODEFS"], {root: process.cwd()}, process.cwd());
phpRuntime.FS.chdir(process.cwd());
  1. First, create the directories and subdirectories
  2. Mount the current working directory
  3. Change directory to be inside the working directory

Here's the emscriptenOptions returned from with-xdebug.ts :

return {
        ENV: {
            ...options.ENV,
            PHP_INI_SCAN_DIR: '/internal/shared/extensions',
        },
        onRuntimeInitialized: (phpRuntime: PHPRuntime) => {
            if (options.onRuntimeInitialized) {
                options.onRuntimeInitialized(phpRuntime);
            }
            /* The extension file previously read
             * is written inside the /extensions directory
             */
            phpRuntime.FS.mkdirTree('/internal/shared/extensions');
            phpRuntime.FS.writeFile(
                `/internal/shared/extensions/${fileName}`,
                new Uint8Array(extension)
            );
            /* The extension has its share of ini entries
             * to write in a separate ini file
             */
            phpRuntime.FS.writeFile( '/internal/shared/extensions/xdebug.ini', [
                'zend_extension=/internal/shared/extensions/xdebug.so',
                'html_errors=Off',
                'xdebug.mode=debug',
                'xdebug.start_with_request=yes',
                'xdebug.log=/xdebug.log'
            ].join('\n') );
            /* The extension needs to mount the current
             * working directory in order to sync with
             * the debugger
             */
            phpRuntime.FS.mkdirTree(process.cwd());
            phpRuntime.FS.mount(phpRuntime.FS.filesystems["NODEFS"], {root: process.cwd()}, process.cwd());
            phpRuntime.FS.chdir(process.cwd());
	},
};

And this is my current simplified test code :

php/xdebug.php

<?php

$test = 42; // I set a breakpoint here

echo "Hello Xdebug World\n";

scripts/node.js

import { PHP } from '@php-wasm/universal';
import { loadNodeRuntime } from '@php-wasm/node';



const php = new PHP( await loadNodeRuntime( '8.4', { withXdebug : true } ) );

const result = await php.run( { scriptPath : `php/xdebug.php` } );

console.log( result.text );

Aaaand step debugging occurs!

@mho22
Copy link
Collaborator Author

mho22 commented Jun 11, 2025

This command will compile every version of xdebug as a dynamic extension :

nx run php-wasm-compile:xdebug:all

And copy/paste it in the correct node jspi directory.

Now every NODE JSPI version of PHP is Xdebug compatible! 🎉

@mho22 mho22 marked this pull request as draft June 13, 2025 07:04
@adamziel
Copy link
Collaborator

adamziel commented Jun 13, 2025

@mho22 Great news! What's missing before this one can be reviewed?

Edit: Ah, nevermind, I just saw the checklist :)

@mho22
Copy link
Collaborator Author

mho22 commented Jun 16, 2025

I have written new tests related to the dynamic loading of Xdebug. Currently, there are 4 tests for each PHP version.

   ✓ PHP 8.4 (4) 2708ms
     ✓ XDebug (4) 2708ms
       ✓ does not load dynamically by default 1250ms
       ✓ supports dynamic loading 507ms
       ✓ has its own ini file and entries 463ms
       ✓ mounts current working directory 487ms

I consider this is not complete since it can't manage two last tests that I tried to implement, without success :

  1. It throws an error when no Socket connection is established
  2. It communicates with default DBGP port

1 : I am not sure if it has to throw an error when the vscode debugger port is not open. I added it during development to be warned when I forgot to open the vscode debugger port. So we could consider removing that error and not implement that test.

2 : But the last test should be implemented. Unfortunately, even if I create a server on port 9003, with createServer and even if I receive some first init data from Xdebug to my 9003 port, there is nothing I can do to communicate back to Xdebug and as a result it fails with a 5000 ms timeout.

Do you have a better approach on this? I guess after that last test is coded, the pull request is ready to be reviewed.

I am also recompiling PHP WASM NODE JSPI entirely due to conflicts.

@adamziel
Copy link
Collaborator

adamziel commented Jun 16, 2025

1 : I am not sure if it has to throw an error when the vscode debugger port is not open. I added it during development to be warned when I forgot to open the vscode debugger port. So we could consider removing that error and not implement that test.

No error is fine. We'll add some problem signaling if and when it's needed – perhaps things will "just work" often enough that we won't need it.

@adamziel
Copy link
Collaborator

@mho22
Remove socket connection error and recompile php

You may need to rebase before rebuilding to resolve the conflicts

@mho22
Copy link
Collaborator Author

mho22 commented Jun 17, 2025

@adamziel Yes! My bad

@mho22 mho22 force-pushed the add-xdebug-support-to-php-wasm-node-jspi branch from a265e42 to 3f59b88 Compare June 17, 2025 12:03
@mho22 mho22 force-pushed the add-xdebug-support-to-php-wasm-node-jspi branch from 3f59b88 to b3cc02c Compare June 17, 2025 12:19
@adamziel
Copy link
Collaborator

What, whaaaat! Flaky e2e aside, it seems like all the XDebug JSPI tests passed without breaking the Asyncify tests! 🎉

@mho22
Copy link
Collaborator Author

mho22 commented Jun 17, 2025

Yes! I wanted to try the test-unit-jspi for Xdebug, thinking it wouldn't work, but to my surprise, it did!

I will test running the other 14 tests on test-unit-jspi in another pull request.

I'm still working on the last test, though.

@adamziel
Copy link
Collaborator

You'll run into another conflict after @bgrgicak merges #2283. Feel free to just override his changes with yours. Nothing changes in the builds from #2283, it just regenerates a few bad builds in trunk.

@mho22 mho22 marked this pull request as ready for review June 17, 2025 17:16
@mho22 mho22 requested a review from a team as a code owner June 17, 2025 17:16
@mho22
Copy link
Collaborator Author

mho22 commented Jun 17, 2025

Alright! I think this pull request is ready for review. I just need to rewrite the description, though.

matrix:
include:
- name: test-unit-jspi (1/1)
target: test-php-dynamic-loading
Copy link
Collaborator

Choose a reason for hiding this comment

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

let's include jspi in this identifier to have that information available wherever we're looking at that test

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

export EMCC_FLAGS=""\n\
fi\n\
/root/emsdk/upstream/emscripten/emcc2 "$@" $EMCC_FLAGS \n' > /root/emsdk/upstream/emscripten/emcc && \
RUN <<EOF
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nice!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@brandonpayton is the one behind that too 😄.

@@ -0,0 +1,220 @@
#include <stdio.h>
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's document what is this file and why do we need it

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This comes from @brandonpayton original pull request. I think he would be the best person to document that part.

echo -n ' -lsqlite3 ' >> /root/.emcc-php-wasm-flags; \
fi;
if [[ "${PHP_VERSION:0:1}" -ge 8 ]] || [ "${PHP_VERSION:0:3}" == "7.4" ]; then \
# PHP bundled its own sqlite3 library in PHP 7.4 and later.
Copy link
Collaborator

Choose a reason for hiding this comment

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

The comment discusses using sqlite3 shipped with PHP in PHP >= 7.4, but the code seems to be bringing in our sqlite3 in PHP >= 7.4. Why? Also, I bet libsqlite3 in PHP 7.2 and 7.3 is super outdated, can we continue using ours?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This comes from @brandonpayton original pull request. I replaced every -l.. flags with wasm-sources instead but I guess he could answer your question better than I can.

COPY ./php/phpwasm-emscripten-library-known-undefined-functions.js /root/phpwasm-emscripten-library-known-undefined-functions.js
ARG WITH_SOURCEMAPS
ARG WITH_DEBUG
RUN set -euxo pipefail; \
mkdir -p /build/output; \
source /root/emsdk/emsdk_env.sh; \
if [ "$WITH_JSPI" = "yes" ]; then \
export ASYNCIFY_FLAGS=" -s ASYNCIFY=2 -sSUPPORT_LONGJMP=wasm -fwasm-exceptions -sJSPI_IMPORTS=js_open_process,js_waitpid,js_process_status,js_create_input_device,wasm_setsockopt,wasm_shutdown,wasm_close -sJSPI_EXPORTS=wasm_sleep,wasm_read,emscripten_sleep,wasm_sapi_handle_request,wasm_sapi_request_shutdown,wasm_poll_socket,wrap_select,__wrap_select,select,php_pollfd_for,fflush,wasm_popen,wasm_read,wasm_php_exec,run_cli -s EXTRA_EXPORTED_RUNTIME_METHODS=ccall,PROXYFS,wasmExports,_malloc "; \
export ASYNCIFY_FLAGS=" -s ASYNCIFY=2 -sSUPPORT_LONGJMP=wasm -fwasm-exceptions -sJSPI_IMPORTS=js_open_process,js_waitpid,js_process_status,js_create_input_device,wasm_setsockopt,wasm_shutdown,wasm_close,wasm_recv -sJSPI_EXPORTS=wasm_sleep,wasm_read,emscripten_sleep,wasm_sapi_handle_request,wasm_sapi_request_shutdown,wasm_poll_socket,wrap_select,__wrap_select,select,php_pollfd_for,fflush,wasm_popen,wasm_read,wasm_php_exec,run_cli,wasm_recv -s EXTRA_EXPORTED_RUNTIME_METHODS=ccall,PROXYFS,wasmExports,_malloc "; \
Copy link
Collaborator

Choose a reason for hiding this comment

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

wasm_recv is added to both imports and exports. is that intentional?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

As strange as it is, it is intentional. I tested without one or the other but it threw errors. To my understanding, if you want to communicate with a wrapped method between a side module and the main module, that wrapped method needs to be in import and export.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's document that inline

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.



# Build PHP
RUN git clone https://github.com/php/php-src.git php-src \
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it make sense reuse the PHP build between this Dockerfile and the php Dockerfile? I guess not since we build it natively here and not as wasm.

Copy link
Collaborator Author

@mho22 mho22 Jun 18, 2025

Choose a reason for hiding this comment

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

I had in mind to separate the builds to avoid having to add specific things related to xdebug extension building inside the php wasm dockerfile. It is also faster to build since it is a minimal version. Do you prefer using the php-wasm cached version instead ?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't have any strong opinions here, I think it's fine as it is

RUN cd /root/php-src && make install



Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we remove all the extra newlines everywhere? As in use at most a single blank line to separate things visually

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

@@ -130,6 +132,11 @@ export async function loadNodeRuntime(
phpRuntime.FS.root.mount.opts.root = '.';
},
};

if (options?.withXdebug === true) {
emscriptenOptions = await withXdebug(phpVersion, emscriptenOptions);
Copy link
Collaborator

Choose a reason for hiding this comment

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

note xdebug emscripten options get passed twice to loadPHPRuntime – once via withNetworking and once via withICUData. Let's only pass a single emscriptenOptions object in there via something along the lines of await withXdebug(await withNetworking(await withICUData(emscriptenOptions)))

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

withXdebug is optional, so this is what I suggest :

if (options?.withXdebug === true) {
	emscriptenOptions = await withXdebug(phpVersion, emscriptenOptions);
}

return await loadPHPRuntime(
	await getPHPLoaderModule(phpVersion),
+	await withNetworking(await withICUData(emscriptenOptions))
-	await withNetworking(emscriptenOptions);
-	await withICUData(emscriptenOptions)
);

or maybe this even if it is redundant :

if (options?.withXdebug === true) {
	emscriptenOptions = await withXdebug(phpVersion, emscriptenOptions);
}

+ emscriptenOptions = await withNetworking(emscriptenOptions);
+ emscriptenOptions = await withICUData(emscriptenOptions);

return await loadPHPRuntime(
	await getPHPLoaderModule(phpVersion),
+	emscriptenOptions
-	await withNetworking(emscriptenOptions);
-	await withICUData(emscriptenOptions)
);

What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sounds good!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

version: SupportedPHPVersion = LatestSupportedPHPVersion,
options: EmscriptenOptions
): Promise<EmscriptenOptions> {
if (await jspi()) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's be explicit about errors. If withXdebug is called in an environment where xdebug is unsupported, let's bale out. Otherwise the user may be confused "but I enabled it, why doesn't it work?"

Suggested change
if (await jspi()) {
if (!await jspi()) {
throw new Error();
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

'xdebug.start_upon_error=yes',
].join('\n')
);
/* The extension needs to mount the current
Copy link
Collaborator

Choose a reason for hiding this comment

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

What are the exact requirements for that? I worry about host path overlapping with a meaningful VFS path, e.g. /wordpress. Can we remap that somehow to always mount cwd at a stable location?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

These are the Xdebug logs when a break occurs on a breakpoint :

[42] Log opened at 2025-06-17 15:44:12.139630
[42] [Step Debug] INFO: Connecting to configured address/port: localhost:9003.
[42] [Step Debug] WARN: Could not set SO_KEEPALIVE: No error information.
[42] [Step Debug] INFO: Connected to debugging client: localhost:0 (through xdebug.client_host/xdebug.client_port).
[42] [Step Debug] -> <init xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" fileuri="file:///internal/shared/auto_prepend_file.php" language="PHP" xdebug:language_version="8.4.0-dev" protocol_version="1.0" appid="42"><engine version="3.4.4-dev"><![CDATA[Xdebug]]></engine><author><![CDATA[Derick Rethans]]></author><url><![CDATA[https://xdebug.org]]></url><copyright><![CDATA[Copyright (c) 2002-2025 by Derick Rethans]]></copyright></init>

[42] [Step Debug] <- feature_set -i 1 -n resolved_breakpoints -v 1
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="1" feature="resolved_breakpoints" success="1"></response>

[42] [Step Debug] <- feature_set -i 2 -n notify_ok -v 1
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="2" feature="notify_ok" success="1"></response>

[42] [Step Debug] <- feature_set -i 3 -n extended_properties -v 1
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="3" feature="extended_properties" success="1"></response>

[42] [Step Debug] <- feature_set -i 4 -n breakpoint_include_return_value -v 1
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="4" feature="breakpoint_include_return_value" success="1"></response>

[42] [Step Debug] <- feature_set -i 5 -n max_children -v 100
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="5" feature="max_children" success="1"></response>

+[42] [Step Debug] <- breakpoint_set -i 6 -t line -f file:///Users/mho/xdebug/template/php/xdebug.php -n 3
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="breakpoint_set" transaction_id="6" id="420001" resolved="unresolved"></response>

[42] [Step Debug] <- run -i 7
+ [42] [Step Debug] -> <notify xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" name="breakpoint_resolved"><breakpoint type="line" resolved="resolved" filename="file:///Users/mho/xdebug/template/php/xdebug.php" lineno="3" state="enabled" hit_count="0" hit_value="0" id="420001"></breakpoint></notify>

+ [42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="run" transaction_id="7" status="break" reason="ok"><xdebug:message filename="file:///Users/mho/xdebug/template/php/xdebug.php" lineno="3"></xdebug:message></response>

[42] [Step Debug] <- stack_get -i 8
+ [42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="stack_get" transaction_id="8"><stack where="{main}" level="0" type="file" filename="file:///Users/mho/xdebug/template/php/xdebug.php" lineno="3"></stack></response>

[42] [Step Debug] <- context_names -i 9 -d 0
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="context_names" transaction_id="9"><context name="Locals" id="0"></context><context name="Superglobals" id="1"></context><context name="User defined constants" id="2"></context></response>

[42] [Step Debug] <- context_get -i 10 -d 0 -c 0
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="context_get" transaction_id="10" context="0"><property name="$test" fullname="$test" type="uninitialized"></property></response>

[42] [Step Debug] <- run -i 11
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="run" transaction_id="11" status="stopping" reason="ok"></response>

[42] [Step Debug] <- stop -i 12
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="stop" transaction_id="12" status="stopped" reason="ok"></response>

This is not easy to read so I added a diff + to show you the important lines here. The debugger identifies the file with its path and the debugger communicates back with Xdebug with the left arrow. So basically, the debugger responds back to Xdebug that he found a breakpoint in xdebug.php on line 3 with this :

breakpoint_set -i 6 -t line -f file:///Users/mho/xdebug/template/php/xdebug.php -n 3

So mounting the current working directory helps Xdebug find the file in filesystem without having to modify the Xdebug behavior.

Copy link
Collaborator

Choose a reason for hiding this comment

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

How does PHPStorm handle that? You can have path mappings there for remote xdebug sessions

Copy link
Collaborator

Choose a reason for hiding this comment

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

OH, I see! We run an XDebug server, not a client

Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's ship this as v1, then do asyncify, and then figure out path mapping in a follow-up PR

@mho22
Copy link
Collaborator Author

mho22 commented Jun 18, 2025

There are two pending reviews left where I will need assistance from @brandonpayton:

  • Documenting the print-wasm-imports-and-exports.c file
  • Reviewing the SQLite3 library in the php/Dockerfile file

@adamziel
Copy link
Collaborator

Yay! Thank you ❤️ one thing I want to note: once asyncify works, we will have to figure out the path mapping not just for the reasons I've mentioned earlier, but also to account for mounts. For example: /wordpress living in VFS, /wordpress/wp-content/plugins being mounted from a host directory, and debugging both in a single session.

@mho22
Copy link
Collaborator Author

mho22 commented Jun 18, 2025

@adamziel I think I am on to something for path mapping in VSCode. Something like this :

.vscode/launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Listen for XDebug",
            "type": "php",
            "request": "launch",
            "port": 9003,
            "pathMappings": {
                "/wordpress": "some/path",
                "/wordpress/wp-content/plugins": "some/other/path",
            }
        }
    ]
}

But I will need to simulate that environment to ensure it works properly. And it is VSCode only.

@adamziel
Copy link
Collaborator

I think I am on to something for path mapping in VSCode. Something like this

Really cool! Can it work for mixing:

  • PHP files that are only in VFS and not on host
  • PHP files that are mounted from a host path to a different VFS path

? If not, that's fine – we could have a limitation such as "you can debug files that live on your host." Tools wrapping Playground CLI could then have a "debug mode" that a) enables XDebug and b) ensures /wordpress is mounted from host.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
No open projects
Status: Inbox
Development

Successfully merging this pull request may close these issues.

3 participants