Skip to content

Commit b10e52d

Browse files
Merge branch '6.4' into 7.3
* 6.4: [Console] Ensure terminal is usable after termination signal bug #61887 [Serializer] Fix discriminator class mapping with allow_extra_attributes=false
2 parents a31c8da + 80f8c48 commit b10e52d

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
@@ -1025,14 +1025,6 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
10251025
if (($commandSignals = $command->getSubscribedSignals()) || $this->dispatcher && $this->signalsToDispatchEvent) {
10261026
$signalRegistry = $this->getSignalRegistry();
10271027

1028-
if (Terminal::hasSttyAvailable()) {
1029-
$sttyMode = shell_exec('stty -g');
1030-
1031-
foreach ([\SIGINT, \SIGQUIT, \SIGTERM] as $signal) {
1032-
$signalRegistry->register($signal, static fn () => shell_exec('stty '.$sttyMode));
1033-
}
1034-
}
1035-
10361028
if ($this->dispatcher) {
10371029
// We register application signals, so that we can dispatch the event
10381030
foreach ($this->signalsToDispatchEvent as $signal) {

Helper/QuestionHelper.php

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
247247
$ofs = -1;
248248
$matches = $autocomplete($ret);
249249
$numMatches = \count($matches);
250-
251-
$sttyMode = shell_exec('stty -g');
252-
$isStdin = 'php://stdin' === (stream_get_meta_data($inputStream)['uri'] ?? null);
253-
$r = [$inputStream];
254-
$w = [];
250+
$inputHelper = new TerminalInputHelper($inputStream);
255251

256252
// Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead)
257253
shell_exec('stty -icanon -echo');
@@ -261,15 +257,13 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
261257

262258
// Read a keypress
263259
while (!feof($inputStream)) {
264-
while ($isStdin && 0 === @stream_select($r, $w, $w, 0, 100)) {
265-
// Give signal handlers a chance to run
266-
$r = [$inputStream];
267-
}
260+
$inputHelper->waitForInput();
268261
$c = fread($inputStream, 1);
269262

270263
// as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false.
271264
if (false === $c || ('' === $ret && '' === $c && null === $question->getDefault())) {
272-
shell_exec('stty '.$sttyMode);
265+
// Restore the terminal so it behaves normally again
266+
$inputHelper->finish();
273267
throw new MissingInputException('Aborted.');
274268
} elseif ("\177" === $c) { // Backspace Character
275269
if (0 === $numMatches && 0 !== $i) {
@@ -371,8 +365,8 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
371365
}
372366
}
373367

374-
// Reset stty so it behaves normally again
375-
shell_exec('stty '.$sttyMode);
368+
// Restore the terminal so it behaves normally again
369+
$inputHelper->finish();
376370

377371
return $fullChoice;
378372
}
@@ -423,23 +417,26 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
423417
return $value;
424418
}
425419

420+
$inputHelper = null;
421+
426422
if (self::$stty && Terminal::hasSttyAvailable()) {
427-
$sttyMode = shell_exec('stty -g');
423+
$inputHelper = new TerminalInputHelper($inputStream);
428424
shell_exec('stty -echo');
429425
} elseif ($this->isInteractiveInput($inputStream)) {
430426
throw new RuntimeException('Unable to hide the response.');
431427
}
432428

429+
$inputHelper?->waitForInput();
430+
433431
$value = fgets($inputStream, 4096);
434432

435433
if (4095 === \strlen($value)) {
436434
$errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
437435
$errOutput->warning('The value was possibly truncated by your shell or terminal emulator');
438436
}
439437

440-
if (self::$stty && Terminal::hasSttyAvailable()) {
441-
shell_exec('stty '.$sttyMode);
442-
}
438+
// Restore the terminal so it behaves normally again
439+
$inputHelper?->finish();
443440

444441
if (false === $value) {
445442
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
@@ -2227,6 +2227,31 @@ public function testSignalableWithEventCommandDoesNotInterruptedOnTermSignals()
22272227
* @group tty
22282228
*/
22292229
public function testSignalableRestoresStty()
2230+
{
2231+
$params = [__DIR__.'/Fixtures/application_signalable.php'];
2232+
$this->runRestoresSttyTest($params, 254, true);
2233+
}
2234+
2235+
/**
2236+
* @group tty
2237+
*
2238+
* @dataProvider provideTerminalInputHelperOption
2239+
*/
2240+
public function testTerminalInputHelperRestoresStty(string $option)
2241+
{
2242+
$params = [__DIR__.'/Fixtures/application_sttyhelper.php', $option];
2243+
$this->runRestoresSttyTest($params, 0, false);
2244+
}
2245+
2246+
public static function provideTerminalInputHelperOption()
2247+
{
2248+
return [
2249+
['--choice'],
2250+
['--hidden'],
2251+
];
2252+
}
2253+
2254+
private function runRestoresSttyTest(array $params, int $expectedExitCode, bool $equals)
22302255
{
22312256
if (!Terminal::hasSttyAvailable()) {
22322257
$this->markTestSkipped('stty not available');
@@ -2238,22 +2263,29 @@ public function testSignalableRestoresStty()
22382263

22392264
$previousSttyMode = shell_exec('stty -g');
22402265

2241-
$p = new Process(['php', __DIR__.'/Fixtures/application_signalable.php']);
2266+
array_unshift($params, 'php');
2267+
$p = new Process($params);
22422268
$p->setTty(true);
22432269
$p->start();
22442270

22452271
for ($i = 0; $i < 10 && shell_exec('stty -g') === $previousSttyMode; ++$i) {
2246-
usleep(100000);
2272+
usleep(200000);
22472273
}
22482274

22492275
$this->assertNotSame($previousSttyMode, shell_exec('stty -g'));
22502276
$p->signal(\SIGINT);
2251-
$p->wait();
2277+
$exitCode = $p->wait();
22522278

22532279
$sttyMode = shell_exec('stty -g');
22542280
shell_exec('stty '.$previousSttyMode);
22552281

22562282
$this->assertSame($previousSttyMode, $sttyMode);
2283+
2284+
if ($equals) {
2285+
$this->assertEquals($expectedExitCode, $exitCode);
2286+
} else {
2287+
$this->assertNotEquals($expectedExitCode, $exitCode);
2288+
}
22572289
}
22582290

22592291
/**

Tests/Fixtures/application_signalable.php

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

2020
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
2121
{
22-
exit(0);
22+
exit(254);
2323
}
2424
})
2525
->setCode(function(InputInterface $input, OutputInterface $output): int {
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)