@@ -2313,6 +2313,26 @@ private function runRestoresSttyTest(array $params, int $expectedExitCode, bool
23132313 }
23142314 }
23152315
2316+ #[RequiresPhpExtension('pcntl ' )]
2317+ public function testSignalHandlersAreCleanedUpAfterCommandRuns ()
2318+ {
2319+ $ application = new Application ();
2320+ $ application ->setAutoExit (false );
2321+ $ application ->setCatchExceptions (false );
2322+ $ application ->addCommand (new SignableCommand (false ));
2323+
2324+ $ signalRegistry = $ application ->getSignalRegistry ();
2325+ $ tester = new ApplicationTester ($ application );
2326+
2327+ $ this ->assertCount (0 , $ this ->getHandlersForSignal ($ signalRegistry , \SIGUSR1 ), 'Registry should be empty initially. ' );
2328+
2329+ $ tester ->run (['command ' => 'signal ' ]);
2330+ $ this ->assertCount (0 , $ this ->getHandlersForSignal ($ signalRegistry , \SIGUSR1 ), 'Registry should be empty after first run. ' );
2331+
2332+ $ tester ->run (['command ' => 'signal ' ]);
2333+ $ this ->assertCount (0 , $ this ->getHandlersForSignal ($ signalRegistry , \SIGUSR1 ), 'Registry should still be empty after second run. ' );
2334+ }
2335+
23162336 #[RequiresPhpExtension('pcntl ' )]
23172337 public function testSignalableInvokableCommand ()
23182338 {
@@ -2329,6 +2349,40 @@ public function testSignalableInvokableCommand()
23292349 $ this ->assertTrue ($ invokable ->signaled );
23302350 }
23312351
2352+ #[RequiresPhpExtension('pcntl ' )]
2353+ public function testSignalHandlersCleanupOnException ()
2354+ {
2355+ $ command = new class ('signal:exception ' ) extends Command implements SignalableCommandInterface {
2356+ public function getSubscribedSignals (): array
2357+ {
2358+ return [\SIGUSR1 ];
2359+ }
2360+
2361+ public function handleSignal (int $ signal , int |false $ previousExitCode = 0 ): int |false
2362+ {
2363+ return false ;
2364+ }
2365+
2366+ protected function execute (InputInterface $ input , OutputInterface $ output ): int
2367+ {
2368+ throw new \RuntimeException ('Test exception ' );
2369+ }
2370+ };
2371+
2372+ $ application = new Application ();
2373+ $ application ->setAutoExit (false );
2374+ $ application ->setCatchExceptions (true );
2375+ $ application ->addCommand ($ command );
2376+
2377+ $ signalRegistry = $ application ->getSignalRegistry ();
2378+ $ tester = new ApplicationTester ($ application );
2379+
2380+ $ this ->assertCount (0 , $ this ->getHandlersForSignal ($ signalRegistry , \SIGUSR1 ), 'Pre-condition: Registry must be empty. ' );
2381+
2382+ $ tester ->run (['command ' => 'signal:exception ' ]);
2383+ $ this ->assertCount (0 , $ this ->getHandlersForSignal ($ signalRegistry , \SIGUSR1 ), 'Signal handlers must be cleaned up even on exception. ' );
2384+ }
2385+
23322386 #[RequiresPhpExtension('pcntl ' )]
23332387 public function testSignalableInvokableCommandThatExtendsBaseCommand ()
23342388 {
@@ -2428,6 +2482,90 @@ public function testAlarmableCommandWithoutInterval()
24282482 $ this ->assertFalse ($ command ->signaled );
24292483 }
24302484
2485+ #[RequiresPhpExtension('pcntl ' )]
2486+ public function testNestedCommandsIsolateSignalHandlers ()
2487+ {
2488+ $ application = new Application ();
2489+ $ application ->setAutoExit (false );
2490+ $ application ->setCatchExceptions (false );
2491+
2492+ $ signalRegistry = $ application ->getSignalRegistry ();
2493+ $ self = $ this ;
2494+
2495+ $ innerCommand = new class ('signal:inner ' ) extends Command implements SignalableCommandInterface {
2496+ public $ signalRegistry ;
2497+ public $ self ;
2498+
2499+ public function getSubscribedSignals (): array
2500+ {
2501+ return [\SIGUSR1 ];
2502+ }
2503+
2504+ public function handleSignal (int $ signal , int |false $ previousExitCode = 0 ): int |false
2505+ {
2506+ return false ;
2507+ }
2508+
2509+ protected function execute (InputInterface $ input , OutputInterface $ output ): int
2510+ {
2511+ $ handlers = $ this ->self ->getHandlersForSignal ($ this ->signalRegistry , \SIGUSR1 );
2512+ $ this ->self ->assertCount (1 , $ handlers , 'Inner command should only see its own handler. ' );
2513+ $ output ->write ('Inner execute. ' );
2514+
2515+ return 0 ;
2516+ }
2517+ };
2518+
2519+ $ outerCommand = new class ('signal:outer ' ) extends Command implements SignalableCommandInterface {
2520+ public $ signalRegistry ;
2521+ public $ self ;
2522+
2523+ public function getSubscribedSignals (): array
2524+ {
2525+ return [\SIGUSR1 ];
2526+ }
2527+
2528+ public function handleSignal (int $ signal , int |false $ previousExitCode = 0 ): int |false
2529+ {
2530+ return false ;
2531+ }
2532+
2533+ protected function execute (InputInterface $ input , OutputInterface $ output ): int
2534+ {
2535+ $ handlersBefore = $ this ->self ->getHandlersForSignal ($ this ->signalRegistry , \SIGUSR1 );
2536+ $ this ->self ->assertCount (1 , $ handlersBefore , 'Outer command must have its handler registered. ' );
2537+
2538+ $ output ->write ('Outer pre-run. ' );
2539+
2540+ $ this ->getApplication ()->find ('signal:inner ' )->run (new ArrayInput ([]), $ output );
2541+
2542+ $ output ->write ('Outer post-run. ' );
2543+
2544+ $ handlersAfter = $ this ->self ->getHandlersForSignal ($ this ->signalRegistry , \SIGUSR1 );
2545+ $ this ->self ->assertCount (1 , $ handlersAfter , 'Outer command \'s handler must be restored. ' );
2546+ $ this ->self ->assertSame ($ handlersBefore , $ handlersAfter , 'Handler stack must be identical after pop. ' );
2547+
2548+ return 0 ;
2549+ }
2550+ };
2551+
2552+ $ innerCommand ->self = $ self ;
2553+ $ innerCommand ->signalRegistry = $ signalRegistry ;
2554+ $ outerCommand ->self = $ self ;
2555+ $ outerCommand ->signalRegistry = $ signalRegistry ;
2556+
2557+ $ application ->addCommand ($ innerCommand );
2558+ $ application ->addCommand ($ outerCommand );
2559+
2560+ $ tester = new ApplicationTester ($ application );
2561+
2562+ $ this ->assertCount (0 , $ this ->getHandlersForSignal ($ signalRegistry , \SIGUSR1 ), 'Pre-condition: Registry must be empty. ' );
2563+ $ tester ->run (['command ' => 'signal:outer ' ]);
2564+ $ this ->assertStringContainsString ('Outer pre-run.Inner execute.Outer post-run. ' , $ tester ->getDisplay ());
2565+
2566+ $ this ->assertCount (0 , $ this ->getHandlersForSignal ($ signalRegistry , \SIGUSR1 ), 'Registry must be empty after all commands are finished. ' );
2567+ }
2568+
24312569 #[RequiresPhpExtension('pcntl ' )]
24322570 public function testAlarmableCommandHandlerCalledAfterEventListener ()
24332571 {
@@ -2445,6 +2583,25 @@ public function testAlarmableCommandHandlerCalledAfterEventListener()
24452583 $ this ->assertSame ([AlarmEventSubscriber::class, AlarmableCommand::class], $ command ->signalHandlers );
24462584 }
24472585
2586+ #[RequiresPhpExtension('pcntl ' )]
2587+ public function testOriginalHandlerRestoredAfterPop ()
2588+ {
2589+ $ this ->assertSame (\SIG_DFL , pcntl_signal_get_handler (\SIGUSR1 ), 'Pre-condition: Original handler for SIGUSR1 must be SIG_DFL. ' );
2590+
2591+ $ application = new Application ();
2592+ $ application ->setAutoExit (false );
2593+ $ application ->setCatchExceptions (false );
2594+ $ application ->addCommand (new SignableCommand (false ));
2595+
2596+ $ tester = new ApplicationTester ($ application );
2597+ $ tester ->run (['command ' => 'signal ' ]);
2598+
2599+ $ this ->assertSame (\SIG_DFL , pcntl_signal_get_handler (\SIGUSR1 ), 'OS-level handler for SIGUSR1 must be restored to SIG_DFL. ' );
2600+
2601+ $ tester ->run (['command ' => 'signal ' ]);
2602+ $ this ->assertSame (\SIG_DFL , pcntl_signal_get_handler (\SIGUSR1 ), 'OS-level handler must remain SIG_DFL after a second run. ' );
2603+ }
2604+
24482605 #[RequiresPhpExtension('pcntl ' )]
24492606 #[TestWith([false ])]
24502607 #[TestWith([4 ])]
@@ -2491,18 +2648,6 @@ public function onAlarm(ConsoleAlarmEvent $event): void
24912648 $ this ->assertSame ([SignalEventSubscriber::class, AlarmEventSubscriber::class], $ command ->signalHandlers );
24922649 }
24932650
2494- private function createSignalableApplication (Command $ command , ?EventDispatcherInterface $ dispatcher ): Application
2495- {
2496- $ application = new Application ();
2497- $ application ->setAutoExit (false );
2498- if ($ dispatcher ) {
2499- $ application ->setDispatcher ($ dispatcher );
2500- }
2501- $ application ->addCommand (new LazyCommand ($ command ->getName (), [], '' , false , fn () => $ command , true ));
2502-
2503- return $ application ;
2504- }
2505-
25062651 public function testShellVerbosityIsRestoredAfterCommandExecutionWithInitialValue ()
25072652 {
25082653 // Set initial SHELL_VERBOSITY
@@ -2598,6 +2743,28 @@ public function testShellVerbosityDoesNotLeakBetweenCommandExecutions()
25982743 $ this ->assertArrayNotHasKey ('SHELL_VERBOSITY ' , $ _ENV );
25992744 $ this ->assertArrayNotHasKey ('SHELL_VERBOSITY ' , $ _SERVER );
26002745 }
2746+
2747+ /**
2748+ * Reads the private "signalHandlers" property of the SignalRegistry for assertions.
2749+ */
2750+ public function getHandlersForSignal (SignalRegistry $ registry , int $ signal ): array
2751+ {
2752+ $ handlers = (\Closure::bind (fn () => $ this ->signalHandlers , $ registry , SignalRegistry::class))();
2753+
2754+ return $ handlers [$ signal ] ?? [];
2755+ }
2756+
2757+ private function createSignalableApplication (Command $ command , ?EventDispatcherInterface $ dispatcher ): Application
2758+ {
2759+ $ application = new Application ();
2760+ $ application ->setAutoExit (false );
2761+ if ($ dispatcher ) {
2762+ $ application ->setDispatcher ($ dispatcher );
2763+ }
2764+ $ application ->addCommand (new LazyCommand ($ command ->getName (), [], '' , false , fn () => $ command , true ));
2765+
2766+ return $ application ;
2767+ }
26012768}
26022769
26032770class CustomApplication extends Application
0 commit comments