Skip to content

[PHP] Add stdin option to PHP.cli()#3523

Open
chubes4 wants to merge 6 commits intoWordPress:trunkfrom
chubes4:cli-stdin-option-clean
Open

[PHP] Add stdin option to PHP.cli()#3523
chubes4 wants to merge 6 commits intoWordPress:trunkfrom
chubes4:cli-stdin-option-clean

Conversation

@chubes4
Copy link
Copy Markdown

@chubes4 chubes4 commented Apr 23, 2026

What

Adds a stdin option to PHP.cli() so host processes can forward piped input to PHP. Closes #3519.

// New in this PR
await php.cli(['php', '-r', 'echo file_get_contents("php://stdin");'], {
  stdin: Buffer.from('hello'),                        // Buffer / Uint8Array
  // stdin: 'hello',                                  // or a string (UTF-8)
  // stdin: someReadableStream,                       // or a ReadableStream<Uint8Array>
});

Why

The WASM PHP runtime reads stdin through an Emscripten Module.stdin callback. 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's process.stdin. Anything piped into the host binary was invisible to PHP. @php-wasm/cli's bin wrapper couldn't surface a wp eval 'echo file_get_contents("php://stdin");' < file pattern. Downstream consumers (like Studio's studio wp ...) hit the same wall.

How

A small CliStdinState object captured by a long-lived Module.stdin closure:

  1. loadPHPRuntime creates a fresh CliStdinState and installs a Module.stdin shim that consults it per byte. If the caller already supplied their own Module.stdin, we defer to them. The state is exposed on the runtime as PHPRuntime.cliStdinState.
  2. PHP.cli(argv, { stdin }) coerces the input to Uint8Array (string/Buffer/Uint8Array/ReadableStream), then — inside #executeWithErrorHandling so it runs after any runtime rotation — writes the bytes into cliStdinState.bytes and resets the cursor, right before invoking run_cli.
  3. PHPWorker.cli forwards the option through the worker-thread boundary unchanged.
  4. @php-wasm/cli/main.ts drains process.stdin when 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 #executeWithErrorHandling put 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 runtime run_cli will execute on. That's the second commit.

Tests

7 focused tests in packages/php-wasm/node/src/test/php-cli-stdin.spec.ts covering 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

  • stdin on PHP.cli() is optional. Omitting it preserves the pre-existing behavior (empty stdin on web, process.stdin.fd on Node via Emscripten's default wiring).
  • If the caller supplied a custom Module.stdin, cliStdinState is left null and the stdin option is a no-op — full caller control preserved.

Files

  • new packages/php-wasm/universal/src/lib/cli-stdin.tsCliStdinState, createCliStdinState, createCliStdinCallback, coerceCliStdin
  • new packages/php-wasm/node/src/test/php-cli-stdin.spec.ts — 7 tests
  • packages/php-wasm/universal/src/lib/load-php-runtime.ts — install shim, attach mutable state
  • packages/php-wasm/universal/src/lib/php.tsPHP.cli accepts stdin, populates state post-rotation
  • packages/php-wasm/universal/src/lib/php-worker.ts — forward stdin through worker boundary
  • packages/php-wasm/universal/src/lib/index.ts — exports
  • packages/php-wasm/cli/src/main.ts — drain host stdin in the bin wrapper

AI assistance

  • AI assistance: Yes
  • Tool(s): Claude Code (Opus 4.7)
  • Used for: Initial implementation of the CliStdinState + Module.stdin shim, the PHP.cli({ stdin }) option plumbing through PHPWorker.cli and the CLI bin wrapper, and the test suite in php-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 #executeWithErrorHandling rotation 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.

Chris Huber and others added 2 commits April 23, 2026 10:53
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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.stdin shim + mutable state to supply stdin bytes to PHP CLI invocations.
  • Extends PHP.cli() / PHPWorker.cli() to accept stdin (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.stdin when 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 }));
},
})
);
chubes4 and others added 4 commits April 23, 2026 15:05
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants