diff --git a/admin/framework/composer.json b/admin/framework/composer.json index aef544f51126..44b42109f824 100644 --- a/admin/framework/composer.json +++ b/admin/framework/composer.json @@ -38,7 +38,9 @@ "ext-memcached": "If you use Cache class MemcachedHandler with Memcached", "ext-mysqli": "If you use MySQL", "ext-oci8": "If you use Oracle Database", + "ext-pcntl": "If you use Signals", "ext-pgsql": "If you use PostgreSQL", + "ext-posix": "If you use Signals", "ext-readline": "Improves CLI::input() usability", "ext-redis": "If you use Cache class RedisHandler", "ext-simplexml": "If you format XML", diff --git a/composer.json b/composer.json index a4692b49c9dc..e0b250aae310 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,9 @@ "ext-memcached": "If you use Cache class MemcachedHandler with Memcached", "ext-mysqli": "If you use MySQL", "ext-oci8": "If you use Oracle Database", + "ext-pcntl": "If you use Signals", "ext-pgsql": "If you use PostgreSQL", + "ext-posix": "If you use Signals", "ext-readline": "Improves CLI::input() usability", "ext-redis": "If you use Cache class RedisHandler", "ext-simplexml": "If you format XML", diff --git a/psalm-autoload.php b/psalm-autoload.php index 852ef4d6aab3..3eede20f1252 100644 --- a/psalm-autoload.php +++ b/psalm-autoload.php @@ -19,6 +19,8 @@ ]; foreach ($directories as $directory) { + $filesToLoad = []; + $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $directory, @@ -45,6 +47,13 @@ continue; } - require_once $file->getPathname(); + $filesToLoad[] = $file->getPathname(); + } + + // Sort files to ensure consistent loading order across operating systems + sort($filesToLoad); + + foreach ($filesToLoad as $file) { + require_once $file; } } diff --git a/system/CLI/SignalTrait.php b/system/CLI/SignalTrait.php new file mode 100644 index 000000000000..92eb2621df32 --- /dev/null +++ b/system/CLI/SignalTrait.php @@ -0,0 +1,400 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use Closure; + +/** + * Signal Trait + * + * Provides PCNTL signal handling capabilities for CLI commands. + * Requires the PCNTL extension (Unix only). + */ +trait SignalTrait +{ + /** + * Whether the process should continue running (false = termination requested). + */ + private bool $running = true; + + /** + * Whether signals are currently blocked. + */ + private bool $signalsBlocked = false; + + /** + * Array of registered signals. + * + * @var list + */ + private array $registeredSignals = []; + + /** + * Signal-to-method mapping. + * + * @var array + */ + private array $signalMethodMap = []; + + /** + * Cached result of PCNTL extension availability. + */ + private static ?bool $isPcntlAvailable = null; + + /** + * Cached result of POSIX extension availability. + */ + private static ?bool $isPosixAvailable = null; + + /** + * Check if PCNTL extension is available (cached). + */ + protected function isPcntlAvailable(): bool + { + if (self::$isPcntlAvailable === null) { + if (is_windows()) { + self::$isPcntlAvailable = false; + } else { + self::$isPcntlAvailable = extension_loaded('pcntl'); + if (! self::$isPcntlAvailable) { + CLI::write(lang('CLI.signals.noPcntlExtension'), 'yellow'); + } + } + } + + return self::$isPcntlAvailable; + } + + /** + * Check if POSIX extension is available (cached). + */ + protected function isPosixAvailable(): bool + { + if (self::$isPosixAvailable === null) { + self::$isPosixAvailable = is_windows() ? false : extension_loaded('posix'); + } + + return self::$isPosixAvailable; + } + + /** + * Register signal handlers. + * + * @param list $signals List of signals to handle + * @param array $methodMap Optional signal-to-method mapping + */ + protected function registerSignals( + array $signals = [SIGTERM, SIGINT, SIGHUP, SIGQUIT], + array $methodMap = [], + ): void { + if (! $this->isPcntlAvailable()) { + return; + } + + if (! $this->isPosixAvailable() && (in_array(SIGTSTP, $signals, true) || in_array(SIGCONT, $signals, true))) { + CLI::write(lang('CLI.signals.noPosixExtension'), 'yellow'); + $signals = array_diff($signals, [SIGTSTP, SIGCONT]); + + // Remove from method map as well + unset($methodMap[SIGTSTP], $methodMap[SIGCONT]); + + if ($signals === []) { + return; + } + } + + // Enable async signals for immediate response + pcntl_async_signals(true); + + $this->signalMethodMap = $methodMap; + + foreach ($signals as $signal) { + if (pcntl_signal($signal, [$this, 'handleSignal'])) { + $this->registeredSignals[] = $signal; + } else { + $signal = $this->getSignalName($signal); + CLI::write(lang('CLI.signals.failedSignal', [$signal]), 'red'); + } + } + } + + /** + * Handle incoming signals. + */ + protected function handleSignal(int $signal): void + { + $this->callCustomHandler($signal); + + // Apply standard Unix signal behavior for registered signals + switch ($signal) { + case SIGTERM: + case SIGINT: + case SIGQUIT: + case SIGHUP: + $this->running = false; + break; + + case SIGTSTP: + // Restore default handler and re-send signal to actually suspend + pcntl_signal(SIGTSTP, SIG_DFL); + posix_kill(posix_getpid(), SIGTSTP); + break; + + case SIGCONT: + // Re-register SIGTSTP handler after resume + pcntl_signal(SIGTSTP, [$this, 'handleSignal']); + break; + } + } + + /** + * Call custom signal handler if one is mapped for this signal. + * Falls back to generic onInterruption() method if no explicit mapping exists. + */ + private function callCustomHandler(int $signal): void + { + // Check for explicit mapping first + $method = $this->signalMethodMap[$signal] ?? null; + + if ($method !== null && method_exists($this, $method)) { + $this->{$method}($signal); + + return; + } + + // If no explicit mapping, try generic catch-all method + if (method_exists($this, 'onInterruption')) { + $this->onInterruption($signal); + } + } + + /** + * Check if command should terminate. + */ + protected function shouldTerminate(): bool + { + return ! $this->running; + } + + /** + * Check if the process is currently running (not terminated). + */ + protected function isRunning(): bool + { + return $this->running; + } + + /** + * Request immediate termination. + */ + protected function requestTermination(): void + { + $this->running = false; + } + + /** + * Reset all states (for testing or restart scenarios). + */ + protected function resetState(): void + { + $this->running = true; + + // Unblock signals if they were blocked + if ($this->signalsBlocked) { + $this->unblockSignals(); + } + } + + /** + * Execute a callable with ALL signals blocked to prevent ANY interruption during critical operations. + * + * This blocks ALL interruptible signals including: + * - Termination signals (SIGTERM, SIGINT, etc.) + * - Pause/resume signals (SIGTSTP, SIGCONT) + * - Custom signals (SIGUSR1, SIGUSR2) + * + * Only SIGKILL (unblockable) can still terminate the process. + * Use this for database transactions, file operations, or any critical atomic operations. + * + * @template TReturn + * + * @param Closure():TReturn $operation + * + * @return TReturn + */ + protected function withSignalsBlocked(Closure $operation) + { + $this->blockSignals(); + + try { + return $operation(); + } finally { + $this->unblockSignals(); + } + } + + /** + * Block ALL interruptible signals during critical sections. + * Only SIGKILL (unblockable) can terminate the process. + */ + protected function blockSignals(): void + { + if (! $this->signalsBlocked && $this->isPcntlAvailable()) { + // Block ALL signals that could interrupt critical operations + pcntl_sigprocmask(SIG_BLOCK, [ + SIGTERM, SIGINT, SIGHUP, SIGQUIT, // Termination signals + SIGTSTP, SIGCONT, // Pause/resume signals + SIGUSR1, SIGUSR2, // Custom signals + SIGPIPE, SIGALRM, // Other common signals + ]); + $this->signalsBlocked = true; + } + } + + /** + * Unblock previously blocked signals. + */ + protected function unblockSignals(): void + { + if ($this->signalsBlocked && $this->isPcntlAvailable()) { + // Unblock the same signals we blocked + pcntl_sigprocmask(SIG_UNBLOCK, [ + SIGTERM, SIGINT, SIGHUP, SIGQUIT, // Termination signals + SIGTSTP, SIGCONT, // Pause/resume signals + SIGUSR1, SIGUSR2, // Custom signals + SIGPIPE, SIGALRM, // Other common signals + ]); + $this->signalsBlocked = false; + } + } + + /** + * Check if signals are currently blocked. + */ + protected function signalsBlocked(): bool + { + return $this->signalsBlocked; + } + + /** + * Add or update signal-to-method mapping at runtime. + */ + protected function mapSignal(int $signal, string $method): void + { + $this->signalMethodMap[$signal] = $method; + } + + /** + * Get human-readable signal name. + */ + protected function getSignalName(int $signal): string + { + return match ($signal) { + SIGTERM => 'SIGTERM', + SIGINT => 'SIGINT', + SIGHUP => 'SIGHUP', + SIGQUIT => 'SIGQUIT', + SIGUSR1 => 'SIGUSR1', + SIGUSR2 => 'SIGUSR2', + SIGPIPE => 'SIGPIPE', + SIGALRM => 'SIGALRM', + SIGTSTP => 'SIGTSTP', + SIGCONT => 'SIGCONT', + default => "Signal {$signal}", + }; + } + + /** + * Unregister all signals (cleanup). + */ + protected function unregisterSignals(): void + { + if (! $this->isPcntlAvailable()) { + return; + } + + foreach ($this->registeredSignals as $signal) { + pcntl_signal($signal, SIG_DFL); + } + + $this->registeredSignals = []; + $this->signalMethodMap = []; + } + + /** + * Check if signals are registered. + */ + protected function hasSignals(): bool + { + return $this->registeredSignals !== []; + } + + /** + * Get list of registered signals. + * + * @return list + */ + protected function getSignals(): array + { + return $this->registeredSignals; + } + + /** + * Get comprehensive process state information. + * + * @return array{ + * pid: int, + * running: bool, + * pcntl_available: bool, + * registered_signals: int, + * registered_signals_names: array, + * signals_blocked: bool, + * explicit_mappings: int, + * memory_usage_mb: float, + * memory_peak_mb: float, + * session_id?: false|int, + * process_group?: false|int, + * has_controlling_terminal?: bool + * } + */ + protected function getProcessState(): array + { + $pid = getmypid(); + $state = [ + // Process identification + 'pid' => $pid, + 'running' => $this->running, + + // Signal handling status + 'pcntl_available' => $this->isPcntlAvailable(), + 'registered_signals' => count($this->registeredSignals), + 'registered_signals_names' => array_map([$this, 'getSignalName'], $this->registeredSignals), + 'signals_blocked' => $this->signalsBlocked, + 'explicit_mappings' => count($this->signalMethodMap), + + // System resources + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2), + ]; + + // Add terminal control info if POSIX extension is available + if ($this->isPosixAvailable()) { + $state['session_id'] = posix_getsid($pid); + $state['process_group'] = posix_getpgid($pid); + $state['has_controlling_terminal'] = posix_isatty(STDIN); + } + + return $state; + } +} diff --git a/system/Commands/Database/Migrate.php b/system/Commands/Database/Migrate.php index b422e4a6db73..9be1a894d4e1 100644 --- a/system/Commands/Database/Migrate.php +++ b/system/Commands/Database/Migrate.php @@ -15,6 +15,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\SignalTrait; use Throwable; /** @@ -22,6 +23,8 @@ */ class Migrate extends BaseCommand { + use SignalTrait; + /** * The group the command is lumped under * when listing commands. @@ -82,9 +85,11 @@ public function run(array $params) $runner->setNamespace($namespace); } - if (! $runner->latest($group)) { - CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore - } + $this->withSignalsBlocked(static function () use ($runner, $group): void { + if (! $runner->latest($group)) { + CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore + } + }); $messages = $runner->getCliMessages(); diff --git a/system/Commands/Database/MigrateRefresh.php b/system/Commands/Database/MigrateRefresh.php index e5e8a6d967f9..b7863a001438 100644 --- a/system/Commands/Database/MigrateRefresh.php +++ b/system/Commands/Database/MigrateRefresh.php @@ -15,6 +15,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\SignalTrait; /** * Does a rollback followed by a latest to refresh the current state @@ -22,6 +23,8 @@ */ class MigrateRefresh extends BaseCommand { + use SignalTrait; + /** * The group the command is lumped under * when listing commands. @@ -83,7 +86,9 @@ public function run(array $params) // @codeCoverageIgnoreEnd } - $this->call('migrate:rollback', $params); - $this->call('migrate', $params); + $this->withSignalsBlocked(function () use ($params): void { + $this->call('migrate:rollback', $params); + $this->call('migrate', $params); + }); } } diff --git a/system/Commands/Database/MigrateRollback.php b/system/Commands/Database/MigrateRollback.php index cb6cbe3a7adb..5fa6ac171bfb 100644 --- a/system/Commands/Database/MigrateRollback.php +++ b/system/Commands/Database/MigrateRollback.php @@ -15,6 +15,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\SignalTrait; use CodeIgniter\Database\MigrationRunner; use Throwable; @@ -24,6 +25,8 @@ */ class MigrateRollback extends BaseCommand { + use SignalTrait; + /** * The group the command is lumped under * when listing commands. @@ -98,9 +101,11 @@ public function run(array $params) CLI::write(lang('Migrations.rollingBack') . ' ' . $batch, 'yellow'); - if (! $runner->regress($batch)) { - CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore - } + $this->withSignalsBlocked(static function () use ($runner, $batch): void { + if (! $runner->regress($batch)) { + CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore + } + }); $messages = $runner->getCliMessages(); diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index f636fb2131d4..247cd0158331 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -52,4 +52,9 @@ 'helpUsage' => 'Usage:', 'invalidColor' => 'Invalid "{0}" color: "{1}".', 'namespaceNotDefined' => 'Namespace "{0}" is not defined.', + 'signals' => [ + 'noPcntlExtension' => 'PCNTL extension not available. Signal handling disabled.', + 'noPosixExtension' => 'SIGTSTP/SIGCONT handling requires POSIX extension. These signals will be removed from registration.', + 'failedSignal' => 'Failed to register handler for signal: "{0}".', + ], ]; diff --git a/tests/_support/Commands/SignalCommand.php b/tests/_support/Commands/SignalCommand.php new file mode 100644 index 000000000000..219f76a48296 --- /dev/null +++ b/tests/_support/Commands/SignalCommand.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\SignalTrait; + +/** + * Mock command class that uses SignalTrait for testing + */ +class SignalCommand extends BaseCommand +{ + use SignalTrait; + + protected $name = 'test:signal'; + protected $description = 'Test signal handling'; + public bool $customHandlerCalled = false; + public bool $fallbackHandlerCalled = false; + public ?int $lastSignalReceived = null; + + public function run(array $params): int + { + return 0; + } + + // Test method to trigger custom handler + public function testCustomHandler(int $signal): void + { + $this->customHandlerCalled = true; + $this->lastSignalReceived = $signal; + } + + // Fallback handler for testing + public function onInterruption(int $signal): void + { + $this->fallbackHandlerCalled = true; + $this->lastSignalReceived = $signal; + } + + // Public test methods to access protected trait methods + public function testRegisterSignals(array $signals, array $methodMap = []): void + { + $this->registerSignals($signals, $methodMap); + } + + public function testCallSignalHandler(int $signal): void + { + $this->handleSignal($signal); + } + + public function testIsRunning(): bool + { + return $this->isRunning(); + } + + public function testShouldTerminate(): bool + { + return $this->shouldTerminate(); + } + + public function testRequestTermination(): void + { + $this->requestTermination(); + } + + public function testResetState(): void + { + $this->resetState(); + } + + public function testWithSignalsBlocked(callable $operation) + { + return $this->withSignalsBlocked($operation); + } + + public function testSignalsBlocked(): bool + { + return $this->signalsBlocked(); + } + + public function testMapSignal(int $signal, string $method): void + { + $this->mapSignal($signal, $method); + } + + public function testGetSignalName(int $signal): string + { + return $this->getSignalName($signal); + } + + public function testUnregisterSignals(): void + { + $this->unregisterSignals(); + } + + public function testHasSignals(): bool + { + return $this->hasSignals(); + } + + public function testGetSignals(): array + { + return $this->getSignals(); + } + + public function testGetProcessState(): array + { + return $this->getProcessState(); + } +} diff --git a/tests/_support/Commands/SignalCommandNoPcntl.php b/tests/_support/Commands/SignalCommandNoPcntl.php new file mode 100644 index 000000000000..2903c714afb3 --- /dev/null +++ b/tests/_support/Commands/SignalCommandNoPcntl.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands; + +/** + * Mock command that simulates missing PCNTL extension + */ +class SignalCommandNoPcntl extends SignalCommand +{ + /** + * Override to simulate PCNTL not being available + */ + protected function isPcntlAvailable(): bool + { + return false; + } +} diff --git a/tests/_support/Commands/SignalCommandNoPosix.php b/tests/_support/Commands/SignalCommandNoPosix.php new file mode 100644 index 000000000000..02ce3a1fcefd --- /dev/null +++ b/tests/_support/Commands/SignalCommandNoPosix.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands; + +/** + * Mock command that simulates missing POSIX extension + */ +class SignalCommandNoPosix extends SignalCommand +{ + /** + * Override to simulate POSIX not being available + */ + protected function isPosixAvailable(): bool + { + return false; + } +} diff --git a/tests/system/CLI/SignalTest.php b/tests/system/CLI/SignalTest.php new file mode 100644 index 000000000000..4c1044d1e8e3 --- /dev/null +++ b/tests/system/CLI/SignalTest.php @@ -0,0 +1,229 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Log\Logger; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Commands\SignalCommand; +use Tests\Support\Commands\SignalCommandNoPcntl; +use Tests\Support\Commands\SignalCommandNoPosix; + +/** + * @internal + */ +#[Group('Others')] +final class SignalTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + private SignalCommand $command; + private Logger $logger; + + protected function setUp(): void + { + if (is_windows()) { + $this->markTestSkipped('Signal handling is not supported on Windows.'); + } + + if (! extension_loaded('pcntl')) { + $this->markTestSkipped('PCNTL extension is required for signal handling tests.'); + } + + if (! extension_loaded('posix')) { + $this->markTestSkipped('POSIX extension is required for signal handling tests.'); + } + + $this->resetServices(); + parent::setUp(); + + $this->logger = service('logger'); + $this->command = new SignalCommand($this->logger, service('commands')); + } + + public function testSignalRegistration(): void + { + $this->command->testRegisterSignals([SIGTERM, SIGINT], [SIGTERM => 'customTermHandler']); + + $this->assertTrue($this->command->testHasSignals()); + $this->assertSame([SIGTERM, SIGINT], $this->command->testGetSignals()); + + $state = $this->command->testGetProcessState(); + $this->assertSame(2, $state['registered_signals']); + $this->assertSame(['SIGTERM', 'SIGINT'], $state['registered_signals_names']); + $this->assertSame(1, $state['explicit_mappings']); + } + + public function testSignalRegistrationWithoutPcntl(): void + { + $command = new SignalCommandNoPcntl($this->logger, service('commands')); + + $command->testRegisterSignals([SIGTERM, SIGINT]); + + $this->assertFalse($command->testHasSignals()); + $this->assertSame([], $command->testGetSignals()); + } + + public function testSignalRegistrationFiltersPosixDependentSignals(): void + { + $this->resetStreamFilterBuffer(); + + $commandNoPosix = new SignalCommandNoPosix($this->logger, service('commands')); + + $commandNoPosix->testRegisterSignals([SIGTERM, SIGTSTP, SIGCONT], [SIGTSTP => 'onPause']); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString( + 'SIGTSTP/SIGCONT handling requires POSIX extension', + $output, + ); + + $this->assertSame([SIGTERM], $commandNoPosix->testGetSignals()); + } + + public function testProcessState(): void + { + $this->command->testRegisterSignals([SIGTERM, SIGINT, SIGUSR1]); + + $state = $this->command->testGetProcessState(); + + // Process identification + $this->assertArrayHasKey('pid', $state); + $this->assertIsInt($state['pid']); + $this->assertTrue($state['running']); + + // Signal handling status + $this->assertTrue($state['pcntl_available']); + $this->assertSame(3, $state['registered_signals']); + $this->assertSame(['SIGTERM', 'SIGINT', 'SIGUSR1'], $state['registered_signals_names']); + $this->assertFalse($state['signals_blocked']); + $this->assertSame(0, $state['explicit_mappings']); + + // System resources + $this->assertArrayHasKey('memory_usage_mb', $state); + $this->assertArrayHasKey('memory_peak_mb', $state); + $this->assertIsFloat($state['memory_usage_mb']); + $this->assertIsFloat($state['memory_peak_mb']); + } + + public function testProcessStateIncludesPosixInfo(): void + { + $state = $this->command->testGetProcessState(); + + $this->assertArrayHasKey('session_id', $state); + $this->assertArrayHasKey('process_group', $state); + $this->assertArrayHasKey('has_controlling_terminal', $state); + + $this->assertIsInt($state['session_id']); + $this->assertIsInt($state['process_group']); + $this->assertIsBool($state['has_controlling_terminal']); + } + + public function testRunningState(): void + { + $this->assertTrue($this->command->testIsRunning()); + $this->assertFalse($this->command->testShouldTerminate()); + + $this->command->testRequestTermination(); + + $this->assertFalse($this->command->testIsRunning()); + $this->assertTrue($this->command->testShouldTerminate()); + } + + public function testSignalBlocking(): void + { + $this->assertFalse($this->command->testSignalsBlocked()); + + $result = $this->command->testWithSignalsBlocked(function (): string { + $this->assertTrue($this->command->testSignalsBlocked()); + + return 'test_result'; + }); + + $this->assertSame('test_result', $result); + $this->assertFalse($this->command->testSignalsBlocked()); + } + + public function testSignalMethodMapping(): void + { + $this->command->testMapSignal(SIGUSR1, 'customHandler'); + + $state = $this->command->testGetProcessState(); + $this->assertSame(1, $state['explicit_mappings']); + } + + public function testResetState(): void + { + $this->command->testRequestTermination(); + $this->command->testWithSignalsBlocked(static fn (): bool => true); + + $this->assertTrue($this->command->testShouldTerminate()); + + $this->command->testResetState(); + + $this->assertFalse($this->command->testShouldTerminate()); + $this->assertFalse($this->command->testSignalsBlocked()); + } + + public function testSignalNameGeneration(): void + { + $this->assertSame('SIGTERM', $this->command->testGetSignalName(SIGTERM)); + $this->assertSame('SIGINT', $this->command->testGetSignalName(SIGINT)); + $this->assertSame('SIGUSR1', $this->command->testGetSignalName(SIGUSR1)); + $this->assertSame('Signal 999', $this->command->testGetSignalName(999)); + } + + public function testUnregisterSignals(): void + { + $this->command->testRegisterSignals([SIGTERM, SIGINT]); + $this->assertTrue($this->command->testHasSignals()); + + $this->command->testUnregisterSignals(); + $this->assertFalse($this->command->testHasSignals()); + $this->assertSame([], $this->command->testGetSignals()); + } + + public function testCustomSignalHandlerCall(): void + { + $this->command->testRegisterSignals([SIGTERM], [SIGTERM => 'testCustomHandler']); + + $this->command->testCallSignalHandler(SIGTERM); + + $this->assertTrue($this->command->customHandlerCalled); + $this->assertSame(SIGTERM, $this->command->lastSignalReceived); + } + + public function testFallbackSignalHandlerCall(): void + { + $this->command->testRegisterSignals([SIGINT]); // No explicit mapping + + $this->command->testCallSignalHandler(SIGINT); + + $this->assertTrue($this->command->fallbackHandlerCalled); + $this->assertSame(SIGINT, $this->command->lastSignalReceived); + } + + public function testSignalHandlerUpdatesRunningState(): void + { + $this->command->testRegisterSignals([SIGTERM]); + + $this->assertTrue($this->command->testIsRunning()); + + $this->command->testCallSignalHandler(SIGTERM); + + $this->assertFalse($this->command->testIsRunning()); + $this->assertTrue($this->command->testShouldTerminate()); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 7defca8f03e7..78eecb7fe7f5 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -53,6 +53,7 @@ Enhancements Libraries ========= +- **CLI:** Added ``SignalTrait`` to provide unified handling of operating system signals in CLI commands. - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. - **CURLRequest:** Added ``fresh_connect`` options to enable/disabled request fresh connection. @@ -99,7 +100,7 @@ Others Message Changes *************** -- Added ``Email.invalidSMTPAuthMethod`` and ``Email.failureSMTPAuthMethod`` +- Added ``Email.invalidSMTPAuthMethod``, ``Email.failureSMTPAuthMethod``, ``CLI.signals.noPcntlExtension``, ``CLI.signals.noPosixExtension`` and ``CLI.signals.failedSignal``. - Deprecated ``Email.failedSMTPLogin`` and ``Image.libPathInvalid`` ******* diff --git a/user_guide_src/source/cli/cli_signals.rst b/user_guide_src/source/cli/cli_signals.rst new file mode 100644 index 000000000000..b5f74a792f35 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals.rst @@ -0,0 +1,341 @@ +########### +CLI Signals +########### + +Unix signals are a fundamental part of process communication and control. They provide a way to interrupt, control, +and communicate with running processes. CodeIgniter's SignalTrait makes it easy to handle signals in your CLI commands, +enabling graceful shutdowns, pause/resume functionality, and custom signal handling. + +.. contents:: + :local: + :depth: 2 + +***************** +What are Signals? +***************** + +Signals are software interrupts delivered to a process by the operating system. They notify processes of various +events, from user-initiated interruptions (like pressing Ctrl+C) to system events (like terminal disconnection). + +``SignalTrait`` adds the ability to perform certain actions before the signal is consumed, as well as the capability +to protect certain pieces of code from signal interruption. This protection mechanism guarantees the proper execution +of critical command operations by ensuring they complete atomically without being interrupted by incoming signals. + +Common Unix Signals +=================== + +Here are the most commonly used signals in CLI applications: + +**Handleable Signals:** + +* **SIGTERM (15)**: Termination signal - requests graceful shutdown +* **SIGINT (2)**: Interrupt signal - typically sent by Ctrl+C +* **SIGHUP (1)**: Hangup signal - terminal disconnected or closed +* **SIGQUIT (3)**: Quit signal - typically sent by Ctrl+\\ +* **SIGTSTP (20)**: Terminal stop - typically sent by Ctrl+Z (suspend) +* **SIGCONT (18)**: Continue signal - resume suspended process (fg command) +* **SIGUSR1 (10)**: User-defined signal 1 +* **SIGUSR2 (12)**: User-defined signal 2 + +**Unhandleable Signals:** + +Some signals cannot be caught, blocked, or handled by user processes: + +* **SIGKILL (9)**: Forceful termination - cannot be caught or ignored +* **SIGSTOP (19)**: Forceful suspend - cannot be caught or ignored + +These signals are handled directly by the kernel and will terminate or suspend your process immediately, bypassing any custom handlers. + +System Requirements +=================== + +Signal handling requires: + +* **Unix-based system** (Linux, macOS, BSD) - Windows is not supported +* **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. + +********************* +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: + +.. literalinclude:: cli_signals/001.php + +This registers three termination signals that will set the ``$running`` state to ``false`` when received. + +Custom Signal Handlers +====================== + +You can map signals to custom methods for specific behavior: + +.. literalinclude:: cli_signals/002.php + +Fallback Signal Handler +======================= + +For signals without explicit mappings, you can implement a generic ``onInterruption()`` method: + +.. literalinclude:: cli_signals/003.php + +***************** +Critical Sections +***************** + +Some operations should never be interrupted (database transactions, file operations). Use ``withSignalsBlocked()`` +to create atomic operations: + +.. literalinclude:: cli_signals/004.php + +During critical sections, ALL signals (including Ctrl+Z) are blocked to prevent data corruption. + +**************** +Pause and Resume +**************** + +The SignalTrait supports proper Unix job control with custom handlers: + +.. literalinclude:: cli_signals/005.php + +How Pause/Resume Works +====================== + +1. **SIGTSTP received**: Custom ``onPause()`` handler runs +2. **Process suspends**: Using standard Unix job control +3. **SIGCONT received**: Process resumes, then ``onResume()`` handler runs + +This allows you to save state before suspension and restore it after resumption while maintaining proper shell integration. + +Important Limitations +===================== + +**Shell Job Control vs Manual Signals** + +There's a critical difference between using shell job control and manually sending signals: + +.. code-block:: bash + + # RECOMMENDED: Use shell job control + php spark my:command + # Press Ctrl+Z to suspend + fg # Resume - maintains terminal control + + # PROBLEMATIC: Manual signal sending + php spark my:command & + kill -TSTP $PID # Suspend + kill -CONT $PID # Resume - may lose terminal control + +**The Problem with Manual SIGCONT** + +When you manually send ``kill -CONT`` from a different terminal: + +**Expected behavior:** + - Process resumes and custom handlers execute + +**Side effects:** + - Process loses foreground terminal control + - Ctrl+C and Ctrl+Z may stop working + - Process runs in background state + +This happens because manual ``kill -CONT`` doesn't restore the process to the terminal's foreground process group. + +**Best Practices for Pause/Resume** + +1. **Use shell job control** (Ctrl+Z, fg, bg) when possible +2. **Document the limitation** if your application needs manual signal control +3. **Provide alternative control methods** for automated environments +4. **Test thoroughly** in your deployment environment + +****************** +Triggering Signals +****************** + +From Command Line +================= + +You can send signals to running processes using the ``kill`` command: + +.. code-block:: bash + + # Get the process ID + php spark long:running:command & + echo $! # Shows PID, e.g., 12345 + + # Send different signals + kill -TERM 12345 # Graceful shutdown + kill -INT 12345 # Interrupt (same as Ctrl+C) + kill -HUP 12345 # Hangup + kill -USR1 12345 # User-defined signal 1 + kill -USR2 12345 # User-defined signal 2 + + # Pause and resume + kill -TSTP 12345 # Suspend (same as Ctrl+Z) + kill -CONT 12345 # Resume (same as fg) + +Keyboard Shortcuts +================== + +These keyboard shortcuts send signals to the foreground process: + +* **Ctrl+C**: Sends SIGINT (interrupt) +* **Ctrl+Z**: Sends SIGTSTP (suspend/pause) +* **Ctrl+\\**: Sends SIGQUIT (quit with core dump) + +Job Control +=========== + +Standard Unix job control works seamlessly: + +.. code-block:: bash + + php spark long:command # Run in foreground + # Press Ctrl+Z to suspend + bg # Move to background + fg # Bring back to foreground + jobs # List suspended jobs + +***************** +Debugging Signals +***************** + +Process State Information +========================= + +Use ``getProcessState()`` to debug signal issues: + +.. literalinclude:: cli_signals/006.php + +This returns comprehensive information including: + +* Process ID and running state +* Registered signals and mappings +* Memory usage statistics +* Terminal control information (session, process group) +* Signal blocking status + +*************** +Class Reference +*************** + +.. php:namespace:: CodeIgniter\CLI + +.. php:trait:: SignalTrait + + .. php:method:: registerSignals($signals = [SIGTERM, SIGINT, SIGHUP, SIGQUIT], $methodMap = []) + + :param array $signals: List of signals to handle + :param array $methodMap: Optional signal-to-method mapping + :rtype: void + + Register signal handlers with optional custom method mapping. + + .. literalinclude:: cli_signals/007.php + + .. note:: Requires the PCNTL extension. On Windows, signal handling is automatically disabled. + + .. php:method:: isRunning() + + :returns: true if the process should continue running, false if not + :rtype: bool + + Check if the process should continue running (not terminated). + + .. literalinclude:: cli_signals/008.php + + .. php:method:: shouldTerminate() + + :returns: true if termination has been requested, false if not + :rtype: bool + + Check if termination has been requested (opposite of ``isRunning()``). + + .. literalinclude:: cli_signals/009.php + + .. php:method:: requestTermination() + + :rtype: void + + Manually request process termination. + + .. literalinclude:: cli_signals/010.php + + .. php:method:: resetState() + + :rtype: void + + Reset all states - useful for testing or restart scenarios. + + .. php:method:: withSignalsBlocked($operation) + + :param callable $operation: The critical operation to execute without interruption + :returns: The result of the operation + :rtype: mixed + + Execute a critical operation with ALL signals blocked to prevent ANY interruption. + + .. note:: This blocks ALL interruptible signals including termination signals (SIGTERM, SIGINT), + pause/resume signals (SIGTSTP, SIGCONT), and custom signals (SIGUSR1, SIGUSR2). + Only SIGKILL (unblockable) can still terminate the process. + + .. php:method:: areSignalsBlocked() + + :returns: true if signals are currently blocked, false if not + :rtype: bool + + Check if signals are currently blocked. + + .. php:method:: mapSignal($signal, $method) + + :param int $signal: Signal constant + :param string $method: Method name to call for this signal + :rtype: void + + Add or update signal-to-method mapping at runtime. + + .. literalinclude:: cli_signals/011.php + + .. php:method:: getSignalName($signal) + + :param int $signal: Signal constant + :returns: Human-readable signal name + :rtype: string + + Get human-readable name for a signal constant. + + .. literalinclude:: cli_signals/012.php + + .. php:method:: hasSignals() + + :returns: true if any signals are registered, false if not + :rtype: bool + + Check if any signals are registered. + + .. php:method:: getSignals() + + :returns: Array of registered signal constants + :rtype: array + + Get array of registered signal constants. + + .. php:method:: getProcessState() + + :returns: Comprehensive process state information + :rtype: array + + Get comprehensive process state information including process ID, memory usage, + signal handling status, and terminal control information. + + .. literalinclude:: cli_signals/013.php + + .. php:method:: unregisterSignals() + + :rtype: void + + Unregister all signals and clean up resources. + + .. note:: This removes all signal handling behavior for all previously registered signals. diff --git a/user_guide_src/source/cli/cli_signals/001.php b/user_guide_src/source/cli/cli_signals/001.php new file mode 100644 index 000000000000..1082802003ea --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/001.php @@ -0,0 +1,25 @@ +registerSignals(); + + // Main processing loop + while ($this->isRunning()) { + // Do work here + $this->processItem(); + + sleep(3); + } + + CLI::write('Command terminated gracefully', 'green'); + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_signals/002.php b/user_guide_src/source/cli/cli_signals/002.php new file mode 100644 index 000000000000..e75c7af56ae6 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/002.php @@ -0,0 +1,52 @@ +registerSignals( + [SIGTERM, SIGINT, SIGUSR1, SIGUSR2], + [ + SIGTERM => 'onGracefulShutdown', + SIGINT => 'onInterrupt', + SIGUSR1 => 'onToggleDebug', + SIGUSR2 => 'onStatusReport', + ], + ); + + while ($this->isRunning()) { + // Call custom method + $this->doWork(); + sleep(1); + } + + return EXIT_SUCCESS; + } + + protected function onGracefulShutdown(int $signal): void + { + CLI::write('Received SIGTERM - shutting down gracefully...', 'yellow'); + } + + protected function onInterrupt(int $signal): void + { + CLI::write('Received SIGINT - stopping!', 'red'); + } + + protected function onToggleDebug(int $signal): void + { + // Custom debug mode + $this->debugMode = ! $this->debugMode; + CLI::write('Debug mode: ' . ($this->debugMode ? 'ON' : 'OFF'), 'blue'); + } + + protected function onStatusReport(int $signal): void + { + $state = $this->getProcessState(); + CLI::write('Status: ' . json_encode($state, JSON_PRETTY_PRINT), 'cyan'); + } +} diff --git a/user_guide_src/source/cli/cli_signals/003.php b/user_guide_src/source/cli/cli_signals/003.php new file mode 100644 index 000000000000..ba95e7a91d8e --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/003.php @@ -0,0 +1,51 @@ +registerSignals([SIGTERM, SIGINT, SIGHUP, SIGUSR1]); + + while ($this->isRunning()) { + $this->doWork(); + sleep(1); + } + + return EXIT_SUCCESS; + } + + /** + * Generic handler for all unmapped signals + */ + protected function onInterruption(int $signal): void + { + $signalName = $this->getSignalName($signal); + CLI::write("Received {$signalName} - handling generically", 'yellow'); + + switch ($signal) { + case SIGTERM: + CLI::write('Graceful shutdown requested', 'green'); + break; + + case SIGINT: + CLI::write('Immediate shutdown requested', 'red'); + break; + + case SIGHUP: + CLI::write('Configuration reload requested', 'blue'); + break; + + case SIGUSR1: + CLI::write('User signal 1 received', 'cyan'); + break; + + default: + CLI::write('Unknown signal received', 'light_gray'); + break; + } + } +} diff --git a/user_guide_src/source/cli/cli_signals/004.php b/user_guide_src/source/cli/cli_signals/004.php new file mode 100644 index 000000000000..83e6bbede0e2 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/004.php @@ -0,0 +1,44 @@ +withSignalsBlocked(function () use ($orderData) { + CLI::write('Starting critical transaction - signals blocked', 'yellow'); + + // Start database transaction + $this->db->transStart(); + + try { + // Create order record + $orderId = $this->createOrder($orderData); + + // Update inventory + $this->updateInventory($orderData['items']); + + // Process payment + $this->processPayment($orderId, $orderData['payment']); + + // Commit transaction + $this->db->transCommit(); + + CLI::write('Transaction completed successfully', 'green'); + + return $orderId; + } catch (\Exception $e) { + // Rollback on error + $this->db->transRollback(); + + throw $e; + } + }); + + CLI::write('Critical section complete - signals restored', 'cyan'); + CLI::write("Order {$result} processed successfully", 'green'); + } +} diff --git a/user_guide_src/source/cli/cli_signals/005.php b/user_guide_src/source/cli/cli_signals/005.php new file mode 100644 index 000000000000..883949df4a0e --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/005.php @@ -0,0 +1,58 @@ +registerSignals( + [SIGTERM, SIGINT, SIGTSTP, SIGCONT], + [ + SIGTSTP => 'onPause', + SIGCONT => 'onResume', + ], + ); + + while ($this->isRunning()) { + $this->processWork(); + sleep(2); + } + + return EXIT_SUCCESS; + } + + protected function onPause(int $signal): void + { + CLI::write('Pausing - saving current date...', 'yellow'); + + // Save current timestamp + $state = [ + 'timestamp' => Time::now()->getTimestamp(), + ]; + + file_put_contents(WRITEPATH . 'app_state.json', json_encode($state)); + + CLI::write('State saved. Process will now suspend.', 'green'); + } + + protected function onResume(int $signal): void + { + CLI::write('Resuming - restoring...', 'green'); + + $file = WRITEPATH . 'app_state.json'; + + // Restore saved state + if (file_exists($file)) { + $state = json_decode(file_get_contents($file), true); + $date = Time::createFromTimestamp($state['timestamp'])->format('Y-m-d H:i:s'); + + CLI::write('Restored from ' . $date, 'cyan'); + } + + CLI::write('Resuming normal operation...', 'green'); + } +} diff --git a/user_guide_src/source/cli/cli_signals/006.php b/user_guide_src/source/cli/cli_signals/006.php new file mode 100644 index 000000000000..9db6ee990cd8 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/006.php @@ -0,0 +1,34 @@ +getProcessState(); + + CLI::write('=== PROCESS DEBUG INFO ===', 'yellow'); + CLI::write('PID: ' . $state['pid'], 'cyan'); + CLI::write('Running: ' . ($state['running'] ? 'YES' : 'NO'), 'cyan'); + CLI::write('PCNTL Available: ' . ($state['pcntl_available'] ? 'YES' : 'NO'), 'cyan'); + CLI::write('Signals Registered: ' . $state['registered_signals'], 'cyan'); + CLI::write('Signal Names: ' . implode(', ', $state['registered_signals_names']), 'cyan'); + CLI::write('Explicit Mappings: ' . $state['explicit_mappings'], 'cyan'); + CLI::write('Signals Blocked: ' . ($state['signals_blocked'] ? 'YES' : 'NO'), 'cyan'); + CLI::write('Memory Usage: ' . $state['memory_usage_mb'] . ' MB', 'cyan'); + CLI::write('Peak Memory: ' . $state['memory_peak_mb'] . ' MB', 'cyan'); + + // POSIX info (if available) + if (isset($state['session_id'])) { + CLI::write('Session ID: ' . $state['session_id'], 'cyan'); + CLI::write('Process Group: ' . $state['process_group'], 'cyan'); + CLI::write('Has Terminal: ' . ($state['has_controlling_terminal'] ? 'YES' : 'NO'), 'cyan'); + } + + CLI::write('========================', 'yellow'); + } +} diff --git a/user_guide_src/source/cli/cli_signals/007.php b/user_guide_src/source/cli/cli_signals/007.php new file mode 100644 index 000000000000..bf0ad2fe959e --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/007.php @@ -0,0 +1,16 @@ +registerSignals(); + +// Register specific signals +$this->registerSignals([SIGTERM, SIGINT]); + +// Register signals with custom method mapping +$this->registerSignals( + [SIGTERM, SIGINT, SIGUSR1], + [ + SIGTERM => 'handleGracefulShutdown', + SIGUSR1 => 'handleReload', + ], +); diff --git a/user_guide_src/source/cli/cli_signals/008.php b/user_guide_src/source/cli/cli_signals/008.php new file mode 100644 index 000000000000..4781a42a8d55 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/008.php @@ -0,0 +1,14 @@ +isRunning()) { + // Process work items + $this->processNextItem(); + + // Small delay to prevent CPU spinning + usleep(100000); // 0.1 seconds +} + +CLI::write('Process terminated gracefully.'); diff --git a/user_guide_src/source/cli/cli_signals/009.php b/user_guide_src/source/cli/cli_signals/009.php new file mode 100644 index 000000000000..c697dd5ad1c0 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/009.php @@ -0,0 +1,21 @@ +shouldTerminate()) { + CLI::write('Termination requested, skipping file processing.'); + + return; +} + +// Process large file +foreach ($largeDataSet as $item) { + // Check periodically during long operations + if ($this->shouldTerminate()) { + CLI::write('Termination requested during processing.'); + break; + } + + $this->processItem($item); +} diff --git a/user_guide_src/source/cli/cli_signals/010.php b/user_guide_src/source/cli/cli_signals/010.php new file mode 100644 index 000000000000..57ebc3812f58 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/010.php @@ -0,0 +1,11 @@ + $this->maxErrors) { + CLI::write("Too many errors ({$errorCount}), requesting termination.", 'red'); + $this->requestTermination(); + + return; +} diff --git a/user_guide_src/source/cli/cli_signals/011.php b/user_guide_src/source/cli/cli_signals/011.php new file mode 100644 index 000000000000..480c2d97364f --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/011.php @@ -0,0 +1,32 @@ +registerSignals([SIGTERM, SIGINT, SIGUSR1, SIGUSR2]); + + // Map signals to specific methods at runtime + $this->mapSignal(SIGUSR1, 'handleReload'); + $this->mapSignal(SIGUSR2, 'handleStatusDump'); + } + + // Custom signal handlers + public function handleReload(int $signal): void + { + CLI::write('Received reload signal, reloading configuration...'); + $this->reloadConfig(); + } + + public function handleStatusDump(int $signal): void + { + CLI::write('=== Process Status ==='); + $this->printStatus($this->getProcessState()); + } +} diff --git a/user_guide_src/source/cli/cli_signals/012.php b/user_guide_src/source/cli/cli_signals/012.php new file mode 100644 index 000000000000..c669e7385633 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/012.php @@ -0,0 +1,22 @@ +getSignalName($signal); + CLI::write("Received signal: {$signalName} ({$signal})", 'yellow'); + } +} diff --git a/user_guide_src/source/cli/cli_signals/013.php b/user_guide_src/source/cli/cli_signals/013.php new file mode 100644 index 000000000000..16131a914d60 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/013.php @@ -0,0 +1,29 @@ +getProcessState(); + + CLI::write('=== Process Debug Information ==='); + CLI::write("PID: {$state['pid']}"); + CLI::write('Running: ' . ($state['running'] ? 'Yes' : 'No')); + CLI::write("Memory Usage: {$state['memory_usage_mb']} MB"); + CLI::write("Peak Memory: {$state['memory_peak_mb']} MB"); + CLI::write('Registered Signals: ' . implode(', ', $state['registered_signals_names'])); + CLI::write('Signals Blocked: ' . ($state['signals_blocked'] ? 'Yes' : 'No')); + } +} diff --git a/user_guide_src/source/cli/index.rst b/user_guide_src/source/cli/index.rst index 01fa70847b0a..cbbb572243e6 100644 --- a/user_guide_src/source/cli/index.rst +++ b/user_guide_src/source/cli/index.rst @@ -13,4 +13,5 @@ CodeIgniter 4 can also be used with command line programs. cli_commands cli_generators cli_library + cli_signals cli_request