TerminalInputHelper.php 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Console\Helper;
  11. /**
  12. * TerminalInputHelper stops Ctrl-C and similar signals from leaving the terminal in
  13. * an unusable state if its settings have been modified when reading user input.
  14. * This can be an issue on non-Windows platforms.
  15. *
  16. * Usage:
  17. *
  18. * $inputHelper = new TerminalInputHelper($inputStream);
  19. *
  20. * ...change terminal settings
  21. *
  22. * // Wait for input before all input reads
  23. * $inputHelper->waitForInput();
  24. *
  25. * ...read input
  26. *
  27. * // Call finish to restore terminal settings and signal handlers
  28. * $inputHelper->finish()
  29. *
  30. * @internal
  31. */
  32. final class TerminalInputHelper
  33. {
  34. /** @var resource */
  35. private $inputStream;
  36. private bool $isStdin;
  37. private string $initialState;
  38. private int $signalToKill = 0;
  39. private array $signalHandlers = [];
  40. private array $targetSignals = [];
  41. /**
  42. * @param resource $inputStream
  43. *
  44. * @throws \RuntimeException If unable to read terminal settings
  45. */
  46. public function __construct($inputStream)
  47. {
  48. if (!\is_string($state = shell_exec('stty -g'))) {
  49. throw new \RuntimeException('Unable to read the terminal settings.');
  50. }
  51. $this->inputStream = $inputStream;
  52. $this->initialState = $state;
  53. $this->isStdin = 'php://stdin' === stream_get_meta_data($inputStream)['uri'];
  54. $this->createSignalHandlers();
  55. }
  56. /**
  57. * Waits for input and terminates if sent a default signal.
  58. */
  59. public function waitForInput(): void
  60. {
  61. if ($this->isStdin) {
  62. $r = [$this->inputStream];
  63. $w = [];
  64. // Allow signal handlers to run, either before Enter is pressed
  65. // when icanon is enabled, or a single character is entered when
  66. // icanon is disabled
  67. while (0 === @stream_select($r, $w, $w, 0, 100)) {
  68. $r = [$this->inputStream];
  69. }
  70. }
  71. $this->checkForKillSignal();
  72. }
  73. /**
  74. * Restores terminal state and signal handlers.
  75. */
  76. public function finish(): void
  77. {
  78. // Safeguard in case an unhandled kill signal exists
  79. $this->checkForKillSignal();
  80. shell_exec('stty '.$this->initialState);
  81. $this->signalToKill = 0;
  82. foreach ($this->signalHandlers as $signal => $originalHandler) {
  83. pcntl_signal($signal, $originalHandler);
  84. }
  85. $this->signalHandlers = [];
  86. $this->targetSignals = [];
  87. }
  88. private function createSignalHandlers(): void
  89. {
  90. if (!\function_exists('pcntl_async_signals') || !\function_exists('pcntl_signal')) {
  91. return;
  92. }
  93. pcntl_async_signals(true);
  94. $this->targetSignals = [\SIGINT, \SIGQUIT, \SIGTERM];
  95. foreach ($this->targetSignals as $signal) {
  96. $this->signalHandlers[$signal] = pcntl_signal_get_handler($signal);
  97. pcntl_signal($signal, function ($signal) {
  98. // Save current state, then restore to initial state
  99. $currentState = shell_exec('stty -g');
  100. shell_exec('stty '.$this->initialState);
  101. $originalHandler = $this->signalHandlers[$signal];
  102. if (\is_callable($originalHandler)) {
  103. $originalHandler($signal);
  104. // Handler did not exit, so restore to current state
  105. shell_exec('stty '.$currentState);
  106. return;
  107. }
  108. // Not a callable, so SIG_DFL or SIG_IGN
  109. if (\SIG_DFL === $originalHandler) {
  110. $this->signalToKill = $signal;
  111. }
  112. });
  113. }
  114. }
  115. private function checkForKillSignal(): void
  116. {
  117. if (\in_array($this->signalToKill, $this->targetSignals, true)) {
  118. // Try posix_kill
  119. if (\function_exists('posix_kill')) {
  120. pcntl_signal($this->signalToKill, \SIG_DFL);
  121. posix_kill(getmypid(), $this->signalToKill);
  122. }
  123. // Best attempt fallback
  124. exit(128 + $this->signalToKill);
  125. }
  126. }
  127. }