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
8 changes: 0 additions & 8 deletions src/Symfony/Component/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -1018,14 +1018,6 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
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.');
}

if (Terminal::hasSttyAvailable()) {
$sttyMode = shell_exec('stty -g');

foreach ([\SIGINT, \SIGTERM] as $signal) {
$this->signalRegistry->register($signal, static fn () => shell_exec('stty '.$sttyMode));
}
}

if ($this->dispatcher) {
// We register application signals, so that we can dispatch the event
foreach ($this->signalsToDispatchEvent as $signal) {
Expand Down
29 changes: 13 additions & 16 deletions src/Symfony/Component/Console/Helper/QuestionHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
$ofs = -1;
$matches = $autocomplete($ret);
$numMatches = \count($matches);

$sttyMode = shell_exec('stty -g');
$isStdin = 'php://stdin' === (stream_get_meta_data($inputStream)['uri'] ?? null);
$r = [$inputStream];
$w = [];
$inputHelper = new TerminalInputHelper($inputStream);

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

// Read a keypress
while (!feof($inputStream)) {
while ($isStdin && 0 === @stream_select($r, $w, $w, 0, 100)) {
// Give signal handlers a chance to run
$r = [$inputStream];
}
$inputHelper->waitForInput();
$c = fread($inputStream, 1);

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

// Reset stty so it behaves normally again
shell_exec('stty '.$sttyMode);
// Restore the terminal so it behaves normally again
$inputHelper->finish();

return $fullChoice;
}
Expand Down Expand Up @@ -434,23 +428,26 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
return $value;
}

$inputHelper = null;

if (self::$stty && Terminal::hasSttyAvailable()) {
$sttyMode = shell_exec('stty -g');
$inputHelper = new TerminalInputHelper($inputStream);
shell_exec('stty -echo');
} elseif ($this->isInteractiveInput($inputStream)) {
throw new RuntimeException('Unable to hide the response.');
}

$inputHelper?->waitForInput();

$value = fgets($inputStream, 4096);

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

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

if (false === $value) {
throw new MissingInputException('Aborted.');
Expand Down
144 changes: 144 additions & 0 deletions src/Symfony/Component/Console/Helper/TerminalInputHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Console\Helper;

/**
* TerminalInputHelper stops Ctrl-C and similar signals from leaving the terminal in
* an unusable state if its settings have been modified when reading user input.
* This can be an issue on non-Windows platforms.
*
* Usage:
*
* $inputHelper = new TerminalInputHelper($inputStream);
*
* ...change terminal settings
*
* // Wait for input before all input reads
* $inputHelper->waitForInput();
*
* ...read input
*
* // Call finish to restore terminal settings and signal handlers
* $inputHelper->finish()
*
* @internal
*/
final class TerminalInputHelper
{
/** @var resource */
private $inputStream;
private bool $isStdin;
private string $initialState;
private int $signalToKill = 0;
private array $signalHandlers = [];
private array $targetSignals = [];

/**
* @param resource $inputStream
*
* @throws \RuntimeException If unable to read terminal settings
*/
public function __construct($inputStream)
{
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();
}

/**
* Waits for input and terminates if sent a default signal.
*/
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
while (0 === @stream_select($r, $w, $w, 0, 100)) {
$r = [$this->inputStream];
}
}
$this->checkForKillSignal();
}

/**
* Restores terminal state and signal handlers.
*/
public function finish(): void
{
// Safeguard in case an unhandled kill signal exists
$this->checkForKillSignal();
shell_exec('stty '.$this->initialState);
$this->signalToKill = 0;

foreach ($this->signalHandlers as $signal => $originalHandler) {
pcntl_signal($signal, $originalHandler);
}
$this->signalHandlers = [];
$this->targetSignals = [];
}

private function createSignalHandlers(): void
{
if (!\function_exists('pcntl_async_signals') || !\function_exists('pcntl_signal')) {
return;
}

pcntl_async_signals(true);
$this->targetSignals = [\SIGINT, \SIGQUIT, \SIGTERM];

foreach ($this->targetSignals as $signal) {
$this->signalHandlers[$signal] = pcntl_signal_get_handler($signal);

pcntl_signal($signal, function ($signal) {
// Save current state, then restore to initial state
$currentState = shell_exec('stty -g');
shell_exec('stty '.$this->initialState);
$originalHandler = $this->signalHandlers[$signal];

if (\is_callable($originalHandler)) {
$originalHandler($signal);
// Handler did not exit, so restore to current state
shell_exec('stty '.$currentState);

return;
}

// Not a callable, so SIG_DFL or SIG_IGN
if (\SIG_DFL === $originalHandler) {
$this->signalToKill = $signal;
}
});
}
}

private function checkForKillSignal(): void
{
if (\in_array($this->signalToKill, $this->targetSignals, true)) {
// Try posix_kill
if (\function_exists('posix_kill')) {
pcntl_signal($this->signalToKill, \SIG_DFL);
posix_kill(getmypid(), $this->signalToKill);
}

// Best attempt fallback
exit(128 + $this->signalToKill);
}
}
}
38 changes: 35 additions & 3 deletions src/Symfony/Component/Console/Tests/ApplicationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2198,6 +2198,31 @@ public function testSignalableWithEventCommandDoesNotInterruptedOnTermSignals()
* @group tty
*/
public function testSignalableRestoresStty()
{
$params = [__DIR__.'/Fixtures/application_signalable.php'];
$this->runRestoresSttyTest($params, 254, true);
}

/**
* @group tty
*
* @dataProvider provideTerminalInputHelperOption
*/
public function testTerminalInputHelperRestoresStty(string $option)
{
$params = [__DIR__.'/Fixtures/application_sttyhelper.php', $option];
$this->runRestoresSttyTest($params, 0, false);
}

public static function provideTerminalInputHelperOption()
{
return [
['--choice'],
['--hidden'],
];
}

private function runRestoresSttyTest(array $params, int $expectedExitCode, bool $equals)
{
if (!Terminal::hasSttyAvailable()) {
$this->markTestSkipped('stty not available');
Expand All @@ -2209,22 +2234,29 @@ public function testSignalableRestoresStty()

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

$p = new Process(['php', __DIR__.'/Fixtures/application_signalable.php']);
array_unshift($params, 'php');
$p = new Process($params);
$p->setTty(true);
$p->start();

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

$this->assertNotSame($previousSttyMode, shell_exec('stty -g'));
$p->signal(\SIGINT);
$p->wait();
$exitCode = $p->wait();

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

$this->assertSame($previousSttyMode, $sttyMode);

if ($equals) {
$this->assertEquals($expectedExitCode, $exitCode);
} else {
$this->assertNotEquals($expectedExitCode, $exitCode);
}
}

private function createSignalableApplication(Command $command, ?EventDispatcherInterface $dispatcher): Application
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function getSubscribedSignals(): array

public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
exit(0);
exit(254);
}
})
->setCode(function(InputInterface $input, OutputInterface $output) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\SingleCommandApplication;

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

(new class extends SingleCommandApplication {})
->setDefinition(new InputDefinition([
new InputOption('choice', null, InputOption::VALUE_NONE, ''),
new InputOption('hidden', null, InputOption::VALUE_NONE, ''),
]))
->setCode(function (InputInterface $input, OutputInterface $output) {
if ($input->getOption('choice')) {
$this->getHelper('question')
->ask($input, $output, new ChoiceQuestion('😊', ['n']));
} else {
$question = new Question('😊');
$question->setHidden(true);
$this->getHelper('question')
->ask($input, $output, $question);
}

return 0;
})
->run()

;
Loading