Skip to content

[ php-wasm ] Add xdebug shared 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 27 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.

Zend extensions and PHP versions

Important

Each version of PHP requires its own version of Xdebug because each version of PHP has a specific Zend Engine API version. Consequently, each version of Xdebug is designed to be compatible with a particular Zend Engine API version.

Example using PHP 8.2 with Xdebug 8.3 :

Xdebug requires Zend Engine API version 420230831.
The Zend Engine API version 420220829 which is installed, is outdated.

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

@adamziel
Copy link
Collaborator

Currently, my thoughts are that if we can't display the content of these VFS-only files in an "editor", it won't be possible for that "editor" to debug them. The second point is technically feasable though.

Good point! There must be a way to display it in the editor, though, right? Editors have supported "Remote debug sessions" for a while where not having all the sources locally is the baseline. Yesterday I found Chrome DevTools Protocol has a Debugger.scriptParsed method that can be used to proactively provide a new source file to the browser. XDebug protocol may support something similar, e.g. 6.7 Dynamic code and virtual files looks promising. And if not, then perhaps PHPStorm and VSCode would have a custom API for doing that.

Note this doesn't have to block this PR. I just mean it as a short-term follow-up that we should track separately.

@mho22
Copy link
Collaborator Author

mho22 commented Jun 19, 2025

I still have to do one thing :

  1. edit Support Xdebug #314 and make sure we're tracking all related work – both complete, needed, and nice-to-have

@mho22
Copy link
Collaborator Author

mho22 commented Jun 19, 2025

Something strange is happening with sqlite and PHP version 7.3 and 7.2 :

  This error originated in "src/test/php-dynamic-loading.spec.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
  The latest test that might've caused the error is "does not load dynamically by default". It might mean one of the following:
  - The error was thrown, while Vitest was running this test.
  - If the error occurred after the test had been completed, this was the last documented test before it was thrown.
  
  ⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
  Error: ENOENT: no such file or directory, open 'libsqlite3.so'

@adamziel
Copy link
Collaborator

adamziel commented Jun 19, 2025

Oh no! I wonder if libsqlite shipped with these older php releases is somehow patched in php-src. Not good. What shows up when you diff it against the official release? If we can apply the same patch and continue using the recent version, let's do that. I worry that downgrading will break a lot of the SQLite integration features

@adamziel
Copy link
Collaborator

It could also be the older PHP buildconf assuming a custom libsqlite means a dynamic library

@mho22
Copy link
Collaborator Author

mho22 commented Jun 19, 2025

So this line :

echo -n ' -I ext/pdo_sqlite -lsqlite3 ' >> /root/.emcc-php-wasm-flags; \

Coupled with this option :

--enable-shared

Returns the error.

So I should replace these lines :

# Add sqlite3 if needed
RUN if [ "$WITH_SQLITE" = "yes" ]; \
	then \
		echo -n ' --with-sqlite3 --enable-pdo --with-pdo-sqlite=/root/lib ' >> /root/.php-configure-flags && \
-		echo -n ' -I ext/pdo_sqlite -lsqlite3 ' >> /root/.emcc-php-wasm-flags; \
+		echo -n ' /root/lib/lib/libsqlite3.a ' >> /root/.emcc-php-wasm-sources; \
	else \
		echo -n ' --without-sqlite3 --disable-pdo ' >> /root/.php-configure-flags; \
	fi

Since it is the new way to implement static libraries. However, for version PHP7.2 and PHP7.3 It returns this list of errors :

