wordpress-playground
wordpress-playground copied to clipboard
Explore using JSPI for asynchronous calls in WASM
Description
Making network calls from PHP requires handling them in JavaScript. However, WASM is synchronous and JavaScript is asynchronous. The two work can together is via Asyncify, but there is a huge downside. Namely, all the synchronous C methods that can possibly be the call stack at the time of making an asynchronous call must be listed during the build:
https://github.com/WordPress/wordpress-playground/blob/1b65d583cdc7e41ea0b0aa9bceab5cb997a14505/src/php-wasm/wasm/Dockerfile#L614-L620
Omitting even one will trigger a crash when it triggers an asynchronous call. This is hard to maintain and prone to errors, so let's explore migrating to the new JSPI API announced by the V8 team.
A relevant note from @fgmccabe:
The current implementation in V8 is essentially 'experimental status'. We have arm64 and x64 implementations. The next steps are to implement on 32 bit arm/intel. That requires us to solve some issues that we did not have to solve so far. As for node.js, my guess is that it is already in node, behind a flag. To remove the flag requirement involves getting other implementations. The best estimate for that is towards the end of this year; but it obviously depends on resources and funding. In addition, it would need further progress in the standardization effort; but, given that it is a 'small' spec, that should not be a long term burden. Hope that this helps you understand the roadmap :)
JSPI is available in Node.js behind the --experimental-wasm-stack-switching
flag – thank you for sharing @fmgccabe!
Here's some notes I took to find that flag:
- Stack switching in V8
- Experimental flag in V8
- The same flag in Node.js
- --trace_wasm_stack_switching used in tests
-
node --v8-options
gives all v8-specific CLI options -
One of them is
--experimental-wasm-stack-switching
It's available in Node 17, 18, 19, and 20. Node 16 exists with a bad option
error message.
I tried:
-s ASYNCIFY=2
-s ASYNCIFY_EXPORTS="$EXPORTED_FUNCTIONS" \
-s ASYNCIFY_IMPORTS='["wasm_setsockopt","js_popen_to_file","wasm_socket_has_data","wasm_poll_socket","wasm_close","wasm_shutdown"]' \
(not all of these functions are needed but I just wanted to get something working)
And ran
nx build php-wasm-node
nx build wp-now
node --experimental-wasm-stack-switching --loader=./packages/nx-extensions/src/executors/built-script/loader.mjs --stack-trace-limit=50 dist/packages/wp-now/main.js start --path=./plugin --php=7.4
And got this error:
TypeError: Cannot read properties of undefined (reading '0')
at sigToWasmTypes (/playground/dist/packages/php-wasm/node/index.cjs:30939:19)
at /playground/dist/packages/php-wasm/node/index.cjs:30959:26
at Object.instrumentWasmImports (/playground/dist/packages/php-wasm/node/index.cjs:30966:11)
at Object.init4 (/playground/dist/packages/php-wasm/node/index.cjs:31353:12)
at loadPHPRuntime (/playground/dist/packages/php-wasm/node/index.cjs:67103:38)
at doLoad (/playground/dist/packages/php-wasm/node/index.cjs:67997:31)
at processTicksAndRejections (node:internal/process/task_queues:95:5)
at async Function.load (/playground/dist/packages/php-wasm/node/index.cjs:67982:12)
at async _WPNow.setup_fn (file:///playground/dist/packages/wp-now/main.js:350:14)
at async Function.create (file:///playground/dist/packages/wp-now/main.js:204:5)
at async startServer (file:///playground/dist/packages/wp-now/main.js:469:17)
at async Object.handler (file:///playground/dist/packages/wp-now/main.js:569:9)
Turns out instrumentWasmImports
generated by Emscripten could had no exports signatures stored. I did some monkeypatching and added this:
var Asyncify = {
instrumentWasmImports: function(imports) {
// ...
if (isAsyncifyImport) {
if (x === 'js_popen_to_file') {
sig = "iiii";
} else if (x === 'wasm_close') {
sig = "ii";
} else if (x === 'wasm_shutdown') {
sig = "iii";
} else if (x === 'wasm_poll_socket') {
sig = "iiii";
} else if (x === 'wasm_setsockopt') {
sig = "piiipii";
}
And got one step further:
RuntimeError: null function or function signature mismatch
at call (wasm://wasm/02952aa6:wasm-function[5683]:0x3f303a)
at ret.<computed> (playground/dist/packages/php-wasm/node/index.cjs:30981:31)
at Module.dynCall_v (playground/dist/packages/php-wasm/node/index.cjs:31527:75)
at invoke_v (playground/dist/packages/php-wasm/node/index.cjs:31628:7)
at invoke_v (wasm://wasm/02952aa6:wasm-function[371]:0x2bfad)
at php_module_startup (wasm://wasm/02952aa6:wasm-function[5142]:0x390ac6)
at wasm_sapi_module_startup (wasm://wasm/02952aa6:wasm-function[4570]:0x31b10b)
at byn$fpcast-emu$wasm_sapi_module_startup (wasm://wasm/02952aa6:wasm-function[5725]:0x3f3cc9)
at php_wasm_init (wasm://wasm/02952aa6:wasm-function[9870]:0x560402)
at ret.<computed> (playground/dist/packages/php-wasm/node/index.cjs:30981:31)
at Module._php_wasm_init (playground/dist/packages/php-wasm/node/index.cjs:31467:89)
at Object.ccall (playground/dist/packages/php-wasm/node/index.cjs:31062:20)
at _NodePHP.#initWebRuntime (playground/dist/packages/php-wasm/node/index.cjs:67350:32)
at _NodePHP.run (playground/dist/packages/php-wasm/node/index.cjs:67315:27)
at PHPRequestHandler.#dispatchToPHP (playground/dist/packages/php-wasm/node/index.cjs:66884:29)
at async PHPRequestHandler.request (playground/dist/packages/php-wasm/node/index.cjs:66802:12)
at async PHPBrowser.request (playground/dist/packages/php-wasm/node/index.cjs:66596:22)
Seems like an error in a dynamic call, so I added invoke_*
to the list of wrapped imports:
var isAsyncifyImport = ASYNCIFY_IMPORTS.indexOf(x2) >= 0
|| x2.startsWith("__asyncjs__")
|| x2.startsWith("invoke")
;
if(x === 'invoke_viidii') {
return; // I couldn't get this one right
} else if(x.startsWith('invoke_v')) {
sig = x.substr(7) + 'i';
}
But I'm not sure if that was a good step, the error now is:
RuntimeError: invalid suspender object for suspend
at invoke_v (wasm://wasm/02952aa6:wasm-function[371]:0x2bfad)
at php_module_startup (wasm://wasm/02952aa6:wasm-function[5142]:0x390ac6)
at wasm_sapi_module_startup (wasm://wasm/02952aa6:wasm-function[4570]:0x31b10b)
at byn$fpcast-emu$wasm_sapi_module_startup (wasm://wasm/02952aa6:wasm-function[5725]:0x3f3cc9)
at php_wasm_init (wasm://wasm/02952aa6:wasm-function[9870]:0x560402)
...
I am running on latest emsdk as of today, which wraps the imports in:
imports[x2] = original = new WebAssembly.Function(type, original, {
suspending: "first"
});
And the exports in:
return new WebAssembly.Function({
parameters,
results: ["externref"]
}, original, {
promising: "first"
});
I'm stuck at this point and the JSPI API is still experimental – let's revisit this once the API is stable.
@adamziel is there a short version of what ASYNCIFY_ONLY
does? is it a macro that wraps those functions or rewrites them into some continuable state machine?
@dmsnell pretty much yes – it rewrites these functions as a continuable state machine.
When the async call is made, it saves the call stack and sets a global flag to make all the functions on that call stack short-circuit. Then it synchronously returns.
When the async call is finished, it restores the call stack, sets other global flags, and continues right after the async call.
Note that we need to use ASYNCIFY_ONLY
now when PHP is built with ASYNCIFY=1
. This issue explores switching to a new experimental API called JSPI
that is activated via ASYNCIFY=2
. Thia naming is confusing as these are two completely different APIs. JSPI is smart about the call stack and only requires us to list asynchronous C-level imports and exports.
Related: https://github.com/WordPress/wordpress-playground/issues/404
JSPI seems to be now available via Origin Trials. I wonder if Playground should ship JSPI code for Chrome and Asyncify code for other browsers. It would be a maintenance burden for a while, but it would get a lot more stable in Chrome. CC @bgrgicak
Most visitors use Chrome, so it would be beneficial to do it. Personally I would prefer not to support two versions, but if we can do it in a clean way it should be ok.
The issue of different browsers is likely to be a temporary phenomenon. E.g., Mozilla is already working on their implementation of JSPI in Firefox.
Let's explore switching to JSPI for the Node.js version of Playground as it would solve a lot of the "null function or function signature mismatch" issues. A few questions to answer:
- Could it work on Node v18+?
- If not, could we ship it as a bun executable with a JSPI support enabled by default?
- Would it be easy to maintain both Asyncify and JSPI implementations concurrently?
- How easy would it be to also ship JSPI implementation in Chrome to fix those errors for 80% of the users?
cc @brandonpayton – would you look into that next?
Also CC @bgrgicak – let's hold on with fixing these one-off "null function or function signature mismatch" errors until we can confirm or reject the JSPI usage in the short term. They take a lot of time to fix and that time is not a good long-term investment considering the new API will solve it all in one go.
Also CC @mho22 as that's relevant to your libcurl explorations.
A couple of comments:
- We are currently in 'origin trial' for JSPI in chrome, as of Chrome M123. I don't know how that relates to Node.js: a. Is there an equivalent process in Node? b. In Chrome, unless you turn on the flag or subscribe to the OT, JSPI is not enabled for you.
- We are currently implementing a revised API for JSPI; based on community feedback. This is not the same as that used currently and will require revised tooling support. The 'old' API will continue to be available throughout the lifetime of the OT. OTOH, we will likely introduce the new API alongside as soon as its ready.
- The next step in the process will be to move the proposal to 'phase 4'. That depends on a bunch of things (second implementation, specification text being ready, potentially more spec tests)
I hope that this helps in understanding the current status of JSPI. Note that, given Node.js tracks V8, and that eventually JSPI will be shipping in V8, it seems that JSPI is actually coming to Node.js.
Webassembly indicates that JSPI
can be enabled in Node with the flag --experimental-wasm-jspi
. And it seems like Node has these options written in two V8 tests. The latest V8 version tag on node is 12.3.22
and it has been updated yesterday.
The last [wasm][jspi]
commit made in V8 was added yesterday too on tag 12.6.55
. I suppose it is just a matter of time before node
updates v8
to 12.6.55
patch and using node
with option --experimental-wasm-jspi
. Still not available on [email protected]
...
Resource about feature flags – sometimes you use Chrome flags, Node flags, V8 flags – I'm confused which is which, but maybe one of those listed there would work https://webassembly.org/features/
I am planning to look at this next, starting with testing a simple C program with no Playground involved.
From @adamziel:
- Can we use JSPI in Node, or not?
- If yes, can we use it with Node.18?
- If not, can we use it with Bun?
I got frustrated with an Asyncify error and spent some time exploring this again and writing up some notes. The last time I failed, I couldn't make it past the TypeError: undefined is not a constructor ('WebAssembly.Function')
. This time, I got JSPI to actually run:
- ✅ It works in Chrome pretty well
- ❌ It doesn't work in Safari, Firefox, etc. Not sure about Chrome-based browsers
- ✅ It works in Node.js v22 with an experimental flag
- ❌ It doesn't work in Bun or Node.js v21.
Prep work
Install Emscripten
Create a simple jspi-experiment.c
program:
#include <emscripten.h>
#include <stdlib.h>
#include <stdio.h>
unsigned int async_call_sleep(unsigned int time)
{
emscripten_sleep(time * 1000);
return time;
}
int main()
{
printf("Hello, World (JSPI!)\n");
async_call_sleep(3);
printf("Goodbye, World (JSPI!)\n");
return 0;
}
Create a build.sh
file with the following content:
#!/bin/bash
emcc -O0 -g2 \
-sENVIRONMENT=$1 \
-sASYNCIFY=2 \
-sEXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' \
-sEXPORTED_FUNCTIONS='["_main"]' \
-o jspi-experiment.$2 \
jspi-experiment.c
Building for Chrome
- Run
bash build.sh web html
- Enable the
#enable-experimental-webassembly-jspi
atchrome://flags
. - Restart Chrome
- Start a local server as
python3 -m http.server
- Go to
http://localhost:8000/jspi-experiment.html
You should see
Hello, World (JSPI!)
Goodbye, World (JSPI!)
The feature flag isn't a show-stopper. JSPI can be enabled for the entire playground.wordpress.net site via origin trials (https://v8.dev/blog/jspi-ot).
Building for Node.js
- Run
bash build.sh node js
- Run
nvm use 22
to switch to Node 22 - Run the script
node --experimental-wasm-jspi jspi-experiment.js
You should see
Hello, World (JSPI!)
Goodbye, World (JSPI!)
JSPI is only available in Node.js, not Bun, and only in v22
You can see a list of all V8 options Node supports with node --v8-options
, JSPI is only available in Node 22:
; nvm use 21
; node --v8-options | grep jspi
; nvm use 22
; node --v8-options | grep jspi
--experimental-wasm-jspi (enable javascript promise integration for Wasm (experimental))
type: bool default: --no-experimental-wasm-jspi
Also, it doesn't work with Bun yet:
1269 | type.parameters.unshift('externref');
1270 | imports[x] = original = new WebAssembly.Function(
^
TypeError: undefined is not a constructor (evaluating 'new WebAssembly.Function(type, original, { suspending: "first" })')
@adamziel if we implement JSPI support, would we have access to the kinds of calls that were being made from PHP -> JS and possibly be able to log those if they aren't in our current Asyncify list?
If so, maybe real world usage of JSPI could help us update the Asyncify lists and avoid crashes in the non-JSPI builds.
You sir are a genius @brandonpayton
Next steps
https://github.com/WordPress/wordpress-playground/issues/134 works in Node.js v22 and Chrome – both require a feature flag or applying to an origin trial.
I don't expect stable JSPI support in all major runtimes (Chrome, Firefox, Safari, mobile browsers, last 3 Node versions) for the next year, two, or even three. I'm happy to be wrong here, but I didn't see any proof of imminent rollout.
Here's what we could do:
- Build both the Asyncify and JSPI versions of the kitchen sink bundle (but not of the light bundle to save on the build time)
- Deploy both to playground.wordpress.net
- Ship the JSPI version to Chrome users, ship the Asyncify versions to everyone else
- Keep the Node.js on Asyncify for the time being
This should get us:
- Less bug reports
- An easy way to ship JSPI to more users as other browsers catch up
Firefox just shipped JSPI support. I'm not sure how extensive it is, but I just managed to run a simple JSPI program in Firefox nightly. I haven't tested PHP.wasm.