Skip to content

Commit

Permalink
Spawning PHP sub-processes in Web Workers (#1069)
Browse files Browse the repository at this point in the history
Adds support for spawning PHP subprocesses via `<?php proc_open(['php',
'activate_theme.php']);`. The spawned subprocess affects the filesystem
used by the parent process.

## Implementation details

This PR updates the default `spawnHandler` in `worker-thread.ts` that
creates another WebPHP instance and mounts the parent filesystem using
Emscripten's PROXYFS.

[A shared filesystem didn't pan out. Synchronizing is the second best
option.](#1027)

This code snippet illustrates the idea – note the actual implementation
is more nuanced:

```ts
php.setSpawnHandler(
	createSpawnHandler(async function (args, processApi) {
		const childPHP = new WebPHP();
		const { exitCode, stdout, stderr } = await childPHP.run({
			scriptPath: args[1]
		});
		processApi.stdout(stdout);
		processApi.stderr(stderr);
		processApi.exit(exitCode);
	})
);
```

## Future work

* Stream `stdout` and `stderr` from `childPHP` to `processApi` instead
of buffering the output and passing everything at once

## Example of how it works

<img width="500"
src="https://github.com/WordPress/wordpress-playground/assets/205419/470d79b2-2f10-4f1a-806c-5f26463766da"
/>

#### /wordpress/spawn.php

```php
<?php
echo "<plaintext>";
echo "Spawning /wordpress/child.php\n";
$handle = proc_open('php /wordpress/child.php', [
	0 => ['pipe', 'r'],
	1 => ['pipe', 'w'],
	2 => ['pipe', 'w'],
], $pipes);

echo "stdout: " . stream_get_contents($pipes[1]) . "\n";
echo "stderr: " . stream_get_contents($pipes[2]) . "\n";
echo "Finished\n";
echo "Contents of the created file: " . file_get_contents("/wordpress/new.txt") . "\n";
```

#### /wordpress/child.php

```php
<?php
echo "<plaintext>";
echo "Spawned, running";
error_log("Here's a message logged to stderr! " . rand());
file_put_contents("/wordpress/new.txt", "Hello, world!" . rand() . "\n");
```

## Testing instructions

1. Update `worker-thread.ts` to create the two files listed above
2. In Playground, navigate to `/spawn.php`
3. Confirm the output is the same as on the screenshot above
  • Loading branch information
adamziel authored Feb 28, 2024
1 parent 0eba044 commit 24f2c61
Show file tree
Hide file tree
Showing 60 changed files with 2,120 additions and 94 deletions.
1 change: 1 addition & 0 deletions packages/php-wasm/compile/php/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,7 @@ RUN set -euxo pipefail; \
-I TSRM/ \
-I /root/lib/include \
-L/root/lib -L/root/lib/lib/ \
-lproxyfs.js \
$(cat /root/.emcc-php-wasm-flags) \
-o /build/output/php.js \
-s EXPORTED_FUNCTIONS="$EXPORTED_FUNCTIONS" \
Expand Down
13 changes: 9 additions & 4 deletions packages/php-wasm/compile/php/php_wasm.c
Original file line number Diff line number Diff line change
Expand Up @@ -954,7 +954,7 @@ void wasm_set_request_port(int port)
*
* stream: The stream to redirect, e.g. stdout or stderr.
*
* path: The path to the file to redirect to, e.g. "/tmp/stdout".
* path: The path to the file to redirect to, e.g. "/internal/stdout".
*
* returns: The exit code: 0 on success, -1 on failure.
*/
Expand Down Expand Up @@ -1175,8 +1175,13 @@ int wasm_sapi_request_init()
// Write to files instead of stdout and stderr because Emscripten truncates null
// bytes from stdout and stderr, and null bytes are a valid output when streaming
// binary data.
stdout_replacement = redirect_stream_to_file(stdout, "/tmp/stdout");
stderr_replacement = redirect_stream_to_file(stderr, "/tmp/stderr");
// We'll use the /internal directory instead of /tmp, because a child process sharing
// the same filesystem and /tmp mount would write to the same stdout and stderr files
// and produce unreadable output intertwined with the parent process output. The /internal
// directory should always stay in per-process MEMFS space and never be shared with
// any other process.
stdout_replacement = redirect_stream_to_file(stdout, "/internal/stdout");
stderr_replacement = redirect_stream_to_file(stderr, "/internal/stderr");
if (stdout_replacement == -1 || stderr_replacement == -1)
{
return -1;
Expand Down Expand Up @@ -1434,7 +1439,7 @@ FILE *headers_file;
*/
static int wasm_sapi_send_headers(sapi_headers_struct *sapi_headers TSRMLS_DC)
{
headers_file = fopen("/tmp/headers.json", "w");
headers_file = fopen("/internal/headers.json", "w");
if (headers_file == NULL)
{
return FAILURE;
Expand Down
5 changes: 5 additions & 0 deletions packages/php-wasm/compile/php/phpwasm-emscripten-library.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const LibraryExample = {
// JavaScript library under the PHPWASM object:
$PHPWASM: {
init: function () {
// The /internal directory is required by the C module. It's where the
// stdout, stderr, and headers information are written for the JavaScript
// code to read later on.
FS.mkdir("/internal");

PHPWASM.EventEmitter = ENVIRONMENT_IS_NODE
? require('events').EventEmitter
: class EventEmitter {
Expand Down
Binary file modified packages/php-wasm/node/public/7_0_33/php_7_0.wasm
Binary file not shown.
Binary file modified packages/php-wasm/node/public/7_1_30/php_7_1.wasm
Binary file not shown.
Binary file modified packages/php-wasm/node/public/7_2_34/php_7_2.wasm
Binary file not shown.
Binary file modified packages/php-wasm/node/public/7_3_33/php_7_3.wasm
Binary file not shown.
Binary file modified packages/php-wasm/node/public/7_4_33/php_7_4.wasm
Binary file not shown.
Binary file modified packages/php-wasm/node/public/8_0_30/php_8_0.wasm
Binary file not shown.
Binary file modified packages/php-wasm/node/public/8_1_23/php_8_1.wasm
Binary file not shown.
Binary file modified packages/php-wasm/node/public/8_2_10/php_8_2.wasm
Binary file not shown.
Binary file modified packages/php-wasm/node/public/8_3_0/php_8_3.wasm
Binary file not shown.
218 changes: 216 additions & 2 deletions packages/php-wasm/node/public/php_7_0.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const dependencyFilename = __dirname + '/7_0_33/php_7_0.wasm';
export { dependencyFilename };
export const dependenciesTotalSize = 11391146;
export const dependenciesTotalSize = 11391167;
export function init(RuntimeName, PHPLoader) {
/**
* Overrides Emscripten's default ExitStatus object which gets
Expand Down Expand Up @@ -1475,6 +1475,216 @@ var NODEFS = {
}
};

var PROXYFS = {
mount(mount) {
return PROXYFS.createNode(null, "/", mount.opts.fs.lstat(mount.opts.root).mode, 0);
},
createNode(parent, name, mode, dev) {
if (!FS.isDir(mode) && !FS.isFile(mode) && !FS.isLink(mode)) {
throw new FS.ErrnoError(ERRNO_CODES.EINVAL);
}
var node = FS.createNode(parent, name, mode);
node.node_ops = PROXYFS.node_ops;
node.stream_ops = PROXYFS.stream_ops;
return node;
},
realPath(node) {
var parts = [];
while (node.parent !== node) {
parts.push(node.name);
node = node.parent;
}
parts.push(node.mount.opts.root);
parts.reverse();
return PATH.join.apply(null, parts);
},
node_ops: {
getattr(node) {
var path = PROXYFS.realPath(node);
var stat;
try {
stat = node.mount.opts.fs.lstat(path);
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
return {
dev: stat.dev,
ino: stat.ino,
mode: stat.mode,
nlink: stat.nlink,
uid: stat.uid,
gid: stat.gid,
rdev: stat.rdev,
size: stat.size,
atime: stat.atime,
mtime: stat.mtime,
ctime: stat.ctime,
blksize: stat.blksize,
blocks: stat.blocks
};
},
setattr(node, attr) {
var path = PROXYFS.realPath(node);
try {
if (attr.mode !== undefined) {
node.mount.opts.fs.chmod(path, attr.mode);
node.mode = attr.mode;
}
if (attr.timestamp !== undefined) {
var date = new Date(attr.timestamp);
node.mount.opts.fs.utime(path, date, date);
}
if (attr.size !== undefined) {
node.mount.opts.fs.truncate(path, attr.size);
}
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
lookup(parent, name) {
try {
var path = PATH.join2(PROXYFS.realPath(parent), name);
var mode = parent.mount.opts.fs.lstat(path).mode;
var node = PROXYFS.createNode(parent, name, mode);
return node;
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
mknod(parent, name, mode, dev) {
var node = PROXYFS.createNode(parent, name, mode, dev);
var path = PROXYFS.realPath(node);
try {
if (FS.isDir(node.mode)) {
node.mount.opts.fs.mkdir(path, node.mode);
} else {
node.mount.opts.fs.writeFile(path, "", {
mode: node.mode
});
}
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
return node;
},
rename(oldNode, newDir, newName) {
var oldPath = PROXYFS.realPath(oldNode);
var newPath = PATH.join2(PROXYFS.realPath(newDir), newName);
try {
oldNode.mount.opts.fs.rename(oldPath, newPath);
oldNode.name = newName;
oldNode.parent = newDir;
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
unlink(parent, name) {
var path = PATH.join2(PROXYFS.realPath(parent), name);
try {
parent.mount.opts.fs.unlink(path);
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
rmdir(parent, name) {
var path = PATH.join2(PROXYFS.realPath(parent), name);
try {
parent.mount.opts.fs.rmdir(path);
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
readdir(node) {
var path = PROXYFS.realPath(node);
try {
return node.mount.opts.fs.readdir(path);
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
symlink(parent, newName, oldPath) {
var newPath = PATH.join2(PROXYFS.realPath(parent), newName);
try {
parent.mount.opts.fs.symlink(oldPath, newPath);
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
readlink(node) {
var path = PROXYFS.realPath(node);
try {
return node.mount.opts.fs.readlink(path);
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
}
},
stream_ops: {
open(stream) {
var path = PROXYFS.realPath(stream.node);
try {
stream.nfd = stream.node.mount.opts.fs.open(path, stream.flags);
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
close(stream) {
try {
stream.node.mount.opts.fs.close(stream.nfd);
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
read(stream, buffer, offset, length, position) {
try {
return stream.node.mount.opts.fs.read(stream.nfd, buffer, offset, length, position);
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
write(stream, buffer, offset, length, position) {
try {
return stream.node.mount.opts.fs.write(stream.nfd, buffer, offset, length, position);
} catch (e) {
if (!e.code) throw e;
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
},
llseek(stream, offset, whence) {
var position = offset;
if (whence === 1) {
position += stream.position;
} else if (whence === 2) {
if (FS.isFile(stream.node.mode)) {
try {
var stat = stream.node.node_ops.getattr(stream.node);
position += stat.size;
} catch (e) {
throw new FS.ErrnoError(ERRNO_CODES[e.code]);
}
}
}
if (position < 0) {
throw new FS.ErrnoError(ERRNO_CODES.EINVAL);
}
return position;
}
}
};

var FS = {
root: null,
mounts: [],
Expand Down Expand Up @@ -2550,7 +2760,8 @@ var FS = {
FS.createSpecialDirectories();
FS.filesystems = {
"MEMFS": MEMFS,
"NODEFS": NODEFS
"NODEFS": NODEFS,
"PROXYFS": PROXYFS
};
},
init: (input, output, error) => {
Expand Down Expand Up @@ -5280,6 +5491,7 @@ var allocateUTF8OnStack = stringToUTF8OnStack;

var PHPWASM = {
init: function() {
FS.mkdir("/internal");
PHPWASM.EventEmitter = ENVIRONMENT_IS_NODE ? require("events").EventEmitter : class EventEmitter {
constructor() {
this.listeners = {};
Expand Down Expand Up @@ -7334,6 +7546,8 @@ Module["ccall"] = ccall;

Module["FS_createPreloadedFile"] = FS.createPreloadedFile;

Module["PROXYFS"] = PROXYFS;

var calledRun;

dependenciesFulfilled = function runCaller() {
Expand Down
Loading

0 comments on commit 24f2c61

Please sign in to comment.