|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +/** |
| 6 | + * This file is part of CodeIgniter 4 framework. |
| 7 | + * |
| 8 | + * (c) CodeIgniter Foundation <[email protected]> |
| 9 | + * |
| 10 | + * For the full copyright and license information, please view |
| 11 | + * the LICENSE file that was distributed with this source code. |
| 12 | + */ |
| 13 | + |
| 14 | +namespace CodeIgniter\CLI; |
| 15 | + |
| 16 | +use Closure; |
| 17 | + |
| 18 | +/** |
| 19 | + * Signal Trait |
| 20 | + * |
| 21 | + * Provides PCNTL signal handling capabilities for CLI commands. |
| 22 | + * Requires the PCNTL extension (Unix only). |
| 23 | + */ |
| 24 | +trait SignalTrait |
| 25 | +{ |
| 26 | + /** |
| 27 | + * Whether the process should continue running (false = termination requested) |
| 28 | + */ |
| 29 | + private bool $running = true; |
| 30 | + |
| 31 | + /** |
| 32 | + * Whether signals are currently blocked |
| 33 | + */ |
| 34 | + private bool $signalsBlocked = false; |
| 35 | + |
| 36 | + /** |
| 37 | + * Array of registered signals |
| 38 | + * |
| 39 | + * @var list<int> |
| 40 | + */ |
| 41 | + private array $registeredSignals = []; |
| 42 | + |
| 43 | + /** |
| 44 | + * Signal-to-method mapping |
| 45 | + * |
| 46 | + * @var array<int, string> |
| 47 | + */ |
| 48 | + private array $signalMethodMap = []; |
| 49 | + |
| 50 | + /** |
| 51 | + * Cached result of PCNTL extension availability |
| 52 | + */ |
| 53 | + private static ?bool $isPcntlAvailable = null; |
| 54 | + |
| 55 | + /** |
| 56 | + * Cached result of POSIX extension availability |
| 57 | + */ |
| 58 | + private static ?bool $isPosixAvailable = null; |
| 59 | + |
| 60 | + /** |
| 61 | + * Check if PCNTL extension is available (cached) |
| 62 | + */ |
| 63 | + protected function isPcntlAvailable(): bool |
| 64 | + { |
| 65 | + if (self::$isPcntlAvailable === null) { |
| 66 | + if (is_windows()) { |
| 67 | + self::$isPcntlAvailable = false; |
| 68 | + } else { |
| 69 | + self::$isPcntlAvailable = extension_loaded('pcntl'); |
| 70 | + if (! self::$isPcntlAvailable) { |
| 71 | + CLI::write('PCNTL extension not available. Signal handling disabled.', 'yellow'); |
| 72 | + } |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + return self::$isPcntlAvailable; |
| 77 | + } |
| 78 | + |
| 79 | + /** |
| 80 | + * Check if POSIX extension is available (cached) |
| 81 | + */ |
| 82 | + protected function isPosixAvailable(): bool |
| 83 | + { |
| 84 | + if (self::$isPosixAvailable === null) { |
| 85 | + self::$isPosixAvailable = is_windows() ? false : extension_loaded('posix'); |
| 86 | + } |
| 87 | + |
| 88 | + return self::$isPosixAvailable; |
| 89 | + } |
| 90 | + |
| 91 | + /** |
| 92 | + * Register signal handlers |
| 93 | + * |
| 94 | + * @param list<int> $signals List of signals to handle |
| 95 | + * @param array<int, string> $methodMap Optional signal-to-method mapping |
| 96 | + */ |
| 97 | + protected function registerSignals( |
| 98 | + array $signals = [SIGTERM, SIGINT, SIGHUP, SIGQUIT], |
| 99 | + array $methodMap = [], |
| 100 | + ): void { |
| 101 | + if (! $this->isPcntlAvailable()) { |
| 102 | + return; |
| 103 | + } |
| 104 | + |
| 105 | + if (! $this->isPosixAvailable() && (in_array(SIGTSTP, $signals, true) || in_array(SIGCONT, $signals, true))) { |
| 106 | + CLI::write('SIGTSTP/SIGCONT handling requires POSIX extension. These signals will be removed from registration.', 'yellow'); |
| 107 | + $signals = array_diff($signals, [SIGTSTP, SIGCONT]); |
| 108 | + |
| 109 | + // Remove from method map as well |
| 110 | + unset($methodMap[SIGTSTP], $methodMap[SIGCONT]); |
| 111 | + |
| 112 | + if ($signals === []) { |
| 113 | + return; |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + // Enable async signals for immediate response |
| 118 | + pcntl_async_signals(true); |
| 119 | + |
| 120 | + $this->signalMethodMap = $methodMap; |
| 121 | + |
| 122 | + foreach ($signals as $signal) { |
| 123 | + if (pcntl_signal($signal, [$this, 'handleSignal'])) { |
| 124 | + $this->registeredSignals[] = $signal; |
| 125 | + } else { |
| 126 | + CLI::write("Failed to register handler for signal: {$signal}", 'red'); |
| 127 | + } |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + /** |
| 132 | + * Handle incoming signals |
| 133 | + */ |
| 134 | + protected function handleSignal(int $signal): void |
| 135 | + { |
| 136 | + $this->callCustomHandler($signal); |
| 137 | + |
| 138 | + // Apply standard Unix signal behavior for registered signals |
| 139 | + switch ($signal) { |
| 140 | + case SIGTERM: |
| 141 | + case SIGINT: |
| 142 | + case SIGQUIT: |
| 143 | + case SIGHUP: |
| 144 | + $this->running = false; |
| 145 | + break; |
| 146 | + |
| 147 | + case SIGTSTP: |
| 148 | + // Restore default handler and re-send signal to actually suspend |
| 149 | + pcntl_signal(SIGTSTP, SIG_DFL); |
| 150 | + posix_kill(posix_getpid(), SIGTSTP); |
| 151 | + break; |
| 152 | + |
| 153 | + case SIGCONT: |
| 154 | + // Re-register SIGTSTP handler after resume |
| 155 | + pcntl_signal(SIGTSTP, [$this, 'handleSignal']); |
| 156 | + break; |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + /** |
| 161 | + * Call custom signal handler if one is mapped for this signal |
| 162 | + * Falls back to generic onInterruption() method if no explicit mapping exists |
| 163 | + */ |
| 164 | + private function callCustomHandler(int $signal): void |
| 165 | + { |
| 166 | + // Check for explicit mapping first |
| 167 | + $method = $this->signalMethodMap[$signal] ?? null; |
| 168 | + |
| 169 | + if ($method !== null && method_exists($this, $method)) { |
| 170 | + $this->{$method}($signal); |
| 171 | + |
| 172 | + return; |
| 173 | + } |
| 174 | + |
| 175 | + // If no explicit mapping, try generic catch-all method |
| 176 | + if (method_exists($this, 'onInterruption')) { |
| 177 | + $this->onInterruption($signal); |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + /** |
| 182 | + * Check if command should terminate |
| 183 | + */ |
| 184 | + protected function shouldTerminate(): bool |
| 185 | + { |
| 186 | + return ! $this->running; |
| 187 | + } |
| 188 | + |
| 189 | + /** |
| 190 | + * Check if the process is currently running (not terminated) |
| 191 | + */ |
| 192 | + protected function isRunning(): bool |
| 193 | + { |
| 194 | + return $this->running; |
| 195 | + } |
| 196 | + |
| 197 | + /** |
| 198 | + * Request immediate termination |
| 199 | + */ |
| 200 | + protected function requestTermination(): void |
| 201 | + { |
| 202 | + $this->running = false; |
| 203 | + } |
| 204 | + |
| 205 | + /** |
| 206 | + * Reset all states (for testing or restart scenarios) |
| 207 | + */ |
| 208 | + protected function resetState(): void |
| 209 | + { |
| 210 | + $this->running = true; |
| 211 | + |
| 212 | + // Unblock signals if they were blocked |
| 213 | + if ($this->signalsBlocked) { |
| 214 | + $this->unblockSignals(); |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + /** |
| 219 | + * Execute a callable with ALL signals blocked to prevent ANY interruption during critical operations |
| 220 | + * |
| 221 | + * This blocks ALL interruptible signals including: |
| 222 | + * - Termination signals (SIGTERM, SIGINT, etc.) |
| 223 | + * - Pause/resume signals (SIGTSTP, SIGCONT) |
| 224 | + * - Custom signals (SIGUSR1, SIGUSR2) |
| 225 | + * |
| 226 | + * Only SIGKILL (unblockable) can still terminate the process. |
| 227 | + * Use this for database transactions, file operations, or any critical atomic operations. |
| 228 | + * |
| 229 | + * @template TReturn |
| 230 | + * |
| 231 | + * @param Closure():TReturn $operation |
| 232 | + * |
| 233 | + * @return TReturn |
| 234 | + */ |
| 235 | + protected function withSignalsBlocked(Closure $operation) |
| 236 | + { |
| 237 | + $this->blockSignals(); |
| 238 | + |
| 239 | + try { |
| 240 | + return $operation(); |
| 241 | + } finally { |
| 242 | + $this->unblockSignals(); |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + /** |
| 247 | + * Block ALL interruptible signals during critical sections |
| 248 | + * Only SIGKILL (unblockable) can terminate the process |
| 249 | + */ |
| 250 | + protected function blockSignals(): void |
| 251 | + { |
| 252 | + if (! $this->signalsBlocked && $this->isPcntlAvailable()) { |
| 253 | + // Block ALL signals that could interrupt critical operations |
| 254 | + pcntl_sigprocmask(SIG_BLOCK, [ |
| 255 | + SIGTERM, SIGINT, SIGHUP, SIGQUIT, // Termination signals |
| 256 | + SIGTSTP, SIGCONT, // Pause/resume signals |
| 257 | + SIGUSR1, SIGUSR2, // Custom signals |
| 258 | + SIGPIPE, SIGALRM, // Other common signals |
| 259 | + ]); |
| 260 | + $this->signalsBlocked = true; |
| 261 | + } |
| 262 | + } |
| 263 | + |
| 264 | + /** |
| 265 | + * Unblock previously blocked signals |
| 266 | + */ |
| 267 | + protected function unblockSignals(): void |
| 268 | + { |
| 269 | + if ($this->signalsBlocked && $this->isPcntlAvailable()) { |
| 270 | + // Unblock the same signals we blocked |
| 271 | + pcntl_sigprocmask(SIG_UNBLOCK, [ |
| 272 | + SIGTERM, SIGINT, SIGHUP, SIGQUIT, // Termination signals |
| 273 | + SIGTSTP, SIGCONT, // Pause/resume signals |
| 274 | + SIGUSR1, SIGUSR2, // Custom signals |
| 275 | + SIGPIPE, SIGALRM, // Other common signals |
| 276 | + ]); |
| 277 | + $this->signalsBlocked = false; |
| 278 | + } |
| 279 | + } |
| 280 | + |
| 281 | + /** |
| 282 | + * Check if signals are currently blocked |
| 283 | + */ |
| 284 | + protected function areSignalsBlocked(): bool |
| 285 | + { |
| 286 | + return $this->signalsBlocked; |
| 287 | + } |
| 288 | + |
| 289 | + /** |
| 290 | + * Add or update signal-to-method mapping at runtime |
| 291 | + */ |
| 292 | + protected function mapSignal(int $signal, string $method): void |
| 293 | + { |
| 294 | + $this->signalMethodMap[$signal] = $method; |
| 295 | + } |
| 296 | + |
| 297 | + /** |
| 298 | + * Get human-readable signal name |
| 299 | + */ |
| 300 | + protected function getSignalName(int $signal): string |
| 301 | + { |
| 302 | + return match ($signal) { |
| 303 | + SIGTERM => 'SIGTERM', |
| 304 | + SIGINT => 'SIGINT', |
| 305 | + SIGHUP => 'SIGHUP', |
| 306 | + SIGQUIT => 'SIGQUIT', |
| 307 | + SIGUSR1 => 'SIGUSR1', |
| 308 | + SIGUSR2 => 'SIGUSR2', |
| 309 | + SIGPIPE => 'SIGPIPE', |
| 310 | + SIGALRM => 'SIGALRM', |
| 311 | + SIGTSTP => 'SIGTSTP', |
| 312 | + SIGCONT => 'SIGCONT', |
| 313 | + default => "Signal {$signal}", |
| 314 | + }; |
| 315 | + } |
| 316 | + |
| 317 | + /** |
| 318 | + * Unregister all signals (cleanup) |
| 319 | + */ |
| 320 | + protected function unregisterSignals(): void |
| 321 | + { |
| 322 | + if (! $this->isPcntlAvailable()) { |
| 323 | + return; |
| 324 | + } |
| 325 | + |
| 326 | + foreach ($this->registeredSignals as $signal) { |
| 327 | + pcntl_signal($signal, SIG_DFL); |
| 328 | + } |
| 329 | + |
| 330 | + $this->registeredSignals = []; |
| 331 | + $this->signalMethodMap = []; |
| 332 | + } |
| 333 | + |
| 334 | + /** |
| 335 | + * Check if signals are registered |
| 336 | + */ |
| 337 | + protected function hasSignals(): bool |
| 338 | + { |
| 339 | + return $this->registeredSignals !== []; |
| 340 | + } |
| 341 | + |
| 342 | + /** |
| 343 | + * Get list of registered signals |
| 344 | + * |
| 345 | + * @return list<int> |
| 346 | + */ |
| 347 | + protected function getSignals(): array |
| 348 | + { |
| 349 | + return $this->registeredSignals; |
| 350 | + } |
| 351 | + |
| 352 | + /** |
| 353 | + * Get comprehensive process state information |
| 354 | + * |
| 355 | + * @return array<string, mixed> |
| 356 | + */ |
| 357 | + protected function getProcessState(): array |
| 358 | + { |
| 359 | + $pid = getmypid(); |
| 360 | + $state = [ |
| 361 | + // Process identification |
| 362 | + 'pid' => $pid, |
| 363 | + 'running' => $this->running, |
| 364 | + |
| 365 | + // Signal handling status |
| 366 | + 'pcntl_available' => $this->isPcntlAvailable(), |
| 367 | + 'registered_signals' => count($this->registeredSignals), |
| 368 | + 'registered_signals_names' => array_map([$this, 'getSignalName'], $this->registeredSignals), |
| 369 | + 'signals_blocked' => $this->signalsBlocked, |
| 370 | + 'explicit_mappings' => count($this->signalMethodMap), |
| 371 | + |
| 372 | + // System resources |
| 373 | + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), |
| 374 | + 'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2), |
| 375 | + ]; |
| 376 | + |
| 377 | + // Add terminal control info if POSIX extension is available |
| 378 | + if ($this->isPosixAvailable()) { |
| 379 | + $state['session_id'] = posix_getsid($pid); |
| 380 | + $state['process_group'] = posix_getpgid($pid); |
| 381 | + $state['has_controlling_terminal'] = posix_isatty(STDIN); |
| 382 | + } |
| 383 | + |
| 384 | + return $state; |
| 385 | + } |
| 386 | +} |
0 commit comments