diff --git a/system/CLI/SignalTrait.php b/system/CLI/SignalTrait.php index 2bc69d51e93c..8ddba04f628c 100644 --- a/system/CLI/SignalTrait.php +++ b/system/CLI/SignalTrait.php @@ -18,11 +18,21 @@ /** * Signal Trait * - * Provides PCNTL signal handling capabilities for CLI commands. - * Requires the PCNTL extension (Unix only). + * Provides signal handling capabilities for CLI commands. + * + * On Unix it requires the PCNTL extension. On Windows, where PCNTL does not + * exist, only Ctrl+C and Ctrl+Break are supported, through + * `sapi_windows_set_ctrl_handler()`. Both are reported as SIGINT (2). */ trait SignalTrait { + /** + * The number of SIGINT on every platform that defines it. The SIG* + * constants come from the PCNTL extension, which Windows does not have. + * Ctrl+C and Ctrl+Break are reported to handlers as this value. + */ + private const INTERRUPT_SIGNAL = 2; + /** * Whether the process should continue running (false = termination requested). */ @@ -47,6 +57,11 @@ trait SignalTrait */ private array $signalMethodMap = []; + /** + * Whether the Windows console control handler is registered. + */ + private bool $windowsCtrlHandlerRegistered = false; + /** * Cached result of PCNTL extension availability. */ @@ -86,9 +101,38 @@ protected function isPosixAvailable(): bool return self::$isPosixAvailable; } + /** + * Check if the Windows console control handler is available. + * + * It is the Windows counterpart of PCNTL, and handles Ctrl+C and Ctrl+Break. + */ + protected function isWindowsCtrlHandlerAvailable(): bool + { + return is_windows() && function_exists('sapi_windows_set_ctrl_handler'); + } + + /** + * Add or remove the Windows console control handler. + * + * Wraps `sapi_windows_set_ctrl_handler()` so it can be replaced in tests, + * as the function is only available on Windows. + * + * @param callable(int): void $handler + */ + protected function setWindowsCtrlHandler(callable $handler, bool $add): bool + { + // Static analysis on other platforms does not know the function. + /** @psalm-suppress UndefinedFunction */ + return sapi_windows_set_ctrl_handler($handler, $add); // @codeCoverageIgnore + } + /** * Register signal handlers. * + * On Windows only Ctrl+C and Ctrl+Break can be handled. They are reported + * as SIGINT (2), and the SIG* constants are not available there, so call it + * without arguments or pass `2`. Any other signal is ignored. + * * @param list $signals List of signals to handle * @param array $methodMap Optional signal-to-method mapping */ @@ -97,6 +141,10 @@ protected function registerSignals( array $methodMap = [], ): void { if (! $this->isPcntlAvailable()) { + if ($this->isWindowsCtrlHandlerAvailable()) { + $this->registerWindowsCtrlHandler($signals, $methodMap); + } + return; } @@ -131,6 +179,46 @@ protected function registerSignals( } } + /** + * Register the handler for Ctrl+C and Ctrl+Break on Windows. + * + * @param list $signals List of signals to handle + * @param array $methodMap Optional signal-to-method mapping + */ + private function registerWindowsCtrlHandler(array $signals, array $methodMap): void + { + if ($signals !== [] && ! in_array(self::INTERRUPT_SIGNAL, $signals, true)) { + return; + } + + $this->signalMethodMap = $methodMap; + + if ($this->windowsCtrlHandlerRegistered) { + return; + } + + if ($this->setWindowsCtrlHandler([$this, 'handleWindowsCtrlEvent'], true)) { + $this->windowsCtrlHandlerRegistered = true; + $this->registeredSignals[] = self::INTERRUPT_SIGNAL; + } else { + CLI::write(lang('CLI.signals.failedSignal', [$this->getSignalName(self::INTERRUPT_SIGNAL)]), 'red'); + } + } + + /** + * Handle a Windows console control event (Ctrl+C or Ctrl+Break). + * + * Windows hands the handler no signal number, so the event is reported as SIGINT. + * Like on Unix, the process is no longer stopped by the event, and the command + * is expected to check `shouldTerminate()` and exit by itself. + */ + protected function handleWindowsCtrlEvent(int $event): void + { + $this->callCustomHandler(self::INTERRUPT_SIGNAL); + + $this->running = false; + } + /** * Handle incoming signals. */ @@ -302,6 +390,11 @@ protected function mapSignal(int $signal, string $method): void */ protected function getSignalName(int $signal): string { + // The SIG* constants come from PCNTL, which Windows does not have. + if (! $this->isPcntlAvailable()) { + return $signal === self::INTERRUPT_SIGNAL ? 'SIGINT' : "Signal {$signal}"; + } + return match ($signal) { SIGTERM => 'SIGTERM', SIGINT => 'SIGINT', @@ -322,6 +415,16 @@ protected function getSignalName(int $signal): string */ protected function unregisterSignals(): void { + if ($this->windowsCtrlHandlerRegistered) { + $this->setWindowsCtrlHandler([$this, 'handleWindowsCtrlEvent'], false); + + $this->windowsCtrlHandlerRegistered = false; + $this->registeredSignals = []; + $this->signalMethodMap = []; + + return; + } + if (! $this->isPcntlAvailable()) { return; } diff --git a/tests/_support/Commands/Legacy/SignalCommandWindows.php b/tests/_support/Commands/Legacy/SignalCommandWindows.php new file mode 100644 index 000000000000..bddc7aa4be0f --- /dev/null +++ b/tests/_support/Commands/Legacy/SignalCommandWindows.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Legacy; + +/** + * Mock command that simulates Windows: no PCNTL or POSIX extension, but a console + * control handler. The handler is recorded instead of being installed, + * as `sapi_windows_set_ctrl_handler()` only exists on Windows. + */ +class SignalCommandWindows extends SignalCommand +{ + /** + * Whether the simulated `sapi_windows_set_ctrl_handler()` succeeds. + */ + public bool $ctrlHandlerSucceeds = true; + + /** + * The handler the command tried to add (`true`) or remove (`false`), in order. + * + * @var list + */ + public array $ctrlHandlerCalls = []; + + protected function isPcntlAvailable(): bool + { + return false; + } + + protected function isPosixAvailable(): bool + { + return false; + } + + protected function isWindowsCtrlHandlerAvailable(): bool + { + return true; + } + + protected function setWindowsCtrlHandler(callable $handler, bool $add): bool + { + $this->ctrlHandlerCalls[] = ['handler' => $handler, 'add' => $add]; + + return $this->ctrlHandlerSucceeds; + } + + public function testHandleWindowsCtrlEvent(int $event): void + { + $this->handleWindowsCtrlEvent($event); + } +} diff --git a/tests/system/CLI/SignalWindowsTest.php b/tests/system/CLI/SignalWindowsTest.php new file mode 100644 index 000000000000..da107e79dfd3 --- /dev/null +++ b/tests/system/CLI/SignalWindowsTest.php @@ -0,0 +1,214 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Commands\Legacy\SignalCommandNoPcntl; +use Tests\Support\Commands\Legacy\SignalCommandWindows; + +/** + * Signal handling through the Windows console control handler. + * + * It does not need PCNTL, nor Windows: the handler is simulated, and the SIG* + * constants are not used, as they do not exist on Windows. + * + * @internal + */ +#[Group('Others')] +final class SignalWindowsTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + private const SIGINT_NUMBER = 2; + + private SignalCommandWindows $command; + + protected function setUp(): void + { + $this->resetServices(); + parent::setUp(); + + $this->command = new SignalCommandWindows(service('logger'), service('commands')); + } + + protected function tearDown(): void + { + CLI::reset(); + + parent::tearDown(); + } + + public function testRegisterAddsTheCtrlHandler(): void + { + $this->command->testRegisterSignals([]); + + $this->assertCount(1, $this->command->ctrlHandlerCalls); + $this->assertTrue($this->command->ctrlHandlerCalls[0]['add']); + $this->assertTrue($this->command->testHasSignals()); + $this->assertSame([self::SIGINT_NUMBER], $this->command->testGetSignals()); + } + + public function testRegisterWithTheInterruptSignal(): void + { + $this->command->testRegisterSignals([self::SIGINT_NUMBER], [self::SIGINT_NUMBER => 'testCustomHandler']); + + $this->assertCount(1, $this->command->ctrlHandlerCalls); + $this->assertSame([self::SIGINT_NUMBER], $this->command->testGetSignals()); + + $state = $this->command->testGetProcessState(); + $this->assertSame(1, $state['explicit_mappings']); + $this->assertSame(['SIGINT'], $state['registered_signals_names']); + } + + public function testRegisterIgnoresOtherSignals(): void + { + $this->command->testRegisterSignals([15, 1]); + + $this->assertSame([], $this->command->ctrlHandlerCalls); + $this->assertFalse($this->command->testHasSignals()); + } + + public function testRegisterTwiceAddsTheHandlerOnce(): void + { + $this->command->testRegisterSignals([]); + $this->command->testRegisterSignals([self::SIGINT_NUMBER]); + + $this->assertCount(1, $this->command->ctrlHandlerCalls); + $this->assertSame([self::SIGINT_NUMBER], $this->command->testGetSignals()); + } + + public function testRegisterReportsAFailureToAddTheHandler(): void + { + $this->resetStreamFilterBuffer(); + + $this->command->ctrlHandlerSucceeds = false; + $this->command->testRegisterSignals([]); + + $this->assertFalse($this->command->testHasSignals()); + $this->assertStringContainsString('SIGINT', $this->getStreamFilterBuffer()); + } + + public function testCtrlEventRequestsTermination(): void + { + $this->command->testRegisterSignals([]); + + $this->assertTrue($this->command->testIsRunning()); + + $this->command->testHandleWindowsCtrlEvent(0); + + $this->assertFalse($this->command->testIsRunning()); + $this->assertTrue($this->command->testShouldTerminate()); + } + + public function testCtrlBreakEventRequestsTermination(): void + { + $this->command->testRegisterSignals([]); + $this->command->testHandleWindowsCtrlEvent(1); + + $this->assertTrue($this->command->testShouldTerminate()); + } + + public function testCtrlEventCallsTheMappedHandler(): void + { + $this->command->testRegisterSignals([], [self::SIGINT_NUMBER => 'testCustomHandler']); + + $this->command->testHandleWindowsCtrlEvent(0); + + $this->assertTrue($this->command->customHandlerCalled); + $this->assertFalse($this->command->fallbackHandlerCalled); + $this->assertSame(self::SIGINT_NUMBER, $this->command->lastSignalReceived); + } + + public function testCtrlEventFallsBackToOnInterruption(): void + { + $this->command->testRegisterSignals([]); + + $this->command->testHandleWindowsCtrlEvent(0); + + $this->assertFalse($this->command->customHandlerCalled); + $this->assertTrue($this->command->fallbackHandlerCalled); + $this->assertSame(self::SIGINT_NUMBER, $this->command->lastSignalReceived); + } + + public function testUnregisterRemovesTheCtrlHandler(): void + { + $this->command->testRegisterSignals([], [self::SIGINT_NUMBER => 'testCustomHandler']); + $this->command->testUnregisterSignals(); + + $this->assertCount(2, $this->command->ctrlHandlerCalls); + $this->assertFalse($this->command->ctrlHandlerCalls[1]['add']); + $this->assertSame($this->command->ctrlHandlerCalls[0]['handler'], $this->command->ctrlHandlerCalls[1]['handler']); + $this->assertFalse($this->command->testHasSignals()); + $this->assertSame([], $this->command->testGetSignals()); + $this->assertSame(0, $this->command->testGetProcessState()['explicit_mappings']); + } + + public function testUnregisterWithoutRegisteringDoesNothing(): void + { + $this->command->testUnregisterSignals(); + + $this->assertSame([], $this->command->ctrlHandlerCalls); + } + + public function testRegisterAgainAfterUnregister(): void + { + $this->command->testRegisterSignals([]); + $this->command->testUnregisterSignals(); + $this->command->testRegisterSignals([]); + + $this->assertCount(3, $this->command->ctrlHandlerCalls); + $this->assertTrue($this->command->ctrlHandlerCalls[2]['add']); + $this->assertTrue($this->command->testHasSignals()); + } + + public function testBlockingSignalsIsANoOp(): void + { + $result = $this->command->testWithSignalsBlocked(function (): string { + $this->assertFalse($this->command->testSignalsBlocked()); + + return 'done'; + }); + + $this->assertSame('done', $result); + } + + public function testSignalNamesWithoutPcntl(): void + { + $this->assertSame('SIGINT', $this->command->testGetSignalName(self::SIGINT_NUMBER)); + $this->assertSame('Signal 15', $this->command->testGetSignalName(15)); + } + + public function testProcessStateOnWindows(): void + { + $this->command->testRegisterSignals([]); + + $state = $this->command->testGetProcessState(); + + $this->assertFalse($state['pcntl_available']); + $this->assertSame(1, $state['registered_signals']); + $this->assertArrayNotHasKey('session_id', $state); + } + + public function testNothingIsRegisteredWithoutAnyHandlerSupport(): void + { + // Neither PCNTL nor a console control handler: Unix without PCNTL, or Windows + // before sapi_windows_set_ctrl_handler() existed. + $command = new SignalCommandNoPcntl(service('logger'), service('commands')); + $command->testRegisterSignals([]); + + $this->assertFalse($command->testHasSignals()); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 3ed4ee70f8ca..4771a23eb343 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -270,6 +270,7 @@ Commands - Likewise, the ``command()`` function now also supports the above enhancements for command-line option parsing when using the function to run commands from code. - Added ``make:request`` generator command to scaffold :ref:`Form Request ` classes. - Added ``key:rotate`` command to demote the current ``encryption.key`` to ``encryption.previousKeys`` in **.env** and generate a new key. See :ref:`spark-key-rotate`. +- ``SignalTrait`` now handles Ctrl+C and Ctrl+Break on Windows, through ``sapi_windows_set_ctrl_handler()``, so a command can stop gracefully there too. The events are reported as ``SIGINT`` (``2``). See :ref:`cli-signals-windows`. - Added ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior output afterwards. See :ref:`modern-commands-call-silently`. - The output side of the ``CLI`` class now lives in instance classes, with ``CLI`` delegating to them: :php:class:`Output ` (``print()``, ``write()``, ``error()``, ``newLine()``, ``deferNewLine()``, ``beep()``, ``clearScreen()``, ``color()``, ``wrap()``), diff --git a/user_guide_src/source/cli/cli_signals.rst b/user_guide_src/source/cli/cli_signals.rst index b5f74a792f35..b535499520a2 100644 --- a/user_guide_src/source/cli/cli_signals.rst +++ b/user_guide_src/source/cli/cli_signals.rst @@ -51,12 +51,32 @@ System Requirements Signal handling requires: -* **Unix-based system** (Linux, macOS, BSD) - Windows is not supported +* **Unix-based system** (Linux, macOS, BSD) - see :ref:`cli-signals-windows` for Windows * **PCNTL extension** - for signal registration and handling * **POSIX extension** - required for pause/resume functionality (SIGTSTP/SIGCONT) .. note:: On systems without these extensions, the SignalTrait will gracefully degrade and disable signal handling. +.. _cli-signals-windows: + +Windows +------- + +Windows has no PCNTL extension and no Unix signals. Since v4.8.0, ``SignalTrait`` handles the one thing Windows can +deliver to a PHP script: the console control events produced by **Ctrl+C** and **Ctrl+Break**. They are handled through +PHP's ``sapi_windows_set_ctrl_handler()`` and reported to your command as ``SIGINT`` (``2``), so ``isRunning()``, +``shouldTerminate()``, custom handlers, and ``onInterruption()`` work as they do on Unix. + +* Call ``registerSignals()`` without arguments. The ``SIG*`` constants come from the PCNTL extension, which is not available on Windows. + If you pass a list, it must contain ``2``. Every other signal is ignored. +* To map the event to your own method, use ``2`` as the key: ``$this->registerSignals([], [2 => 'handleInterrupt']);``. +* ``withSignalsBlocked()`` runs the operation, but cannot block anything. The event does not stop the process by itself once a handler is + registered, so a critical section is still completed. Your handler runs when the event arrives. +* Pause and resume (``SIGTSTP`` / ``SIGCONT``), and every other signal, do not exist on Windows. + +.. important:: Once a handler is registered, Ctrl+C and Ctrl+Break no longer end the process. Your command must check + ``shouldTerminate()`` and return, as on Unix. + ********************* Using the SignalTrait ********************* @@ -235,7 +255,7 @@ Class Reference .. literalinclude:: cli_signals/007.php - .. note:: Requires the PCNTL extension. On Windows, signal handling is automatically disabled. + .. note:: Requires the PCNTL extension. On Windows, only Ctrl+C and Ctrl+Break are handled, as ``SIGINT`` (``2``). See :ref:`cli-signals-windows`. .. php:method:: isRunning()