TerminalInputHelper.php 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  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. private bool $withStty;
  42. /**
  43. * @param resource $inputStream
  44. *
  45. * @throws \RuntimeException If unable to read terminal settings
  46. */
  47. public function __construct($inputStream, bool $withStty = true)
  48. {
  49. $this->inputStream = $inputStream;
  50. $this->isStdin = 'php://stdin' === stream_get_meta_data($inputStream)['uri'];
  51. $this->withStty = $withStty;
  52. if ($withStty) {
  53. if (!\is_string($state = shell_exec('stty -g'))) {
  54. throw new \RuntimeException('Unable to read the terminal settings.');
  55. }
  56. $this->initialState = $state;
  57. $this->createSignalHandlers();
  58. }
  59. }
  60. /**
  61. * Waits for input.
  62. */
  63. public function waitForInput(): void
  64. {
  65. if ($this->isStdin) {
  66. $r = [$this->inputStream];
  67. $w = [];
  68. // Allow signal handlers to run
  69. while (0 === @stream_select($r, $w, $w, 0, 100)) {
  70. $r = [$this->inputStream];
  71. }
  72. }
  73. if ($this->withStty) {
  74. $this->checkForKillSignal();
  75. }
  76. }
  77. /**
  78. * Restores terminal state and signal handlers.
  79. */
  80. public function finish(): void
  81. {
  82. if (!$this->withStty) {
  83. return;
  84. }
  85. // Safeguard in case an unhandled kill signal exists
  86. $this->checkForKillSignal();
  87. shell_exec('stty '.$this->initialState);
  88. $this->signalToKill = 0;
  89. foreach ($this->signalHandlers as $signal => $originalHandler) {
  90. pcntl_signal($signal, $originalHandler);
  91. }
  92. $this->signalHandlers = [];
  93. $this->targetSignals = [];
  94. }
  95. private function createSignalHandlers(): void
  96. {
  97. if (!\function_exists('pcntl_async_signals') || !\function_exists('pcntl_signal')) {
  98. return;
  99. }
  100. pcntl_async_signals(true);
  101. $this->targetSignals = [\SIGINT, \SIGQUIT, \SIGTERM];
  102. foreach ($this->targetSignals as $signal) {
  103. $this->signalHandlers[$signal] = pcntl_signal_get_handler($signal);
  104. pcntl_signal($signal, function ($signal) {
  105. // Save current state, then restore to initial state
  106. $currentState = shell_exec('stty -g');
  107. shell_exec('stty '.$this->initialState);
  108. $originalHandler = $this->signalHandlers[$signal];
  109. if (\is_callable($originalHandler)) {
  110. $originalHandler($signal);
  111. // Handler did not exit, so restore to current state
  112. shell_exec('stty '.$currentState);
  113. return;
  114. }
  115. // Not a callable, so SIG_DFL or SIG_IGN
  116. if (\SIG_DFL === $originalHandler) {
  117. $this->signalToKill = $signal;
  118. }
  119. });
  120. }
  121. }
  122. private function checkForKillSignal(): void
  123. {
  124. if (\in_array($this->signalToKill, $this->targetSignals, true)) {
  125. // Try posix_kill
  126. if (\function_exists('posix_kill')) {
  127. pcntl_signal($this->signalToKill, \SIG_DFL);
  128. posix_kill(getmypid(), $this->signalToKill);
  129. }
  130. // Best attempt fallback
  131. exit(128 + $this->signalToKill);
  132. }
  133. }
  134. }