Skip to content

Commit d75b027

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

File tree

6 files changed

+227
-28
lines changed

6 files changed

+227
-28
lines changed

Application.php

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,14 +1039,6 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
10391039
if (($commandSignals = $command->getSubscribedSignals()) || $this->dispatcher && $this->signalsToDispatchEvent) {
10401040
$signalRegistry = $this->getSignalRegistry();
10411041

1042-
if (Terminal::hasSttyAvailable()) {
1043-
$sttyMode = shell_exec('stty -g');
1044-
1045-
foreach ([\SIGINT, \SIGQUIT, \SIGTERM] as $signal) {
1046-
$signalRegistry->register($signal, static fn () => shell_exec('stty '.$sttyMode));
1047-
}
1048-
}
1049-
10501042
if ($this->dispatcher) {
10511043
// We register application signals, so that we can dispatch the event
10521044
foreach ($this->signalsToDispatchEvent as $signal) {

Helper/QuestionHelper.php

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

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

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

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

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

378372
return $fullChoice;
379373
}
@@ -424,23 +418,26 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
424418
return $value;
425419
}
426420

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

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

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

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

445442
if (false === $value) {
446443
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: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2255,6 +2255,28 @@ public function testSignalableWithEventCommandDoesNotInterruptedOnTermSignals()
22552255

22562256
#[Group('tty')]
22572257
public function testSignalableRestoresStty()
2258+
{
2259+
$params = [__DIR__.'/Fixtures/application_signalable.php'];
2260+
$this->runRestoresSttyTest($params, 254, true);
2261+
}
2262+
2263+
#[Group('tty')]
2264+
#[DataProvider('provideTerminalInputHelperOption')]
2265+
public function testTerminalInputHelperRestoresStty(string $option)
2266+
{
2267+
$params = [__DIR__.'/Fixtures/application_sttyhelper.php', $option];
2268+
$this->runRestoresSttyTest($params, 0, false);
2269+
}
2270+
2271+
public static function provideTerminalInputHelperOption()
2272+
{
2273+
return [
2274+
['--choice'],
2275+
['--hidden'],
2276+
];
2277+
}
2278+
2279+
private function runRestoresSttyTest(array $params, int $expectedExitCode, bool $equals)
22582280
{
22592281
if (!Terminal::hasSttyAvailable()) {
22602282
$this->markTestSkipped('stty not available');
@@ -2266,22 +2288,29 @@ public function testSignalableRestoresStty()
22662288

22672289
$previousSttyMode = shell_exec('stty -g');
22682290

2269-
$p = new Process(['php', __DIR__.'/Fixtures/application_signalable.php']);
2291+
array_unshift($params, 'php');
2292+
$p = new Process($params);
22702293
$p->setTty(true);
22712294
$p->start();
22722295

22732296
for ($i = 0; $i < 10 && shell_exec('stty -g') === $previousSttyMode; ++$i) {
2274-
usleep(100000);
2297+
usleep(200000);
22752298
}
22762299

22772300
$this->assertNotSame($previousSttyMode, shell_exec('stty -g'));
22782301
$p->signal(\SIGINT);
2279-
$p->wait();
2302+
$exitCode = $p->wait();
22802303

22812304
$sttyMode = shell_exec('stty -g');
22822305
shell_exec('stty '.$previousSttyMode);
22832306

22842307
$this->assertSame($previousSttyMode, $sttyMode);
2308+
2309+
if ($equals) {
2310+
$this->assertEquals($expectedExitCode, $exitCode);
2311+
} else {
2312+
$this->assertNotEquals($expectedExitCode, $exitCode);
2313+
}
22852314
}
22862315

22872316
#[RequiresPhpExtension('pcntl')]

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)