Skip to content

studio wp silently drops host stdin when the site is running (breaks wp db query < file.sql, wp post create --content=-, etc.) #3200

Description

@chubes4

Summary

studio wp <command> silently drops host stdin when the target site's WordPress server is running. Any wp-cli invocation that reads from php://stdin sees an empty string; there is no error, no warning, and the command returns a 0 exit code — so callers can believe their operation succeeded when in fact no user data reached PHP at all.

Canonical reproduction

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

Expected: got: [hello-from-host-stdin].

100% reproducible on a fresh shell against any running Studio site. The same command works correctly when the site is not running (Studio falls through to a different code path — see Why below).

Impact

Breaks the entire class of wp-cli workflows that use stdin. This is idiomatic wp-cli usage, not an edge case:

  • Database restore: gunzip -c backup.sql.gz | studio wp db query — appears to succeed, imports nothing
  • Raw SQL: studio wp db query < file.sql — no rows processed
  • Content creation: studio wp post create --post_content=- (wp-cli's - sentinel) — creates an empty post
  • Import: cat export.xml | studio wp import - — imports nothing
  • Running a script from stdin: cat script.php | studio wp eval-file - — runs an empty script
  • AI-generated content: Studio's own AI tools (apps/cli/ai/tools.ts) go through sendWpCliCommand, which is the broken path. Anything an agent pipes as content is lost.

The silent-failure mode is what makes this particularly dangerous — a user running gunzip -c prod.sql.gz | studio wp db query against a newly cloned site gets no indication that the dump wasn't applied. They open the site, it looks wrong, they re-run, same result, spiral into debugging WP internals — when the actual answer is that their bytes were never delivered.

This also works correctly on native WordPress. Same wp-cli.phar, same Studio site folder, but invoked via the system PHP binary:

$ echo "real-wp-eval" | php /Users/chubes/.studio/server-files/wp-cli.phar \
    --path=~/Studio/<site-name> \
    eval 'echo "got: [" . file_get_contents("php://stdin") . "]\n";'
got: [real-wp-eval
]

Users migrating local dev workflows to Studio trip over this the first time they pipe anything at it.

Where the bytes die

I bisected the layers. There are three process/thread boundaries between the user's shell and the PHP runtime, and studio wp drops stdin at the very first one:

your shell pipe
      │
      ▼
studio wp              ← Node process A, holds your pipe as process.stdin
  (apps/cli/commands/wp.ts)
      │
      │  never reads process.stdin  ← DROP HERE (Studio-side, the bug)
      │
      ▼  IPC message: { topic: 'wp-cli-command', data: { args } }   (no stdin in payload)
      │
wordpress-server-child ← Node process B, long-running daemon child, its own process.stdin
  (apps/cli/wordpress-server-child.ts)
      │
      │  calls server.playground.cli(args)
      │
      ▼  comlink → worker_threads
      │
PlaygroundCliWorker    ← Node worker thread C, hosts the PHP-WASM runtime
      │
      ▼  php.cli(args) — API has no stdin option anyway
      │
PHP-WASM               ← Emscripten's default FS_stdin_getChar reads worker thread's fd 0,
                         which is not your shell pipe. Returns "" for php://stdin.

Proof it's Studio-side and not downstream:

Path Stdin arrives?
studio wp ... (daemon path, site running) got: []
studio wp --php-version=8.2 ... (forces in-proc runWpCliCommand path) got: [...]
Bare php-wasm-cli (standalone Playground CLI, no Studio) got: [...]
Direct loadNodeRuntime + php.cli(...) in the same Node process as the pipe got: [...]

Only the daemon IPC path drops stdin. The WASM PHP runtime and Playground's php.cli() both behave correctly when host stdin is available to the hosting Node process.

Why the in-proc path works

When studio wp is invoked with a PHP version that differs from the site's configured version (or when the site isn't running), the code in apps/cli/commands/wp.ts falls through to runWpCliCommand (apps/cli/lib/run-wp-cli-command.ts), which calls loadNodeRuntime + php.cli(...) inside the studio wp Node process itself. That process still owns the user's pipe, so Emscripten's default FS_stdin_getChar — which under the hood does fs.readSync(process.stdin.fd, …) — ends up reading your bytes. Implicit and accidental, but it works.

The daemon path doesn't get this for free, because the WASM runtime lives in a worker thread of a long-running daemon-spawned child process. No stdin inheritance chain reaches it.

Relationship to the Playground API

There's a complementary gap upstream: @php-wasm/universal's PHP.cli(argv, options) has no stdin option, so even if Studio drained process.stdin in studio wp, there is currently no public API to hand those bytes to PHP. Filed upstream: WordPress/wordpress-playground#3519.

Ideal fix sequence:

  1. Land the Playground API: php.cli(argv, { stdin: Uint8Array | Buffer | ReadableStream | string }).
  2. Land the Studio consumer:
    • In apps/cli/commands/wp.ts, if !process.stdin.isTTY, drain process.stdin to a Buffer upfront.
    • Extend the wp-cli-command IPC payload schema (apps/cli/lib/types/wordpress-server-ipc.ts) to carry stdin?: string (base64) alongside args.
    • In apps/cli/wordpress-server-child.ts, decode and forward via server.playground.cli(args, { stdin }).
    • Forward the same buffer in the in-proc runWpCliCommand path for symmetry.

Studio-only workaround that sidesteps the Playground gap: force the in-proc path whenever stdin is non-TTY. Cost: a cold-start penalty (~1s) for stdin-piped commands instead of the warm daemon. Fine as a stopgap, not great as a long-term design — loses the warm-server benefit for exactly the commands most likely to be expensive (large pipes).

Environment

  • studio 1.7.9 on macOS 26.4.1 (ARM64)
  • Node v24.13.1
  • Confirmed present on Studio trunk — the code paths involved (apps/cli/commands/wp.ts, apps/cli/lib/wordpress-server-manager.ts, apps/cli/wordpress-server-child.ts) do not read process.stdin anywhere in the wp-cli command flow, and the IPC payload schema for wp-cli-command has no stdin field.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

Status
Triage

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions