Skip to content

Commit e041cb3

Browse files
bug #61962 [Console] Handle signals on text input (valx76)
This PR was squashed before being merged into the 6.4 branch. Discussion ---------- [Console] Handle signals on text input | Q | A | ------------- | --- | Branch? | 6.4 | Bug fix? | yes | New feature? | no | Deprecations? | no | Issues | | License | MIT `QuestionHelper` is blocking signals because of its `fgets`/`fgetc` use. This PR uses `fread` so signals can be dispatched in text inputs. Since Windows is not supported by the PCNTL extension, it was working fine on this OS. This change has been tested in Windows, Linux and MacOS. > [!NOTE] > This PR replaces #61878. --- Snippet to test the behavior manually: ```php use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; #[AsCommand('app:test')] class TestCommand 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(); $result = $helper->ask($input, $output, $question); $output->writeln('Result: '.$result); return Command::SUCCESS; } } ``` Usage: - Single line input: `php bin/console app:test single` - Multiline input: `php bin/console app:test multi` Commits ------- 7c40cad Handle signals on text input
2 parents 32ce197 + 7c40cad commit e041cb3

File tree

4 files changed

+128
-26
lines changed

4 files changed

+128
-26
lines changed

src/Symfony/Component/Console/Helper/QuestionHelper.php

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -437,9 +437,7 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
437437
throw new RuntimeException('Unable to hide the response.');
438438
}
439439

440-
$inputHelper?->waitForInput();
441-
442-
$value = fgets($inputStream, 4096);
440+
$value = $this->doReadInput($inputStream, helper: $inputHelper);
443441

444442
if (4095 === \strlen($value)) {
445443
$errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
@@ -449,9 +447,6 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
449447
// Restore the terminal so it behaves normally again
450448
$inputHelper?->finish();
451449

452-
if (false === $value) {
453-
throw new MissingInputException('Aborted.');
454-
}
455450
if ($trimmable) {
456451
$value = trim($value);
457452
}
@@ -511,7 +506,7 @@ private function readInput($inputStream, Question $question): string|false
511506
{
512507
if (!$question->isMultiline()) {
513508
$cp = $this->setIOCodepage();
514-
$ret = fgets($inputStream, 4096);
509+
$ret = $this->doReadInput($inputStream);
515510

516511
return $this->resetIOCodepage($cp, $ret);
517512
}
@@ -521,14 +516,8 @@ private function readInput($inputStream, Question $question): string|false
521516
return false;
522517
}
523518

524-
$ret = '';
525519
$cp = $this->setIOCodepage();
526-
while (false !== ($char = fgetc($multiLineStreamReader))) {
527-
if ("\x4" === $char || \PHP_EOL === "{$ret}{$char}") {
528-
break;
529-
}
530-
$ret .= $char;
531-
}
520+
$ret = $this->doReadInput($multiLineStreamReader, "\x4");
532521

533522
if (stream_get_meta_data($inputStream)['seekable']) {
534523
fseek($inputStream, ftell($multiLineStreamReader));
@@ -598,4 +587,35 @@ private function cloneInputStream($inputStream)
598587

599588
return $cloneStream;
600589
}
590+
591+
/**
592+
* @param resource $inputStream
593+
*/
594+
private function doReadInput($inputStream, ?string $exitChar = null, ?TerminalInputHelper $helper = null): string
595+
{
596+
$ret = '';
597+
$helper ??= new TerminalInputHelper($inputStream, false);
598+
599+
while (!feof($inputStream)) {
600+
$helper->waitForInput();
601+
$char = fread($inputStream, 1);
602+
603+
// as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false.
604+
if (false === $char || ('' === $ret && '' === $char)) {
605+
throw new MissingInputException('Aborted.');
606+
}
607+
608+
if (\PHP_EOL === "{$ret}{$char}" || $exitChar === $char) {
609+
break;
610+
}
611+
612+
$ret .= $char;
613+
614+
if (null === $exitChar && "\n" === $char) {
615+
break;
616+
}
617+
}
618+
619+
return $ret;
620+
}
601621
}

src/Symfony/Component/Console/Helper/TerminalInputHelper.php

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,51 +37,63 @@ final class TerminalInputHelper
3737
/** @var resource */
3838
private $inputStream;
3939
private bool $isStdin;
40-
private string $initialState;
40+
private string $initialState = '';
4141
private int $signalToKill = 0;
4242
private array $signalHandlers = [];
4343
private array $targetSignals = [];
44+
private bool $withStty;
4445

