From d750e90e027bf7c670f0d1c75a0e638c00f84c44 Mon Sep 17 00:00:00 2001 From: Valentin PONS Date: Mon, 29 Sep 2025 11:16:51 +0200 Subject: [PATCH 1/3] Handle signals on text input --- .../Console/Helper/QuestionHelper.php | 42 +++++++++++++---- .../Fixtures/application_test_sigint.php | 45 +++++++++++++++++++ .../Tests/Helper/QuestionHelperTest.php | 28 ++++++++++++ 3 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 src/Symfony/Component/Console/Tests/Fixtures/application_test_sigint.php 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..a5aca8e8c9b78 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 testExitCommandOnEmptySingleLineInputSIGINT(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); From 22b8aaab94b7d2f59d66eb9130e6b066129e7d6e Mon Sep 17 00:00:00 2001 From: Valentin PONS Date: Mon, 29 Sep 2025 11:22:19 +0200 Subject: [PATCH 2/3] Fix test name --- .../Component/Console/Tests/Helper/QuestionHelperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php index a5aca8e8c9b78..732481e63a780 100644 --- a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php +++ b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php @@ -962,7 +962,7 @@ public function testAutocompleteMoveCursorBackwards() } #[DataProvider('modeProvider')] - public function testExitCommandOnEmptySingleLineInputSIGINT(string $mode) + public function testExitCommandOnInputSIGINT(string $mode) { if (!SignalRegistry::isSupported()) { $this->markTestSkipped('pcntl signals not available'); From b970c2ce6135e5df64caa8537e82c4d397cc66d6 Mon Sep 17 00:00:00 2001 From: Valentin PONS Date: Mon, 29 Sep 2025 11:40:21 +0200 Subject: [PATCH 3/3] Update CHANGELOG --- src/Symfony/Component/Console/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 ---