wordpress-playground icon indicating copy to clipboard operation
wordpress-playground copied to clipboard

Explore using JSPI for asynchronous calls in WASM

Open adamziel opened this issue 2 years ago • 10 comments

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 :)

adamziel avatar Feb 14 '23 14:02 adamziel

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:

It's available in Node 17, 18, 19, and 20. Node 16 exists with a bad option error message.

adamziel avatar May 08 '23 08:05 adamziel

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"
      });

adamziel avatar May 08 '23 12:05 adamziel

I'm stuck at this point and the JSPI API is still experimental – let's revisit this once the API is stable.

adamziel avatar May 08 '23 12:05 adamziel

@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 avatar May 08 '23 12:05 dmsnell

@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.

adamziel avatar May 08 '23 12:05 adamziel

Related: https://github.com/WordPress/wordpress-playground/issues/404

adamziel avatar May 22 '23 07:05 adamziel

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

adamziel avatar Mar 11 '24 14:03 adamziel

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.

Screenshot from 2024-03-12 06-17-44

bgrgicak avatar Mar 12 '24 05:03 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.

fgmccabe avatar Mar 12 '24 15:03 fgmccabe

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.

adamziel avatar Apr 19 '24 09:04 adamziel

A couple of comments:

  1. 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.
  2. 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.
  3. 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.

fgmccabe avatar Apr 19 '24 16:04 fgmccabe

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]...

mho22 avatar Apr 20 '24 12:04 mho22

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/

adamziel avatar Apr 22 '24 14:04 adamziel

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?

brandonpayton avatar Apr 22 '24 16:04 brandonpayton

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

  1. Run bash build.sh web html
  2. Enable the #enable-experimental-webassembly-jspi at chrome://flags.
  3. Restart Chrome
  4. Start a local server as python3 -m http.server
  5. 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

  1. Run bash build.sh node js
  2. Run nvm use 22 to switch to Node 22
  3. 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 avatar Apr 26 '24 12:04 adamziel

@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.

brandonpayton avatar Apr 26 '24 18:04 brandonpayton

You sir are a genius @brandonpayton

adamziel avatar Apr 26 '24 19:04 adamziel

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

adamziel avatar Apr 30 '24 11:04 adamziel

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.

adamziel avatar May 17 '24 16:05 adamziel