Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 34 additions & 14 deletions src/Symfony/Component/Console/Helper/QuestionHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -437,9 +437,7 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
throw new RuntimeException('Unable to hide the response.');
}

$inputHelper?->waitForInput();

$value = fgets($inputStream, 4096);
$value = $this->doReadInput($inputStream, helper: $inputHelper);

if (4095 === \strlen($value)) {
$errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
Expand All @@ -449,9 +447,6 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
// Restore the terminal so it behaves normally again
$inputHelper?->finish();

if (false === $value) {
throw new MissingInputException('Aborted.');
}
if ($trimmable) {
$value = trim($value);
}
Expand Down Expand Up @@ -511,7 +506,7 @@ private function readInput($inputStream, Question $question): string|false
{
if (!$question->isMultiline()) {
$cp = $this->setIOCodepage();
$ret = fgets($inputStream, 4096);
$ret = $this->doReadInput($inputStream);

return $this->resetIOCodepage($cp, $ret);
}
Expand All @@ -521,14 +516,8 @@ private function readInput($inputStream, Question $question): string|false
return false;
}

$ret = '';
$cp = $this->setIOCodepage();
while (false !== ($char = fgetc($multiLineStreamReader))) {
if ("\x4" === $char || \PHP_EOL === "{$ret}{$char}") {
break;
}
$ret .= $char;
}
$ret = $this->doReadInput($multiLineStreamReader, "\x4");

if (stream_get_meta_data($inputStream)['seekable']) {
fseek($inputStream, ftell($multiLineStreamReader));
Expand Down Expand Up @@ -598,4 +587,35 @@ private function cloneInputStream($inputStream)

return $cloneStream;
}

/**
* @param resource $inputStream
*/
private function doReadInput($inputStream, ?string $exitChar = null, ?TerminalInputHelper $helper = null): string
{
$ret = '';
$helper ??= new TerminalInputHelper($inputStream, false);

while (!feof($inputStream)) {
$helper->waitForInput();
$char = fread($inputStream, 1);

// as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false.
if (false === $char || ('' === $ret && '' === $char)) {
throw new MissingInputException('Aborted.');
}

if (\PHP_EOL === "{$ret}{$char}" || $exitChar === $char) {
break;
}

$ret .= $char;

if (null === $exitChar && "\n" === $char) {
break;
}
}

return $ret;
}
}
36 changes: 24 additions & 12 deletions src/Symfony/Component/Console/Helper/TerminalInputHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,51 +37,63 @@ final class TerminalInputHelper
/** @var resource */
private $inputStream;
private bool $isStdin;
private string $initialState;
private string $initialState = '';
private int $signalToKill = 0;
private array $signalHandlers = [];
private array $targetSignals = [];
private bool $withStty;

/**
* @param resource $inputStream
*
* @throws \RuntimeException If unable to read terminal settings
*/
public function __construct($inputStream)
public function __construct($inputStream, bool $withStty = true)
{
if (!\is_string($state = shell_exec('stty -g'))) {
throw new \RuntimeException('Unable to read the terminal settings.');
}
$this->inputStream = $inputStream;
$this->initialState = $state;
$this->isStdin = 'php://stdin' === stream_get_meta_data($inputStream)['uri'];
$this->createSignalHandlers();
$this->withStty = $withStty;

if ($withStty) {
if (!\is_string($state = shell_exec('stty -g'))) {
throw new \RuntimeException('Unable to read the terminal settings.');
}

$this->initialState = $state;

$this->createSignalHandlers();
}
}

/**
* Waits for input and terminates if sent a default signal.
* Waits for input.
*/
public function waitForInput(): void
{
if ($this->isStdin) {
$r = [$this->inputStream];
$w = [];

// Allow signal handlers to run, either before Enter is pressed
// when icanon is enabled, or a single character is entered when
// icanon is disabled
// Allow signal handlers to run
while (0 === @stream_select($r, $w, $w, 0, 100)) {
$r = [$this->inputStream];
}
}
$this->checkForKillSignal();

if ($this->withStty) {
$this->checkForKillSignal();
}
}

/**
* Restores terminal state and signal handlers.
*/
public function finish(): void
{
if (!$this->withStty) {
return;
}

// Safeguard in case an unhandled kill signal exists
$this->checkForKillSignal();
shell_exec('stty '.$this->initialState);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;

$vendor = __DIR__;
while (!file_exists($vendor.'/vendor')) {
$vendor = \dirname($vendor);
}
require $vendor.'/vendor/autoload.php';

(new class extends Command {
protected function configure(): void
{
$this->addArgument('mode', InputArgument::OPTIONAL, default: 'single');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getArgument('mode');

$question = new Question('Enter text: ');
$question->setMultiline($mode !== 'single');

$helper = new QuestionHelper();

pcntl_async_signals(true);
pcntl_signal(\SIGALRM, function () {
posix_kill(posix_getpid(), \SIGINT);
pcntl_signal_dispatch();
});
pcntl_alarm(1);

$helper->ask($input, $output, $question);

return Command::SUCCESS;
}
})
->run(new ArgvInput($argv), new ConsoleOutput())
;
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Terminal;
use Symfony\Component\Console\Tester\ApplicationTester;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\Process;

/**
* @group tty
Expand Down Expand Up @@ -929,6 +931,28 @@ public function testAutocompleteMoveCursorBackwards()
$this->assertStringEndsWith("\033[1D\033[K\033[2D\033[K\033[1D\033[K", stream_get_contents($stream));
}

/**
* @testWith ["single"]
* ["multi"]
*/
public function testExitCommandOnInputSIGINT(string $mode)
{
if (!\function_exists('pcntl_signal')) {
$this->markTestSkipped('pcntl signals not available');
}

$p = new Process(
['php', dirname(__DIR__).'/Fixtures/application_test_sigint.php', $mode],
timeout: 2, // the process will auto shutdown if not killed by SIGINT, to prevent blocking
);
$p->setPty(true);
$p->start();

$this->expectException(ProcessSignaledException::class);
$this->expectExceptionMessage('The process has been signaled with signal "2".');
$p->wait();
}

protected function getInputStream($input)
{
$stream = fopen('php://memory', 'r+', false);
Expand Down
Loading