...
9.936 wasm-ld: error: duplicate symbol: sqlite3_malloc64
9.936 >>> defined in /root/lib/libphp.a(lt1-sqlite3.o)
9.936 >>> defined in /root/lib/lib/libsqlite3.a(sqlite3.o)
9.936 
9.936 wasm-ld: error: duplicate symbol: sqlite3_free
9.936 >>> defined in /root/lib/libphp.a(lt1-sqlite3.o)
9.936 >>> defined in /root/lib/lib/libsqlite3.a(sqlite3.o)
9.936 
9.936 wasm-ld: error: duplicate symbol: sqlite3_realloc
9.936 >>> defined in /root/lib/libphp.a(lt1-sqlite3.o)
9.936 >>> defined in /root/lib/lib/libsqlite3.a(sqlite3.o

Indicating that there are duplicate symbols between libphp and libsqlite3. That is why it was only sourced for PHP version over 7.3

This is exactly what said @brandonpayton in the original Pull Request : Use built-in sqlite3 lib for PHP 7.2 and 7.3 to avoid duplicate symbols.

@adamziel
Copy link
Collaborator

  • Can we do the same thing PHP 7.2 and 7.3 do to make their libsqlite work?
  • If not, can we exclude one set of symbols from the final build?

@mho22
Copy link
Collaborator Author

mho22 commented Jun 19, 2025

I reset the php version to reactivate the sqlite static extension for php7.2 and php7.3, in order to verify their versions and you were right :

PHP 7.2

[versionString] => 3.28.0
[versionNumber] => 3028000


PHP 7.4

[versionString] => 3.40.1
[versionNumber] => 3040001

While in another project it shows this :

PHP 7.2

[versionString] => 3.40.1
[versionNumber] => 3040001


PHP 7.4

[versionString] => 3.40.1
[versionNumber] => 3040001

I have no clue how to fix this but, I am about to investigate this further.

@adamziel
Copy link
Collaborator

adamziel commented Jun 19, 2025

2019-04-16 (3.28.0) is 6 years old :( I wonder if we could just replace php-src/ext/sqlite3/libsqlite/sqlite3.c and make it magically work

@adamziel
Copy link
Collaborator

https://github.com/php/php-src/commits/PHP-7.2/ext/sqlite3/libsqlite/sqlite3.c the history seems to be just pulling the latest SQLite3 amalgamation every time so perhaps 🤞

@adamziel
Copy link
Collaborator

@mho22
Copy link
Collaborator Author

mho22 commented Jun 19, 2025

Or maybe we could unbundle libsqlite3 like they did for PHP 7.4 ?

@adamziel
Copy link
Collaborator

Whatever is the easiest to maintain

@mho22
Copy link
Collaborator Author

mho22 commented Jun 19, 2025

It needed two files and 4 lines in php/Dockerfile :

# Workaround to unbundle sqlite from PHP <= 7.3.
# https://github.com/php/php-src/commit/6083a38
RUN if [[ "${PHP_VERSION:0:1}" -eq "7" && "${PHP_VERSION:2:1}" -le "3" ]]; then \
		cp /root/builds/libsqlite3/pdo_sqlite_config.m4 /root/php-src/ext/pdo_sqlite/config.m4; \
		cp /root/builds/libsqlite3/sqlite_config0.m4 /root/php-src/ext/sqlite3/config0.m4; \
	fi;

I deduced it was probably the easiest to maintain 😄 .

@adamziel
Copy link
Collaborator

Lovely, thank you! Let's document the rationale for unbundling inline including the error message and a link to this discussion. Also, let's use the directory structure to indicate these two files are only used in older php versions. Maybe by storing them inside in <=7.4 directory?

# enable the flag and pay the price of the additional overhead.
# https://emscripten.org/docs/porting/guidelines/function_pointer_issues.html
RUN if [ "${PHP_VERSION:0:1}" -lt "8" ]; then \
echo -n ' -s EMULATE_FUNCTION_POINTER_CASTS=1' >> /root/.emcc-php-wasm-flags; \
Copy link
Collaborator

@adamziel adamziel Jun 19, 2025

Choose a reason for hiding this comment

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

What are the consequences of removing this? I remember seeing some crashes recently on a php 7.4 build without this option enabled. I think it was related to proc_open(), http requests, and fclose(). I wish I written down the full error. Is there something else in this PR that prevents those types of errors? Or is this flag just removed to make the build work, but without addressing the documented problem?

Copy link
Collaborator

@adamziel adamziel Jun 19, 2025

Choose a reason for hiding this comment

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

We need to find a case that breaks without pointer events casts before this pr. If it works with this PR applied, bueno. If it breaks, we need to add a failing unit test and make it pass.

I wonder if the failures were specific to asyncify? I want to say hes but I vaguely remember struggling with then in a JSPI build. Maybe I remember wrong? Not sure.

Copy link
Collaborator Author

@mho22 mho22 Jun 23, 2025

Choose a reason for hiding this comment

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

You're right, I removed that part earlier to make the builds work in JSPI. I rebuilt PHP 7.4 after re-adding that if statement and, as expected, some tests failed. So there is a direct link between EMULATE_FUNCTION_POINTER_CASTS and Xdebug's failure.

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 find a reproducible way of crashing PHP without pointer casts - there may be a way to fix the underlying problem and get rid of the flash.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I found the commit that added that portion of code.

Unfortunately as a lot of things have changed since Dec 28 2022 in php/Dockerfile, I don't know if something has solved that part since then.

Copy link
Collaborator Author

@mho22 mho22 Jun 23, 2025

Choose a reason for hiding this comment

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

I found a way to crash PHP without the pointer cast with this script :

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


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

const result = await php.runStream( { code : `<?php

    function test_curl() {
        for ($i = 0; $i < 10; $i++) {
            var_dump( $i );
            $ch = curl_init();
            curl_setopt($ch, CURLOPT_URL, "http://example.com");
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_exec($ch);
            curl_close($ch); // This could trigger zend_list_free() with indirect pointer
        }
    }

echo "STARTED";

test_curl();

echo "STOPPED";`

} );


console.log( await result.stdoutText, await result.stderrText );

It crashes with node --experimental-wasm-stack-switching scripts/pointer.js :

Error: null function or function signature mismatch
    at file:///Users/mho/xdebug/template/node_modules/@php-wasm/universal/index.js:1714:80 {
  cause: RuntimeError: null function or function signature mismatch
      at php.wasm.ZEND_ASSIGN_SPEC_CV_VAR_RETVAL_UNUSED_HANDLER (wasm://wasm/php.wasm-06f290ba:wasm-function[7637]:0x616df8)
      at php.wasm.execute_ex (wasm://wasm/php.wasm-06f290ba:wasm-function[7463]:0x60bee2)
      at php.wasm.zend_execute (wasm://wasm/php.wasm-06f290ba:wasm-function[7465]:0x60c0e8)
      at php.wasm.zend_execute_scripts (wasm://wasm/php.wasm-06f290ba:wasm-function[6607]:0x5c6b63)
      at php.wasm.php_execute_script (wasm://wasm/php.wasm-06f290ba:wasm-function[5701]:0x565190)
      at php.wasm.wasm_sapi_handle_request (wasm://wasm/php.wasm-06f290ba:wasm-function[8574]:0x668a2d)
      at async file:///Users/mho/xdebug/template/node_modules/@php-wasm/universal/index.js:1687:14
}
node:internal/modules/run_main:104
    triggerUncaughtException(
    ^

RuntimeError: null function or function signature mismatch
    at php.wasm.ZEND_ASSIGN_SPEC_CV_VAR_RETVAL_UNUSED_HANDLER (wasm://wasm/php.wasm-06f290ba:wasm-function[7637]:0x616df8)
    at php.wasm.execute_ex (wasm://wasm/php.wasm-06f290ba:wasm-function[7463]:0x60bee2)
    at php.wasm.zend_execute (wasm://wasm/php.wasm-06f290ba:wasm-function[7465]:0x60c0e8)
    at php.wasm.zend_execute_scripts (wasm://wasm/php.wasm-06f290ba:wasm-function[6607]:0x5c6b63)
    at php.wasm.php_execute_script (wasm://wasm/php.wasm-06f290ba:wasm-function[5701]:0x565190)
    at php.wasm.wasm_sapi_handle_request (wasm://wasm/php.wasm-06f290ba:wasm-function[8574]:0x668a2d)
    at async file:///Users/mho/xdebug/template/node_modules/@php-wasm/universal/index.js:1687:14

while it doesn't with node scripts/pointer.js since I didn't modify Asyncify versions of PHP :

STARTEDint(0)
int(1)
int(2)
int(3)
int(4)
int(5)
int(6)
int(7)
int(8)
int(9)
STOPPED

However, PHP 7.2 is not crashing, only 7.4 and 7.3

Copy link
Collaborator Author

@mho22 mho22 Jun 23, 2025

Choose a reason for hiding this comment

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

Another example that crashes PHP is as follows :

<?php

function test_proc() {
    for ($i = 0; $i < 10; $i++) {
        $h = proc_open("echo hello", [
            ["pipe", "r"],
            ["pipe", "w"],
            ["pipe", "w"]
        ], $pipes);

        // simulate some usage
        fwrite($pipes[0], "test\n");
        fclose($pipes[0]);
        fclose($pipes[1]);
        fclose($pipes[2]);

        proc_close($h); // triggers php_proc_close
    }
}

test_proc();

and returns the following error :

RuntimeError: null function or function signature mismatch
    at php.wasm.zend_array_destroy (wasm://wasm/php.wasm-06f290ba:wasm-function[6874]:0x5d8636)
    at php.wasm.zval_ptr_dtor (wasm://wasm/php.wasm-06f290ba:wasm-function[6568]:0x5c2f0d)
    at php.wasm.zif_proc_open (wasm://wasm/php.wasm-06f290ba:wasm-function[8538]:0x6671f1)
    at php.wasm.execute_internal (wasm://wasm/php.wasm-06f290ba:wasm-function[7449]:0x60af68)
    at wasm://wasm/000dda0a:wasm-function[232]:0xb182
    at php.wasm.ZEND_DO_FCALL_SPEC_RETVAL_USED_HANDLER (wasm://wasm/php.wasm-06f290ba:wasm-function[7866]:0x634513)
    at php.wasm.execute_ex (wasm://wasm/php.wasm-06f290ba:wasm-function[7463]:0x60bee2)
    at wasm://wasm/000dda0a:wasm-function[231]:0xaf05
    at php.wasm.ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER (wasm://wasm/php.wasm-06f290ba:wasm-function[7861]:0x633bf4)
    at php.wasm.execute_ex (wasm://wasm/php.wasm-06f290ba:wasm-function[7463]:0x60bee2)

I used xdebug to step debug in it and it actually throws the Error when the second proc_open(...) is called.
Same for the second curl_init() in the first example above.

I am glad to have Xdebug enabled. Yet, this won't be easy to solve.

Copy link
Collaborator

@adamziel adamziel Jun 23, 2025

Choose a reason for hiding this comment

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

@mho22 how is the relevant function signature different with pointer casts enabled? Maybe we just need to patch one or two PHP function signatures to resolve it?

Copy link
Collaborator

Choose a reason for hiding this comment

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

It seems to be related to the destructor logic around here – it's interesting to see how it evolved throughout PHP releases:

PHP 7.2 uses a switch statement in _zval_dtor_func that defaults to no-op behavior. PHP 7.3 switched to declaring destructors in an object and calling them like zend_rc_dtor_func[GC_TYPE(p)](p); which could lead to a null pointer exception. PHP 8.0 seems to have more assertions in place to protect from that.

zend_array_destroy is also interesting to observe between PHP releases:

I think our most important clue is in the function signatures – pointer event casts somehow resolves the crash.

Btw, @brandonpayton had a setup in place to debug the C code with source maps, you may want to step through that code to pinpoint where exactly it crashes. See https://github.com/Automattic/wordpress-playground-private/pull/47. @brandonpayton – let's create a doc page for that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes! I was tracking a possible function signature mismatch related to zend_register_list_destructors_ex yesterday. I'll keep digging into this today. There is a paragraph in emscripten documentation :

Debugging function pointer issues
The SAFE_HEAP and ASSERTION options can catch some of these errors at runtime and provide useful information. You can also see if EMULATE_FUNCTION_POINTER_CASTS fixes things for you, but see later down about the overhead.

Thank you, I'll analyze your findings. I hope this won't lead to a list of arbitrary wrappers to add in specific files.

I also found out pointer casts are not compatible with dynamic linking so we have to get rid of that EMULATE_FUNCTION_POINTER_CASTS option anyways.


return {
contents: contents.replace(
/import\.meta\.dirname,\s*`\.\.\/\.\.\/\.\.\//g,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nice! My only note is - there's nothing in the xdebug file to indicate this specific pattern matters during the build. How about we surround it with a comment explaining this maneuver and match on something dedicated? E.g.

/**
 * Hack: Keeping the path working in
 * both the source file and the final
 * bundle requires...
 *
 * ...This is auto replaced in esbuild.js to...
 * 
 * ... always modify both places. you can test if it worked like this:...
 */
readFile(
// XDEBUG_PATH_START
   import.meta.dirname
// XDEBUG_PATH_END
   , otherArg
);

...also, I thought esbuild supported custom loaders such as ?url via plugins. Was that approach more complicated?

Copy link
Collaborator Author

@mho22 mho22 Jun 23, 2025

Choose a reason for hiding this comment

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

@adamziel I can't find a way to make the dynamic importing with ?url in esbuild work as it is a runtime import based on dynamic variables. Esbuild doesn't support that. I maybe missed something here but I haven't found a better solution than the direct url rewrite during build.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah, I was missing the dynamic import part. Makes sense 👌 then yes, let's just overcommunicate the replacement

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.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Actually, maybe ?url and the dynamic import can still work together? see #2247 (comment)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

On it. I need to try that again but when I meant dynamic importing I forgot to mention the wildcard characteristic of this line :

`../../../jspi/extensions/xdebug/${directoryName}/${fileName}`

Meaning esbuild won't process that line because of the unknown directoryName.

But I need to try that again to be sure.

Copy link
Collaborator

@adamziel adamziel Jun 24, 2025

Choose a reason for hiding this comment

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

can we populate that line statically somehow and avoid runtime interpolation? Like when we load the wasm module we have a static switch statement with a separate case for each PHP version for this exact reason

Copy link
Collaborator

Choose a reason for hiding this comment

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

switch (version) {
case '8.4':
// @ts-ignore
return await import('../../public/php/jspi/php_8_4.js');
case '8.3':
// @ts-ignore
return await import('../../public/php/jspi/php_8_3.js');
case '8.2':
// @ts-ignore
return await import('../../public/php/jspi/php_8_2.js');
case '8.1':
// @ts-ignore
return await import('../../public/php/jspi/php_8_1.js');
case '8.0':
// @ts-ignore
return await import('../../public/php/jspi/php_8_0.js');
case '7.4':
// @ts-ignore
return await import('../../public/php/jspi/php_7_4.js');
case '7.3':
// @ts-ignore
return await import('../../public/php/jspi/php_7_3.js');
case '7.2':
// @ts-ignore
return await import('../../public/php/jspi/php_7_2.js');
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I feel guilty for not thinking about that before 😅 .

Copy link
Collaborator Author

@mho22 mho22 Jun 24, 2025

Choose a reason for hiding this comment

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

@adamziel I made a commit dedicated to that improvement. It is also heavily related to the issue on static files you opened today.

As discussed, There are multiple things needed here :

  1. Use of await import with ?url
  2. Resolving imported files for Vitest using a Vite Plugin named import-url
  3. Resolving imported files for ESBuild using a ESBuild Plugin named import-url

The particularity between Vitest and ESBuild :

Vitest returns :

export default ${JSON.stringify(absoluteFilePath)};

ESBuild returns :

import path from 'path';
export default path.resolve(__dirname, ${JSON.stringify(relativePath)});`,

Some remarks :

  1. ESBuild: import path is needed even if there's a ton of path in index.js but they all have associated numbers.
  2. ESBuild: __dirname is needed for CommonJS.
  3. Vitest: An absolute path is enough to make the test pass.

Some thoughts to myself :

  1. ESBuild really do need statically analyzable imports to resolve them in a plugin ?

@adamziel
Copy link
Collaborator

I've left a few last notes, other than that we're there. Let's figure out this last stretch and set this PR aside until Brandon we merge fcntl. Asyncify support could be a stacked pr against this one.

@zaerl zaerl mentioned this pull request Jun 20, 2025
@mho22 mho22 changed the title [ php-wasm ] Add xdebug dynamic extension to @php-wasm/node JSPI [ php-wasm ] Add xdebug shared extension to @php-wasm/node JSPI Jun 20, 2025
@adamziel adamziel mentioned this pull request Jun 23, 2025
4 tasks
@mho22
Copy link
Collaborator Author

mho22 commented Jun 23, 2025

Lovely, thank you! Let's document the rationale for unbundling inline including the error message and a link to this discussion. Also, let's use the directory structure to indicate these two files are only used in older php versions. Maybe by storing them inside in <=7.4 directory?

Done.

Three last things !

  1. Reproduce a bug with EMULATE_FUNCTION_POINTER_CASTS disabled.
  2. Merge conflicts with trunk.
  3. Edit Support Xdebug #314 and make sure we're tracking all related work – both complete, needed, and nice-to-have.

… so?url imports relative to tests and builds
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.

4 participants