From 77e0a6e4169bb7d8cbd00b24ba7f6413c2bdd556 Mon Sep 17 00:00:00 2001 From: Aure Dulvresse <147180124+AureDulvresse@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:23:06 +0100 Subject: [PATCH] Fix console prompts hanging when isInteractive() is unreliable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command::ask()/confirm()/secret()/choice()/multiChoice() relied solely on $input->isInteractive(), which reflects --no-interaction/-n and Symfony's own best-effort stdin check — observed returning true (i.e. "safe to prompt") on at least one real environment whose stdin would in fact never produce an answer, hanging the command indefinitely. Added canPrompt(), which also requires stream_isatty(STDIN); every prompt now falls back to its default immediately when either check fails, rather than attempting to read one. Found and reproduced while testing marrow/anvil's and marrow/ai-context's own interactive commands — both updated alongside this to use the new guard. --- CHANGELOG.md | 12 ++++++++++++ src/Console/Command.php | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9949a6..1f31d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ Les versions `0.1.x`/`0.2.0` (juin 2026) correspondent à la phase de prototypag ## [Unreleased] +## [3.0.1] - 2026-10-06 + +### Fixed + +- **An interactive console prompt (`Command::ask()`/`confirm()`/`secret()`/`choice()`/`multiChoice()`) could hang + indefinitely instead of either prompting or falling back to its default** — these relied solely on + `$input->isInteractive()`, which reflects `--no-interaction`/`-n` and Symfony's own best-effort stdin check, + observed returning `true` (i.e. "safe to prompt") in at least one real environment whose stdin would in fact + never produce an answer. Every prompt now also requires `stream_isatty(STDIN)` via a new `canPrompt()` guard, + falling back to its default immediately when either check fails — found while testing `marrow/anvil`'s and + `marrow/ai-context`'s own interactive commands, both updated alongside this. + ## [3.0.0] - 2026-10-06 ### Changed (breaking) diff --git a/src/Console/Command.php b/src/Console/Command.php index 8abd0da..496fd21 100644 --- a/src/Console/Command.php +++ b/src/Console/Command.php @@ -292,23 +292,51 @@ private function insertIntoProviders(string $source, string $fqcn): ?string return substr($source, 0, $openBracket + 1) . $newInner . substr($source, $closeBracket); } + /** + * `$input->isInteractive()` alone isn't reliable in every environment — + * it reflects whether `--no-interaction`/`-n` was passed and Symfony's + * own best-effort stdin check, which has been observed returning true + * (i.e. "safe to prompt") on a stdin that will in fact never produce an + * answer, hanging the command indefinitely. Every prompt below also + * requires a real `stream_isatty(STDIN)` before attempting to read one, + * falling back to its default immediately otherwise — a command that + * wants an unattended run should still pass `--no-interaction`/check its + * own flags first, this is a last-resort safety net, not a substitute. + */ + protected function canPrompt(): bool + { + return $this->input->isInteractive() && stream_isatty(STDIN); + } + protected function ask(string $question, ?string $default = null): string { + if (!$this->canPrompt()) { + return (string) ($default ?? ''); + } return (string) $this->io->ask($question, $default); } protected function confirm(string $question, bool $default = false): bool { + if (!$this->canPrompt()) { + return $default; + } return $this->io->confirm($question, $default); } protected function secret(string $question): string { + if (!$this->canPrompt()) { + return ''; + } return (string) $this->io->askHidden($question); } protected function choice(string $question, array $choices, mixed $default = null): string { + if (!$this->canPrompt()) { + return (string) ($default ?? ($choices[array_key_first($choices)] ?? '')); + } return (string) $this->io->choice($question, $choices, $default); } @@ -322,6 +350,9 @@ protected function choice(string $question, array $choices, mixed $default = nul */ protected function multiChoice(string $question, array $choices, array $default = []): array { + if (!$this->canPrompt()) { + return $default; + } $result = $this->io->choice($question, $choices, $default === [] ? null : implode(',', $default), true); return array_values((array) $result); }