diff --git a/docs/changelogs/v4.8.0.md b/docs/changelogs/v4.8.0.md index 74ab79e58e5d..01a52c086665 100644 --- a/docs/changelogs/v4.8.0.md +++ b/docs/changelogs/v4.8.0.md @@ -223,6 +223,7 @@ Release Date: Unreleased - 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 [Form Request](../incoming/form_requests.md#form-requests) classes. - Added `key:rotate` command to demote the current `encryption.key` to `encryption.previousKeys` in **.env** and generate a new key. See [Rotating with the `key:rotate` Command](../libraries/encryption.md#rotating-with-the-keyrotate-command). +- `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 [Windows](../cli/cli_signals.md#windows). - Added `AbstractCommand::callSilently()` to invoke another command with its output discarded, restoring the prior output afterwards. See [Calling Silently](../cli/cli_modern_commands.md#calling-silently). - The output side of the `CLI` class now lives in instance classes, with `CLI` delegating to them: `Output` (`print()`, `write()`, `error()`, `newLine()`, `deferNewLine()`, `beep()`, `clearScreen()`, `color()`, `wrap()`), `Terminal` (dimensions and color support), `ProgressBar`, `Table`, and `Color` (ANSI codes and `Color::width()`). `CLI::getOutput()` and `CLI::getTerminal()` return the shared instances. See [The Classes Behind the Facade](../cli/cli_library.md#the-classes-behind-the-facade). - Modern commands can write through `$this->getOutput()`, the same `Output` used by the static `CLI` methods. `AbstractCommand::run()` and `Commands::runCommand()` accept an `Output` to run against, which `Console` passes down and sub-commands inherit. See [Writing Output](../cli/cli_modern_commands.md#writing-output). diff --git a/docs/cli/cli_signals.md b/docs/cli/cli_signals.md index 8187bf3410bc..e87817137848 100644 --- a/docs/cli/cli_signals.md +++ b/docs/cli/cli_signals.md @@ -36,13 +36,25 @@ These signals are handled directly by the kernel and will terminate or suspend y Signal handling requires: -- **Unix-based system** (Linux, macOS, BSD) - Windows is not supported +- **Unix-based system** (Linux, macOS, BSD) - see [Windows](#windows) for Windows - **PCNTL extension** - for signal registration and handling - **POSIX extension** - required for pause/resume functionality (SIGTSTP/SIGCONT) !!! note "Note" On systems without these extensions, the SignalTrait will gracefully degrade and disable signal handling. +### 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 "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 The `SignalTrait` provides a comprehensive signal handling system for CLI commands. To use it, simply add the trait to your command class and register signals in your command's `run()` method: @@ -491,7 +503,7 @@ $this->registerSignals( ``` !!! note "Note" - Requires the PCNTL extension. On Windows, signal handling is automatically disabled. + Requires the PCNTL extension. On Windows, only Ctrl+C and Ctrl+Break are handled, as `SIGINT` (`2`). See [Windows](#windows). #### isRunning() 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()); + } +}