Skip to content

Commit 1b28130

Browse files
valx76nicolas-grekas
authored andcommitted
Handle signals on text input
1 parent 5a92ed2 commit 1b28130

File tree

4 files changed

+128
-26
lines changed

4 files changed

+128
-26
lines changed

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
}

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+
;

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)