From f0cbffd6e3cde88f54a76ae5e0d32210d38e0ee6 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 24 Jun 2026 17:54:34 -0400 Subject: [PATCH] fix: prevent devai:install composer hang on invisible prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The devai:install docs-driver step shelled out to `composer require --dev` through CommandRunner, which opened only stdout/stderr pipes and left the child inheriting the parent's TTY stdin. Composer therefore saw an interactive terminal and could block on a prompt (e.g. the allow-plugins trust question) whose text was buffered and never echoed — an invisible, infinite hang. Detach the child's stdin to /dev/null in CommandRunner so no shelled-out command can ever block on terminal input, and pass --no-interaction --no-progress to the composer require call so composer is contractually forbidden from prompting. Add a CommandRunner test proving a stdin-reading child returns immediately instead of deadlocking. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/devai/src/Commands/InstallCommand.php | 5 ++++- packages/devai/src/Process/CommandRunner.php | 10 +++++++++- .../tests/Unit/Commands/InstallCommandTest.php | 14 +++++++++++--- .../devai/tests/Unit/Process/CommandRunnerTest.php | 13 +++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/devai/src/Commands/InstallCommand.php b/packages/devai/src/Commands/InstallCommand.php index b138b199..5d438ea9 100644 --- a/packages/devai/src/Commands/InstallCommand.php +++ b/packages/devai/src/Commands/InstallCommand.php @@ -122,7 +122,10 @@ private function maybeInstallDocsDriver( } $output->writeLine("Installing $pkg via composer (this may take a moment)…"); - $result = $this->commandRunner->run('composer', ['require', '--dev', $pkg]); + $result = $this->commandRunner->run( + 'composer', + ['require', '--dev', '--no-interaction', '--no-progress', $pkg] + ); if ($result['exitCode'] !== 0) { $stderr = trim($result['stderr']); diff --git a/packages/devai/src/Process/CommandRunner.php b/packages/devai/src/Process/CommandRunner.php index 282a9ab9..13ec7f09 100644 --- a/packages/devai/src/Process/CommandRunner.php +++ b/packages/devai/src/Process/CommandRunner.php @@ -15,7 +15,15 @@ public function run( array $args = [], ): array { $cmd = escapeshellcmd($command) . ' ' . implode(' ', array_map('escapeshellarg', $args)); - $proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + // Detach stdin (read from /dev/null) so a child can never block waiting on + // terminal input. Without this the child inherits the parent's TTY and any + // unexpected prompt — e.g. composer's allow-plugins trust question — deadlocks + // forever, with the prompt hidden because we buffer the child's output. + $proc = proc_open( + $cmd, + [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); if (!is_resource($proc)) { return ['exitCode' => -1, 'stdout' => '', 'stderr' => 'proc_open failed']; diff --git a/packages/devai/tests/Unit/Commands/InstallCommandTest.php b/packages/devai/tests/Unit/Commands/InstallCommandTest.php index 7fa58dff..c03c896b 100644 --- a/packages/devai/tests/Unit/Commands/InstallCommandTest.php +++ b/packages/devai/tests/Unit/Commands/InstallCommandTest.php @@ -37,7 +37,10 @@ public function isInteractive(): bool return $this->interactive; } - public function confirm(string $question, bool $default): bool + public function confirm( + string $question, + bool $default, + ): bool { return $this->answer; } @@ -60,7 +63,10 @@ public function __construct( private readonly int $requireExitCode, ) {} - public function run(string $command, array $args = []): array + public function run( + string $command, + array $args = [], + ): array { $this->calls[] = [$command, $args]; @@ -209,7 +215,9 @@ function readInstallCmdOutput(mixed $stream): string ['stream' => $stream, 'output' => $output] = makeInstallCmdOutput(); $cmd->execute(new Input(['marko', 'devai:install']), $output); - expect($runner->calls)->toContain(['composer', ['require', '--dev', 'marko/docs-fts']]); + expect($runner->calls)->toContain( + ['composer', ['require', '--dev', '--no-interaction', '--no-progress', 'marko/docs-fts']] + ); }); it('does not install anything when the user answers no', function (): void { diff --git a/packages/devai/tests/Unit/Process/CommandRunnerTest.php b/packages/devai/tests/Unit/Process/CommandRunnerTest.php index d919c3b4..a463d925 100644 --- a/packages/devai/tests/Unit/Process/CommandRunnerTest.php +++ b/packages/devai/tests/Unit/Process/CommandRunnerTest.php @@ -33,6 +33,19 @@ ->and(strlen($result['stderr']))->toBeGreaterThan(65536); })->group('integration'); + it('does not block when the child reads stdin (stdin detached to /dev/null)', function (): void { + $runner = new CommandRunner(); + // A child that reads stdin would deadlock forever if it inherited the parent's + // TTY. With stdin detached to /dev/null it gets immediate EOF and returns. + $started = microtime(true); + $result = $runner->run('sh', ['-c', 'read line; echo "done"']); + $elapsed = microtime(true) - $started; + + expect($elapsed)->toBeLessThan(10.0) + ->and($result['stdout'])->toContain('done') + ->and($result['exitCode'])->toBe(0); + }); + it('returns the proc_open failure shape when the process cannot start', function (): void { $runner = new CommandRunner(); // Pass a command that proc_open will fail on by using a completely invalid path