-
Notifications
You must be signed in to change notification settings - Fork 303
[ php-wasm ] Add xdebug
dynamic extension to @php-wasm/node JSPI
#2248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
[ php-wasm ] Add xdebug
dynamic extension to @php-wasm/node JSPI
#2248
Conversation
xdebug
dynamic extension to @php wasm node JSPIxdebug
dynamic extension to @php-wasm/node JSPI
Here is a mega-comment containing all our comments and investigation while working in a private repo. Original PR DescriptionMotivation for the change, related issuesIt would be great to offer XDebug support with Studio, and in general, developers using @php-wasm/node could benefit from more debugging tools. Implementation detailsTBD Testing Instructions (or ideally a Blueprint)TBD Comments from Pull Request #60 in Automattic/wordpress-playground-privateComment by brandonpayton at 2025-02-26This 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:
Comment by adamziel at 2025-02-26AFAIR 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-26Thanks 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-27Check 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-27Ah you already do it, nevermind them :D Comment by adamziel at 2025-02-27I vaguely remember I had to actually edit the xdebug makefile FWIW Comment by brandonpayton at 2025-02-27
Ah, that's a good clue. Thank you! Comment by brandonpayton at 2025-03-04
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 Comment by brandonpayton at 2025-03-04It was so handy to run a shell within that image: 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-05I am testing loading the extension like this:
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:
Comment by adamziel at 2025-03-05I documented my dynamic linking discoveries in #673 Comment by brandonpayton at 2025-03-05
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
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-15I 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 - After that, there was one more symbol conflict around Now, I'm running into errors like this when trying to load the xdebug.so extension:
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-15Does 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
I hadn't tried yet with JSPI, but taking the JS code from your previous PR which actually marks
Now I am running into an issue with sleep() callbacks in JS.
This may be some kind of linking-related error. Planning to push the current config shortly... Comment by brandonpayton at 2025-03-17
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-18UPDATE: 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:
When async dlopen() is used, there is an "unreachable code should not be executed" error:
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-18The 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-18A 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-18It 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-18Correction: 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
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-18Node v23 with the Comment by brandonpayton at 2025-03-19We 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.
IIRC, when I looked into this error recently, I'm also wondering whether it would be cleaner or less problematic to define Google's summary of
And PHP enables 64-bit integers when that is defined:
There are few other mentions of I may try switching to Comment by brandonpayton at 2025-03-19
A couple notes:
Comment by adamziel at 2025-03-19
Comment by adamziel at 2025-03-20
This sounds related to Comment by adamziel at 2025-03-20I 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-20Also, 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
Cool!
I was originally building xdebug with 64-bit integers (using the same
Good idea. Here's a stacktrace from a JSPI build of PHP 8.3:
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-20I'm also curious if upgrading to the latest Emscripten version would have any impact here. Comment by brandonpayton at 2025-03-20I disabled optimizations and built xdebug with debug symbols, and call stack with JSPI is more informative:
Comment by brandonpayton at 2025-03-20Ok! @adamziel, I was able to get the xdebug extension loading with both JSPI and ASYNCIFY with 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-21I pushed JSPI and ASYNCIFY builds for PHP 8.3. To see the extension loading, you can run: Comment by brandonpayton at 2025-03-21export default async function runExecutor(options: BuiltScriptExecutorSchema) {
const args = [
+ ...(options.nodeArg || []), brandonpayton [on Mar 21] Comment by brandonpayton at 2025-03-21async function run() {
// @ts-ignore
- const defaultPhpIniPath = await import('./php.ini');
+ const defaultPhpIniPath = (await import('./php.ini')).default; brandonpayton[on Mar 21] 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 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] If I change the commands from EMCC_SKIP="..." export EMCC_SKIP="..."; brandonpayton [on Mar 21]
Nah, that seems fine. It looks like the real issue is that, in the MAIN_MODULE compilation, we are specifying both I think we need to pick one way or the other to link to those libs. If we use the
brandonpayton [on Mar 21] I'm not sure why we use both the brandonpayton [on Mar 22] adamziel [on Mar 22] brandonpayton [on Mar 23] Aw, thanks, Adam! Comment by adamziel at 2025-03-24So 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
:) Agreed. I am hopeful as well. Some additional notes:
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
No blockers from my end, AFAIR that solved some built-time error. Let's see what @bgrgicak says. Comment by brandonpayton at 2025-03-25I'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 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
There are naming conventions Emscripten uses to segment imports, and once we have the list from Comment by brandonpayton at 2025-03-25
As a semi-related aside: Comment by adamziel at 2025-03-25Emscripten 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
Interesting! Looking at their implementation could be helpful as well.
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-07My 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
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-15I 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-15There is a VSCode extension for debugging WebAssembly with DWARF debug info: Hopefully we will be able to use it here. Comment by mho22 at 2025-04-23I found out this line was creating the socket in Xdebug :
I decided to add a bunch of
I got these informations in the
I
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 @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.
+ 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 By adding this in _dlopen_js__deps: ['$dlopenInternal'],
+ _dlopen_js__async: false Here is the code I use to run xdebug in
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 :
Comment by adamziel at 2025-04-23I just can't overstate how happy I am about the work happening here. Thank you both ❤️ Comment by adamziel at 2025-04-23
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-23Also, 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-23Wait, is this an outbound socket or a listening socket? For the latter, we need a server handler. I've dine one here: Comment by mho22 at 2025-04-23@adamziel I followed your advice, corrected the errors and noticed a break in the Xdebug process logs :
My script :
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
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
@brandonpayton I modified 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
To display the correct port in the Xdebug logs, without that it was confusing to be connected to Comment by mho22 at 2025-04-24I 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.
Xdebug should call 5 times
and returns this :
There seems to be a problem when connecting to the webserver. Even if :
Something is perhaps happening during handshake. So I should look for Or maybe things are processing too quickly, and the connection can't be established before Xdebug stops? Comment by mho22 at 2025-04-24It was indeed a matter of time. The connection couldn't be established quickly enough. To correct that I modified the
running the script returns this when VSCode extension PHP DEBUG is running on port 9003 :
else it returns this when VSCode extension PHP DEBUG port 9003 is closed :
However, nothing more happens for now. But it's a start! @brandonpayton This is what I added :
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 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 Under normal circumstances, Adding a while loop with 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 /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 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-30NICE! 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:
Comment by brandonpayton at 2025-04-30
This is awesome, @mho22! Comment by mho22 at 2025-05-06This is what I learned so far : How Xdebug runs in php-wasm/node with VSCode : With
When running
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 A last error occurs. VSCode needs to recognize the file based on current working directory :
And because it needs an absolute path. We will have to mount it :
A transaction between xdebug and vscode will now look like this :
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
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();
}
}
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 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. :
I will create a new Draft in which I write my findings about Comment by adamziel at 2025-05-06Fantastic 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:
Comment by bgrgicak at 2025-05-16
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].
Comment by bgrgicak at 2025-05-21
🤦 😅 @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-28Any updates here @mho22 ? Comment by mho22 at 2025-05-29I am still in the middle of my experiments, but here's a brief summary so far. I started testing with I found out a way to show verbosity in socket processes :
This gave me key insights. I noticed that the values expected by Xdebug’s I decided to investigate this anomaly further, so I temporarily set aside 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
And, it works. 🎉 I confirmed that including This setup doesn’t yet qualify as full Questions : What if
And, this may be too far-fetched, but if the above approach works, would building with What do you think is the better approach: continuing with 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
Comment by adamziel at 2025-05-29
Comment by mho22 at 2025-05-29While I was testing PHP-WASM NODE JSPI version 7.4 with Xdebug enabled, I faced a particular issue :
Strange, when it comes to JSPI. I found out that part was adding an option in php/Dockerfile :
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 :
Currently, I have successful step debugging for PHP versions Next steps:
Comment by mho22 at 2025-06-02I 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 And
It follows the same logic as Let's test the other PHP versions. Comment by adamziel at 2025-06-02Amazing 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 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-02Also, what would it take to ship XDebug with JSPI before we ship asyncify support? If it creates a significant overhead, a ton of Comment by mho22 at 2025-06-03
Ow, I should have thought about this sooner. Thank you!
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.
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 :
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-03Fresh public pr -> yes. I'll answer the rest later |
The next main steps are :
Side steps :
Next follow-up PRs suggestion :
|
This command will compile a given version of xdebug as a dynamic extension :
And copy/paste it in the correct |
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 And even if I find a way to set the php ini entries inside the @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 ); |
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. |
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 |
I followed your suggestion and used the
Additionally, when 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 phpRuntime.FS.mkdirTree(process.cwd());
phpRuntime.FS.mount(phpRuntime.FS.filesystems["NODEFS"], {root: process.cwd()}, process.cwd());
phpRuntime.FS.chdir(process.cwd());
Here's the 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
$test = 42; // I set a breakpoint here
echo "Hello Xdebug World\n";
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! |
This command will compile every version of xdebug as a dynamic extension :
And copy/paste it in the correct node jspi directory. Now every NODE JSPI version of PHP is Xdebug compatible! 🎉 |
@mho22 Great news! What's missing before this one can be reviewed? Edit: Ah, nevermind, I just saw the checklist :) |
I have written new tests related to the dynamic loading of Xdebug. Currently, there are 4 tests for each PHP version.
I consider this is not complete since it can't manage two last tests that I tried to implement, without success :
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 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. |
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. |
You may need to rebase before rebuilding to resolve the conflicts |
@adamziel Yes! My bad |
a265e42
to
3f59b88
Compare
3f59b88
to
b3cc02c
Compare
What, whaaaat! Flaky e2e aside, it seems like all the XDebug JSPI tests passed without breaking the Asyncify tests! 🎉 |
Yes! I wanted to try the I will test running the other 14 tests on I'm still working on the last test, though. |
Alright! I think this pull request is ready for review. I just need to rewrite the description, though. |
.github/workflows/ci.yml
Outdated
matrix: | ||
include: | ||
- name: test-unit-jspi (1/1) | ||
target: test-php-dynamic-loading |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's include jspi
in this identifier to have that information available wherever we're looking at that test
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
export EMCC_FLAGS=""\n\ | ||
fi\n\ | ||
/root/emsdk/upstream/emscripten/emcc2 "$@" $EMCC_FLAGS \n' > /root/emsdk/upstream/emscripten/emcc && \ | ||
RUN <<EOF |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@brandonpayton is the one behind that too 😄.
@@ -0,0 +1,220 @@ | |||
#include <stdio.h> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's document what is this file and why do we need it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comes from @brandonpayton original pull request. I think he would be the best person to document that part.
echo -n ' -lsqlite3 ' >> /root/.emcc-php-wasm-flags; \ | ||
fi; | ||
if [[ "${PHP_VERSION:0:1}" -ge 8 ]] || [ "${PHP_VERSION:0:3}" == "7.4" ]; then \ | ||
# PHP bundled its own sqlite3 library in PHP 7.4 and later. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment discusses using sqlite3 shipped with PHP in PHP >= 7.4, but the code seems to be bringing in our sqlite3 in PHP >= 7.4. Why? Also, I bet libsqlite3 in PHP 7.2 and 7.3 is super outdated, can we continue using ours?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comes from @brandonpayton original pull request. I replaced every -l..
flags with wasm-sources instead but I guess he could answer your question better than I can.
COPY ./php/phpwasm-emscripten-library-known-undefined-functions.js /root/phpwasm-emscripten-library-known-undefined-functions.js | ||
ARG WITH_SOURCEMAPS | ||
ARG WITH_DEBUG | ||
RUN set -euxo pipefail; \ | ||
mkdir -p /build/output; \ | ||
source /root/emsdk/emsdk_env.sh; \ | ||
if [ "$WITH_JSPI" = "yes" ]; then \ | ||
export ASYNCIFY_FLAGS=" -s ASYNCIFY=2 -sSUPPORT_LONGJMP=wasm -fwasm-exceptions -sJSPI_IMPORTS=js_open_process,js_waitpid,js_process_status,js_create_input_device,wasm_setsockopt,wasm_shutdown,wasm_close -sJSPI_EXPORTS=wasm_sleep,wasm_read,emscripten_sleep,wasm_sapi_handle_request,wasm_sapi_request_shutdown,wasm_poll_socket,wrap_select,__wrap_select,select,php_pollfd_for,fflush,wasm_popen,wasm_read,wasm_php_exec,run_cli -s EXTRA_EXPORTED_RUNTIME_METHODS=ccall,PROXYFS,wasmExports,_malloc "; \ | ||
export ASYNCIFY_FLAGS=" -s ASYNCIFY=2 -sSUPPORT_LONGJMP=wasm -fwasm-exceptions -sJSPI_IMPORTS=js_open_process,js_waitpid,js_process_status,js_create_input_device,wasm_setsockopt,wasm_shutdown,wasm_close,wasm_recv -sJSPI_EXPORTS=wasm_sleep,wasm_read,emscripten_sleep,wasm_sapi_handle_request,wasm_sapi_request_shutdown,wasm_poll_socket,wrap_select,__wrap_select,select,php_pollfd_for,fflush,wasm_popen,wasm_read,wasm_php_exec,run_cli,wasm_recv -s EXTRA_EXPORTED_RUNTIME_METHODS=ccall,PROXYFS,wasmExports,_malloc "; \ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wasm_recv is added to both imports and exports. is that intentional?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As strange as it is, it is intentional. I tested without one or the other but it threw errors. To my understanding, if you want to communicate with a wrapped method between a side module and the main module, that wrapped method needs to be in import and export.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's document that inline
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
|
||
|
||
# Build PHP | ||
RUN git clone https://github.com/php/php-src.git php-src \ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make sense reuse the PHP build between this Dockerfile and the php Dockerfile? I guess not since we build it natively here and not as wasm.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had in mind to separate the builds to avoid having to add specific things related to xdebug extension building inside the php wasm dockerfile. It is also faster to build since it is a minimal version. Do you prefer using the php-wasm cached version instead ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have any strong opinions here, I think it's fine as it is
RUN cd /root/php-src && make install | ||
|
||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we remove all the extra newlines everywhere? As in use at most a single blank line to separate things visually
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
@@ -130,6 +132,11 @@ export async function loadNodeRuntime( | |||
phpRuntime.FS.root.mount.opts.root = '.'; | |||
}, | |||
}; | |||
|
|||
if (options?.withXdebug === true) { | |||
emscriptenOptions = await withXdebug(phpVersion, emscriptenOptions); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note xdebug emscripten options get passed twice to loadPHPRuntime – once via withNetworking and once via withICUData. Let's only pass a single emscriptenOptions object in there via something along the lines of await withXdebug(await withNetworking(await withICUData(emscriptenOptions)))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
withXdebug
is optional, so this is what I suggest :
if (options?.withXdebug === true) {
emscriptenOptions = await withXdebug(phpVersion, emscriptenOptions);
}
return await loadPHPRuntime(
await getPHPLoaderModule(phpVersion),
+ await withNetworking(await withICUData(emscriptenOptions))
- await withNetworking(emscriptenOptions);
- await withICUData(emscriptenOptions)
);
or maybe this even if it is redundant :
if (options?.withXdebug === true) {
emscriptenOptions = await withXdebug(phpVersion, emscriptenOptions);
}
+ emscriptenOptions = await withNetworking(emscriptenOptions);
+ emscriptenOptions = await withICUData(emscriptenOptions);
return await loadPHPRuntime(
await getPHPLoaderModule(phpVersion),
+ emscriptenOptions
- await withNetworking(emscriptenOptions);
- await withICUData(emscriptenOptions)
);
What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
version: SupportedPHPVersion = LatestSupportedPHPVersion, | ||
options: EmscriptenOptions | ||
): Promise<EmscriptenOptions> { | ||
if (await jspi()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's be explicit about errors. If withXdebug is called in an environment where xdebug is unsupported, let's bale out. Otherwise the user may be confused "but I enabled it, why doesn't it work?"
if (await jspi()) { | |
if (!await jspi()) { | |
throw new Error(); | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
'xdebug.start_upon_error=yes', | ||
].join('\n') | ||
); | ||
/* The extension needs to mount the current |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What are the exact requirements for that? I worry about host path overlapping with a meaningful VFS path, e.g. /wordpress
. Can we remap that somehow to always mount cwd
at a stable location?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are the Xdebug logs when a break occurs on a breakpoint :
[42] Log opened at 2025-06-17 15:44:12.139630
[42] [Step Debug] INFO: Connecting to configured address/port: localhost:9003.
[42] [Step Debug] WARN: Could not set SO_KEEPALIVE: No error information.
[42] [Step Debug] INFO: Connected to debugging client: localhost:0 (through xdebug.client_host/xdebug.client_port).
[42] [Step Debug] -> <init xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" fileuri="file:///internal/shared/auto_prepend_file.php" language="PHP" xdebug:language_version="8.4.0-dev" protocol_version="1.0" appid="42"><engine version="3.4.4-dev"><![CDATA[Xdebug]]></engine><author><![CDATA[Derick Rethans]]></author><url><![CDATA[https://xdebug.org]]></url><copyright><![CDATA[Copyright (c) 2002-2025 by Derick Rethans]]></copyright></init>
[42] [Step Debug] <- feature_set -i 1 -n resolved_breakpoints -v 1
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="1" feature="resolved_breakpoints" success="1"></response>
[42] [Step Debug] <- feature_set -i 2 -n notify_ok -v 1
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="2" feature="notify_ok" success="1"></response>
[42] [Step Debug] <- feature_set -i 3 -n extended_properties -v 1
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="3" feature="extended_properties" success="1"></response>
[42] [Step Debug] <- feature_set -i 4 -n breakpoint_include_return_value -v 1
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="4" feature="breakpoint_include_return_value" success="1"></response>
[42] [Step Debug] <- feature_set -i 5 -n max_children -v 100
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="feature_set" transaction_id="5" feature="max_children" success="1"></response>
+[42] [Step Debug] <- breakpoint_set -i 6 -t line -f file:///Users/mho/xdebug/template/php/xdebug.php -n 3
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="breakpoint_set" transaction_id="6" id="420001" resolved="unresolved"></response>
[42] [Step Debug] <- run -i 7
+ [42] [Step Debug] -> <notify xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" name="breakpoint_resolved"><breakpoint type="line" resolved="resolved" filename="file:///Users/mho/xdebug/template/php/xdebug.php" lineno="3" state="enabled" hit_count="0" hit_value="0" id="420001"></breakpoint></notify>
+ [42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="run" transaction_id="7" status="break" reason="ok"><xdebug:message filename="file:///Users/mho/xdebug/template/php/xdebug.php" lineno="3"></xdebug:message></response>
[42] [Step Debug] <- stack_get -i 8
+ [42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="stack_get" transaction_id="8"><stack where="{main}" level="0" type="file" filename="file:///Users/mho/xdebug/template/php/xdebug.php" lineno="3"></stack></response>
[42] [Step Debug] <- context_names -i 9 -d 0
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="context_names" transaction_id="9"><context name="Locals" id="0"></context><context name="Superglobals" id="1"></context><context name="User defined constants" id="2"></context></response>
[42] [Step Debug] <- context_get -i 10 -d 0 -c 0
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="context_get" transaction_id="10" context="0"><property name="$test" fullname="$test" type="uninitialized"></property></response>
[42] [Step Debug] <- run -i 11
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="run" transaction_id="11" status="stopping" reason="ok"></response>
[42] [Step Debug] <- stop -i 12
[42] [Step Debug] -> <response xmlns="urn:debugger_protocol_v1" xmlns:xdebug="https://xdebug.org/dbgp/xdebug" command="stop" transaction_id="12" status="stopped" reason="ok"></response>
This is not easy to read so I added a diff +
to show you the important lines here. The debugger identifies the file with its path and the debugger communicates back with Xdebug with the left arrow. So basically, the debugger responds back to Xdebug that he found a breakpoint in xdebug.php
on line 3
with this :
breakpoint_set -i 6 -t line -f file:///Users/mho/xdebug/template/php/xdebug.php -n 3
So mounting the current working directory helps Xdebug find the file in filesystem without having to modify the Xdebug behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does PHPStorm handle that? You can have path mappings there for remote xdebug sessions
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OH, I see! We run an XDebug server, not a client
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's ship this as v1, then do asyncify, and then figure out path mapping in a follow-up PR
There are two pending reviews left where I will need assistance from @brandonpayton:
|
Yay! Thank you ❤️ one thing I want to note: once asyncify works, we will have to figure out the path mapping not just for the reasons I've mentioned earlier, but also to account for mounts. For example: /wordpress living in VFS, /wordpress/wp-content/plugins being mounted from a host directory, and debugging both in a single session. |
@adamziel I think I am on to something for path mapping in VSCode. Something like this :
But I will need to simulate that environment to ensure it works properly. And it is VSCode only. |
Really cool! Can it work for mixing:
? If not, that's fine – we could have a limitation such as "you can debug files that live on your host." Tools wrapping Playground CLI could then have a "debug mode" that a) enables XDebug and b) ensures /wordpress is mounted from host. |
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 numerouswasm-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 inloadNodeRuntime
. AddingwithXdebug : true
will execute multiple tasks from functionwithXdebug
inwith-xdebug.ts
:.so
file from the Node filesystem./internal/shared/extensions
.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 byphp-wasm
will trigger it if that tunnel is set.Testing Instructions
Two files are needed. A PHP file to test the step debugger. A JS file to run PHP 8.4 node JSPI.
php/xdebug.php
scripts/node.js
To achieve the test, you first need to start debugging [ with F5 or Run > Start debugging in VSCode ], then run :