diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 722045091ff49..79b156ecaaf79 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -10,6 +10,7 @@ CHANGELOG * Add `BackedEnum` support with `#[Argument]` and `#[Option]` inputs in invokable commands * Allow Usages to be specified via `#[AsCommand]` attribute. * Allow passing invokable commands to `Symfony\Component\Console\Tester\CommandTester` + * Add optional timeout for interaction in `QuestionHelper` 7.3 --- diff --git a/src/Symfony/Component/Console/Helper/QuestionHelper.php b/src/Symfony/Component/Console/Helper/QuestionHelper.php index 9b65c321368fe..ccd11e84082e8 100644 --- a/src/Symfony/Component/Console/Helper/QuestionHelper.php +++ b/src/Symfony/Component/Console/Helper/QuestionHelper.php @@ -502,6 +502,19 @@ private function isInteractiveInput($inputStream): bool */ private function readInput($inputStream, Question $question): string|false { + if (null !== $question->getTimeoutSeconds() && $this->isInteractiveInput($inputStream)) { + $read = [$inputStream]; + $write = null; + $except = null; + $timeoutSeconds = $question->getTimeoutSeconds(); + $changedStreams = stream_select($read, $write, $except, $timeoutSeconds); + + if (0 === $changedStreams) { + $plural = 1 === $timeoutSeconds ? '' : 's'; + throw new MissingInputException("Timed out after waiting for input for $timeoutSeconds second$plural."); + } + } + if (!$question->isMultiline()) { $cp = $this->setIOCodepage(); $ret = fgets($inputStream, 4096); diff --git a/src/Symfony/Component/Console/Question/Question.php b/src/Symfony/Component/Console/Question/Question.php index cb65bd6746ee0..5f8b1494648e0 100644 --- a/src/Symfony/Component/Console/Question/Question.php +++ b/src/Symfony/Component/Console/Question/Question.php @@ -38,6 +38,7 @@ class Question private ?\Closure $normalizer = null; private bool $trimmable = true; private bool $multiline = false; + private ?int $timeoutSeconds = null; /** * @param string $question The question to ask to the user @@ -85,6 +86,27 @@ public function setMultiline(bool $multiline): static return $this; } + /** + * Returns the timeout in seconds. + */ + public function getTimeoutSeconds(): ?int + { + return $this->timeoutSeconds; + } + + /** + * The timeout is the maximum time the user has to answer the question. + * If the user does not answer within this time, an exception will be thrown. + * + * @return $this + */ + public function setTimeout(?int $seconds): static + { + $this->timeoutSeconds = $seconds; + + return $this; + } + /** * Returns whether the user response must be hidden. */ diff --git a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php index 0e91dd85b199e..38977f42fff78 100644 --- a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php +++ b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php @@ -186,6 +186,45 @@ public function testAskNonTrimmed() $this->assertEquals('What time is it?', stream_get_contents($output->getStream())); } + public function testAskTimeout() + { + $dialog = new QuestionHelper(); + + $question = new Question('What is your name?'); + $question->setTimeout(1); + + $this->expectException(MissingInputException::class); + $this->expectExceptionMessage('Timed out after waiting for input for 1 second.'); + + try { + $startTime = microtime(true); + $dialog->ask($this->createStreamableInputInterfaceMock(\STDIN), $this->createOutputInterface(), $question); + } finally { + $elapsedTime = microtime(true) - $startTime; + self::assertGreaterThanOrEqual(1, $elapsedTime, 'The question should timeout after 1 second'); + } + } + + public function testAskTimeoutWithIncompatibleStream() + { + $dialog = new QuestionHelper(); + $inputStream = $this->getInputStream(''); + + $question = new Question('What is your name?'); + $question->setTimeout(1); + + $this->expectException(MissingInputException::class); + $this->expectExceptionMessage('Aborted.'); + + try { + $startTime = microtime(true); + $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question); + } finally { + $elapsedTime = microtime(true) - $startTime; + self::assertLessThan(1, $elapsedTime, 'Question should not wait for input on a non-interactive stream'); + } + } + public function testAskWithAutocomplete() { if (!Terminal::hasSttyAvailable()) {