Skip to content

Commit a3c21b3

Browse files
janedbalfabpot
authored andcommitted
[Console][QuestionHelper] add optional timeout for human interaction
1 parent db6206e commit a3c21b3

File tree

4 files changed

+74
-0
lines changed

4 files changed

+74
-0
lines changed

src/Symfony/Component/Console/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ CHANGELOG
1212
* Allow Usages to be specified via `#[AsCommand]` attribute.
1313
* Allow passing invokable commands to `Symfony\Component\Console\Tester\CommandTester`
1414
* Add `#[Input]` attribute to support DTOs in commands
15+
* Add optional timeout for interaction in `QuestionHelper`
1516

1617
7.3
1718
---

src/Symfony/Component/Console/Helper/QuestionHelper.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,18 @@ private function isInteractiveInput($inputStream): bool
502502
*/
503503
private function readInput($inputStream, Question $question): string|false
504504
{
505+
if (null !== $question->getTimeout() && $this->isInteractiveInput($inputStream)) {
506+
$read = [$inputStream];
507+
$write = null;
508+
$except = null;
509+
$timeoutSeconds = $question->getTimeout();
510+
$changedStreams = stream_select($read, $write, $except, $timeoutSeconds);
511+
512+
if (0 === $changedStreams) {
513+
throw new MissingInputException(\sprintf('Timed out after waiting for input for %d second%s.', $timeoutSeconds, 1 === $timeoutSeconds ? '' : 's'));
514+
}
515+
}
516+
505517
if (!$question->isMultiline()) {
506518
$cp = $this->setIOCodepage();
507519
$ret = fgets($inputStream, 4096);

src/Symfony/Component/Console/Question/Question.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Question
3838
private ?\Closure $normalizer = null;
3939
private bool $trimmable = true;
4040
private bool $multiline = false;
41+
private ?int $timeout = null;
4142

4243
/**
4344
* @param string $question The question to ask to the user
@@ -85,6 +86,27 @@ public function setMultiline(bool $multiline): static
8586
return $this;
8687
}
8788

89+
/**
90+
* Returns the timeout in seconds.
91+
*/
92+
public function getTimeout(): ?int
93+
{
94+
return $this->timeout;
95+
}
96+
97+
/**
98+
* Sets the maximum time the user has to answer the question.
99+
* If the user does not answer within this time, an exception will be thrown.
100+
*
101+
* @return $this
102+
*/
103+
public function setTimeout(?int $seconds): static
104+
{
105+
$this->timeout = $seconds;
106+
107+
return $this;
108+
}
109+
88110
/**
89111
* Returns whether the user response must be hidden.
90112
*/

src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,45 @@ public function testAskNonTrimmed()
186186
$this->assertEquals('What time is it?', stream_get_contents($output->getStream()));
187187
}
188188

189+
public function testAskTimeout()
190+
{
191+
$dialog = new QuestionHelper();
192+
193+
$question = new Question('What is your name?');
194+
$question->setTimeout(1);
195+
196+
$this->expectException(MissingInputException::class);
197+
$this->expectExceptionMessage('Timed out after waiting for input for 1 second.');
198+
199+
try {
200+
$startTime = microtime(true);
201+
$dialog->ask($this->createStreamableInputInterfaceMock(\STDIN), $this->createOutputInterface(), $question);
202+
} finally {
203+
$elapsedTime = microtime(true) - $startTime;
204+
self::assertGreaterThanOrEqual(1, $elapsedTime, 'The question should timeout after 1 second');
205+
}
206+
}
207+
208+
public function testAskTimeoutWithIncompatibleStream()
209+
{
210+
$dialog = new QuestionHelper();
211+
$inputStream = $this->getInputStream('');
212+
213+
$question = new Question('What is your name?');
214+
$question->setTimeout(1);
215+
216+
$this->expectException(MissingInputException::class);
217+
$this->expectExceptionMessage('Aborted.');
218+
219+
try {
220+
$startTime = microtime(true);
221+
$dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question);
222+
} finally {
223+
$elapsedTime = microtime(true) - $startTime;
224+
self::assertLessThan(1, $elapsedTime, 'Question should not wait for input on a non-interactive stream');
225+
}
226+
}
227+
189228
public function testAskWithAutocomplete()
190229
{
191230
if (!Terminal::hasSttyAvailable()) {

0 commit comments

Comments
 (0)