4546
/**
4647
* @param resource $inputStream
4748
*
4849
* @throws \RuntimeException If unable to read terminal settings
4950
*/
50-
public function __construct($inputStream)
51+
public function __construct($inputStream, bool $withStty = true)
5152
{
52-
if (!\is_string($state = shell_exec('stty -g'))) {
53-
throw new \RuntimeException('Unable to read the terminal settings.');
54-
}
5553
$this->inputStream = $inputStream;
56-
$this->initialState = $state;
5754
$this->isStdin = 'php://stdin' === stream_get_meta_data($inputStream)['uri'];
58-
$this->createSignalHandlers();
55+
$this->withStty = $withStty;
56+
57+
if ($withStty) {
58+
if (!\is_string($state = shell_exec('stty -g'))) {
59+
throw new \RuntimeException('Unable to read the terminal settings.');
60+
}
61+
62+
$this->initialState = $state;
63+
64+
$this->createSignalHandlers();
65+
}
5966
}
6067

6168
/**
62-
* Waits for input and terminates if sent a default signal.
69+
* Waits for input.
6370
*/
6471
public function waitForInput(): void
6572
{
6673
if ($this->isStdin) {
6774
$r = [$this->inputStream];
6875
$w = [];
6976

70-
// Allow signal handlers to run, either before Enter is pressed
71-
// when icanon is enabled, or a single character is entered when
72-
// icanon is disabled
77+
// Allow signal handlers to run
7378
while (0 === @stream_select($r, $w, $w, 0, 100)) {
7479
$r = [$this->inputStream];
7580
}
7681
}
77-
$this->checkForKillSignal();
82+
83+
if ($this->withStty) {
84+
$this->checkForKillSignal();
85+
}
7886
}
7987

8088
/**
8189
* Restores terminal state and signal handlers.
8290
*/
8391
public function finish(): void
8492
{
93+
if (!$this->withStty) {
94+
return;
95+
}
96+
8597
// Safeguard in case an unhandled kill signal exists
8698
$this->checkForKillSignal();
8799
shell_exec('stty '.$this->initialState);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
use Symfony\Component\Console\Command\Command;
4+
use Symfony\Component\Console\Helper\QuestionHelper;
5+
use Symfony\Component\Console\Input\ArgvInput;
6+
use Symfony\Component\Console\Input\InputArgument;
7+
use Symfony\Component\Console\Input\InputInterface;
8+
use Symfony\Component\Console\Output\ConsoleOutput;
9+
use Symfony\Component\Console\Output\OutputInterface;
10+
use Symfony\Component\Console\Question\Question;
11+
12+
$vendor = __DIR__;
13+
while (!file_exists($vendor.'/vendor')) {
14+
$vendor = \dirname($vendor);
15+
}
16+
require $vendor.'/vendor/autoload.php';
17+
18+
(new class extends Command {
19+
protected function configure(): void
20+
{
21+
$this->addArgument('mode', InputArgument::OPTIONAL, default: 'single');
22+
}
23+
24+
protected function execute(InputInterface $input, OutputInterface $output): int
25+
{
26+
$mode = $input->getArgument('mode');
27+
28+
$question = new Question('Enter text: ');
29+
$question->setMultiline($mode !== 'single');
30+
31+
$helper = new QuestionHelper();
32+
33+
pcntl_async_signals(true);
34+
pcntl_signal(\SIGALRM, function () {
35+
posix_kill(posix_getpid(), \SIGINT);
36+
pcntl_signal_dispatch();
37+
});
38+
pcntl_alarm(1);
39+
40+
$helper->ask($input, $output, $question);
41+
42+
return Command::SUCCESS;
43+
}
44+
})
45+
->run(new ArgvInput($argv), new ConsoleOutput())
46+
;

src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
use Symfony\Component\Console\Question\Question;
2727
use Symfony\Component\Console\Terminal;
2828
use Symfony\Component\Console\Tester\ApplicationTester;
29+
use Symfony\Component\Process\Exception\ProcessSignaledException;
30+
use Symfony\Component\Process\Process;
2931

3032
/**
3133
* @group tty
@@ -929,6 +931,28 @@ public function testAutocompleteMoveCursorBackwards()
929931
$this->assertStringEndsWith("\033[1D\033[K\033[2D\033[K\033[1D\033[K", stream_get_contents($stream));
930932
}
931933

934+
/**
935+
* @testWith ["single"]
936+
* ["multi"]
937+
*/
938+
public function testExitCommandOnInputSIGINT(string $mode)
939+
{
940+
if (!\function_exists('pcntl_signal')) {
941+
$this->markTestSkipped('pcntl signals not available');
942+
}
943+
944+
$p = new Process(
945+
['php', dirname(__DIR__).'/Fixtures/application_test_sigint.php', $mode],
946+
timeout: 2, // the process will auto shutdown if not killed by SIGINT, to prevent blocking
947+
);
948+
$p->setPty(true);
949+
$p->start();
950+
951+
$this->expectException(ProcessSignaledException::class);
952+
$this->expectExceptionMessage('The process has been signaled with signal "2".');
953+
$p->wait();
954+
}
955+
932956
protected function getInputStream($input)
933957
{
934958
$stream = fopen('php://memory', 'r+', false);

0 commit comments

Comments
 (0)