diff --git a/Application.php b/Application.php index 87b536fb3..36eb144c4 100644 --- a/Application.php +++ b/Application.php @@ -424,6 +424,15 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti if (CompletionInput::TYPE_OPTION_NAME === $input->getCompletionType()) { $suggestions->suggestOptions($this->getDefinition()->getOptions()); } + + if ( + CompletionInput::TYPE_OPTION_VALUE === $input->getCompletionType() + && ($definition = $this->getDefinition())->hasOption($input->getCompletionName()) + ) { + $definition->getOption($input->getCompletionName())->complete($input, $suggestions); + + return; + } } /** @@ -726,15 +735,14 @@ public function find(string $name): Command $message = \sprintf('Command "%s" is not defined.', $name); if ($alternatives = $this->findAlternatives($name, $allCommands)) { - // remove hidden commands - $alternatives = array_filter($alternatives, fn ($name) => !$this->get($name)->isHidden()); + $wantHelps = $this->wantHelps; + $this->wantHelps = false; - if (1 == \count($alternatives)) { - $message .= "\n\nDid you mean this?\n "; - } else { - $message .= "\n\nDid you mean one of these?\n "; + // remove hidden commands + if ($alternatives = array_filter($alternatives, fn ($name) => !$this->get($name)->isHidden())) { + $message .= \sprintf("\n\nDid you mean %s?\n %s", 1 === \count($alternatives) ? 'this' : 'one of these', implode("\n ", $alternatives)); } - $message .= implode("\n ", $alternatives); + $this->wantHelps = $wantHelps; } throw new CommandNotFoundException($message, array_values($alternatives)); @@ -782,9 +790,9 @@ public function find(string $name): Command } } - $command = $this->get(reset($commands)); + $command = $commands ? $this->get(reset($commands)) : null; - if ($command->isHidden()) { + if (!$command || $command->isHidden()) { throw new CommandNotFoundException(\sprintf('The command "%s" does not exist.', $name)); } @@ -993,9 +1001,13 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI } } + $registeredSignals = false; if (($commandSignals = $command->getSubscribedSignals()) || $this->dispatcher && $this->signalsToDispatchEvent) { $signalRegistry = $this->getSignalRegistry(); + $registeredSignals = true; + $this->getSignalRegistry()->pushCurrentHandlers(); + if ($this->dispatcher) { // We register application signals, so that we can dispatch the event foreach ($this->signalsToDispatchEvent as $signal) { @@ -1052,7 +1064,13 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI } if (null === $this->dispatcher) { - return $command->run($input, $output); + try { + return $command->run($input, $output); + } finally { + if ($registeredSignals) { + $this->getSignalRegistry()->popPreviousHandlers(); + } + } } // bind before the console.command event, so the listeners have access to input options/arguments @@ -1082,6 +1100,10 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI if (0 === $exitCode = $event->getExitCode()) { $e = null; } + } finally { + if ($registeredSignals) { + $this->getSignalRegistry()->popPreviousHandlers(); + } } $event = new ConsoleTerminateEvent($command, $input, $output, $exitCode); diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 87dc838b7..3381be1a9 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -426,9 +426,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; @@ -438,9 +436,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); } @@ -500,7 +495,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); } @@ -510,14 +505,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)); @@ -587,4 +576,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; + } } diff --git a/Helper/TerminalInputHelper.php b/Helper/TerminalInputHelper.php index 750229a8f..d6f07db8b 100644 --- a/Helper/TerminalInputHelper.php +++ b/Helper/TerminalInputHelper.php @@ -37,29 +37,36 @@ 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 { @@ -67,14 +74,15 @@ public function waitForInput(): void $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(); + } } /** @@ -82,6 +90,10 @@ public function waitForInput(): void */ public function finish(): void { + if (!$this->withStty) { + return; + } + // Safeguard in case an unhandled kill signal exists $this->checkForKillSignal(); shell_exec('stty '.$this->initialState); diff --git a/SignalRegistry/SignalRegistry.php b/SignalRegistry/SignalRegistry.php index 8c2939eec..4019a7f88 100644 --- a/SignalRegistry/SignalRegistry.php +++ b/SignalRegistry/SignalRegistry.php @@ -13,8 +13,21 @@ final class SignalRegistry { + /** + * @var array> + */ private array $signalHandlers = []; + /** + * @var array>> + */ + private array $stack = []; + + /** + * @var array + */ + private array $originalHandlers = []; + public function __construct() { if (\function_exists('pcntl_async_signals')) { @@ -24,17 +37,21 @@ public function __construct() public function register(int $signal, callable $signalHandler): void { - if (!isset($this->signalHandlers[$signal])) { - $previousCallback = pcntl_signal_get_handler($signal); + $previous = pcntl_signal_get_handler($signal); + + if (!isset($this->originalHandlers[$signal])) { + $this->originalHandlers[$signal] = $previous; + } - if (\is_callable($previousCallback)) { - $this->signalHandlers[$signal][] = $previousCallback; + if (!isset($this->signalHandlers[$signal])) { + if (\is_callable($previous) && [$this, 'handle'] !== $previous) { + $this->signalHandlers[$signal][] = $previous; } } $this->signalHandlers[$signal][] = $signalHandler; - pcntl_signal($signal, $this->handle(...)); + pcntl_signal($signal, [$this, 'handle']); } public static function isSupported(): bool @@ -55,6 +72,40 @@ public function handle(int $signal): void } } + /** + * Pushes the current active handlers onto the stack and clears the active list. + * + * This prepares the registry for a new set of handlers within a specific scope. + * + * @internal + */ + public function pushCurrentHandlers(): void + { + $this->stack[] = $this->signalHandlers; + $this->signalHandlers = []; + } + + /** + * Restores the previous handlers from the stack, making them active. + * + * This also restores the original OS-level signal handler if no + * more handlers are registered for a signal that was just popped. + * + * @internal + */ + public function popPreviousHandlers(): void + { + $popped = $this->signalHandlers; + $this->signalHandlers = array_pop($this->stack) ?? []; + + // Restore OS handler if no more Symfony handlers for this signal + foreach ($popped as $signal => $handlers) { + if (!($this->signalHandlers[$signal] ?? false) && isset($this->originalHandlers[$signal])) { + pcntl_signal($signal, $this->originalHandlers[$signal]); + } + } + } + /** * @internal */ diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 13314d8b0..7f8367b75 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -2288,6 +2288,28 @@ private function runRestoresSttyTest(array $params, int $expectedExitCode, bool } } + /** + * @requires extension pcntl + */ + public function testSignalHandlersAreCleanedUpAfterCommandRuns() + { + $application = new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new SignableCommand(false)); + + $signalRegistry = $application->getSignalRegistry(); + $tester = new ApplicationTester($application); + + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry should be empty initially.'); + + $tester->run(['command' => 'signal']); + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry should be empty after first run.'); + + $tester->run(['command' => 'signal']); + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry should still be empty after second run.'); + } + /** * @requires extension pcntl */ @@ -2306,6 +2328,42 @@ public function testSignalableInvokableCommand() $this->assertTrue($invokable->signaled); } + /** + * @requires extension pcntl + */ + public function testSignalHandlersCleanupOnException() + { + $command = new class('signal:exception') extends Command implements SignalableCommandInterface { + public function getSubscribedSignals(): array + { + return [\SIGUSR1]; + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + return false; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + throw new \RuntimeException('Test exception'); + } + }; + + $application = new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(true); + $application->add($command); + + $signalRegistry = $application->getSignalRegistry(); + $tester = new ApplicationTester($application); + + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Pre-condition: Registry must be empty.'); + + $tester->run(['command' => 'signal:exception']); + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Signal handlers must be cleaned up even on exception.'); + } + /** * @requires extension pcntl */ @@ -2417,6 +2475,92 @@ public function testAlarmableCommandWithoutInterval() $this->assertFalse($command->signaled); } + /** + * @requires extension pcntl + */ + public function testNestedCommandsIsolateSignalHandlers() + { + $application = new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + $signalRegistry = $application->getSignalRegistry(); + $self = $this; + + $innerCommand = new class('signal:inner') extends Command implements SignalableCommandInterface { + public $signalRegistry; + public $self; + + public function getSubscribedSignals(): array + { + return [\SIGUSR1]; + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + return false; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $handlers = $this->self->getHandlersForSignal($this->signalRegistry, \SIGUSR1); + $this->self->assertCount(1, $handlers, 'Inner command should only see its own handler.'); + $output->write('Inner execute.'); + + return 0; + } + }; + + $outerCommand = new class('signal:outer') extends Command implements SignalableCommandInterface { + public $signalRegistry; + public $self; + + public function getSubscribedSignals(): array + { + return [\SIGUSR1]; + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + return false; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $handlersBefore = $this->self->getHandlersForSignal($this->signalRegistry, \SIGUSR1); + $this->self->assertCount(1, $handlersBefore, 'Outer command must have its handler registered.'); + + $output->write('Outer pre-run.'); + + $this->getApplication()->find('signal:inner')->run(new ArrayInput([]), $output); + + $output->write('Outer post-run.'); + + $handlersAfter = $this->self->getHandlersForSignal($this->signalRegistry, \SIGUSR1); + $this->self->assertCount(1, $handlersAfter, 'Outer command\'s handler must be restored.'); + $this->self->assertSame($handlersBefore, $handlersAfter, 'Handler stack must be identical after pop.'); + + return 0; + } + }; + + $innerCommand->self = $self; + $innerCommand->signalRegistry = $signalRegistry; + $outerCommand->self = $self; + $outerCommand->signalRegistry = $signalRegistry; + + $application->add($innerCommand); + $application->add($outerCommand); + + $tester = new ApplicationTester($application); + + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Pre-condition: Registry must be empty.'); + $tester->run(['command' => 'signal:outer']); + $this->assertStringContainsString('Outer pre-run.Inner execute.Outer post-run.', $tester->getDisplay()); + + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry must be empty after all commands are finished.'); + } + /** * @requires extension pcntl */ @@ -2436,6 +2580,74 @@ public function testAlarmableCommandHandlerCalledAfterEventListener() $this->assertSame([AlarmEventSubscriber::class, AlarmableCommand::class], $command->signalHandlers); } + /** + * @requires extension pcntl + */ + public function testOriginalHandlerRestoredAfterPop() + { + $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGUSR1), 'Pre-condition: Original handler for SIGUSR1 must be SIG_DFL.'); + + $application = new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new SignableCommand(false)); + + $tester = new ApplicationTester($application); + $tester->run(['command' => 'signal']); + + $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGUSR1), 'OS-level handler for SIGUSR1 must be restored to SIG_DFL.'); + + $tester->run(['command' => 'signal']); + $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGUSR1), 'OS-level handler must remain SIG_DFL after a second run.'); + } + + public function testFindAmbiguousHiddenCommands() + { + $application = new Application(); + + $application->add(new Command('test:foo')); + $application->add(new Command('test:foobar')); + $application->get('test:foo')->setHidden(true); + $application->get('test:foobar')->setHidden(true); + + $this->expectException(CommandNotFoundException::class); + $this->expectExceptionMessage('The command "t:f" does not exist.'); + + $application->find('t:f'); + } + + public function testDoesNotFindHiddenCommandAsAlternativeIfHelpOptionIsPresent() + { + $application = new Application(); + $application->setAutoExit(false); + $application->add(new \FooHiddenCommand()); + + $tester = new ApplicationTester($application); + $tester->setInputs(['yes']); + $tester->run(['command' => 'foohidden', '--help' => true]); + + $this->assertStringContainsString('Command "foohidden" is not defined.', $tester->getDisplay(true)); + $this->assertStringNotContainsString('Did you mean', $tester->getDisplay(true)); + $this->assertStringNotContainsString('Do you want to run', $tester->getDisplay(true)); + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + } + + public function testsPreservedHelpOptionWhenItsAnAlternative() + { + $application = new Application(); + $application->setAutoExit(false); + $application->add(new \FoobarCommand()); + + $tester = new ApplicationTester($application); + $tester->setInputs(['yes']); + $tester->run(['command' => 'foobarfoo', '--help' => true]); + + $this->assertStringContainsString('Command "foobarfoo" is not defined.', $tester->getDisplay(true)); + $this->assertStringContainsString('Do you want to run "foobar:foo" instead?', $tester->getDisplay(true)); + $this->assertStringContainsString('The foobar:foo command', $tester->getDisplay(true)); + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + } + /** * @requires extension pcntl * @@ -2485,18 +2697,6 @@ public function onAlarm(ConsoleAlarmEvent $event): void $this->assertSame([SignalEventSubscriber::class, AlarmEventSubscriber::class], $command->signalHandlers); } - private function createSignalableApplication(Command $command, ?EventDispatcherInterface $dispatcher): Application - { - $application = new Application(); - $application->setAutoExit(false); - if ($dispatcher) { - $application->setDispatcher($dispatcher); - } - $application->add(new LazyCommand($command->getName(), [], '', false, fn () => $command, true)); - - return $application; - } - public function testShellVerbosityIsRestoredAfterCommandExecutionWithInitialValue() { // Set initial SHELL_VERBOSITY @@ -2592,6 +2792,28 @@ public function testShellVerbosityDoesNotLeakBetweenCommandExecutions() $this->assertArrayNotHasKey('SHELL_VERBOSITY', $_ENV); $this->assertArrayNotHasKey('SHELL_VERBOSITY', $_SERVER); } + + /** + * Reads the private "signalHandlers" property of the SignalRegistry for assertions. + */ + public function getHandlersForSignal(SignalRegistry $registry, int $signal): array + { + $handlers = (\Closure::bind(fn () => $this->signalHandlers, $registry, SignalRegistry::class))(); + + return $handlers[$signal] ?? []; + } + + private function createSignalableApplication(Command $command, ?EventDispatcherInterface $dispatcher): Application + { + $application = new Application(); + $application->setAutoExit(false); + if ($dispatcher) { + $application->setDispatcher($dispatcher); + } + $application->add(new LazyCommand($command->getName(), [], '', false, fn () => $command, true)); + + return $application; + } } class CustomApplication extends Application diff --git a/Tests/Command/CompleteCommandTest.php b/Tests/Command/CompleteCommandTest.php index 75519eb49..72db26697 100644 --- a/Tests/Command/CompleteCommandTest.php +++ b/Tests/Command/CompleteCommandTest.php @@ -19,6 +19,7 @@ use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Completion\Output\BashCompletionOutput; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; @@ -34,6 +35,8 @@ protected function setUp(): void $this->application = new Application(); $this->application->add(new CompleteCommandTest_HelloCommand()); + $this->application->getDefinition() + ->addOption(new InputOption('global-option', null, InputOption::VALUE_REQUIRED, suggestedValues: ['foo', 'bar', 'baz'])); $this->command->setApplication($this->application); $this->tester = new CommandTester($this->command); @@ -119,10 +122,12 @@ public function testCompleteCommandInputDefinition(array $input, array $suggesti public static function provideCompleteCommandInputDefinitionInputs() { - yield 'definition' => [['bin/console', 'hello', '-'], ['--help', '--silent', '--quiet', '--verbose', '--version', '--ansi', '--no-ansi', '--no-interaction']]; + yield 'definition' => [['bin/console', 'hello', '-'], ['--help', '--silent', '--quiet', '--verbose', '--version', '--ansi', '--no-ansi', '--no-interaction', '--global-option']]; yield 'custom' => [['bin/console', 'hello'], ['Fabien', 'Robin', 'Wouter']]; - yield 'definition-aliased' => [['bin/console', 'ahoy', '-'], ['--help', '--silent', '--quiet', '--verbose', '--version', '--ansi', '--no-ansi', '--no-interaction']]; + yield 'definition-aliased' => [['bin/console', 'ahoy', '-'], ['--help', '--silent', '--quiet', '--verbose', '--version', '--ansi', '--no-ansi', '--no-interaction', '--global-option']]; yield 'custom-aliased' => [['bin/console', 'ahoy'], ['Fabien', 'Robin', 'Wouter']]; + yield 'global-option-values' => [['bin/console', '--global-option'], ['foo', 'bar', 'baz']]; + yield 'global-option-with-command-values' => [['bin/console', 'ahoy', '--global-option'], ['foo', 'bar', 'baz']]; } private function execute(array $input) @@ -147,6 +152,10 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti { if ($input->mustSuggestArgumentValuesFor('name')) { $suggestions->suggestValues(['Fabien', 'Robin', 'Wouter']); + + return; } + + parent::complete($input, $suggestions); } } diff --git a/Tests/Fixtures/application_test_sigint.php b/Tests/Fixtures/application_test_sigint.php new file mode 100644 index 000000000..4a3d4eab0 --- /dev/null +++ b/Tests/Fixtures/application_test_sigint.php @@ -0,0 +1,46 @@ +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()) +; diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index 76e40cef0..52d13c1b1 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -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 @@ -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); diff --git a/Tests/SignalRegistry/SignalRegistryTest.php b/Tests/SignalRegistry/SignalRegistryTest.php index 92d500f9e..8a48bc287 100644 --- a/Tests/SignalRegistry/SignalRegistryTest.php +++ b/Tests/SignalRegistry/SignalRegistryTest.php @@ -130,4 +130,67 @@ public function testTwoCallbacksForASignalPreviousCallbackFromAnotherRegistry() $this->assertTrue($isHandled1); $this->assertTrue($isHandled2); } + + public function testPushPopIsolatesHandlers() + { + $registry = new SignalRegistry(); + + $signal = \SIGUSR1; + + $handler1 = static function () {}; + $handler2 = static function () {}; + + $registry->pushCurrentHandlers(); + $registry->register($signal, $handler1); + + $this->assertCount(1, $this->getHandlersForSignal($registry, $signal)); + + $registry->pushCurrentHandlers(); + $registry->register($signal, $handler2); + + $this->assertCount(1, $this->getHandlersForSignal($registry, $signal)); + $this->assertSame([$handler2], $this->getHandlersForSignal($registry, $signal)); + + $registry->popPreviousHandlers(); + + $this->assertCount(1, $this->getHandlersForSignal($registry, $signal)); + $this->assertSame([$handler1], $this->getHandlersForSignal($registry, $signal)); + + $registry->popPreviousHandlers(); + + $this->assertCount(0, $this->getHandlersForSignal($registry, $signal)); + } + + public function testRestoreOriginalOnEmptyAfterPop() + { + if (!\extension_loaded('pcntl')) { + $this->markTestSkipped('PCNTL extension required'); + } + + $registry = new SignalRegistry(); + + $signal = \SIGUSR2; + + $original = pcntl_signal_get_handler($signal); + + $handler = static function () {}; + + $registry->pushCurrentHandlers(); + $registry->register($signal, $handler); + + $this->assertNotEquals($original, pcntl_signal_get_handler($signal)); + + $registry->popPreviousHandlers(); + + $this->assertEquals($original, pcntl_signal_get_handler($signal)); + } + + private function getHandlersForSignal(SignalRegistry $registry, int $signal): array + { + $ref = new \ReflectionClass($registry); + $prop = $ref->getProperty('signalHandlers'); + $handlers = $prop->getValue($registry); + + return $handlers[$signal] ?? []; + } }