You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[PHP] Support multiple workers for NODEFS /wordpress mounts – Asyncify (#2317)
## Overview
Adds multi-worker support for Node.js Asyncify builds to complement
[JSPI
support](#2231)
and enable usage in Node < v23. Note this doesn't work with Asyncify in
web browsers.
This PR also adds `fileLockManager` support for `@php-wasm/cli`
## Implementation
This PR is different from other Asyncify PRs in that it doesn't actually
make things work with Asyncify. Instead, it switches to synchronous
message passing when JSPI is unavailable. This way, the Asyncify builds
never have to engage in stack switching around `fd_close()` or
`fcntl()`. This wasn't the first choice, but getting the Asyncify builds
right was just too challenging, so we had to use another approach.
### Synchronous message passing via Comlink
> [!IMPORTANT]
> This PR forks the Comlink library to add `afterResponseSent?: (ev:
MessageEvent) => void` argument to the `expose()` function. The rest is
unchanged. Comlink isn't getting many new PRs so skipping updates (or
backporting occasionally) seems fine.
Playground already uses Comlink for RPC between workers. This PR adds
synchronous bindings via `exposeSync` and `wrapSync`.
The specific technique of exchanging messages is described in
https://github.com/adamziel/js-synchronous-messaging:
- Worker A calls `postMessage()` on Worker B's MessagePort to start the
RPC exchange.
- Worker A uses
[Atomics.wait](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/wait)
(with a `SharedArrayBuffer`) to synchronously wait for a notification
from the Worker B.
- Worker A uses
[receiveMessageOnPort](https://nodejs.org/api/worker_threads.html#workerreceivemessageonportport)
to synchronously read the data sent by Worker B.
For usage example, see
[comlink-sync.spec.ts](https://github.com/WordPress/wordpress-playground/blob/042387f57846a046b98e5837956111f53fa862fa/packages/php-wasm/universal/src/lib/comlink-sync.spec.ts#L87).
The upsides of this approach:
* Saves dozens-to-hundreds of hours on debugging Asyncify issues
* Increased reliability
* Provides useful stack traces when errors do happen.
The downsides:
* Fragmentation: Both synchronous and asynchronous handlers exist to get
the best our of both Asyncify and JSPI.
* Node.js-only: This extension does not implement a Safari-friendly
transport. SharedArrayBuffer is an option, but
it requires more restrictive CORP+COEP headers which breaks, e.g.,
YouTube embeds. Synchronous XHR
might work if we really need Safari support for one of the new
asynchronous features, but other than
that let's just skip adding new asynchronous WASM features to Safari
until WebKit supports stack switching.
* Message passing between workers is slow. Avoid using synchronous
messaging for syscalls that are invoked frequently and handled
asynchronously in the same worker.
### Dual channel support
The Emscripten-built php.js requires either:
* A synchronous or asynchronous `fileLockManager` – when JSPI is
available
* A synchronous `fileLockManager` – when JSPI is not available
This is implemented with preprocessor directives, e.g.
```
#if ASYNCIFY == 2
return Asyncify.handleAsync(async () => {
#endif
// ..code..
#if ASYNCIFY == 2
});
#endif
```
Why support both methods and not always use synchronous calls? Because
web browsers have no `receiveMessageOnPort` and can only handle
asynchronous message passing. Supporting both sync and async message
channels provides maximum compatibility. The only environment where
`fileLockManager` is not supported is Safari.
## Testing Instructions (or ideally a Blueprint)
### Playground CLI
Run Playground CLI server with 5 workers using Asyncify:
```shell
node --disable-warning=ExperimentalWarning --experimental-strip-types --experimental-transform-types --import ./packages/meta/src/node-es-module-loader/register.mts ./packages/playground/cli/src/cli.ts server --experimental-multi-worker=5 --mount-before-install ./tmp/new-site:/wordpress
```
Create some posts, install some plugins, confirm it does not crash.
Then do the same with JSPI and confirm everything continues to work:
```shell
node --experimental-wasm-jspi --disable-warning=ExperimentalWarning --experimental-strip-types --experimental-transform-types --import ./packages/meta/src/node-es-module-loader/register.mts ./packages/playground/cli/src/cli.ts server --experimental-multi-worker=5 --mount-before-install ./tmp/new-site:/wordpress
```
### PHP.wasm CLI
Run the test script below and confirm it does not crash or corrupt the
database:
```shell
node --loader=./packages/meta/src/node-es-module
-loader/loader.mts ./packages/php-wasm/cli/src/main.ts test.php
```
test.php
```php
<?php
$db = new SQLite3('./db.sqlite');
$db->exec('CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price REAL NOT NULL,
category TEXT NOT NULL
)');
// Insert some product data
$db->exec("INSERT INTO products (name, price, category) VALUES ('Laptop', 999.99, 'Electronics')");
$db->exec("INSERT INTO products (name, price, category) VALUES ('Coffee Mug', 12.50, 'Kitchen')");
$db->exec("INSERT INTO products (name, price, category) VALUES ('Notebook', 5.99, 'Office')");
$db->exec("INSERT INTO products (name, price, category) VALUES ('Headphones', 79.99, 'Electronics')");
$result = $db->query('SELECT * FROM products ORDER BY category, name');
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
echo "- {$row['name']} ({$row['category']}): $" . number_format($row['price'], 2) . "\n";
}
$db->close();
```
---------
Co-authored-by: Adam Zieliński <[email protected]>
0 commit comments