8000 [Console] Add support for managing exit code while handling signals · symfony/symfony@1650e38 · GitHub
[go: up one dir, main page]

Skip to content

Commit 1650e38

Browse files
committed
[Console] Add support for managing exit code while handling signals
1 parent 5b1a20d commit 1650e38

11 files changed

+354
-103
lines changed

.github/expected-missing-return-types.diff

Lines changed: 84 additions & 74 deletions
Large diffs are not rendered by default.

UPGRADE-6.3.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
UPGRADE FROM 6.2 to 6.3
22
=======================
33

4+
Console
5+
-------
6+
7+
* Return int or false from `SignalableCommandInterface::handleSignal()` instead
8+
of void and add a second argument `$previousExitCode`
9+
410
DependencyInjection
511
-------------------
612

src/Symfony/Component/Console/Application.php

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,37 +1000,62 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
10001000
}
10011001
}
10021002

1003-
if ($this->signalsToDispatchEvent) {
1004-
$commandSignals = $command instanceof SignalableCommandInterface ? $command->getSubscribedSignals() : [];
1005-
1006-
if ($commandSignals || null !== $this->dispatcher) {
1007-
if (!$this->signalRegistry) {
1008-
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.');
1009-
}
1003+
$commandSignals = $command instanceof SignalableCommandInterface ? $command->getSubscribedSignals() : [];
1004+
if ($commandSignals || $this->dispatcher && $this->signalsToDispatchEvent) {
1005+
if (!$this->signalRegistry) {
1006+
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.');
1007+
}
10101008

1011-
if (Terminal::hasSttyAvailable()) {
1012-
$sttyMode = shell_exec('stty -g');
1009+
if (Terminal::hasSttyAvailable()) {
1010+
$sttyMode = shell_exec('stty -g');
10131011

1014-
foreach ([\SIGINT, \SIGTERM] as $signal) {
1015-
$this->signalRegistry->register($signal, static function () use ($sttyMode) {
1016-
shell_exec('stty '.$sttyMode);
1017-
});
1018-
}
1012+
foreach ([\SIGINT, \SIGTERM] as $signal) {
1013+
$this->signalRegistry->register($signal, static fn () => shell_exec('stty '.$sttyMode));
10191014
}
10201015
}
10211016

1022-
if (null !== $this->dispatcher) {
1017+
if ($this->dispatcher) {
1018+
// We register application signals, so that we can dispatch the event
10231019
foreach ($this->signalsToDispatchEvent as $signal) {
10241020
$event = new ConsoleSignalEvent($command, $input, $output, $signal);
10251021

1026-
$this->signalRegistry->register($signal, function () use ($event) {
1022+
$this->signalRegistry->register($signal, function ($signal) use ($event, $command, $commandSignals) {
10271023
$this->dispatcher->dispatch($event, ConsoleEvents::SIGNAL);
1024+
$exitCode = $event->getExitCode();
1025+
1026+
// If the command is signalable, we call the handleSignal() method
1027+
if (\in_array($signal, $commandSignals, true)) {
1028+
$exitCode = $command->handleSignal($signal, $exitCode);
1029+
// BC layer for Symfony <= 5
1030+
if (null === $exitCode) {
1031+
trigger_deprecation('symfony/console', '6.3', 'Not returning an exit code from "%s::handleSignal()" is deprecated, return "false" to keep the command running or "0" to exit successfully.', get_debug_type($command));
1032+
$exitCode = 0;
1033+
}
1034+
}
1035+
1036+
if (false !== $exitCode) {
1037+
exit($exitCode);
1038+
}
10281039
});
10291040
}
1041+
1042+
// then we register command signals, but not if already handled after the dispatcher
1043+
$commandSignals = array_diff($commandSignals, $this->signalsToDispatchEvent);
10301044
}
10311045

10321046
foreach ($commandSignals as $signal) {
1033-
$this->signalRegistry->register($signal, [$command, 'handleSignal']);
1047+
$this->signalRegistry->register($signal, function (int $signal) use ($command): void {
1048+
$exitCode = $command->handleSignal($signal);
1049+
// BC layer for Symfony <= 5
1050+
if (null === $exitCode) {
1051+
trigger_deprecation('symfony/console', '6.3', 'Not returning an exit code from "%s::handleSignal()" is deprecated, return "false" to keep the command running or "0" to exit successfully.', get_debug_type($command));
1052+
$exitCode = 0;
1053+
}
1054+
1055+
if (false !== $exitCode) {
1056+
exit($exitCode);
1057+
}
1058+
});
10341059
}
10351060
}
10361061

src/Symfony/Component/Console/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ CHANGELOG
44
6.3
55
---
66

7-
* Remove `exit` call in `Application` signal handlers. Commands will no longer be automatically interrupted after receiving signal other than `SIGUSR1` or `SIGUSR2`
7+
* Add support for choosing exit code while handling signal, or to not exit at all
88
* Add `ProgressBar::setPlaceholderFormatter` to set a placeholder attached to a instance, instead of being global.
99
* Add `ReStructuredTextDescriptor`
1010

src/Symfony/Component/Console/Command/SignalableCommandInterface.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ public function getSubscribedSignals(): array;
2525

2626
/**
2727
* The method will be called when the application is signaled.
28+
*
29+
* @param int|false $previousExitCode
30+
31+
* @return int|false The exit code to return or false to continue the normal execution
2832
*/
29-
public function handleSignal(int $signal): void;
33+
public function handleSignal(int $signal, /* int|false $previousExitCode = 0 */);
3034
}

src/Symfony/Component/Console/Event/ConsoleSignalEvent.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,36 @@
2121
final class ConsoleSignalEvent extends ConsoleEvent
2222
{
2323
private int $handlingSignal;
24+
private int|false $exitCode;
2425

25-
public function __construct(Command $command, InputInterface $input, OutputInterface $output, int $handlingSignal)
26+
public function __construct(Command $command, InputInterface $input, OutputInterface $output, int $handlingSignal, int|false $exitCode = 0)
2627
{
2728
parent::__construct($command, $input, $output);
2829
$this->handlingSignal = $handlingSignal;
30+
$this->exitCode = $exitCode;
2931
}
3032

3133
public function getHandlingSignal(): int
3234
{
3335
return $this->handlingSignal;
3436
}
37+
38+
public function setExitCode(int $exitCode): void
39+
{
40+
if ($exitCode < 0 || $exitCode > 255) {
41+
throw new \InvalidArgumentException('Exit code must be between 0 and 255.');
42+
}
43+
44+
$this->exitCode = $exitCode;
45+
}
46+
47+
public function abortExit(): void
48+
{
49+
$this->exitCode = false;
50+
}
51+
52+
public function getExitCode(): int|false
53+
{
54+
return $this->exitCode;
55+
}
3556
}

src/Symfony/Component/Console/Tests/ApplicationTest.php

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\Component\Console\Command\SignalableCommandInterface;
2121
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
2222
use Symfony\Component\Console\CommandLoader\FactoryCommandLoader;
23+
use Symfony\Component\Console\ConsoleEvents;
2324
use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass;
2425
use Symfony\Component\Console\Event\ConsoleCommandEvent;
2526
use Symfony\Component\Console\Event\ConsoleErrorEvent;
@@ -1929,7 +1930,8 @@ public function testSignalListener()
19291930

19301931
$dispatcherCalled = false;
19311932
$dispatcher = new EventDispatcher();
1932-
$dispatcher->addListener('console.signal', function () use (&$dispatcherCalled) {
1933+
$dispatcher->addListener('console.signal', function (ConsoleSignalEvent $e) use (&$dispatcherCalled) {
1934+
$e->abortExit();
19331935
$dispatcherCalled = true;
19341936
});
19351937

@@ -1978,6 +1980,34 @@ public function testSignalSubscriber()
19781980
$this->assertTrue($subscriber2->signaled);
19791981
}
19801982

1983+
/**
1984+
* @requires extension pcntl
1985+
*/
1986+
public function testSignalDispatchWithoutEventToDispatch()
1987+
{
1988+
$command = new SignableCommand();
1989+
1990+
$application = $this->createSignalableApplication($command, null);
1991+
$application->setSignalsToDispatchEvent();
1992+
1993+
$this->assertSame(1, $application->run(new ArrayInput(['signal'])));
1994+
$this->assertTrue($command->signaled);
1995+
}
1996+
1997+
/**
1998+
* @requires extension pcntl
1999+
*/
2000+
public function testSignalDispatchWithoutEventDispatcher()
2001+
{
2002+
$command = new SignableCommand();
2003+
2004+
$application = $this->createSignalableApplication($command, null);
2005+
$application->setSignalsToDispatchEvent(\SIGUSR1);
2006+
2007+
$this->assertSame(1, $application->run(new ArrayInput(['signal'])));
2008+
$this->assertTrue($command->signaled);
2009+
}
2010+
19812011
/**
19822012
* @requires extension pcntl
19832013
*/
@@ -2077,9 +2107,36 @@ public function testSignalableCommandDoesNotInterruptedOnTermSignals()
20772107
$application->setAutoExit(false);
20782108
$application->setDispatcher($dispatcher);
20792109
$application->add($command);
2110+
20802111
$this->assertSame(129, $application->run(new ArrayInput(['signal'])));
20812112
}
20822113

2114+
public function testSignalableWithEventCommandDoesNotInterruptedOnTermSignals()
2115+
{
2116+
if (!\defined('SIGINT')) {
2117+
$this->markTestSkipped('SIGINT not available');
2118+
}
2119+
2120+
$command = new TerminatableWithEventCommand();
2121+
2122+
$dispatcher = new EventDispatcher();
2123+
$dispatcher->addSubscriber($command);
2124+
$application = new Application();
2125+
$application->setAutoExit(false);
2126+
$application->setDispatcher($dispatcher);
2127+
$application->add($command);
2128+
$tester = new ApplicationTester($application);
2129+
$this->assertSame(51, $tester->run(['signal']));
2130+
$expected = <<<EOTXT
2131+
Still processing...
2132+
["handling event",2,0]
2133+
["exit code",2,125]
2134+
Wrapping up, wait a sec...
2135+
2136+
EOTXT;
2137+
$this->assertSame($expected, $tester->getDisplay(true));
2138+
}
2139+
20832140
/**
20842141
* @group tty
20852142
*/
@@ -2217,10 +2274,12 @@ public function getSubscribedSignals(): array
22172274
return SignalRegistry::isSupported() ? [\SIGUSR1] : [];
22182275
}
22192276

2220-
public function handleSignal(int $signal): void
2277+
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
22212278
{
22222279
$this->signaled = true;
22232280
$this->signalHandlers[] = __CLASS__;
2281+
2282+
return false;
22242283
}
22252284
}
22262285

@@ -2232,10 +2291,61 @@ public function getSubscribedSignals(): array
22322291
return SignalRegistry::isSupported() ? [\SIGINT] : [];
22332292
}
22342293

2235-
public function handleSignal(int $signal): void
2294+
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
22362295
{
22372296
$this->signaled = true;
22382297
$this->signalHandlers[] = __CLASS__;
2298+
2299+
return false;
2300+
}
2301+
}
2302+
2303+
#[AsCommand(name: 'signal')]
2304+
class TerminatableWithEventCommand extends Command implements SignalableCommandInterface, EventSubscriberInterface
2305+
{
2306+
private bool $shouldContinue = true;
2307+
private OutputInterface $output;
2308+
2309+
protected function execute(InputInterface $input, OutputInterface $output): int
2310+
{
2311+
$this->output = $output;
2312+
2313+
for ($i = 0; $i <= 10 && $this->shouldContinue; ++$i) {
2314+
$output->writeln('Still processing...');
2315+
posix_kill(posix_getpid(), SIGINT);
2316+
}
2317+
2318+
$output->writeln('Wrapping up, wait a sec...');
2319+
2320+
return 51;
2321+
}
2322+
2323+
public function getSubscribedSignals(): array
2324+
{
2325+
return [\SIGINT];
2326+
}
2327+
2328+
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
2329+
{
2330+
$this->shouldContinue = false;
2331+
2332+
$this->output->writeln(json_encode(['exit code', $signal, $previousExitCode]));
2333+
2334+
return false;
2335+
}
2336+
2337+
public function handleSignalEvent(ConsoleSignalEvent $event): void
2338+
{
2339+
$this->output->writeln(json_encode(['handling event', $event->getHandlingSignal(), $event->getExitCode()]));
2340+
2341+
$event->setExitCode(125);
2342+
}
2343+
2344+
public static function getSubscribedEvents(): array
2345+
{
2346+
return [
2347+
ConsoleEvents::SIGNAL => 'handleSignalEvent',
2348+
];
22392349
}
22402350
}
22412351

@@ -2248,6 +2358,8 @@ public function onSignal(ConsoleSignalEvent $event): void
22482358
$this->signaled = true;
22492359
$event->getCommand()->signaled = true;
22502360
$event->getCommand()->signalHandlers[] = __CLASS__;
2361+
2362+
$event->abortExit();
22512363
}
22522364

