[PHP] Add stdin option to PHP.cli()#3523
Open
chubes4 wants to merge 6 commits intoWordPress:trunkfrom
Open
Conversation
Expose dynamic stdin for non-interactive CLI invocations. Bytes
provided via the new option are delivered to PHP's CLI SAPI, so
file_get_contents('php://stdin'), fread(STDIN, ...), wp-cli's '-'
sentinel, and reading a script from stdin all observe them.
Implementation uses a per-runtime mutable CliStdinState consulted
by an Emscripten Module.stdin shim installed at runtime init. The
shim returns EOF when no bytes have been provided, so consumers
that want to forward host stdin must do so explicitly via the new
option. This replaces the previous implicit 'Emscripten inherits
process.stdin.fd' behavior, which worked only when the runtime
lived in the same process as the user's pipe and silently dropped
stdin across any IPC or worker-thread boundary (see WordPress#3519).
@php-wasm/cli is updated to drain process.stdin when non-TTY and
forward it via the new option, preserving the observable behavior
of the bare CLI bin while making the stdin path explicit.
Accepts string, Uint8Array, Buffer, and ReadableStream<Uint8Array>.
Preserves binary bytes (nulls, high-bit, control bytes) with no
UTF-8 mangling. Verified end-to-end via a focused spec covering
all input types plus fread and binary round-trip.
Fixes WordPress#3519
When PHP.cli() is called, #executeWithErrorHandling may rotate the underlying runtime before invoking run_cli. The stdin state population was happening BEFORE #executeWithErrorHandling, so the bytes landed on the old runtime's cliStdinState while run_cli executed on a fresh runtime whose Module.stdin shim saw no bytes and returned EOF immediately. Move the population inside the #executeWithErrorHandling callback so it runs after any rotation, on the runtime that will actually execute run_cli. No API change.
This was referenced Apr 23, 2026
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a stdin option to PHP.cli() to allow forwarding piped/streamed input into the WASM PHP runtime (including across worker/IPC boundaries), plus a Node CLI wrapper behavior to forward host stdin when non-TTY.
Changes:
- Introduces a per-runtime
Module.stdinshim + mutable state to supply stdin bytes to PHP CLI invocations. - Extends
PHP.cli()/PHPWorker.cli()to acceptstdin(string / bytes /ReadableStream) and wires it into the runtime at call time. - Adds Node-focused tests for stdin forwarding and updates the CLI wrapper to drain
process.stdinwhen piped.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/php-wasm/universal/src/lib/php.ts | Adds stdin option + populates runtime stdin state during CLI execution; updates docs. |
| packages/php-wasm/universal/src/lib/php-worker.ts | Forwards stdin option across worker boundary. |
| packages/php-wasm/universal/src/lib/load-php-runtime.ts | Installs Module.stdin shim and exposes cliStdinState on the runtime. |
| packages/php-wasm/universal/src/lib/index.ts | Exports new stdin helpers/state types. |
| packages/php-wasm/universal/src/lib/cli-stdin.ts | New helpers for stdin state, callback, and coercion from multiple input types. |
| packages/php-wasm/node/src/test/php-cli-stdin.spec.ts | Adds tests validating stdin forwarding behavior. |
| packages/php-wasm/node/project.json | Registers the new test file in test targets. |
| packages/php-wasm/cli/src/main.ts | Drains host stdin when non-TTY and forwards it via PHP.cli({ stdin }). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+151
to
+153
| const cliStdinState: CliStdinState = createCliStdinState(); | ||
| const cliStdinCallback = createCliStdinCallback(cliStdinState); | ||
| const userStdin = phpModuleArgs.stdin; |
| // fixes it. | ||
| locateFile: (path) => path, | ||
| ...phpModuleArgs, | ||
| stdin: userStdin ?? cliStdinCallback, |
Comment on lines
+1539
to
+1547
| * 'php://stdin')`, and running a script from stdin (e.g. `php < script. | ||
| * php`) all observe these bytes. This is how a consumer like a CLI | ||
| * host process hands a user's piped input to PHP when the runtime | ||
| * lives across an IPC or worker-thread boundary from the user's shell. | ||
| * | ||
| * When `options.stdin` is omitted, the runtime's default stdin | ||
| * behavior applies — which on Node means reading from the host | ||
| * process's `process.stdin.fd` (Emscripten default), and on Web means | ||
| * an empty stdin. |
Comment on lines
+1539
to
+1547
| * 'php://stdin')`, and running a script from stdin (e.g. `php < script. | ||
| * php`) all observe these bytes. This is how a consumer like a CLI | ||
| * host process hands a user's piped input to PHP when the runtime | ||
| * lives across an IPC or worker-thread boundary from the user's shell. | ||
| * | ||
| * When `options.stdin` is omitted, the runtime's default stdin | ||
| * behavior applies — which on Node means reading from the host | ||
| * process's `process.stdin.fd` (Emscripten default), and on Web means | ||
| * an empty stdin. |
Comment on lines
+1586
to
+1591
| if (stdinBytes !== null) { | ||
| const cliStdinState = this[__private__dont__use].cliStdinState; | ||
| if (cliStdinState) { | ||
| cliStdinState.bytes = stdinBytes; | ||
| cliStdinState.cursor = 0; | ||
| } |
Comment on lines
+63
to
+77
| while (true) { | ||
| const { value, done } = await reader.read(); | ||
| if (done) break; | ||
| if (value) { | ||
| chunks.push(value); | ||
| total += value.byteLength; | ||
| } | ||
| } | ||
| const out = new Uint8Array(total); | ||
| let offset = 0; | ||
| for (const chunk of chunks) { | ||
| out.set(chunk, offset); | ||
| offset += chunk.byteLength; | ||
| } | ||
| return out; |
Comment on lines
+254
to
+263
| let hostStdin: Buffer | undefined; | ||
| if (!process.stdin.isTTY) { | ||
| const chunks: Buffer[] = []; | ||
| for await (const chunk of process.stdin) { | ||
| chunks.push(chunk as Buffer); | ||
| } | ||
| if (chunks.length > 0) { | ||
| hostStdin = Buffer.concat(chunks); | ||
| } | ||
| } |
| chunks.push(decoder.decode(chunk, { stream: true })); | ||
| }, | ||
| }) | ||
| ); |
Always clear cliStdinState.bytes/cursor at the top of PHP.cli(), even when no stdin option is provided. This is defense in depth against runtime-rotation edge cases: a call without stdin should never read stale bytes from a prior invocation, regardless of whether rotation succeeded or the runtime was reused. Also corrects the doc comment about omitted-stdin behavior: PHP sees an empty stdin (EOF), not Emscripten's implicit process.stdin.fd inheritance. The previous implicit path was an undocumented side-effect that silently broke across IPC / worker boundaries — closing that gap is the whole point of the stdin option.
Node's async iterator on process.stdin yields Buffers in binary mode (the default), but yields strings if an upstream dependency called setEncoding(). Buffer.concat throws on strings, so coerce each chunk defensively before concatenating. Cheap, strictly correct, matches the kind of thing a transitive dep can do without the host knowing.
- coerceCliStdin() now releases the ReadableStream reader lock in a finally block so callers aren't left holding a locked stream when the stream errors mid-read. Good Web Streams citizenship. - Tests flush the streaming TextDecoder with a final zero-arg decode() before joining chunks, to avoid silently dropping a multi-byte sequence that straddles a chunk boundary. Test robustness only.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a
stdinoption toPHP.cli()so host processes can forward piped input to PHP. Closes #3519.Why
The WASM PHP runtime reads stdin through an Emscripten
Module.stdincallback. That callback is captured when the runtime initializes and cannot be replaced afterwards — so there was no way to hand PHP bytes from across an IPC or worker-thread boundary.Concretely:
PHP.cli()runs in a worker thread (via Comlink) which is disconnected from the host process'sprocess.stdin. Anything piped into the host binary was invisible to PHP.@php-wasm/cli's bin wrapper couldn't surface awp eval 'echo file_get_contents("php://stdin");' < filepattern. Downstream consumers (like Studio'sstudio wp ...) hit the same wall.How
A small
CliStdinStateobject captured by a long-livedModule.stdinclosure:loadPHPRuntimecreates a freshCliStdinStateand installs aModule.stdinshim that consults it per byte. If the caller already supplied their ownModule.stdin, we defer to them. The state is exposed on the runtime asPHPRuntime.cliStdinState.PHP.cli(argv, { stdin })coerces the input toUint8Array(string/Buffer/Uint8Array/ReadableStream), then — inside#executeWithErrorHandlingso it runs after any runtime rotation — writes the bytes intocliStdinState.bytesand resets the cursor, right before invokingrun_cli.PHPWorker.cliforwards the option through the worker-thread boundary unchanged.@php-wasm/cli/main.tsdrainsprocess.stdinwhen non-TTY and forwards via the new option. Explicit (not implicit) — consumers that don't want this behavior just don't drain host stdin.Rotation timing turned out to matter: populating the state before
#executeWithErrorHandlingput the bytes on the old runtime, and rotation swapped in a fresh runtime whose state was empty. Populating inside the callback lands the bytes on whichever runtimerun_cliwill execute on. That's the second commit.Tests
7 focused tests in
packages/php-wasm/node/src/test/php-cli-stdin.spec.tscovering string / Uint8Array / Buffer / ReadableStream inputs, empty stdin, large payloads, and the no-stdin fallthrough path. All pass.Also validated end-to-end in Studio's daemon path (downstream consumer) across 100 sequential + 20 parallel same-length-different-content + 5×1MB md5 roundtrip + alternating stdin/no-stdin + UTF-8 + binary-with-nulls + empty-stdin + no-trailing-newline + 10MB random binary. All green.
No API changes
stdinonPHP.cli()is optional. Omitting it preserves the pre-existing behavior (empty stdin on web,process.stdin.fdon Node via Emscripten's default wiring).Module.stdin,cliStdinStateis leftnulland thestdinoption is a no-op — full caller control preserved.Files
packages/php-wasm/universal/src/lib/cli-stdin.ts—CliStdinState,createCliStdinState,createCliStdinCallback,coerceCliStdinpackages/php-wasm/node/src/test/php-cli-stdin.spec.ts— 7 testspackages/php-wasm/universal/src/lib/load-php-runtime.ts— install shim, attach mutable statepackages/php-wasm/universal/src/lib/php.ts—PHP.cliacceptsstdin, populates state post-rotationpackages/php-wasm/universal/src/lib/php-worker.ts— forwardstdinthrough worker boundarypackages/php-wasm/universal/src/lib/index.ts— exportspackages/php-wasm/cli/src/main.ts— drain host stdin in the bin wrapperAI assistance
CliStdinState+Module.stdinshim, thePHP.cli({ stdin })option plumbing throughPHPWorker.cliand the CLI bin wrapper, and the test suite inphp-cli-stdin.spec.ts. The runtime-rotation race was diagnosed interactively via an instrumented trace log in a live Studio daemon after I stepped through the#executeWithErrorHandlingrotation path; the fix (moving state population inside the callback) and the 100+ brute-force integration tests against a real Studio site were my own review. All code was read, understood, and exercised end-to-end before submission.