Skip to content

[Bug]: Windows: cpx freezes until a key is pressed; package output only flushes afterwards #66

Description

@muhannad17

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions