-
Notifications
You must be signed in to change notification settings - Fork 272
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
JSPI support #1339
JSPI support #1339
Conversation
Got stuck at ``` RuntimeError: trying to suspend JS frames ``` Maybe I need to build with wasm flags
@adamziel If I'm understanding this correctly, will JSPI ultimately replace Asyncify? I tried to run the code but I couldn't build
Needless to say, this still looks brilliant! |
@mho22 only web light 8.0 works for now, I'm figuring out a node build as we speak. JSPI will eventually replace Asyncify, yes, but I'm not sure about the timeline. I don't want to maintain two versions of PHP so maybe we'll wait for a better support in other runtimes and only use it to produce the ASYNCIFY_ONLY list for now. |
@mho22 I got it to work on Node.js with curl! It doesn't seem to receive any data yet, but it doesn't crash at least. |
Something's wrong with writing to the socket β the WS proxy seems to be connecting, but there's no data going either way.
|
@adamziel I added an option in
What about that strange It seems that it correctly calls
Here are the two last
After digging this, I found out the
where
|
@adamziel Hourray : here we go ! Next step β‘ nodecurl DEV=1 node --experimental-wasm-stack-switching cli.js curl.php
[WS Server] Binding the WebSockets server to 127.0.0.1:58384...
* STATE: INIT => CONNECT handle 0x1037ab8; line 1619 (connection #-5000)
* Added connection 0. The cache now contains 1 members
* Trying 172.29.1.0:443...
* STATE: CONNECT => WAITCONNECT handle 0x1037ab8; line 1675 (connection #0)
[WS Server] 127.0.0.1: WebSocket connection from : 127.0.0.1 at URL /?host=wordpress.org&port=443
[WS Server] 127.0.0.1: Version undefined, subprotocol: binary
[WS Server] 127.0.0.1: resolving wordpress.org...
[WS Server] 127.0.0.1: resolved wordpress.org -> 198.143.164.252
[WS Server] 127.0.0.1: Opening a socket connection to 198.143.164.252:443
* Connected to wordpress.org (172.29.1.0) port 443 (#0)
* STATE: WAITCONNECT => SENDPROTOCONNECT handle 0x1037ab8; line 1795 (connection #0)
* Marked for [keep alive]: HTTP default
node:internal/process/promises:289
triggerUncaughtException(err, true /* fromPromise */);
^
[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "#<ErrnoError>".] {
code: 'ERR_UNHANDLED_REJECTION'
}
Node.js v22.0.0 To make this happen, I had to create a Wasm poll socket method code hereEM_ASYNC_JS(int, wasm_poll_socket, (php_socket_t socketd, int events, int timeout), {
const POLLIN = 1;
const POLLPRI = 2;
const POLLOUT = 4;
const POLLERR = 8;
const POLLHUP = 16;
const POLLNVAL = 32;
return new Promise( async (wakeUp) =>
{
const polls = [];
if( socketd in PHPWASM.child_proc_by_fd )
{
const procInfo = PHPWASM.child_proc_by_fd[socketd];
if( procInfo.exited )
{
wakeUp(0);
return 0;
}
polls.push(PHPWASM.awaitEvent(procInfo.stdout, "data"));
}
else
{
const sock = getSocketFromFD(socketd);
if(!sock)
{
wakeUp(0);
return 0;
}
const lookingFor = /* @__PURE__ */ new Set();
if( events & POLLIN || events & POLLPRI )
{
if( sock.server )
{
for( const client of sock.pending )
{
if( ( client.recv_queue || [] ).length > 0 )
{
wakeUp(1);
return 1;
}
}
}
else if( ( sock.recv_queue || []).length > 0 )
{
wakeUp(1);
return 1;
}
}
const webSockets = PHPWASM.getAllWebSockets(sock);
if( !webSockets.length )
{
wakeUp(0);
return 0;
}
for( const ws of webSockets )
{
if( events & POLLIN || events & POLLPRI )
{
polls.push(PHPWASM.awaitData(ws));
lookingFor.add("POLLIN");
}
if( events & POLLOUT )
{
polls.push(PHPWASM.awaitConnection(ws));
lookingFor.add("POLLOUT");
}
if( events & POLLHUP )
{
polls.push(PHPWASM.awaitClose(ws));
lookingFor.add("POLLHUP");
}
if( events & POLLERR || events & POLLNVAL )
{
polls.push(PHPWASM.awaitError(ws));
lookingFor.add("POLLERR");
}
}
}
if( polls.length === 0 )
{
console.warn( "Unsupported poll event " + events + ", defaulting to setTimeout()." );
setTimeout( function()
{
wakeUp(0);
return 0;
}, timeout );
return 0;
}
const promises = polls.map(([promise]) => promise);
const clearPolling = () => polls.forEach(([, clear]) => clear());
let awaken = false;
let timeoutId;
Promise.race(promises).then( function(results)
{
if( !awaken )
{
awaken = true;
wakeUp(1);
return 1;
if( timeoutId )
{
clearTimeout(timeoutId);
}
clearPolling();
}
});
if( timeout !== -1 )
{
timeoutId = setTimeout( function()
{
if( !awaken )
{
awaken = true;
wakeUp(0);
clearPolling();
return 0;
}
}, timeout );
}
} );
} ); |
@mho22 It worked! I did what you said, moved a few more functions over to C, and added the
|
Multihandle requests also work: function handle_for_url($url)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_VERBOSE, 1);
curl_setopt($ch, CURLOPT_TCP_NODELAY, 0);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYSTATUS, 0);
$streamVerboseHandle = fopen('php://stdout', 'w+');
curl_setopt($ch, CURLOPT_STDERR, $streamVerboseHandle);
return $ch;
}
$ch1 = handle_for_url('https://api.wordpress.org/stats/locale/1.0/');
$ch2 = handle_for_url('https://api.wordpress.org/stats/wordpress/1.0/');
$ch3 = handle_for_url('https://api.wordpress.org/stats/php/1.0/');
$mh = curl_multi_init();
curl_multi_add_handle($mh, $ch1);
curl_multi_add_handle($mh, $ch2);
curl_multi_add_handle($mh, $ch3);
$running = null;
do {
curl_multi_exec($mh, $running);
} while ($running);
$output1 = curl_multi_getcontent($ch1);
$output2 = curl_multi_getcontent($ch2);
$output3 = curl_multi_getcontent($ch3);
curl_multi_remove_handle($mh, $ch1);
curl_multi_remove_handle($mh, $ch2);
curl_multi_remove_handle($mh, $ch3);
curl_multi_close($mh);
var_dump($output1);
var_dump($output2);
var_dump($output3); |
I replaced
|
@adamziel I attempted to troubleshoot the issue on my end to understand the process, but encountered errors such as :
There is something creating a However, despite these issues, this improvement is quite exciting! I'm concerned that the 'ASYNCIFY_ONLY' list might already contain the methods you listed above, so it would be perfect to have the exact list from 'run_cli' to the end. |
@mho22 I must have made a typo before committing. Both
Actually... I added them to the list, rebuilt PHP, and Curl worked :-) I'll update your PR shortly. |
@adamziel I hadn't seen the |
@mho22 it's teamwork :-) It wouldn't get this far without your perseverance. You've done brilliant work on curl support β thank you for that! π |
Ships the Node.js version of PHP built with `--with-libcurl` option to support the curl extension. It also changes two nuances in the overall PHP build process: * It replaces the `select(2)` function using `-Wl,--wrap=select` emcc option instead of patching PHP source code β this enables supporting asynchronous `select(2)` in curl without additional patches. * Brings the `__wrap_select` implementation more in line with `select(2)`, add support for `POLLERR`. * Adds support for polling file descriptors that represent neither child processes nor streams in `poll(2)` β that's because `libcurl` polls `/dev/urandom`. Builds on top of and supersedes #1133 ## Debugging Asyncify problems The [typical way of resolving Asyncify crashes](https://wordpress.github.io/wordpress-playground/architecture/wasm-asyncify/) didn't work during the work on this PR. Functions didn't come up in the error messages and even raw stack traces. The reasons are unclear. [The JSPI build of PHP](#1339) was more helpful as it enabled logging the current stack trace in all the asynchronous calls, which quickly revealed all the missing `ASYNCIFY_ONLY` functions. This is the way to debug any future issues until we fully migrate to JSPI. ## Testing Instructions Confirm the CI checks pass. This PR ships a few new tests specifically targeting networking with curl. ## Related resources * #85 * #1093 --------- Co-authored-by: Adam ZieliΕski <adam@adamziel.com> Co-authored-by: MHO <yannick@chillpills.io>
A note in case it is valuable: |
It looks like Firefox support for JSPI may be progressing well: |
Let's unblock this. The way forward here is to ship two set of WASM binaries, (one using Asyncify and one using JSPI) and use feature detection to load the right one for the current runtime. As maintainers, we'll have to spend twice as long rebuilding WASM binaries, so be it. That's the price to pay for giving the users a much more reliable platform. Node.js v20 will be supported until 2026 and there's no clear timeline for JSPI support in Safari. We don't have to hold this work up because of that. Chrome, Firefox, and Node v22 all support JSPI and we can give those users a much better experience while also moving on from maintaining the list of Asyncify C functions. Notably, Node v22 is about to become the active LTS release. |
680cd19
to
2e376d2
Compare
β¦sink" one Maintaining the "light" PHP.wasm bundle is a burden (#1848), especially with the JSPI support on the horizon (#1339). Only 1.3% of Playgrounds are loaded with the `light` extension bundle (see #1848). Let's thus simplify the project maintenance and remove the "light" bundle. After this PR, every Playground will be loaded with the "kitchen-sink" extension bundle. This PR also drops the name "kitchen-sink". From now on, it's just "php", ## Testing instructions * Confirm Playground continues to work in the local dev env * Confirm the CI is green β it would perform a thorough test run of many user flows. Closes #1848
This was a great exploratory PR. Let's close it, keep it for reference, and clean get JSPI ready for production in #1867 |
β¦n-sink" build (#1861) Maintaining the "light" PHP.wasm bundle is a burden (#1848), especially with the JSPI support on the horizon (#1339). Only 1.3% of Playgrounds are loaded with the `light` extension bundle (see #1848). Let's thus simplify the project maintenance and remove the "light" bundle. After this PR, every Playground will be loaded with the "kitchen-sink" extension bundle. This PR also drops the name "kitchen-sink". From now on, it's just "php", ## Developer notes The web version of Playground at playground.wordpress.net no longer ships the `light` build of PHP.wasm. Instead, it only ships the `kitchen-sink` build where popular PHP extensions are available (e.g. libxml, libopenssl). Note that the `kitchen-sink` build was already used by 98.7% of Playgrounds and most users are not affected by this. This change will help Playground maintainers focus on a single version and enable solving a lot of PHP.wasm stability issues by [migrating to JSPI](#1339). The CLI version of Playground is unaffected by this change. The following APIs are now deprecated. Playground will accept them without crashing, but they will no longer have any effect: * `phpExtensionBundles` Blueprint setting * `php-extension-bundle` Query API parameter ## Follow up work - [ ] Publish the developer notes on https://make.wordpress.org/playground/ - [x] Share this in the WP.org Slack channel ([done](https://wordpress.slack.com/archives/C04EWKGDJ0K/p1728405828152549)) ## Testing instructions * Confirm Playground continues to work in the local dev env * Confirm the CI is green β it would perform a thorough test run of many user flows. Closes #1848
## Motivation Ships every PHP.wasm build and dependency in two versions: JSPI, Asyncify. Updates `@php-wasm/web` and `@php-wasm/node` to use the JSPI version when the current runtime supports it, and and fall back to Asyncify otherwise. Why use JSPI? See #134. Tl;dr it will make PHP.wasm a whole lot more reliable. ## Implementation details This builds on top of the explorations done in #1339 β check the description and discussion there for the full "getting there" journey and detailed learnings. ### @php-wasm/compile The main Makefile ships an `_asyncify` and a `_jspi` version of every build task. Libraries, such as `libcurl` and `libedit`, store now each ship an Asyncify build and a JSPI build. Every JSPI build uses `-sSUPPORT_LONGJMP=wasm -fwasm-exceptions` flags. Asyncify builds are the same as before this PR and don't use those flags. ### @php-wasm/web and @php-wasm/node * PHP builds are shipped in `jspi` and `asyncify` subdirectories. * Emscripten doesn't export `free()` when using JSPI so we're exporting our own `wasm_free()` function. * `getPHPLoaderModule()` uses [wasm-feature-detect](https://github.com/GoogleChromeLabs/wasm-feature-detect) to check for JSPI support and load the right build. * Asynchronous JavaScript functions were moved from `phpwasm-emscripten-library.js` to `php_wasm.c` using the `EM_ASYNC_JS` macro for JSPI builds and `EM_JS` macro for Asyncify builds. * Unit tests are now ran separately on JSPI and Asyncify builds. ## Runtime support as of Oct 10th, 2024 JSPI is supported in: - β Google Chrome with `#enable-experimental-webassembly-jspi` enabled at `chrome://flags`, or with sites where the JSPI origin trial is enabled. playground.wordpress.net is enrolled in the origin trial. - β Node.js v22+ with `--experimental-wasm-stack-switching` feature flag. - β Deno, with `--v8-flags=--experimental-wasm-jspi` feature flag - β [Firefox](https://bugzilla.mozilla.org/show_bug.cgi?id=1850627) - ? Chrome-based browsers like Edge - β Safari - β Non-Chrome, non-Firefox web browsers - β Node.js <= 21 - β Non-v8 JS runtimes like Bun ## Testing instructions The E2E tests are a great source of insights: * Chrome supports JSPI * Firefox supports JSPI but has a separate implementation * Safari doesn't support JSPI and will use the Asyncify build We should also run the unit tests in Node v20 and v23 using with the experimental JSPI support flag.
What is this PR doing?
π§ DO NOT MERGE β it would break the web and Node.js Playground for everyone π§
I got Playground to build with JSPI support!
This PR is based on the CURL PR by @mho22. Curl support was merged so this PR ships more parts than needed β let's clean them up.
Testing
In the browser
In Node.js
Run this on Node v22:
Runtime support as of April 29th, 2024
JSPI is supported on:
#enable-experimental-webassembly-jspi
enabled atchrome://flags
, or with sites where the JSPI origin trial is enabled.--experimental-wasm-stack-switching
feature flag.--v8-flags=--experimental-wasm-jspi
feature flagDebugging Asyncify issues with JSPI
global.asyncifyFunctions
(or log juste.stack
in that snippet)ASYNCIFY_ONLY
listRemaining work
wasm_free
method to export here instead of_free
as we can no longer use the_free
reference.Rant
Building for JSPI is extremely difficult.
I tried JSPI a year ago and gave up. These errors seemed opaque, there were no documentation or guidance on how to solve them, I'd have to debug emscripten which I wasn't willing to do as I didn't have enough confidence JSPI even worked.
This time I also nearly gave up, concluding Emscripten doesn't support this or that feature β there's a lot of discussions with conflicting information on the internet. Then I created a repository with a bunch of simpler builds just to verify the features PHP uses can actually work β and they all worked once I figured out the right flags.
There are cryptic errors behind every corner, there's no documentation, and even tracing the error message to the line that creates an error in v8 isn't very helpful as it's unclear how to proceed.
I only got it to work because I got lucky. I went down the wrong rabbit hole in the beginning, chasing a
-fwasm-exceptions
flag and a-sSUPPORT_LONGJMP=wasm
flag that I thought was necessary to build. It wasn't, I just tried using libraries built without JSPI with an older Emscripten. But spending a few hours reading related issues allowed me to make the mental connections I needed to fix a few errors later on, like:missing function: saveSetjmp
(when a dependent library was build without-fwasm-exceptions -sSUPPORT_LONGJMP=wasm
or with-fexceptions
or with-sSUPPORT_LONGJMP=emscripten
).invalid suspender object for suspend
(when the called function is not listen in theASYNCIFY_EXPORT
list)trying to suspend JS frames
(When the main program is built without-fwasm-exceptions -sSUPPORT_LONGJMP=wasm
and an asynchronous call happens with a JavaScriptinvoke_iii
function in a call stack, apparently JSPI doesn't do stack switching like Asyncify did but sets aside an entire wasm context. Because there is no stack rewinding in the same way as with Asyncify, there's no way to put the JS functions back in the call stack when the call is resumed later on.)At one point I lucky-guessed an undocumented ASYNCIFY_EXPORT option exists.
At another I found someone on GitHub posing a JavaScript patch necessary to get the JavaScript module Emscripten produces to work with dynamic calls:
That patch solved the problem at hand, but later on it turned out my dependencies were built in a wrong way and I had to go back and rebuild everything.
It would be so useful to have documentation that explains:
make
pipeline when there is no direct access toemcc cc
call (Playground patches theemcc
script to both add and remove flags).What I've learned
Consider the following example:
Emscripten offers two APIs for integrating asynchronous JavaScript code with the synchronous WASM code: Asyncify and JSPI. Both APIs would pause the C program when the asynchronous
javascript_fetch_status_code()
call is encountered, but they work in vastly different ways and it took me a while to figure out how to work with them.Asyncify
The older API is called Asyncify and implements an idea called stack switching. When using Asyncify, the
handleSleep()
function saves ("unwinds") the current call stack and yields control back to JavaScript to perform thefetch()
call. When the response comes back andwakeUp()
is called, Asyncify "rewinds" the call stack by setting an internalAsyncify.state
variable toAsyncify.State.Rewinding
and calling all the functions on the call stack until it reaches thehandleSleep()
call. At that point, thewakeUp()
argument (HTTP code200
) is returned from thejavascript_fetch_status_code()
function and the C (WASM) code execution continues.The downside of stack switching is that every C and JavaScript function on the call stuck must be instrumented to support rewinding/unwinding. Why? Because our compiled
get_http_status_code
would we called twice: before the async call, and after the async call. However, theget_url();
function will only be called once, before the stack unwinding.Asyncify automatically detects the C functions triggering asynchronous calls and instruments them at the compile time. JavaScript functions must be instrumented by the implementer, typically by adding a check like
if( Asyncify.state === Asyncify.State.Normal ) { }
to ensure the code beforereturn handleSleep()
only runs once before the stack unwinding and is not executed again by the rewinding process.The compilation is simple:
However, the automated instrumentation added by Asyncify makes the build larger and slower.
Asyncify Overhead
For PHP.wasm, the automated Asyncify instrumentation made the build 2x larger and ~4x slower, meaning it took 4s-5s to boot. Would you use a CLI tool that makes you wait at least 5s for the output every time? I wouldn't, so I found an alternative approach.
Asyncify allows you to provide a list of specific functions to instrument via the
-s ASYNCIFY_ONLY
flag and skip all the automated detection. I did that and, today, the PHP.wasm build command lists around 300 PHP functions that may appear on the call stack in different asynchronous contexts. That's a huge improvement over the 70,000 functions that Asyncify would otherwise auto-instrument.Debugging issues
Missing even a single function from the
ASYNCIFY_ONLY
list leads a fatal crash when that function is present on the call stack when the asynchronous call happens.The message is typically a cryptic
"unreachable" WASM instruction executed
with no additional debugging information. You can't even check the stack trace because the way Emscripten optimizes against memory leaks gives you no useful.stack
property on caught JavaScript errors.WordPress Playground patches Emscripten to preserve the stack traces and report which C function wasn't properly instrumented, but it's not perfect and sometimes won't reveal the right function.
In those scenarios, we typically had to read the PHP (or curl, or openssl, or...) source code and try to figure out the right function to list β as you can imagine it was a difficult and tedious process.
Summary
JSPI
JSPI is a newer API that enables async operations without instrumenting all the call stack functions.
Both Asyncify and JSPI perform stack switching, but Asyncify does it in JavaScript while JSPI does it in WebAssembly thanks to new browser APIs outlined in the spec proposal.
In practice, this means:
While Asyncify played nicely with Emscripten-generated JavaScript wrappers for dynamic calls, e.g.
invoke_i
,invoke_viii
, ordynCall_iiii
, JSPI won't work when they're present. It will error out trying to suspend JS frames. You need to rebuild your WASM program and all the depedent libraries with-sSUPPORT_LONGJMP=wasm -fwasm-exceptions
to get rid of those wrappers. Building with either no flags or-sSUPPORT_LONGJMP=emscripten -fexceptions
will produce the JSPI-incompatible JavaScript wrappers.Note these are compile-time flags, not linker flags β I lost a few hours figuring that out.
If you notice any
invoke_*
functions in the built JavaScript module, it's likely in there because one of your build dependencies was built with JavaScript instrumentation, not WASM instrumentation. You'll have to go back and rebuild it and all its dependencies until the entire build graph is JSPI-compatible.Another thing β Asyncify supported asynchronous calls in functions exposed from a
--js-library
, but with JSPI you need to use theEM_ASYNC_JS
C macro and specify those functions in C. I didn't figure out exactly why yet, as their JavaScript part is still included verbatim in the final JS module, but it didn't work for us otherwise.Summary
ASYNCIFY_EXPORTS
) and the asynchronous JS function they call (viaASYNCIFY_IMPORTS
).Related resources
These were helpful along the way:
cc @brandonpayton @mho22 @dmsnell @bgrgicak