22532365
public static function getSubscribedEvents(): array

src/Symfony/Component/Console/Tests/ConsoleEventsTest.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@
3030

3131
class ConsoleEventsTest extends TestCase
3232
{
33+
protected function tearDown(): void
34+
{
35+
if (\function_exists('pcntl_signal')) {
36+
pcntl_async_signals(false);
37+
// We reset all signals to their default value to avoid side effects
38+
for ($i = 1; $i <= 15; ++$i) {
39+
if (9 === $i) {
40+
continue;
41+
}
42+
pcntl_signal($i, SIG_DFL);
43+
}
44+
}
45+
}
46+
3347
public function testEventAliases()
3448
{
3549
$container = new ContainerBuilder();

src/Symfony/Component/Console/Tests/Fixtures/application_signalable.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
<?php
22

3-
use Symfony\Component\Console\Application;
43
use Symfony\Component\Console\Command\SignalableCommandInterface;
5-
use Symfony\Component\Console\Helper\QuestionHelper;
64
use Symfony\Component\Console\Input\InputInterface;
75
use Symfony\Component\Console\Output\OutputInterface;
86
use Symfony\Component\Console\Question\ChoiceQuestion;
@@ -20,9 +18,9 @@ public function getSubscribedSignals(): array
2018
return [SIGINT];
2119
}
2220

23-
public function handleSignal(int $signal): void
21+
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
2422
{
25-
exit;
23+
exit(0);
2624
}
2725
})
2826
->setCode(function(InputInterface $input, OutputInterface $output) {

src/Symfony/Component/Console/Tests/SignalRegistry/SignalRegistryTest.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ class SignalRegistryTest extends TestCase
2222
protected function tearDown(): void
2323
{
2424
pcntl_async_signals(false);
25-
pcntl_signal(\SIGUSR1, \SIG_DFL);
26-
pcntl_signal(\SIGUSR2, \SIG_DFL);
25+
// We reset all signals to their default value to avoid side effects
26+
for ($i = 1; $i <= 15; ++$i) {
27+
if (9 === $i) {
28+
continue;
29+
}
30+
pcntl_signal($i, SIG_DFL);
31+
}
2732
}
2833

2934
public function testOneCallbackForASignalSignalIsHandled()

0 commit comments

Comments
 (0)
0