['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); $status = proc_get_status($handle); $pid = (int) $status['pid']; foreach ($pipes as $pipe) { fclose($pipe); } /* proc_open forks, then the child execs sleep. Until the exec lands, the child still reports the parent's command name, so an identity check that runs in that window would see a false match. Wait for the exec to settle before returning so the test observes 'sleep', not the transient 'php'. */ $comm = '/proc/' . $pid . '/comm'; for ($i = 0; $i < 200; $i++) { if (is_readable($comm) && trim((string) file_get_contents($comm)) === 'sleep') { break; } usleep(5000); } return $pid; } test('a pid that cannot be valid is never reported running', function () { expect(cacti_process_still_running(0))->toBeFalse() ->and(cacti_process_still_running(-1))->toBeFalse() ->and(cacti_process_still_running(-99999))->toBeFalse(); }); test('a pid wider than pid_t is never reported existing or running', function () { if (PHP_INT_SIZE < 8) { test()->markTestSkipped('a 32 bit build cannot express a pid wider than pid_t'); } /* 4294967295 is the largest value processes.pid can hold. Handing it to an unguarded posix_kill() is unsafe in two different ways: through PHP 8.4 the argument narrows to a 32 bit pid_t of -1, which kill(2) reads as every process the caller may signal, and from PHP 8.5 the same call raises a ValueError that takes the poller down instead. Refusing the value ahead of the call covers both, so this asserts the refusal rather than whichever behaviour the running interpreter would have produced. */ expect(cacti_process_pid_exists(4294967295))->toBeFalse() ->and(cacti_process_still_running(4294967295))->toBeFalse() ->and(cacti_process_pid_exists(PHP_INT_MAX))->toBeFalse() ->and(cacti_process_still_running(PHP_INT_MAX))->toBeFalse(); }); test('every value pid_t can hold is still answered by the kernel', function () { /* The bound sits at pid_t and not below it, so the largest pid_t is still passed to posix_kill() and a pid that does exist is still recognised. */ expect(posix_kill(2147483647, 0))->toBeFalse() ->and(cacti_process_pid_exists(2147483647))->toBeFalse() ->and(cacti_process_pid_exists(getmypid()))->toBeTrue(); }); test('the running process recognises itself', function () { expect(cacti_process_still_running(getmypid()))->toBeTrue(); }); test('a permissions failure still means the pid exists', function () { $eperm = defined('SOCKET_EPERM') ? SOCKET_EPERM : 1; if (posix_kill(1, 0) || posix_get_last_error() !== $eperm) { test()->markTestSkipped('pid 1 does not produce EPERM for this user'); } expect(cacti_process_pid_exists(1))->toBeTrue(); }); test('a pid that has already exited is not reported running', function () { $pipes = []; $handle = proc_open(['true'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); $pid = (int) proc_get_status($handle)['pid']; foreach ($pipes as $pipe) { fclose($pipe); } proc_close($handle); expect(cacti_process_still_running($pid))->toBeFalse(); }); /** * The identity half of the check, which is the whole point of the fix. It needs * the command names under /proc, so it only means anything on Linux. */ test('a live pid belonging to a different program is not reported running', function () { if (!is_dir('/proc')) { test()->markTestSkipped('no /proc, so command names cannot be compared'); } if (PHP_SAPI !== 'cli') { test()->markTestSkipped('the identity check only applies to CLI callers'); } $handle = null; $pid = pid_guard_spawn_foreign($handle); if (!is_readable('/proc/' . getmypid() . '/comm') || !is_readable('/proc/' . $pid . '/comm')) { proc_terminate($handle); proc_close($handle); test()->markTestSkipped('process command names are not readable under /proc'); } // the pid is alive, so the bare existence test the fix replaced said yes expect(posix_kill($pid, 0))->toBeTrue() ->and(cacti_process_still_running($pid))->toBeFalse(); proc_terminate($handle); proc_close($handle); }); /** * Where /proc is absent the function is documented to fall back to the bare * existence test, so this pins that it stays no worse than before rather than * silently reporting healthy processes dead. */ test('without /proc the check falls back to plain existence', function () { if (is_dir('/proc')) { test()->markTestSkipped('/proc is present, so the fallback does not apply'); } $handle = null; $pid = pid_guard_spawn_foreign($handle); expect(cacti_process_still_running($pid))->toBeTrue(); proc_terminate($handle); proc_close($handle); }); test('every registry kill site checks the pid before signalling it', function () { $poller = file_get_contents(dirname(__DIR__, 4) . '/lib/poller.php'); $dsstats = file_get_contents(dirname(__DIR__, 4) . '/lib/dsstats.php'); $batchgapfix = file_get_contents(dirname(__DIR__, 4) . '/cli/batchgapfix.php'); expect($poller)->not->toBeFalse('lib/poller.php must be readable') ->and($dsstats)->not->toBeFalse('lib/dsstats.php must be readable') ->and($batchgapfix)->not->toBeFalse('cli/batchgapfix.php must be readable'); // the bare existence test is what the fix removed; a reappearance means a // call site went back to trusting a recycled pid expect($poller)->not->toContain("posix_kill(\$r['pid'], 0)") ->and($dsstats)->not->toContain("posix_kill(\$p['pid'], 0)") ->and($batchgapfix)->toContain("cacti_process_still_running((int) \$r['pid'])") ->and($batchgapfix)->not->toContain("posix_kill(\$r['pid'], 0)"); }); /** * The registry holds CLI tasks. A web request asking whether one is alive has a * different command name by definition, so comparing identities there would * report a running poller dead and let a second copy start. */ test('the identity check is limited to CLI callers', function () { $body = file_get_contents(dirname(__DIR__, 4) . '/lib/poller.php'); $start = strpos($body, 'function cacti_process_still_running('); expect($start)->not->toBeFalse(); $function = substr($body, $start, strpos($body, "\nfunction ", $start + 1) - $start); $sapi_guard = strpos($function, "PHP_SAPI !== 'cli'"); $proc_read = strpos($function, '/proc/'); expect($sapi_guard)->not->toBeFalse() ->and($sapi_guard)->toBeLessThan($proc_read); }); /** * The registry's children are spawned from path_php_binary, which is routinely a * versioned path, while the parent may have been started as plain php. Both are * the same interpreter but report different command names, so an identity check * built on the name alone calls a live task dead and lets a second copy start. */ test('a child started through another name for our interpreter is still ours', function () { if (!is_dir('/proc') || PHP_SAPI !== 'cli') { test()->markTestSkipped('the identity check needs /proc and a CLI caller'); } $alias = sys_get_temp_dir() . '/cacti_php_alias_' . bin2hex(random_bytes(6)); if (!@symlink(PHP_BINARY, $alias)) { test()->markTestSkipped('cannot create a second name for the interpreter'); } $pipes = []; $handle = proc_open([$alias, '-r', 'sleep(30);'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); $pid = (int) proc_get_status($handle)['pid']; foreach ($pipes as $pipe) { fclose($pipe); } for ($wait = 0; $wait < 50 && !is_readable('/proc/' . $pid . '/comm'); $wait++) { usleep(20000); } $mine = trim((string) @file_get_contents('/proc/' . getmypid() . '/comm')); $theirs = trim((string) @file_get_contents('/proc/' . $pid . '/comm')); /* Whether a second path to the interpreter produces a second command name is the runner's business, not ours. Where php is already the plain name both sides read alike and there is no mismatch left to demonstrate, so skip rather than assert an environment detail. */ if ($theirs === $mine) { proc_terminate($handle); proc_close($handle); unlink($alias); test()->markTestSkipped('this interpreter reports one command name for both paths'); } // the names differ here, so the check this replaced called the child a stranger expect(cacti_process_still_running($pid))->toBeTrue(); proc_terminate($handle); proc_close($handle); unlink($alias); });