Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Component/Console/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ CHANGELOG
* Allow Usages to be specified via `#[AsCommand]` attribute.
* Allow passing invokable commands to `Symfony\Component\Console\Tester\CommandTester`
* Add `#[Input]` attribute to support DTOs in commands
* Add optional timeout for interaction in `QuestionHelper`

7.3
---
Expand Down
12 changes: 12 additions & 0 deletions src/Symfony/Component/Console/Helper/QuestionHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,18 @@ private function isInteractiveInput($inputStream): bool
*/
private function readInput($inputStream, Question $question): string|false
{
if (null !== $question->getTimeout() && $this->isInteractiveInput($inputStream)) {
$read = [$inputStream];
$write = null;
$except = null;
$timeoutSeconds = $question->getTimeout();
$changedStreams = stream_select($read, $write, $except, $timeoutSeconds);

if (0 === $changedStreams) {
throw new MissingInputException(\sprintf('Timed out after waiting for input for %d second%s.', $timeoutSeconds, 1 === $timeoutSeconds ? '' : 's'));
}
}

if (!$question->isMultiline()) {
$cp = $this->setIOCodepage();
$ret = fgets($inputStream, 4096);
Expand Down
22 changes: 22 additions & 0 deletions src/Symfony/Component/Console/Question/Question.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class Question
private ?\Closure $normalizer = null;
private bool $trimmable = true;
private bool $multiline = false;
private ?int $timeout = null;

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

/**
* Returns the timeout in seconds.
*/
public function getTimeout(): ?int
{
return $this->timeout;
}

/**
* Sets 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->timeout = $seconds;

return $this;
}

/**
* Returns whether the user response must be hidden.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Loading