Skip to content

Commit 07a5ed4

Browse files
committed
bug #61861 [Console] Ensure terminal is usable after termination signal (johnstevenson)
This PR was squashed before being merged into the 6.4 branch. Discussion ---------- [Console] Ensure terminal is usable after termination signal | Q | A | ------------- | --- | Branch? | 6.4 | Bug fix? | yes | New feature? | no | Deprecations? | no | Issues | Fix #61732 | License | MIT This PR introduces a new helper `TerminalInputHelper` that restores the terminal to its original state after it has been modified when reading using input. This ensures that the terminal is not broken when a terminating signal, like Ctrl-C, is received. QuestionHelper.php disables `icanon` and `echo` when accepting input from a selection, and disables `echo` when accepting hidden input. If these are not restored before termination, the user's terminal can end up in a broken state. Usage: ```php $inputHelper = new TerminalInputHelper($inputStream); // Change terminal settings then wait for input before all input reads $inputHelper->waitForInput(); // Read the input then call finish to restore terminal settings and signal handlers $inputHelper->finish() ``` The helper creates its own signal handlers (for `SIGINT`, `SIGQUIT`, and `SIGTERM`) that restore the original terminal settings then call any original handler callback. If the original handler callback does not terminate the process then the current terminal settings are restored. If there is no original signal handler callback and the signal's disposition is set to the default action (`SIG_DFL`), then that action is invoked by a `posix_kill` call. The `finish` method restores the terminal settings and replaces the new signal handlers with the original ones. Commits ------- fe75d75 [Console] Ensure terminal is usable after termination signal
2 parents c97f93b + fe75d75 commit 07a5ed4

File tree

6 files changed

+230
-28
lines changed

6 files changed

+230
-28
lines changed

src/Symfony/Component/Console/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) {

src/Symfony/Component/Console/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.');
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+
}

src/Symfony/Component/Console/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

src/Symfony/Component/Console/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)