Skip to content
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

Ship the JSPI kitchen-sink build to Chrome users #134

Closed
adamziel opened this issue Feb 14, 2023 · 21 comments
Closed

Ship the JSPI kitchen-sink build to Chrome users #134

adamziel opened this issue Feb 14, 2023 · 21 comments

Comments

@adamziel
Copy link
Collaborator

adamziel commented Feb 14, 2023

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:

  1. JSPI: Enable the origin trial on Chrome #1346
  2. 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)
  3. Deploy both bundles to playground.wordpress.net
  4. Ship the JSPI version to Chrome users, ship the Asyncify versions to everyone else
  5. 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-jspi enabled at chrome://flags, or with sites where the JSPI origin trial is enabled.
  • ✅ Node.js v22+ with --experimental-wasm-stack-switching feature flag.
  • Firefox nightly
  • ❌ Safari, older browsers
  • ❌ Bun, Node.js <= 21
  • ❔ I'm Not sure about Chrome-based browsers
@adamziel
Copy link
Collaborator Author

adamziel commented May 8, 2023

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
Copy link
Collaborator Author

adamziel commented May 8, 2023

@adamziel
Copy link
Collaborator Author

adamziel commented May 8, 2023

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
Copy link
Collaborator Author

adamziel commented May 8, 2023

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

@dmsnell
Copy link
Member

dmsnell commented May 8, 2023

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

@adamziel
Copy link
Collaborator Author

adamziel commented May 8, 2023

@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
Copy link
Collaborator Author

Related: #404

@adamziel adamziel added this to the Zero Crashes milestone Feb 29, 2024
@adamziel
Copy link
Collaborator Author

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

@bgrgicak
Copy link
Collaborator

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

@fgmccabe
Copy link

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.

@adamziel
Copy link
Collaborator Author

adamziel commented Apr 19, 2024

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.

@fgmccabe
Copy link

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.

@mho22
Copy link
Contributor

mho22 commented Apr 20, 2024

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

@adamziel
Copy link
Collaborator Author

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/

@brandonpayton brandonpayton self-assigned this Apr 22, 2024
@brandonpayton
Copy link
Member

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?

@adamziel
Copy link
Collaborator Author

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

@brandonpayton
Copy link
Member

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

@adamziel
Copy link
Collaborator Author

You sir are a genius @brandonpayton

@adamziel
Copy link
Collaborator Author

adamziel commented Apr 30, 2024

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

@adamziel adamziel changed the title Explore using JSPI for asynchronous calls in WASM Ship JSPI build to Chrome users Apr 30, 2024
@adamziel adamziel changed the title Ship JSPI build to Chrome users Ship the JSPI kitchen-sink build to Chrome users Apr 30, 2024
@adamziel
Copy link
Collaborator Author

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 adamziel moved this to Future work in Playground Board Jun 30, 2024
adamziel added a commit that referenced this issue Oct 9, 2024
 ## 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.
@adamziel
Copy link
Collaborator Author

adamziel commented Oct 9, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants