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.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:
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.
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().
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.
In packages/php-wasm/universal/src/lib/php.tsPHP.cli(), accept the new option.
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 instanceletcursor=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.
Restore the previous stdin handler (or undefined) when the call completes so subsequent calls don't leak state.
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.crun_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.
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 defaultFS_stdin_getCharcallsfs.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 wholoadNodeRuntimeand callphp.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 ownprocess.stdinwhich is not the user's pipe.Concrete downstream example:
Automattic/studio'sstudio wpCLI silently drops host stdin when the site is running (the daemon path sends{ args }over IPC to a child process; that child then callsserver.playground.cli(args)which dispatches to a pooled worker thread; neither hop has any way to forward bytes). Canonical symptom:Filed alongside a corresponding Studio issue that will consume the new API once it exists.
Motivation
php://stdinis a first-class Unix interface. Real wp-cli users pipe to it constantly:gunzip -c backup.sql.gz | wp db querywp db query < file.sqlwp 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 withwp-cli.pharwhich has its own SAPI input handling).Reproduction
Two minimal probes show the layering. Both use the same shell pipe.
Passes — direct
php.cliwith no IPC boundary:Works, because the Node process running the script is the one holding the user's pipe, and Emscripten's default
FS_stdin_getCharpicks it up.Fails — same PHP code, crossed IPC / worker-thread boundaries (via Studio CLI):
The difference is not the WASM build — same runtime in both cases. The difference is that
studio wpdrains its args, sends{ args }over IPC towordpress-server-child, which callsserver.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 callsphp.cli().Proposed API
Add an optional
stdintoPHP.cli():When
stdinis 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/clialready uses —Uint8Array/ArrayBuffertravel as transferables,stringcoerces,ReadableStreamwould 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.packages/php-wasm/universal/src/lib/php.tsPHP.cli(), accept the new option.ccall('run_cli', ...), coercestdinto aUint8Array, keep an index, and install a callback like:FS_stdin_getCharhonorsModule.stdinwhen provided (returningnullfor EOF), overriding the defaultfs.readSync(process.stdin.fd, …)path.stdinhandler (orundefined) when the call completes so subsequent calls don't leak state.packages/php-wasm/node/src/test/that instantiates a runtime, callsphp.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.crun_cli()) to symmetrically redirect stdin the way it already does stdout/stderr viaredirect_stream_to_file. Not required if theModule.stdinapproach proves stable, and avoids a WASM rebuild.Non-goals / out of scope
php -ashell 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.Related
Environment
wordpress-playgroundtrunk + PR Fix intermittent 500 errors from worker pool concurrency issues #3494 (pool proxy 500 fix) stacked — unrelated to this bugstudiotrunk + PRs Update package lock files in WordPress builds themes to fix current security issues #3057/CLI fails to start server:__dirname is not defined caused by: Comlink method call failed#3095/[CI] Add test for @php-wasm/node direct usage in Jest #3099/Translatable Blueprints #3120 stacked — unrelated to this bug; confirmed present on stock trunk