Skip to content
Closed
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 @@ -13,6 +13,7 @@ CHANGELOG
* 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`
* Handle signals for text inputs in `QuestionHelper`

7.3
---
Expand Down
42 changes: 34 additions & 8 deletions src/Symfony/Component/Console/Helper/QuestionHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ private function readInput($inputStream, Question $question): string|false

if (!$question->isMultiline()) {
$cp = $this->setIOCodepage();
$ret = fgets($inputStream, 4096);
$ret = $this->doReadInput($inputStream, "\n");

return $this->resetIOCodepage($cp, $ret);
}
Expand All @@ -526,14 +526,8 @@ private function readInput($inputStream, Question $question): string|false
return false;
}

$ret = '';
$cp = $this->setIOCodepage();
while (false !== ($char = fgetc($multiLineStreamReader))) {
if ("\x4" === $char || \PHP_EOL === "{$ret}{$char}") {
break;
}
$ret .= $char;
}
$ret = $this->doReadInput($multiLineStreamReader, "\x4");

if (stream_get_meta_data($inputStream)['seekable']) {
fseek($inputStream, ftell($multiLineStreamReader));
Expand Down Expand Up @@ -603,4 +597,36 @@ private function cloneInputStream($inputStream)

return $cloneStream;
}

/**
* @param resource $inputStream
*/
private function doReadInput($inputStream, string $exitChar): string
{
$ret = '';
$isStdin = 'php://stdin' === (stream_get_meta_data($inputStream)['uri'] ?? null);
$r = [$inputStream];
$w = [];

while (!feof($inputStream)) {
while ($isStdin && 0 === @stream_select($r, $w, $w, 0, 100)) {
// Give signal handlers a chance to run
$r = [$inputStream];
}
$char = fread($inputStream, 1);

// as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false.
if (false === $char || ('' === $ret && '' === $char)) {
throw new MissingInputException('Aborted.');
}

if ($exitChar === $char || \PHP_EOL === "{$ret}{$char}") {
break;
}

$ret .= $char;
}

return $ret;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;

$vendor = __DIR__;
while (!file_exists($vendor.'/vendor')) {
$vendor = \dirname($vendor);
}
require $vendor.'/vendor/autoload.php';

(new class extends Command {
protected function configure(): void
{
$this->addArgument('mode', InputArgument::OPTIONAL, default: 'single');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getArgument('mode');

$question = new Question('Enter text: ');
$question->setMultiline($mode !== 'single');

$helper = new QuestionHelper();

pcntl_async_signals(true);
pcntl_alarm(1);
pcntl_signal(\SIGALRM, function () {
posix_kill(posix_getpid(), \SIGINT);
});

$helper->ask($input, $output, $question);

return Command::SUCCESS;
}
})
->run(new ArgvInput($argv), new ConsoleOutput())
;
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\SignalRegistry\SignalRegistry;
use Symfony\Component\Console\Terminal;
use Symfony\Component\Console\Tester\ApplicationTester;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\Process;

#[Group('tty')]
class QuestionHelperTest extends AbstractQuestionHelperTestCase
Expand Down Expand Up @@ -958,6 +961,31 @@ public function testAutocompleteMoveCursorBackwards()
$this->assertStringEndsWith("\033[1D\033[K\033[2D\033[K\033[1D\033[K", stream_get_contents($stream));
}

#[DataProvider('modeProvider')]
public function testExitCommandOnInputSIGINT(string $mode)
{
if (!SignalRegistry::isSupported()) {
$this->markTestSkipped('pcntl signals not available');
}

$p = new Process(['php', dirname(__DIR__).'/Fixtures/application_test_sigint.php', $mode]);
$p->setPty(true);
$p->setTimeout(2); // the process will auto shutdown if not killed by SIGINT, to prevent blocking
$p->start();

$this->expectException(ProcessSignaledException::class);
$this->expectExceptionMessage('The process has been signaled with signal "2".');
$p->wait();
}

public static function modeProvider(): array
{
return [
['single'],
['multi'],
];
}

protected function getInputStream($input)
{
$stream = fopen('php://memory', 'r+', false);
Expand Down
Loading