Skip to content

Commit 250376a

Browse files
johnstevensonfabpot
authored andcommitted
[Console] Ensure terminal is usable after termination signal
1 parent d42098f commit 250376a

File tree

6 files changed

+230
-28
lines changed

6 files changed

+230
-28
lines changed

Application.php

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,14 +1018,6 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
10181018
throw new RuntimeException('Unable to subscribe to signal events. Make sure that the "pcntl" extension is installed and that "pcntl_*" functions are not disabled by your php.ini\'s "disable_functions" directive.');
10191019
}
10201020

1021-
if (Terminal::hasSttyAvailable()) {
1022-
$sttyMode = shell_exec('stty -g');
1023-
1024-
foreach ([\SIGINT, \SIGTERM] as $signal) {
1025-
$this->signalRegistry->register($signal, static fn () => shell_exec('stty '.$sttyMode));
1026-
}
1027-
}
1028-
10291021
if ($this->dispatcher) {
10301022
// We register application signals, so that we can dispatch the event
10311023
foreach ($this->signalsToDispatchEvent as $signal) {

Helper/QuestionHelper.php

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
258258
$ofs = -1;
259259
$matches = $autocomplete($ret);
260260
$numMatches = \count($matches);
261-
262-
$sttyMode = shell_exec('stty -g');
263-
$isStdin = 'php://stdin' === (stream_get_meta_data($inputStream)['uri'] ?? null);
264-
$r = [$inputStream];
265-
$w = [];
261+
$inputHelper = new TerminalInputHelper($inputStream);
266262

267263
// Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead)
268264
shell_exec('stty -icanon -echo');
@@ -272,15 +268,13 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
272268

273269
// Read a keypress
274270
while (!feof($inputStream)) {
275-
while ($isStdin && 0 === @stream_select($r, $w, $w, 0, 100)) {
276-
// Give signal handlers a chance to run
277-
$r = [$inputStream];
278-
}
271+
$inputHelper->waitForInput();
279272
$c = fread($inputStream, 1);
280273

281274
// as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false.
282275
if (false === $c || ('' === $ret && '' === $c && null === $question->getDefault())) {
283-
shell_exec('stty '.$sttyMode);
276+
// Restore the terminal so it behaves normally again
277+
$inputHelper->finish();
284278
throw new MissingInputException('Aborted.');
285279
} elseif ("\177" === $c) { // Backspace Character
286280
if (0 === $numMatches && 0 !== $i) {
@@ -382,8 +376,8 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
382376
}
383377
}
384378

385-
// Reset stty so it behaves normally again
386-
shell_exec('stty '.$sttyMode);
379+
// Restore the terminal so it behaves normally again
380+
$inputHelper->finish();
387381

388382
return $fullChoice;
389383
}
@@ -434,23 +428,26 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
434428
return $value;
435429
}
436430

431+
$inputHelper = null;
432+
437433
if (self::$stty && Terminal::hasSttyAvailable()) {
438-
$sttyMode = shell_exec('stty -g');
434+
$inputHelper = new TerminalInputHelper($inputStream);
439435
shell_exec('stty -echo');
440436
} elseif ($this->isInteractiveInput($inputStream)) {
441437
throw new RuntimeException('Unable to hide the response.');
442438
}
443439

440+
$inputHelper?->waitForInput();
441+
444442
$value = fgets($inputStream, 4096);
445443

446444
if (4095 === \strlen($value)) {
447445
$errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
448446
$errOutput->warning('The value was possibly truncated by your shell or terminal emulator');
449447
}
450448

451-
if (self::$stty && Terminal::hasSttyAvailable()) {
452-
shell_exec('stty '.$sttyMode);
453-
}
449+
// Restore the terminal so it behaves normally again
450+
$inputHelper?->finish();
454451

455452
if (false === $value) {
456453
throw new MissingInputException('Aborted.');

Helper/TerminalInputHelper.php

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Helper;
13+
14+
/**
15+
* TerminalInputHelper stops Ctrl-C and similar signals from leaving the terminal in
16+
* an unusable state if its settings have been modified when reading user input.
17+
* This can be an issue on non-Windows platforms.
18+
*
19+
* Usage:
20+
*
21+
* $inputHelper = new TerminalInputHelper($inputStream);
22+
*
23+
* ...change terminal settings
24+
*
25+
* // Wait for input before all input reads
26+
* $inputHelper->waitForInput();
27+
*
28+
* ...read input
29+
*
30+
* // Call finish to restore terminal settings and signal handlers
31+
* $inputHelper->finish()
32+
*
33+
* @internal
34+
*/
35+
final class TerminalInputHelper
36+
{
37+
/** @var resource */
38+
private $inputStream;
39+
private bool $isStdin;
40+
private string $initialState;
41+
private int $signalToKill = 0;
42+
private array $signalHandlers = [];
43+
private array $targetSignals = [];
44+
45+
/**
46+
* @param resource $inputStream
47+
*
48+
* @throws \RuntimeException If unable to read terminal settings
49+
*/
50+
public function __construct($inputStream)
51+
{
52+
if (!\is_string($state = shell_exec('stty -g'))) {
53+
throw new \RuntimeException('Unable to read the terminal settings.');
54+
}
55+
$this->inputStream = $inputStream;
56+
$this->initialState = $state;
57+
$this->isStdin = 'php://stdin' === stream_get_meta_data($inputStream)['uri'];
58+
$this->createSignalHandlers();
59+
}
60+
61+
/**
62+
* Waits for input and terminates if sent a default signal.
63+
*/
64+
public function waitForInput(): void
65+
{
66+
if ($this->isStdin) {
67+
$r = [$this->inputStream];
68+
$w = [];
69+
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
73+
while (0 === @stream_select($r, $w, $w, 0, 100)) {
74+
$r = [$this->inputStream];
75+
}
76+
}
77+
$this->checkForKillSignal();
78+
}
79+
80+
/**
81+
* Restores terminal state and signal handlers.
82+
*/
83+
public function finish(): void
84+
{
85+
// Safeguard in case an unhandled kill signal exists
86+
$this->checkForKillSignal();
87+
shell_exec('stty '.$this->initialState);
88+
$this->signalToKill = 0;
89+
90+
foreach ($this->signalHandlers as $signal => $originalHandler) {
91+
pcntl_signal($signal, $originalHandler);
92+
}
93+
$this->signalHandlers = [];
94+
$this->targetSignals = [];
95+
}
96+
97+
private function createSignalHandlers(): void
98+
{
99+
if (!\function_exists('pcntl_async_signals') || !\function_exists('pcntl_signal')) {
100+
return;
101+
}
102+
103+
pcntl_async_signals(true);
104+
$this->targetSignals = [\SIGINT, \SIGQUIT, \SIGTERM];
105+
106+
foreach ($this->targetSignals as $signal) {
107+
$this->signalHandlers[$signal] = pcntl_signal_get_handler($signal);
108+
109+
pcntl_signal($signal, function ($signal) {
110+
// Save current state, then restore to initial state
111+
$currentState = shell_exec('stty -g');
112+
shell_exec('stty '.$this->initialState);
113+
$originalHandler = $this->signalHandlers[$signal];
114+
115+
if (\is_callable($originalHandler)) {
116+
$originalHandler($signal);
117+
// Handler did not exit, so restore to current state
118+
shell_exec('stty '.$currentState);
119+
120+
return;
121+
}
122+
123+
// Not a callable, so SIG_DFL or SIG_IGN
124+
if (\SIG_DFL === $originalHandler) {
125+
$this->signalToKill = $signal;
126+
}
127+
});
128+
}
129+
}
130+
131+
private function checkForKillSignal(): void
132+
{
133+
if (\in_array($this->signalToKill, $this->targetSignals, true)) {
134+
// Try posix_kill
135+
if (\function_exists('posix_kill')) {
136+
pcntl_signal($this->signalToKill, \SIG_DFL);
137+
posix_kill(getmypid(), $this->signalToKill);
138+
}
139+
140+
// Best attempt fallback
141+
exit(128 + $this->signalToKill);
142+
}
143+
}
144+
}

Tests/ApplicationTest.php

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2198,6 +2198,31 @@ public function testSignalableWithEventCommandDoesNotInterruptedOnTermSignals()
21982198
* @group tty
21992199
*/
22002200
public function testSignalableRestoresStty()
2201+
{
2202+
$params = [__DIR__.'/Fixtures/application_signalable.php'];
2203+
$this->runRestoresSttyTest($params, 254, true);
2204+
}
2205+
2206+
/**
2207+
* @group tty
2208+
*
2209+
* @dataProvider provideTerminalInputHelperOption
2210+
*/
2211+
public function testTerminalInputHelperRestoresStty(string $option)
2212+
{
2213+
$params = [__DIR__.'/Fixtures/application_sttyhelper.php', $option];
2214+
$this->runRestoresSttyTest($params, 0, false);
2215+
}
2216+
2217+
public static function provideTerminalInputHelperOption()
2218+
{
2219+
return [
2220+
['--choice'],
2221+
['--hidden'],
2222+
];
2223+
}
2224+
2225+
private function runRestoresSttyTest(array $params, int $expectedExitCode, bool $equals)
22012226
{
22022227
if (!Terminal::hasSttyAvailable()) {
22032228
$this->markTestSkipped('stty not available');
@@ -2209,22 +2234,29 @@ public function testSignalableRestoresStty()
22092234

22102235
$previousSttyMode = shell_exec('stty -g');
22112236

2212-
$p = new Process(['php', __DIR__.'/Fixtures/application_signalable.php']);
2237+
array_unshift($params, 'php');
2238+
$p = new Process($params);
22132239
$p->setTty(true);
22142240
$p->start();
22152241

22162242
for ($i = 0; $i < 10 && shell_exec('stty -g') === $previousSttyMode; ++$i) {
2217-
usleep(100000);
2243+
usleep(200000);
22182244
}
22192245

22202246
$this->assertNotSame($previousSttyMode, shell_exec('stty -g'));
22212247
$p->signal(\SIGINT);
2222-
$p->wait();
2248+
$exitCode = $p->wait();
22232249

22242250
$sttyMode = shell_exec('stty -g');
22252251
shell_exec('stty '.$previousSttyMode);
22262252

22272253
$this->assertSame($previousSttyMode, $sttyMode);
2254+
2255+
if ($equals) {
2256+
$this->assertEquals($expectedExitCode, $exitCode);
2257+
} else {
2258+
$this->assertNotEquals($expectedExitCode, $exitCode);
2259+
}
22282260
}
22292261

22302262
private function createSignalableApplication(Command $command, ?EventDispatcherInterface $dispatcher): Application

Tests/Fixtures/application_signalable.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public function getSubscribedSignals(): array
2020

2121
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
2222
{
23-
exit(0);
23+
exit(254);
2424
}
2525
})
2626
->setCode(function(InputInterface $input, OutputInterface $output) {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
use Symfony\Component\Console\Input\InputDefinition;
4+
use Symfony\Component\Console\Input\InputInterface;
5+
use Symfony\Component\Console\Input\InputOption;
6+
use Symfony\Component\Console\Output\OutputInterface;
7+
use Symfony\Component\Console\Question\ChoiceQuestion;
8+
use Symfony\Component\Console\Question\Question;
9+
use Symfony\Component\Console\SingleCommandApplication;
10+
11+
$vendor = __DIR__;
12+
while (!file_exists($vendor.'/vendor')) {
13+
$vendor = dirname($vendor);
14+
}
15+
require $vendor.'/vendor/autoload.php';
16+
17+
(new class extends SingleCommandApplication {})
18+
->setDefinition(new InputDefinition([
19+
new InputOption('choice', null, InputOption::VALUE_NONE, ''),
20+
new InputOption('hidden', null, InputOption::VALUE_NONE, ''),
21+
]))
22+
->setCode(function (InputInterface $input, OutputInterface $output) {
23+
if ($input->getOption('choice')) {
24+
$this->getHelper('question')
25+
->ask($input, $output, new ChoiceQuestion('😊', ['n']));
26+
} else {
27+
$question = new Question('😊');
28+
$question->setHidden(true);
29+
$this->getHelper('question')
30+
->ask($input, $output, $question);
31+
}
32+
33+
return 0;
34+
})
35+
->run()
36+
37+
;

0 commit comments

Comments
 (0)