cpx Version
2.1.0
PHP Version
8.4.11
Operating System
Windows 11
Description
On Windows, running any package binary through cpx from an interactive terminal hangs
after cpx prints its own banner ("Running hello from ..."). No output from the spawned
binary appears until the user presses a character + Enter, at which point everything the
binary already produced flushes at once.
The cause is in cpx itself, not in the packages: ProcessRunner::execute() forwards the
parent's STDIN resource to every spawned process via $process->setInput(STDIN). On
Windows, Symfony Process cannot allocate a TTY (Process::isTtySupported() is false), so
this fallback path always runs. Symfony's pipe loop then calls fread(STDIN) inside its
main poll cycle, and stream_set_blocking(STDIN, false) is a no-op for console handles on
Windows — so the first poll iteration blocks until the user presses Enter. Since that same
loop relays child stdout/stderr, display is frozen too. macOS/Linux are unaffected because
the setTty(true) branch handles interactive runs.
Piped stdin keeps working today because pipes deliver EOF instead of blocking — which is
also why this went unnoticed in non-interactive contexts and CI.
Steps To Reproduce
Create a throwaway local package anywhere (no Composer dependencies needed):
dummy-package/composer.json
dummy-package/bin/hello
dummy-package/vendor/autoload.php <- empty file
dummy-package/composer.json:
{
"name": "issue/dummy-package",
"description": "Repro for cpx stdin freeze",
"bin": ["bin/hello"]
}
dummy-package/bin/hello - prints timestamped lines for ~1 second and never reads STDIN:
#!/usr/bin/env php
<?php
fwrite(STDOUT, 'hello: start ' . date('H:i:s') . "\n");
for ($i = 1; $i <= 5; $i++) {
usleep(200000);
fwrite(STDOUT, "hello: line {$i} " . date('H:i:s') . "\n");
}
fwrite(STDOUT, 'hello: done ' . date('H:i:s') . "\n");
dummy-package/vendor/autoload.php — create it empty. cpx refuses to run a local package
directory unless this file exists, and the empty file is enough for this repro.
Then run from an interactive terminal:
cpx ./dummy-package hello
Expected: the six lines appear progressively over ~1 second.
Actual: the terminal sits frozen after cpx's banner. After pressing any character +
Enter, all six lines print instantly — even though the binary itself finished within ~1
second of starting (its own timestamps prove it).
Deterministic variant without needing a console (stdin held open and silent vs closed):
# Git Bash / any POSIX shell
time ({ sleep 6; printf 'x\n'; } | cpx ./dummy-package hello) # ~6.3s total
time (cpx ./dummy-package hello < /dev/null) # ~2.4s total
The extra wall time is exactly how long stdin stays silent: cpx's output relay loop is
blocked reading STDIN the whole time.
Analysis
src/Process/ProcessRunner.php, fallback path after the setTty branch:
$process->setInput(self::$fakeInput ?? STDIN) — runs unconditionally on Windows.
- symfony/process
AbstractPipes::write() reads forwarded stream input synchronously
inside the polling loop; on Windows console handles block regardless of
stream_set_blocking.
- Pressing Enter after the child exited additionally produces
fwrite(): Write of 2 bytes failed ... errno=22 against the dead child's stdin pipe
(observed during debugging). That failed write conveniently aborts Symfony's inner read
loop before the next blocking fread, which is why a single keypress is enough to
unstick the terminal.
Proposed fix
Withhold console TTY stdin where it cannot be passed through safely; keep forwarding
pipes and files exactly as today:
protected function childInput(): mixed
{
$input = self::$fakeInput ?? STDIN;
if (is_resource($input) && stream_isatty($input) && ! Process::isTtySupported()) {
return '';
}
return $input;
}
PR incoming with this change plus regression tests.
Notes
No response
cpx Version
2.1.0
PHP Version
8.4.11
Operating System
Windows 11
Description
On Windows, running any package binary through cpx from an interactive terminal hangs
after cpx prints its own banner ("Running hello from ..."). No output from the spawned
binary appears until the user presses a character + Enter, at which point everything the
binary already produced flushes at once.
The cause is in cpx itself, not in the packages:
ProcessRunner::execute()forwards theparent's STDIN resource to every spawned process via
$process->setInput(STDIN). OnWindows, Symfony Process cannot allocate a TTY (
Process::isTtySupported()is false), sothis fallback path always runs. Symfony's pipe loop then calls
fread(STDIN)inside itsmain poll cycle, and
stream_set_blocking(STDIN, false)is a no-op for console handles onWindows — so the first poll iteration blocks until the user presses Enter. Since that same
loop relays child stdout/stderr, display is frozen too. macOS/Linux are unaffected because
the
setTty(true)branch handles interactive runs.Piped stdin keeps working today because pipes deliver EOF instead of blocking — which is
also why this went unnoticed in non-interactive contexts and CI.
Steps To Reproduce
Create a throwaway local package anywhere (no Composer dependencies needed):
dummy-package/composer.json:{ "name": "issue/dummy-package", "description": "Repro for cpx stdin freeze", "bin": ["bin/hello"] }dummy-package/bin/hello- prints timestamped lines for ~1 second and never reads STDIN:dummy-package/vendor/autoload.php— create it empty. cpx refuses to run a local packagedirectory unless this file exists, and the empty file is enough for this repro.
Then run from an interactive terminal:
Expected: the six lines appear progressively over ~1 second.
Actual: the terminal sits frozen after cpx's banner. After pressing any character +
Enter, all six lines print instantly — even though the binary itself finished within ~1
second of starting (its own timestamps prove it).
Deterministic variant without needing a console (stdin held open and silent vs closed):
The extra wall time is exactly how long stdin stays silent: cpx's output relay loop is
blocked reading STDIN the whole time.
Analysis
src/Process/ProcessRunner.php, fallback path after thesetTtybranch:$process->setInput(self::$fakeInput ?? STDIN)— runs unconditionally on Windows.AbstractPipes::write()reads forwarded stream input synchronouslyinside the polling loop; on Windows console handles block regardless of
stream_set_blocking.fwrite(): Write of 2 bytes failed ... errno=22against the dead child's stdin pipe(observed during debugging). That failed write conveniently aborts Symfony's inner read
loop before the next blocking
fread, which is why a single keypress is enough tounstick the terminal.
Proposed fix
Withhold console TTY stdin where it cannot be passed through safely; keep forwarding
pipes and files exactly as today:
PR incoming with this change plus regression tests.
Notes
No response