Skip to content

Commit 32d9216

Browse files
yoeunesnicolas-grekas
authored andcommitted
[Console] Fix signal handlers not being cleared after command termination
1 parent 13d3176 commit 32d9216

File tree

4 files changed

+310
-7
lines changed

4 files changed

+310
-7
lines changed

Application.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1012,12 +1012,16 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
10121012
}
10131013
}
10141014

1015+
$registeredSignals = false;
10151016
$commandSignals = $command instanceof SignalableCommandInterface ? $command->getSubscribedSignals() : [];
10161017
if ($commandSignals || $this->dispatcher && $this->signalsToDispatchEvent) {
10171018
if (!$this->signalRegistry) {
10181019
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.');
10191020
}
10201021

1022+
$registeredSignals = true;
1023+
$this->getSignalRegistry()->pushCurrentHandlers();
1024+
10211025
if ($this->dispatcher) {
10221026
// We register application signals, so that we can dispatch the event
10231027
foreach ($this->signalsToDispatchEvent as $signal) {
@@ -1067,7 +1071,13 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
10671071
}
10681072

10691073
if (null === $this->dispatcher) {
1070-
return $command->run($input, $output);
1074+
try {
1075+
return $command->run($input, $output);
1076+
} finally {
1077+
if ($registeredSignals) {
1078+
$this->getSignalRegistry()->popPreviousHandlers();
1079+
}
1080+
}
10711081
}
10721082

10731083
// bind before the console.command event, so the listeners have access to input options/arguments
@@ -1097,6 +1107,10 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
10971107
if (0 === $exitCode = $event->getExitCode()) {
10981108
$e = null;
10991109
}
1110+
} finally {
1111+
if ($registeredSignals) {
1112+
$this->getSignalRegistry()->popPreviousHandlers();
1113+
}
11001114
}
11011115

11021116
$event = new ConsoleTerminateEvent($command, $input, $output, $exitCode);

SignalRegistry/SignalRegistry.php

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,21 @@
1313

1414
final class SignalRegistry
1515
{
16+
/**
17+
* @var array<int, array<callable>>
18+
*/
1619
private array $signalHandlers = [];
1720

21+
/**
22+
* @var array<array<int, array<callable>>>
23+
*/
24+
private array $stack = [];
25+
26+
/**
27+
* @var array<int, callable|int|string>
28+
*/
29+
private array $originalHandlers = [];
30+
1831
public function __construct()
1932
{
2033
if (\function_exists('pcntl_async_signals')) {
@@ -24,17 +37,21 @@ public function __construct()
2437

2538
public function register(int $signal, callable $signalHandler): void
2639
{
27-
if (!isset($this->signalHandlers[$signal])) {
28-
$previousCallback = pcntl_signal_get_handler($signal);
40+
$previous = pcntl_signal_get_handler($signal);
41+
42+
if (!isset($this->originalHandlers[$signal])) {
43+
$this->originalHandlers[$signal] = $previous;
44+
}
2945

30-
if (\is_callable($previousCallback)) {
31-
$this->signalHandlers[$signal][] = $previousCallback;
46+
if (!isset($this->signalHandlers[$signal])) {
47+
if (\is_callable($previous) && [$this, 'handle'] !== $previous) {
48+
$this->signalHandlers[$signal][] = $previous;
3249
}
3350
}
3451

3552
$this->signalHandlers[$signal][] = $signalHandler;
3653

37-
pcntl_signal($signal, $this->handle(...));
54+
pcntl_signal($signal, [$this, 'handle']);
3855
}
3956

4057
public static function isSupported(): bool
@@ -54,4 +71,38 @@ public function handle(int $signal): void
5471
$signalHandler($signal, $hasNext);
5572
}
5673
}
74+
75+
/**
76+
* Pushes the current active handlers onto the stack and clears the active list.
77+
*
78+
* This prepares the registry for a new set of handlers within a specific scope.
79+
*
80+
* @internal
81+
*/
82+
public function pushCurrentHandlers(): void
83+
{
84+
$this->stack[] = $this->signalHandlers;
85+
$this->signalHandlers = [];
86+
}
87+
88+
/**
89+
* Restores the previous handlers from the stack, making them active.
90+
*
91+
* This also restores the original OS-level signal handler if no
92+
* more handlers are registered for a signal that was just popped.
93+
*
94+
* @internal
95+
*/
96+
public function popPreviousHandlers(): void
97+
{
98+
$popped = $this->signalHandlers;
99+
$this->signalHandlers = array_pop($this->stack) ?? [];
100+
101+
// Restore OS handler if no more Symfony handlers for this signal
102+
foreach ($popped as $signal => $handlers) {
103+
if (!($this->signalHandlers[$signal] ?? false) && isset($this->originalHandlers[$signal])) {
104+
pcntl_signal($signal, $this->originalHandlers[$signal]);
105+
}
106+
}
107+
}
57108
}

Tests/ApplicationTest.php

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -824,7 +824,7 @@ public function testSetCatchErrors(bool $catchExceptions)
824824

825825
try {
826826
$tester->run(['command' => 'boom']);
827-
$this->fail('The exception is not catched.');
827+
$this->fail('The exception is not caught.');
828828
} catch (\Throwable $e) {
829829
$this->assertInstanceOf(\Error::class, $e);
830830
$this->assertSame('This is an error.', $e->getMessage());
@@ -2259,6 +2259,181 @@ private function runRestoresSttyTest(array $params, int $expectedExitCode, bool
22592259
}
22602260
}
22612261

2262+
/**
2263+
* @requires extension pcntl
2264+
*/
2265+
public function testSignalHandlersAreCleanedUpAfterCommandRuns()
2266+
{
2267+
$application = new Application();
2268+
$application->setAutoExit(false);
2269+
$application->setCatchExceptions(false);
2270+
$application->add(new SignableCommand(false));
2271+
2272+
$signalRegistry = $application->getSignalRegistry();
2273+
$tester = new ApplicationTester($application);
2274+
2275+
$this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry should be empty initially.');
2276+
2277+
$tester->run(['command' => 'signal']);
2278+
$this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry should be empty after first run.');
2279+
2280+
$tester->run(['command' => 'signal']);
2281+
$this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry should still be empty after second run.');
2282+
}
2283+
2284+
/**
2285+
* @requires extension pcntl
2286+
*/
2287+
public function testSignalHandlersCleanupOnException()
2288+
{
2289+
$command = new class('signal:exception') extends Command implements SignalableCommandInterface {
2290+
public function getSubscribedSignals(): array
2291+
{
2292+
return [\SIGUSR1];
2293+
}
2294+
2295+
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
2296+
{
2297+
return false;
2298+
}
2299+
2300+
protected function execute(InputInterface $input, OutputInterface $output): int
2301+
{
2302+
throw new \RuntimeException('Test exception');
2303+
}
2304+
};
2305+
2306+
$application = new Application();
2307+
$application->setAutoExit(false);
2308+
$application->setCatchExceptions(true);
2309+
$application->add($command);
2310+
2311+
$signalRegistry = $application->getSignalRegistry();
2312+
$tester = new ApplicationTester($application);
2313+
2314+
$this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Pre-condition: Registry must be empty.');
2315+
2316+
$tester->run(['command' => 'signal:exception']);
2317+
$this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Signal handlers must be cleaned up even on exception.');
2318+
}
2319+
2320+
/**
2321+
* @requires extension pcntl
2322+
*/
2323+
public function testNestedCommandsIsolateSignalHandlers()
2324+
{
2325+
$application = new Application();
2326+
$application->setAutoExit(false);
2327+
$application->setCatchExceptions(false);
2328+
2329+
$signalRegistry = $application->getSignalRegistry();
2330+
$self = $this;
2331+
2332+
$innerCommand = new class('signal:inner') extends Command implements SignalableCommandInterface {
2333+
public $signalRegistry;
2334+
public $self;
2335+
2336+
public function getSubscribedSignals(): array
2337+
{
2338+
return [\SIGUSR1];
2339+
}
2340+
2341+
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
2342+
{
2343+
return false;
2344+
}
2345+
2346+
protected function execute(InputInterface $input, OutputInterface $output): int
2347+
{
2348+
$handlers = $this->self->getHandlersForSignal($this->signalRegistry, \SIGUSR1);
2349+
$this->self->assertCount(1, $handlers, 'Inner command should only see its own handler.');
2350+
$output->write('Inner execute.');
2351+
2352+
return 0;
2353+
}
2354+
};
2355+
2356+
$outerCommand = new class('signal:outer') extends Command implements SignalableCommandInterface {
2357+
public $signalRegistry;
2358+
public $self;
2359+
2360+
public function getSubscribedSignals(): array
2361+
{
2362+
return [\SIGUSR1];
2363+
}
2364+
2365+
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
2366+
{
2367+
return false;
2368+
}
2369+
2370+
protected function execute(InputInterface $input, OutputInterface $output): int
2371+
{
2372+
$handlersBefore = $this->self->getHandlersForSignal($this->signalRegistry, \SIGUSR1);
2373+
$this->self->assertCount(1, $handlersBefore, 'Outer command must have its handler registered.');
2374+
2375+
$output->write('Outer pre-run.');
2376+
2377+
$this->getApplication()->find('signal:inner')->run(new ArrayInput([]), $output);
2378+
2379+
$output->write('Outer post-run.');
2380+
2381+
$handlersAfter = $this->self->getHandlersForSignal($this->signalRegistry, \SIGUSR1);
2382+
$this->self->assertCount(1, $handlersAfter, 'Outer command\'s handler must be restored.');
2383+
$this->self->assertSame($handlersBefore, $handlersAfter, 'Handler stack must be identical after pop.');
2384+
2385+
return 0;
2386+
}
2387+
};
2388+
2389+
$innerCommand->self = $self;
2390+
$innerCommand->signalRegistry = $signalRegistry;
2391+
$outerCommand->self = $self;
2392+
$outerCommand->signalRegistry = $signalRegistry;
2393+
2394+
$application->add($innerCommand);
2395+
$application->add($outerCommand);
2396+
2397+
$tester = new ApplicationTester($application);
2398+
2399+
$this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Pre-condition: Registry must be empty.');
2400+
$tester->run(['command' => 'signal:outer']);
2401+
$this->assertStringContainsString('Outer pre-run.Inner execute.Outer post-run.', $tester->getDisplay());
2402+
2403+
$this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry must be empty after all commands are finished.');
2404+
}
2405+
2406+
/**
2407+
* @requires extension pcntl
2408+
*/
2409+
public function testOriginalHandlerRestoredAfterPop()
2410+
{
2411+
$this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGUSR1), 'Pre-condition: Original handler for SIGUSR1 must be SIG_DFL.');
2412+
2413+
$application = new Application();
2414+
$application->setAutoExit(false);
2415+
$application->setCatchExceptions(false);
2416+
$application->add(new SignableCommand(false));
2417+
2418+
$tester = new ApplicationTester($application);
2419+
$tester->run(['command' => 'signal']);
2420+
2421+
$this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGUSR1), 'OS-level handler for SIGUSR1 must be restored to SIG_DFL.');
2422+
2423+
$tester->run(['command' => 'signal']);
2424+
$this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGUSR1), 'OS-level handler must remain SIG_DFL after a second run.');
2425+
}
2426+
2427+
/**
2428+
* Reads the private "signalHandlers" property of the SignalRegistry for assertions.
2429+
*/
2430+
public function getHandlersForSignal(SignalRegistry $registry, int $signal): array
2431+
{
2432+
$handlers = (\Closure::bind(fn () => $this->signalHandlers, $registry, SignalRegistry::class))();
2433+
2434+
return $handlers[$signal] ?? [];
2435+
}
2436+
22622437
private function createSignalableApplication(Command $command, ?EventDispatcherInterface $dispatcher): Application
22632438
{
22642439
$application = new Application();

Tests/SignalRegistry/SignalRegistryTest.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,67 @@ public function testTwoCallbacksForASignalPreviousCallbackFromAnotherRegistry()
129129
$this->assertTrue($isHandled1);
130130
$this->assertTrue($isHandled2);
131131
}
132+
133+
public function testPushPopIsolatesHandlers()
134+
{
135+
$registry = new SignalRegistry();
136+
137+
$signal = \SIGUSR1;
138+
139+
$handler1 = static function () {};
140+
$handler2 = static function () {};
141+
142+
$registry->pushCurrentHandlers();
143+
$registry->register($signal, $handler1);
144+
145+
$this->assertCount(1, $this->getHandlersForSignal($registry, $signal));
146+
147+
$registry->pushCurrentHandlers();
148+
$registry->register($signal, $handler2);
149+
150+
$this->assertCount(1, $this->getHandlersForSignal($registry, $signal));
151+
$this->assertSame([$handler2], $this->getHandlersForSignal($registry, $signal));
152+
153+
$registry->popPreviousHandlers();
154+
155+
$this->assertCount(1, $this->getHandlersForSignal($registry, $signal));
156+
$this->assertSame([$handler1], $this->getHandlersForSignal($registry, $signal));
157+
158+
$registry->popPreviousHandlers();
159+
160+
$this->assertCount(0, $this->getHandlersForSignal($registry, $signal));
161+
}
162+
163+
public function testRestoreOriginalOnEmptyAfterPop()
164+
{
165+
if (!\extension_loaded('pcntl')) {
166+
$this->markTestSkipped('PCNTL extension required');
167+
}
168+
169+
$registry = new SignalRegistry();
170+
171+
$signal = \SIGUSR2;
172+
173+
$original = pcntl_signal_get_handler($signal);
174+
175+
$handler = static function () {};
176+
177+
$registry->pushCurrentHandlers();
178+
$registry->register($signal, $handler);
179+
180+
$this->assertNotEquals($original, pcntl_signal_get_handler($signal));
181+
182+
$registry->popPreviousHandlers();
183+
184+
$this->assertEquals($original, pcntl_signal_get_handler($signal));
185+
}
186+
187+
private function getHandlersForSignal(SignalRegistry $registry, int $signal): array
188+
{
189+
$ref = new \ReflectionClass($registry);
190+
$prop = $ref->getProperty('signalHandlers');
191+
$handlers = $prop->getValue($registry);
192+
193+
return $handlers[$signal] ?? [];
194+
}
132195
}

0 commit comments

Comments
 (0)