Skip to content

Commit ae261a3

Browse files
committed
feat: signals
1 parent 3c10cc1 commit ae261a3

File tree

23 files changed

+1566
-8
lines changed

23 files changed

+1566
-8
lines changed

system/CLI/SignalTrait.php

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
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

Comments
 (0)