Skip to content

Commit d497cbe

Browse files
committed
feat: add shared utilities for parallel processing
- CpuDetector: centralized CPU core detection (Linux/macOS) - ProcessManager: child process timeout with WNOHANG polling - ProcessManager: secure temp files with 0600 permissions - ProcessManager: SIGTERM/SIGINT cleanup handlers These utilities address security and reliability issues identified in code review: indefinite hangs, world-readable temp files, and duplicated CPU detection logic.
1 parent 8e16a48 commit d497cbe

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace T3Docs\GuidesExtension\Util;
6+
7+
/**
8+
* Utility for detecting CPU core count for parallel processing.
9+
*
10+
* Provides a single implementation used across all parallel processing classes
11+
* to avoid code duplication and ensure consistent behavior.
12+
*/
13+
final class CpuDetector
14+
{
15+
/**
16+
* Detect the number of CPU cores available.
17+
*
18+
* @param int $maxWorkers Maximum number of workers to return (default 8)
19+
* @param int $defaultWorkers Default if detection fails (default 4)
20+
* @return int Number of CPU cores (capped at $maxWorkers)
21+
*/
22+
public static function detectCores(int $maxWorkers = 8, int $defaultWorkers = 4): int
23+
{
24+
// Try /proc/cpuinfo on Linux
25+
if (is_file('/proc/cpuinfo')) {
26+
$cpuinfo = file_get_contents('/proc/cpuinfo');
27+
if ($cpuinfo !== false) {
28+
$count = substr_count($cpuinfo, 'processor');
29+
if ($count > 0) {
30+
return min($count, $maxWorkers);
31+
}
32+
}
33+
}
34+
35+
// Try nproc command (Linux/GNU)
36+
$nproc = @shell_exec('nproc 2>/dev/null');
37+
if ($nproc !== null && $nproc !== false) {
38+
$count = (int) trim($nproc);
39+
if ($count > 0) {
40+
return min($count, $maxWorkers);
41+
}
42+
}
43+
44+
// Try sysctl on macOS/BSD
45+
$sysctl = @shell_exec('sysctl -n hw.ncpu 2>/dev/null');
46+
if ($sysctl !== null && $sysctl !== false) {
47+
$count = (int) trim($sysctl);
48+
if ($count > 0) {
49+
return min($count, $maxWorkers);
50+
}
51+
}
52+
53+
return $defaultWorkers;
54+
}
55+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace T3Docs\GuidesExtension\Util;
6+
7+
use RuntimeException;
8+
9+
/**
10+
* Utility for managing forked child processes with timeouts and cleanup.
11+
*
12+
* Provides consistent process management across all parallel processing classes:
13+
* - Non-blocking wait with configurable timeout
14+
* - SIGTERM handling for cleanup
15+
* - Secure temp file creation with proper permissions
16+
*/
17+
final class ProcessManager
18+
{
19+
/** Default timeout in seconds for waiting on child processes */
20+
public const int DEFAULT_TIMEOUT_SECONDS = 300;
21+
22+
/** Poll interval in microseconds (10ms) */
23+
private const int POLL_INTERVAL_USEC = 10000;
24+
25+
/** @var list<string> Temp files to clean on shutdown */
26+
private static array $tempFilesToClean = [];
27+
28+
/** @var bool Whether shutdown handler is registered */
29+
private static bool $shutdownRegistered = false;
30+
31+
/**
32+
* Wait for all child processes with timeout.
33+
*
34+
* Uses non-blocking WNOHANG to poll process status, allowing timeout detection.
35+
* Sends SIGKILL to stuck processes after timeout expires.
36+
*
37+
* @param array<int, int> $childPids Map of workerId => pid
38+
* @param int $timeoutSeconds Maximum time to wait (default 300s)
39+
* @return array{successes: list<int>, failures: array<int, string>}
40+
* @throws RuntimeException If all workers fail
41+
*/
42+
public static function waitForChildrenWithTimeout(
43+
array $childPids,
44+
int $timeoutSeconds = self::DEFAULT_TIMEOUT_SECONDS,
45+
): array {
46+
$startTime = time();
47+
$remaining = $childPids;
48+
$successes = [];
49+
$failures = [];
50+
51+
while ($remaining !== []) {
52+
foreach ($remaining as $workerId => $pid) {
53+
$status = 0;
54+
$result = pcntl_waitpid($pid, $status, WNOHANG);
55+
56+
if ($result === 0) {
57+
// Still running
58+
continue;
59+
}
60+
61+
if ($result === -1) {
62+
// Error - child doesn't exist
63+
$failures[$workerId] = 'waitpid failed';
64+
unset($remaining[$workerId]);
65+
continue;
66+
}
67+
68+
// Child exited
69+
unset($remaining[$workerId]);
70+
71+
if (pcntl_wifexited($status)) {
72+
$exitCode = pcntl_wexitstatus($status);
73+
if ($exitCode === 0) {
74+
$successes[] = $workerId;
75+
} else {
76+
$failures[$workerId] = sprintf('exit code %d', $exitCode);
77+
}
78+
} elseif (pcntl_wifsignaled($status)) {
79+
$signal = pcntl_wtermsig($status);
80+
$failures[$workerId] = sprintf('killed by signal %d', $signal);
81+
}
82+
}
83+
84+
// Check timeout
85+
if (time() - $startTime > $timeoutSeconds) {
86+
// Kill remaining children
87+
foreach ($remaining as $workerId => $pid) {
88+
posix_kill($pid, SIGKILL);
89+
pcntl_waitpid($pid, $status); // Reap zombie
90+
$failures[$workerId] = sprintf('killed after %ds timeout', $timeoutSeconds);
91+
}
92+
break;
93+
}
94+
95+
// Don't spin-wait if processes still running
96+
if ($remaining !== []) {
97+
usleep(self::POLL_INTERVAL_USEC);
98+
}
99+
}
100+
101+
return ['successes' => $successes, 'failures' => $failures];
102+
}
103+
104+
/**
105+
* Create a secure temp file with restricted permissions.
106+
*
107+
* Creates temp file with 0600 permissions to prevent other users from reading.
108+
* Registers file for cleanup on shutdown/signal.
109+
*
110+
* @param string $prefix Temp file prefix
111+
* @return string|false Path to temp file, or false on failure
112+
*/
113+
public static function createSecureTempFile(string $prefix): string|false
114+
{
115+
self::ensureShutdownHandler();
116+
117+
$tempFile = tempnam(sys_get_temp_dir(), $prefix);
118+
if ($tempFile === false) {
119+
return false;
120+
}
121+
122+
// Set restrictive permissions (owner read/write only)
123+
chmod($tempFile, 0o600);
124+
125+
// Register for cleanup
126+
self::$tempFilesToClean[] = $tempFile;
127+
128+
return $tempFile;
129+
}
130+
131+
/**
132+
* Remove a temp file from cleanup list (already cleaned).
133+
*/
134+
public static function unregisterTempFile(string $tempFile): void
135+
{
136+
$key = array_search($tempFile, self::$tempFilesToClean, true);
137+
if ($key !== false) {
138+
unset(self::$tempFilesToClean[$key]);
139+
self::$tempFilesToClean = array_values(self::$tempFilesToClean);
140+
}
141+
}
142+
143+
/**
144+
* Clean up a temp file and unregister it.
145+
*/
146+
public static function cleanupTempFile(string $tempFile): void
147+
{
148+
@unlink($tempFile);
149+
self::unregisterTempFile($tempFile);
150+
}
151+
152+
/**
153+
* Ensure shutdown and signal handlers are registered.
154+
*
155+
* Note: Handlers are only registered when pcntl is available (CLI mode)
156+
* and we're not in a test environment, to avoid conflicts with PHPUnit.
157+
*/
158+
private static function ensureShutdownHandler(): void
159+
{
160+
if (self::$shutdownRegistered) {
161+
return;
162+
}
163+
164+
// Skip handler registration in test environments
165+
if (defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__')) {
166+
self::$shutdownRegistered = true;
167+
return;
168+
}
169+
170+
// Register shutdown function for normal termination
171+
register_shutdown_function([self::class, 'cleanupAllTempFiles']);
172+
173+
// Register signal handlers if pcntl available
174+
if (function_exists('pcntl_signal')) {
175+
// SIGTERM - graceful termination
176+
pcntl_signal(SIGTERM, [self::class, 'handleSignal']);
177+
// SIGINT - Ctrl+C
178+
pcntl_signal(SIGINT, [self::class, 'handleSignal']);
179+
}
180+
181+
self::$shutdownRegistered = true;
182+
}
183+
184+
/**
185+
* Handle termination signals by cleaning up temp files.
186+
*/
187+
public static function handleSignal(int $signal): void
188+
{
189+
self::cleanupAllTempFiles();
190+
exit(128 + $signal);
191+
}
192+
193+
/**
194+
* Clean up all registered temp files.
195+
*/
196+
public static function cleanupAllTempFiles(): void
197+
{
198+
foreach (self::$tempFilesToClean as $file) {
199+
if (file_exists($file)) {
200+
@unlink($file);
201+
}
202+
}
203+
self::$tempFilesToClean = [];
204+
}
205+
}

0 commit comments

Comments
 (0)