Skip to content

Commit 4808873

Browse files
committed
feature symfony#61092 [Console][QuestionHelper] add optional timeout for human interaction (janedbal)
This PR was squashed before being merged into the 7.4 branch. Discussion ---------- [Console][QuestionHelper] add optional timeout for human interaction | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | no | New feature? | yes <!-- if yes, also update src/**/CHANGELOG.md --> | Deprecations? | no | Issues | None | License | MIT **About:** - Adds timeout functionality to console questions, allowing developers to set a maximum time limit for user input. - Our usecase is that some developers added interactive questions inside opened db transactions. Without timeout, it can lead to hours long trx if somebody forgets to repond and keeps the terminal open. - The feature is backward compatible - existing code continues to work without timeouts unless explicitly set. Commits ------- a3c21b3 [Console][QuestionHelper] add optional timeout for human interaction
2 parents db6206e + a3c21b3 commit 4808873

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)