From 8579accc173d66d35f87712f73228075713afe3c Mon Sep 17 00:00:00 2001 From: MD Ali Kadar Date: Sun, 1 Nov 2026 23:10:56 +0600 Subject: [PATCH 1/7] feat: handle Ctrl+C and Ctrl+Break in SignalTrait on Windows --- system/CLI/SignalTrait.php | 105 ++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/system/CLI/SignalTrait.php b/system/CLI/SignalTrait.php index 2bc69d51e93c..91feea7392fb 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,36 @@ 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 + { + return sapi_windows_set_ctrl_handler($handler, $add); + } + /** * 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 +139,10 @@ protected function registerSignals( array $methodMap = [], ): void { if (! $this->isPcntlAvailable()) { + if ($this->isWindowsCtrlHandlerAvailable()) { + $this->registerWindowsCtrlHandler($signals, $methodMap); + } + return; } @@ -131,6 +177,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 +388,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 +413,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; } From 441eaeb02f19877e10f6eb44c0bbd20581c24f06 Mon Sep 17 00:00:00 2001 From: MD Ali Kadar Date: Sun, 1 Nov 2026 23:11:11 +0600 Subject: [PATCH 2/7] test: cover SignalTrait console control handling on Windows --- .../Commands/Legacy/SignalCommandWindows.php | 61 +++++ tests/system/CLI/SignalWindowsTest.php | 214 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 tests/_support/Commands/Legacy/SignalCommandWindows.php create mode 100644 tests/system/CLI/SignalWindowsTest.php 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()); + } +} From dc86d70ed7a4626088b3ccd88ee1e5fa56d5bc20 Mon Sep 17 00:00:00 2001 From: MD Ali Kadar Date: Sun, 1 Nov 2026 23:11:39 +0600 Subject: [PATCH 3/7] docs: document SignalTrait support for Ctrl+C on Windows --- docs/changelogs/v4.8.0.md | 1 + docs/cli/cli_signals.md | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) 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() From 965afec51b9e618a9578233668f3232a58ad9ba1 Mon Sep 17 00:00:00 2001 From: MD Ali Kadar Date: Sun, 1 Nov 2026 23:28:46 +0600 Subject: [PATCH 4/7] fix: silence Psalm for the Windows-only sapi_windows_set_ctrl_handler() --- system/CLI/SignalTrait.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/system/CLI/SignalTrait.php b/system/CLI/SignalTrait.php index 91feea7392fb..8ddba04f628c 100644 --- a/system/CLI/SignalTrait.php +++ b/system/CLI/SignalTrait.php @@ -121,7 +121,9 @@ protected function isWindowsCtrlHandlerAvailable(): bool */ protected function setWindowsCtrlHandler(callable $handler, bool $add): bool { - return sapi_windows_set_ctrl_handler($handler, $add); + // Static analysis on other platforms does not know the function. + /** @psalm-suppress UndefinedFunction */ + return sapi_windows_set_ctrl_handler($handler, $add); // @codeCoverageIgnore } /** From 6d377004574fa42e02a21b1b441fa566c7158f0d Mon Sep 17 00:00:00 2001 From: MD Ali Kadar Date: Fri, 6 Nov 2026 22:29:04 +0600 Subject: [PATCH 5/7] fix: bind and release the Windows Ctrl handler callable in SignalTrait --- system/CLI/SignalTrait.php | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/system/CLI/SignalTrait.php b/system/CLI/SignalTrait.php index 8ddba04f628c..349931a8e1d3 100644 --- a/system/CLI/SignalTrait.php +++ b/system/CLI/SignalTrait.php @@ -108,7 +108,12 @@ protected function isPosixAvailable(): bool */ protected function isWindowsCtrlHandlerAvailable(): bool { - return is_windows() && function_exists('sapi_windows_set_ctrl_handler'); + // Every Windows build defines the function, but PHP throws an Error when + // it is called outside a console SAPI (`cli`, `cli-server`), for example + // by a command run through `command()` from a web request. + return is_windows() + && str_starts_with(PHP_SAPI, 'cli') + && function_exists('sapi_windows_set_ctrl_handler'); } /** @@ -117,9 +122,12 @@ protected function isWindowsCtrlHandlerAvailable(): bool * 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 + * Removing a handler leaves PHP holding the callable it was given. Passing + * `null` with `$add` set to `false` afterwards releases it. + * + * @param (callable(int): void)|null $handler */ - protected function setWindowsCtrlHandler(callable $handler, bool $add): bool + protected function setWindowsCtrlHandler(?callable $handler, bool $add): bool { // Static analysis on other platforms does not know the function. /** @psalm-suppress UndefinedFunction */ @@ -197,7 +205,9 @@ private function registerWindowsCtrlHandler(array $signals, array $methodMap): v return; } - if ($this->setWindowsCtrlHandler([$this, 'handleWindowsCtrlEvent'], true)) { + // PHP resolves a callable stored by the native API in the scope it interrupts, + // where the protected method may not be accessible. A Closure is bound already. + if ($this->setWindowsCtrlHandler($this->handleWindowsCtrlEvent(...), true)) { $this->windowsCtrlHandlerRegistered = true; $this->registeredSignals[] = self::INTERRUPT_SIGNAL; } else { @@ -416,7 +426,11 @@ protected function getSignalName(int $signal): string protected function unregisterSignals(): void { if ($this->windowsCtrlHandlerRegistered) { - $this->setWindowsCtrlHandler([$this, 'handleWindowsCtrlEvent'], false); + // Detaching needs a callable, which PHP then keeps, along with this object. + // Passing null afterwards releases it and leaves Ctrl+C at its default. + if ($this->setWindowsCtrlHandler($this->handleWindowsCtrlEvent(...), false)) { + $this->setWindowsCtrlHandler(null, false); + } $this->windowsCtrlHandlerRegistered = false; $this->registeredSignals = []; From c84885335872aca85170a36f13845641d72262ca Mon Sep 17 00:00:00 2001 From: MD Ali Kadar Date: Fri, 6 Nov 2026 22:29:22 +0600 Subject: [PATCH 6/7] test: cover the Windows Ctrl handler callable and its release --- .../Commands/Legacy/SignalCommandNoPcntl.php | 9 ++++ .../Commands/Legacy/SignalCommandWindows.php | 5 ++- tests/system/CLI/SignalWindowsTest.php | 43 +++++++++++++++++-- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/tests/_support/Commands/Legacy/SignalCommandNoPcntl.php b/tests/_support/Commands/Legacy/SignalCommandNoPcntl.php index 43a5d0f9e65d..b5ec5e49634e 100644 --- a/tests/_support/Commands/Legacy/SignalCommandNoPcntl.php +++ b/tests/_support/Commands/Legacy/SignalCommandNoPcntl.php @@ -25,4 +25,13 @@ protected function isPcntlAvailable(): bool { return false; } + + /** + * Override to simulate no Windows console control handler either, + * so that no native handler is installed when this runs on Windows. + */ + protected function isWindowsCtrlHandlerAvailable(): bool + { + return false; + } } diff --git a/tests/_support/Commands/Legacy/SignalCommandWindows.php b/tests/_support/Commands/Legacy/SignalCommandWindows.php index bddc7aa4be0f..10b6347b71c5 100644 --- a/tests/_support/Commands/Legacy/SignalCommandWindows.php +++ b/tests/_support/Commands/Legacy/SignalCommandWindows.php @@ -27,8 +27,9 @@ class SignalCommandWindows extends SignalCommand /** * The handler the command tried to add (`true`) or remove (`false`), in order. + * A `null` handler releases the callable PHP keeps after a removal. * - * @var list + * @var list */ public array $ctrlHandlerCalls = []; @@ -47,7 +48,7 @@ protected function isWindowsCtrlHandlerAvailable(): bool return true; } - protected function setWindowsCtrlHandler(callable $handler, bool $add): bool + protected function setWindowsCtrlHandler(?callable $handler, bool $add): bool { $this->ctrlHandlerCalls[] = ['handler' => $handler, 'add' => $add]; diff --git a/tests/system/CLI/SignalWindowsTest.php b/tests/system/CLI/SignalWindowsTest.php index da107e79dfd3..8241c947955b 100644 --- a/tests/system/CLI/SignalWindowsTest.php +++ b/tests/system/CLI/SignalWindowsTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\CLI; +use Closure; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; @@ -61,6 +62,21 @@ public function testRegisterAddsTheCtrlHandler(): void $this->assertSame([self::SIGINT_NUMBER], $this->command->testGetSignals()); } + public function testTheCtrlHandlerCanBeCalledFromAnyScope(): void + { + $this->command->testRegisterSignals([]); + + // PHP calls the handler from whichever scope it interrupts. A callable + // array naming the protected method would not be accessible from here. + $handler = $this->command->ctrlHandlerCalls[0]['handler']; + + $this->assertInstanceOf(Closure::class, $handler); + + $handler(0); + + $this->assertTrue($this->command->testShouldTerminate()); + } + public function testRegisterWithTheInterruptSignal(): void { $this->command->testRegisterSignals([self::SIGINT_NUMBER], [self::SIGINT_NUMBER => 'testCustomHandler']); @@ -148,14 +164,33 @@ public function testUnregisterRemovesTheCtrlHandler(): void $this->command->testRegisterSignals([], [self::SIGINT_NUMBER => 'testCustomHandler']); $this->command->testUnregisterSignals(); - $this->assertCount(2, $this->command->ctrlHandlerCalls); + $this->assertCount(3, $this->command->ctrlHandlerCalls); + + // The handler is detached... $this->assertFalse($this->command->ctrlHandlerCalls[1]['add']); - $this->assertSame($this->command->ctrlHandlerCalls[0]['handler'], $this->command->ctrlHandlerCalls[1]['handler']); + $this->assertInstanceOf(Closure::class, $this->command->ctrlHandlerCalls[1]['handler']); + + // ...then PHP is told to drop the callable it keeps, which would retain the command. + $this->assertFalse($this->command->ctrlHandlerCalls[2]['add']); + $this->assertNull($this->command->ctrlHandlerCalls[2]['handler']); + $this->assertFalse($this->command->testHasSignals()); $this->assertSame([], $this->command->testGetSignals()); $this->assertSame(0, $this->command->testGetProcessState()['explicit_mappings']); } + public function testUnregisterDoesNotReleaseTheCallableWhenRemovalFails(): void + { + $this->command->testRegisterSignals([]); + + $this->command->ctrlHandlerSucceeds = false; + $this->command->testUnregisterSignals(); + + $this->assertCount(2, $this->command->ctrlHandlerCalls); + $this->assertFalse($this->command->ctrlHandlerCalls[1]['add']); + $this->assertNotNull($this->command->ctrlHandlerCalls[1]['handler']); + } + public function testUnregisterWithoutRegisteringDoesNothing(): void { $this->command->testUnregisterSignals(); @@ -169,8 +204,8 @@ public function testRegisterAgainAfterUnregister(): void $this->command->testUnregisterSignals(); $this->command->testRegisterSignals([]); - $this->assertCount(3, $this->command->ctrlHandlerCalls); - $this->assertTrue($this->command->ctrlHandlerCalls[2]['add']); + $this->assertCount(4, $this->command->ctrlHandlerCalls); + $this->assertTrue($this->command->ctrlHandlerCalls[3]['add']); $this->assertTrue($this->command->testHasSignals()); } From 426d125c2b46d520867dac77849e6d6fac31a55a Mon Sep 17 00:00:00 2001 From: MD Ali Kadar Date: Fri, 6 Nov 2026 22:29:38 +0600 Subject: [PATCH 7/7] docs: note that the Windows Ctrl handler needs a console --- docs/cli/cli_signals.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/cli/cli_signals.md b/docs/cli/cli_signals.md index e87817137848..031ff0d8ac63 100644 --- a/docs/cli/cli_signals.md +++ b/docs/cli/cli_signals.md @@ -51,6 +51,7 @@ Windows has no PCNTL extension and no Unix signals. Since v4.8.0, `SignalTrait` - 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. +- PHP only accepts the handler in a console. When a command runs inside a web request, for example through `command()` in a controller, `registerSignals()` does nothing. !!! 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.