Skip to content

Commit 6d0d25c

Browse files
Merge branch '6.4' into 7.3
* 6.4: Handle signals on text input [TwigBridge] Fix form constraint [Runtime] Reuse the already created Request object when the app needs it as argument returns a kernel Update validators.el.xlf Fix MoneyType: add missing step attribute when html5=true [Console] Preserve `--help` option when a command is not found [Messenger] Fix PHP 8.5 deprecation for pgsqlGetNotify() in PostgreSQL transport chore: PHP CS Fixer - do not use deprecated sets in config verify spanish translations with state needs-review-translation
2 parents 87ca0e4 + 1b28130 commit 6d0d25c

File tree

6 files changed

+166
-33
lines changed

6 files changed

+166
-33
lines changed

Application.php

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -735,15 +735,14 @@ public function find(string $name): Command
735735
$message = \sprintf('Command "%s" is not defined.', $name);
736736

737737
if ($alternatives = $this->findAlternatives($name, $allCommands)) {
738-
// remove hidden commands
739-
$alternatives = array_filter($alternatives, fn ($name) => !$this->get($name)->isHidden());
738+
$wantHelps = $this->wantHelps;
739+
$this->wantHelps = false;
740740

741-
if (1 == \count($alternatives)) {
742-
$message .= "\n\nDid you mean this?\n ";
743-
} else {
744-
$message .= "\n\nDid you mean one of these?\n ";
741+
// remove hidden commands
742+
if ($alternatives = array_filter($alternatives, fn ($name) => !$this->get($name)->isHidden())) {
743+
$message .= \sprintf("\n\nDid you mean %s?\n %s", 1 === \count($alternatives) ? 'this' : 'one of these', implode("\n ", $alternatives));
745744
}
746-
$message .= implode("\n ", $alternatives);
745+
$this->wantHelps = $wantHelps;
747746
}
748747

749748
throw new CommandNotFoundException($message, array_values($alternatives));

Helper/QuestionHelper.php

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -426,9 +426,7 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
426426
throw new RuntimeException('Unable to hide the response.');
427427
}
428428

429-
$inputHelper?->waitForInput();
430-
431-
$value = fgets($inputStream, 4096);
429+
$value = $this->doReadInput($inputStream, helper: $inputHelper);
432430

433431
if (4095 === \strlen($value)) {
434432
$errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
@@ -438,9 +436,6 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
438436
// Restore the terminal so it behaves normally again
439437
$inputHelper?->finish();
440438

441-
if (false === $value) {
442-
throw new MissingInputException('Aborted.');
443-
}
444439
if ($trimmable) {
445440
$value = trim($value);
446441
}
@@ -500,7 +495,7 @@ private function readInput($inputStream, Question $question): string|false
500495
{
501496
if (!$question->isMultiline()) {
502497
$cp = $this->setIOCodepage();
503-
$ret = fgets($inputStream, 4096);
498+
$ret = $this->doReadInput($inputStream);
504499

505500
return $this->resetIOCodepage($cp, $ret);
506501
}
@@ -510,14 +505,8 @@ private function readInput($inputStream, Question $question): string|false
510505
return false;
511506
}
512507

513-
$ret = '';
514508
$cp = $this->setIOCodepage();
515-
while (false !== ($char = fgetc($multiLineStreamReader))) {
516-
if ("\x4" === $char || \PHP_EOL === "{$ret}{$char}") {
517-
break;
518-
}
519-
$ret .= $char;
520-
}
509+
$ret = $this->doReadInput($multiLineStreamReader, "\x4");
521510

522511
if (stream_get_meta_data($inputStream)['seekable']) {
523512
fseek($inputStream, ftell($multiLineStreamReader));
@@ -587,4 +576,35 @@ private function cloneInputStream($inputStream)
587576

588577
return $cloneStream;
589578
}
579+
580+
/**
581+
* @param resource $inputStream
582+
*/
583+
private function doReadInput($inputStream, ?string $exitChar = null, ?TerminalInputHelper $helper = null): string
584+
{
585+
$ret = '';
586+
$helper ??= new TerminalInputHelper($inputStream, false);
587+
588+
while (!feof($inputStream)) {
589+
$helper->waitForInput();
590+
$char = fread($inputStream, 1);
591+
592+
// as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false.
593+
if (false === $char || ('' === $ret && '' === $char)) {
594+
throw new MissingInputException('Aborted.');
595+
}
596+
597+
if (\PHP_EOL === "{$ret}{$char}" || $exitChar === $char) {
598+
break;
599+
}
600+
601+
$ret .= $char;
602+
603+
if (null === $exitChar && "\n" === $char) {
604+
break;
605+
}
606+
}
607+
608+
return $ret;
609+
}
590610
}

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

Tests/ApplicationTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2616,6 +2616,38 @@ public function testFindAmbiguousHiddenCommands()
26162616
$application->find('t:f');
26172617
}
26182618

2619+
public function testDoesNotFindHiddenCommandAsAlternativeIfHelpOptionIsPresent()
2620+
{
2621+
$application = new Application();
2622+
$application->setAutoExit(false);
2623+
$application->add(new \FooHiddenCommand());
2624+
2625+
$tester = new ApplicationTester($application);
2626+
$tester->setInputs(['yes']);
2627+
$tester->run(['command' => 'foohidden', '--help' => true]);
2628+
2629+
$this->assertStringContainsString('Command "foohidden" is not defined.', $tester->getDisplay(true));
2630+
$this->assertStringNotContainsString('Did you mean', $tester->getDisplay(true));
2631+
$this->assertStringNotContainsString('Do you want to run', $tester->getDisplay(true));
2632+
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
2633+
}
2634+
2635+
public function testsPreservedHelpOptionWhenItsAnAlternative()
2636+
{
2637+
$application = new Application();
2638+
$application->setAutoExit(false);
2639+
$application->add(new \FoobarCommand());
2640+
2641+
$tester = new ApplicationTester($application);
2642+
$tester->setInputs(['yes']);
2643+
$tester->run(['command' => 'foobarfoo', '--help' => true]);
2644+
2645+
$this->assertStringContainsString('Command "foobarfoo" is not defined.', $tester->getDisplay(true));
2646+
$this->assertStringContainsString('Do you want to run "foobar:foo" instead?', $tester->getDisplay(true));
2647+
$this->assertStringContainsString('The foobar:foo command', $tester->getDisplay(true));
2648+
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
2649+
}
2650+
26192651
/**
26202652
* @requires extension pcntl
26212653
*
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)