Skip to content

php-wasm: PHP.cli() has no way to accept stdin bytes, silently drops input across IPC / worker-thread boundaries #3519

@chubes4

Description

@chubes4

Summary

PHP.cli(argv, options) — the public embedding API in @php-wasm/universal — has no way for a caller to provide stdin bytes to the CLI invocation. The only stdin path that works today is implicit: Emscripten's default FS_stdin_getChar calls fs.readSync(process.stdin.fd, …) on Node, so PHP ends up reading whatever file descriptor 0 of the hosting Node process happens to be connected to.

That works for bare/in-process use (e.g. @php-wasm/cli, direct embedders who loadNodeRuntime and call php.cli(...) in the same process as the user's pipe), but fails the moment a consumer puts an IPC or worker-thread boundary between the user's stdin and the Node process that actually hosts the WASM runtime — because that hosting process has its own process.stdin which is not the user's pipe.

Concrete downstream example: Automattic/studio's studio wp CLI silently drops host stdin when the site is running (the daemon path sends { args } over IPC to a child process; that child then calls server.playground.cli(args) which dispatches to a pooled worker thread; neither hop has any way to forward bytes). Canonical symptom:

$ echo "hello" | studio wp eval 'echo "got: [" . file_get_contents("php://stdin") . "]";'
got: []

Filed alongside a corresponding Studio issue that will consume the new API once it exists.

Motivation

php://stdin is a first-class Unix interface. Real wp-cli users pipe to it constantly:

  • gunzip -c backup.sql.gz | wp db query
  • wp db query < file.sql
  • wp post create --post_content=- (wp-cli's - sentinel reads from stdin)
  • wp eval-file -
  • cat content.xml | wp import -

All of those work on native PHP against a real WordPress install. They fail silently through any IPC-hosted Playground consumer today.

The current "inherit fd 0 from the hosting Node process" behavior is accidental correctness, not documented contract. It's an artifact of Emscripten defaults; no Playground API surface exposes it. A consumer who wants to explicitly hand bytes to php.cli() from outside the hosting process has no option short of hacking the VFS (write bytes to a file, then pre-pend a PHP bootstrap that reopens fd 0 onto that file — ugly, brittle, and doesn't compose well with wp-cli.phar which has its own SAPI input handling).

Reproduction

Two minimal probes show the layering. Both use the same shell pipe.

Passes — direct php.cli with no IPC boundary:

// repro-direct.mjs
import { loadNodeRuntime } from '@php-wasm/node';
import { PHP, ProcessIdAllocator } from '@php-wasm/universal';

const alloc = new ProcessIdAllocator();
const id = await loadNodeRuntime('8.3', {
  emscriptenOptions: { processId: alloc.claim() },
});
const php = new PHP(id);
await php.setSapiName('cli');

const response = await php.cli([
  'php', '-r',
  'echo "got: [" . file_get_contents("php://stdin") . "]\n";',
]);
await response.stdout.pipeTo(new WritableStream({ write(c) { process.stdout.write(c); } }));
process.exit(await response.exitCode);
$ echo "probe-$(date +%s)" | node repro-direct.mjs
got: [probe-1776894388
]

Works, because the Node process running the script is the one holding the user's pipe, and Emscripten's default FS_stdin_getChar picks it up.

Fails — same PHP code, crossed IPC / worker-thread boundaries (via Studio CLI):

$ echo "probe-$(date +%s)" | studio wp eval 'echo "got: [" . file_get_contents("php://stdin") . "]";'
got: []

The difference is not the WASM build — same runtime in both cases. The difference is that studio wp drains its args, sends { args } over IPC to wordpress-server-child, which calls server.playground.cli(args), which dispatches into a pooled worker thread. Three process/thread boundaries, zero of them carry stdin. The user's bytes never reach the Node process that calls php.cli().

Proposed API

Add an optional stdin to PHP.cli():

async cli(
  argv: string[],
  options: {
    env?: Record<string, string>;
    cwd?: string;
    stdin?: Uint8Array | Buffer | ReadableStream<Uint8Array> | string;
  } = {}
): Promise<StreamedPHPResponse>

When stdin is provided, it is fed to PHP's CLI SAPI as fd 0; file_get_contents("php://stdin"), fread(STDIN, …), wp-cli's - sentinel, and reading the script from stdin (php < script.php) all see it.

Shape should round-trip cleanly across the Pooled<PlaygroundCliWorker> / comlink transport that @wp-playground/cli already uses — Uint8Array / ArrayBuffer travel as transferables, string coerces, ReadableStream would need either transferable-stream support or a simpler "collect then send" path.

Implementation sketch

Least-invasive option: wire at the Emscripten layer via Module.stdin.

  1. In packages/php-wasm/universal/src/lib/php.ts PHP.cli(), accept the new option.
  2. Before calling ccall('run_cli', ...), coerce stdin to a Uint8Array, keep an index, and install a callback like:
    // phpRuntime is the Emscripten Module for this PHP instance
    let cursor = 0;
    phpRuntime['stdin'] = () => (cursor < bytes.length ? bytes[cursor++] : null);
    Emscripten's FS_stdin_getChar honors Module.stdin when provided (returning null for EOF), overriding the default fs.readSync(process.stdin.fd, …) path.
  3. Restore the previous stdin handler (or undefined) when the call completes so subsequent calls don't leak state.
  4. Add a focused spec under packages/php-wasm/node/src/test/ that instantiates a runtime, calls php.cli(['php','-r','echo file_get_contents("php://stdin");'], { stdin: 'hello' }), and asserts the stdout body is "hello".

Heavier alternative: extend the C side (packages/php-wasm/compile/php/php_wasm.c run_cli()) to symmetrically redirect stdin the way it already does stdout/stderr via redirect_stream_to_file. Not required if the Module.stdin approach proves stable, and avoids a WASM rebuild.

Non-goals / out of scope

  • Interactive php -a shell support. That's a separate, much harder problem (raw mode, libedit, termios). Tracked in Support all CLI inputs in PHP interactive shell #118. This issue is strictly about non-interactive, one-shot CLI invocations receiving a byte buffer as stdin.
  • Changes to the web build's stdin story. In-browser Playground has its own story; this issue is about the Node API surface the CLI / embedders rely on.

Related

Environment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions