diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 51cafcd49428b..9aabf966ca42e 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -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 --- diff --git a/src/Symfony/Component/Console/Helper/QuestionHelper.php b/src/Symfony/Component/Console/Helper/QuestionHelper.php index 3b85e0c5a421d..f8c35a90aacb8 100644 --- a/src/Symfony/Component/Console/Helper/QuestionHelper.php +++ b/src/Symfony/Component/Console/Helper/QuestionHelper.php @@ -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); } @@ -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)); @@ -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; + } } diff --git a/src/Symfony/Component/Console/Tests/Fixtures/application_test_sigint.php b/src/Symfony/Component/Console/Tests/Fixtures/application_test_sigint.php new file mode 100644 index 0000000000000..7ce3af9b5044f --- /dev/null +++ b/src/Symfony/Component/Console/Tests/Fixtures/application_test_sigint.php @@ -0,0 +1,45 @@ +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()) +; diff --git a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php index be109d9feb749..732481e63a780 100644 --- a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php +++ b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php @@ -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 @@ -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);