Ship the JSPI kitchen-sink build to Chrome users
adamziel opened this issue · 21 comments
Description
We use Asyncify to call asynchronous JavaScript functions from synchronous WebAssembly code.
However, Asyncify forces us to maintain a large list of all the C functions that can be at the call stack at the time of making an asynchronous call must be listed during the build. When we miss even one, that code path triggers a fatal crash ("unreachable" WASM instruction executed). People report those crashes rather often.
JSPI (proposal) is the new API that doesn't require maintaining that list and produces a smaller and faster binary.
Let's explore migrating to JSPI to solve these issues and more:
Next steps
We have a PR adding JSPI Support. It 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:
- #1346
- Adjust the PR to 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 bundles 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 people experiencing Asyncify crashes
- An easy way to upgrade more users to JSPI as other browsers catch up
Runtime support as of April 29th, 2024
JSPI is supported on:
- ✅ Google Chrome with
#enable-experimental-webassembly-jspienabled atchrome://flags, or with sites where the JSPI origin trial is enabled. - ✅ Node.js v22+ with
--experimental-wasm-stack-switchingfeature flag. - ✅ Firefox nightly
- ❌ Safari, older browsers
- ❌ Bun, Node.js <= 21
- ❔ I'm Not sure about Chrome-based browsers
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-optionsgives 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.
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
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 node@21.7.3...
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-jspiatchrome://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 22to 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
#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.
Done in #1867 🎉 JSPI-enabled browsers (FF, Chrome) will load the JSPI version and the rest will load the Asyncify versions. It's based on feature detection so we don't have to do anything special to upgrade other browsers here. Let's just revisit this every couple of months until we can remove the Asyncify builds for the web.
