fix(poller): stop the registry pid probe narrowing a wide pid to -1 (#7956)

* fix(poller): stop the registry pid probe narrowing a wide pid to -1

processes.pid is int(10) unsigned, so a corrupted row can hold 4294967295.
Through PHP 8.4 posix_kill() narrows that to a 32 bit pid_t of -1 and kill(2)
reads it as every process the caller may signal, so cacti_process_pid_exists()
calls a dead registry row live and the two kill sites that ask it first SIGTERM
the caller's whole session. From PHP 8.5 the same value raises a ValueError and
ends the poller instead. Refusing it ahead of the call covers both.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* hardening: bound every process table PID before signalling it

Twelve call sites read a pid out of a process table and handed it straight to
posix_kill(). processes.pid is int(10) unsigned, so a corrupted row can carry
4294967295, which narrows to a 32 bit pid_t of -1 and signals every process
the caller owns, or from PHP 8.5 raises a ValueError that ends the collector.
They now go through cacti_process_kill(), which refuses init, anything that
cannot name a process, and anything wider than pid_t.

poller_recovery read its pid with a bare posix_kill($pid, 0) and treats false
as "stale, clear the row and run". cacti_process_signalable() keeps exactly
that meaning, including EPERM as stale, and adds the bound. Neither
cacti_process_pid_exists() nor cacti_process_still_running() fits there: both
report a pid live when signalling it is merely forbidden, so a recycled
recovery_pid would have wedged recovery until an operator cleared the row.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: place process guards in poller domain

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(poller): honor platform PID ranges

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(poller): cover platform signal semantics

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
This commit is contained in:
Thomas Vincent authored and GitHub committed 2026-09-12 15:24:27 -04:00
1 parent a2c8df612e
commit 3f377531b7
15 files changed
+473 -23

No files matched your search

+1
View File
@@ -0,0 +1 @@
Refuse to signal a process table PID that names no process a task can own, or that pid_t cannot hold
+1 -1
View File
@@ -233,7 +233,7 @@ if ($child == 0) {
if (cacti_process_still_running((int) $r['pid'])) {
printf('NOTE: Process with PID: %s being killed.' . PHP_EOL, $r['pid']);
posix_kill((int) $r['pid'], SIGTERM);
cacti_process_kill((int) $r['pid'], SIGTERM, 'SYSTEM');
} else {
printf('NOTE: Process with PID: %s is no longer the registered process.' . PHP_EOL, $r['pid']);
}
+1 -1
View File
@@ -722,7 +722,7 @@ function float_kill_running_processes() : void {
if (cacti_process_still_running((int) $p['pid'])) {
cacti_log(sprintf('WARNING: Killing Cleanup %s PID %d due to another due to signal or overrun.', ucfirst($p['taskname']), $p['pid']), false, 'RFLOAT');
posix_kill($p['pid'], SIGTERM);
cacti_process_kill((int) $p['pid'], SIGTERM, 'RFLOAT');
}
unregister_process($p['tasktype'], $p['taskname'], $p['taskid'], $p['pid']);
+1 -1
View File
@@ -469,7 +469,7 @@ function reindex_kill_running_processes() : void {
if (cacti_process_still_running((int) $p['pid'])) {
cacti_log(sprintf('WARNING: Killing Cleanup %s PID %d due to another due to signal or overrun.', ucfirst($p['taskname']), $p['pid']), false, 'REINDEX');
posix_kill($p['pid'], SIGTERM);
cacti_process_kill((int) $p['pid'], SIGTERM, 'REINDEX');
}
unregister_process($p['tasktype'], $p['taskname'], $p['taskid'], $p['pid']);
+1 -1
View File
@@ -427,7 +427,7 @@ function pushout_kill_running_processes() : void {
if (cacti_process_still_running((int) $p['pid'])) {
cacti_log(sprintf('WARNING: Killing Cleanup %s PID %d due to another due to signal or overrun.', ucfirst($p['taskname']), $p['pid']), false, 'PUSHOUT');
posix_kill($p['pid'], SIGTERM);
cacti_process_kill((int) $p['pid'], SIGTERM, 'PUSHOUT');
}
unregister_process($p['tasktype'], $p['taskname'], $p['taskid'], $p['pid']);
+1 -1
View File
@@ -1470,7 +1470,7 @@ function dsstats_kill_running_processes() : void {
foreach ($processes as $p) {
if (cacti_process_still_running((int) $p['pid'])) {
cacti_log(sprintf('WARNING: Killing DSStats %s PID %d due to signal or overrun.', ucfirst($p['taskname']), $p['pid']), false, 'BOOST');
posix_kill($p['pid'], SIGTERM);
cacti_process_kill((int) $p['pid'], SIGTERM, 'BOOST');
}
unregister_process($p['tasktype'], $p['taskname'], $p['taskid'], $p['pid']);
+99 -3
View File
@@ -2742,6 +2742,94 @@ function cacti_process_still_running(int $pid) : bool {
return cacti_process_pid_exists($pid);
}
/**
* Returns the largest process id accepted by the platform signal adapter.
*
* Windows process IDs are unsigned 32-bit values and Cacti's posix_kill()
* compatibility shim does not narrow them through the POSIX pid_t type.
*/
function cacti_process_pid_max() : int {
return PHP_OS_FAMILY === 'Windows' && PHP_INT_SIZE >= 8 ? 4294967295 : 2147483647;
}
/**
* Whether this process could deliver a signal to $pid.
*
* Two things separate this from cacti_process_still_running(). It reads EPERM as
* stale rather than live, because a pid this process cannot signal has been
* recycled by somebody else and the row naming it is dead. And it skips the
* /proc identity check, because its caller reads false as "stale, clear the row
* and run"; an identity check that answered no for a live process would clear a
* row out from under a running collector and let a second one start.
*
* Bounded first, so a value pid_t cannot hold never reaches the kernel as -1.
*
* @param int $pid The pid recorded in a table.
*
* @return bool True when a signal from this process would reach that pid.
*/
function cacti_process_signalable(int $pid) : bool {
if ($pid <= 1 || $pid > cacti_process_pid_max() || !function_exists('posix_kill')) {
return false;
}
return posix_kill($pid, 0);
}
/**
* Sends a signal to a pid that was read from a process table.
*
* A stored pid can hold a value the kernel will not read as the caller means
* it. processes.pid is int(10) unsigned, so a corrupted row can carry
* 4294967295, and posix_kill() narrows that to a 32 bit pid_t of -1, which
* kill(2) reads as every process the caller is permitted to signal. From PHP
* 8.5 the same value raises a ValueError and ends the collector. Refusing it
* ahead of the call covers both, and logs the row so an operator can find it.
*
* init is refused as well, since a recycled or tampered row naming pid 1 would
* otherwise reach the host's own service manager. The floor stops there.
* Taking in the rest of the low range would exclude Cacti's own children
* inside a pid namespace, where they hold single and double digit pids, and
* every caller below unregisters the row whether or not the signal lands.
* Refusing there would leave a live collector with no registry row and a
* second one free to start against the same RRDs.
*
* This bounds the pid only. It deliberately does not test liveness, so the
* call sites keep the semantics they had; those that want an identity check
* still call cacti_process_still_running() first. A caller that owns the
* process it is signalling, a child it started itself for instance, already
* knows the pid is real and does not need this.
*
* @param int $pid The pid recorded in a process table.
* @param int $signal The signal to send.
* @param string $environ The log environment to record a refusal under.
*
* @return bool True when the signal was sent.
*/
function cacti_process_kill(int $pid, int $signal = SIGTERM, string $environ = 'POLLER') : bool {
/* Split, because the two refusals are refused for different reasons and an
operator reading the log should be told which. A non-positive pid is not
out of pid_t range at all: kill(2) reads 0 as this process group and -1
as every process the caller may signal. */
if ($pid <= 1) {
cacti_log(sprintf('WARNING: Refusing to signal PID %s from a process table, which does not name a process a Cacti task can own!', $pid), false, $environ);
return false;
}
if ($pid > cacti_process_pid_max()) {
cacti_log(sprintf('WARNING: Refusing to signal PID %s from a process table, which is wider than pid_t and would reach the kernel as -1!', $pid), false, $environ);
return false;
}
if (!function_exists('posix_kill')) {
return false;
}
return posix_kill($pid, $signal);
}
/**
* Tests whether a pid exists without treating a permissions failure as exit.
*
@@ -2749,12 +2837,20 @@ function cacti_process_still_running(int $pid) : bool {
* signal it. PHP exposes errno through posix_get_last_error(), while the
* numeric EPERM constant is supplied by ext-sockets rather than ext-posix.
*
* A pid wider than pid_t is refused before it reaches posix_kill(). Through PHP
* 8.4 that call narrows its argument to a 32 bit pid_t, so a processes.pid of
* 4294967295, the maximum its int(10) unsigned column holds, arrives as -1 and
* kill(2) reads it as every process the caller may signal; a caller that asks
* here first then reads a dead registry row as live and SIGTERMs it. From PHP
* 8.5 the same value raises a ValueError, which ends the poller instead. Sites
* that signal a stored pid without asking are unaffected by this bound.
*
* @param int $pid The pid to check.
*
* @return bool True when the process exists or signalling it is forbidden.
*/
function cacti_process_pid_exists(int $pid) : bool {
if ($pid <= 0 || !function_exists('posix_kill')) {
if ($pid <= 0 || $pid > cacti_process_pid_max() || !function_exists('posix_kill')) {
return false;
}
@@ -2874,7 +2970,7 @@ function register_process_start_locked(string $tasktype, string $taskname, int $
if (cacti_process_still_running((int) $r['pid'])) {
cacti_log(sprintf('ERROR: Process being killed due to timeout! (%s, %s, %s, Process %s, Time %s, Timeout %s, Timestamp %s)', $tasktype, $taskname, $taskid, $r['pid'], $r['timeout_exceeded'], $r['timeout'], $r['current_timestamp']), false, 'POLLER');
posix_kill($r['pid'], SIGTERM);
cacti_process_kill((int) $r['pid'], SIGTERM);
}
unregister_process($tasktype, $taskname, $taskid);
@@ -3027,7 +3123,7 @@ function timeout_kill_registered_processes(string $tasktype = '', string $taskna
foreach ($processes as $r) {
if (cacti_process_still_running((int) $r['pid'])) {
cacti_log(sprintf('ERROR: Process killed due to timeout! (%s, %s, %s, %s)', $r['tasktype'], $r['taskname'], $r['taskid'], $r['pid']), false, 'POLLER');
posix_kill($r['pid'], SIGTERM);
cacti_process_kill((int) $r['pid'], SIGTERM);
} else {
cacti_log(sprintf('ERROR: Detected process that is gone and did not unregister first! (%s, %s, %s, %s)', $r['tasktype'], $r['taskname'], $r['taskid'], $r['pid']), false, 'POLLER');
}
+1 -1
View File
@@ -1016,7 +1016,7 @@ function rrdcheck_kill_running_processes() : void {
if (cacti_process_still_running((int) $p['pid'])) {
cacti_log(sprintf('WARNING: Killing rrdcheck %s PID %d due to another due to signal or overrun.', ucfirst($p['taskname']), $p['pid']), false, 'BOOST');
posix_kill($p['pid'], SIGTERM);
cacti_process_kill((int) $p['pid'], SIGTERM, 'BOOST');
}
unregister_process($p['tasktype'], $p['taskname'], $p['taskid'], $p['pid']);
+1 -1
View File
@@ -67,7 +67,7 @@ function sig_handler(int $signo) : void {
foreach ($running_processes as $process) {
if (function_exists('posix_kill')) {
cacti_log("WARNING: Termination poller process with pid '" . $process['pid'] . "'", true, 'POLLER', POLLER_VERBOSITY_LOW);
posix_kill($process['pid'], SIGTERM);
cacti_process_kill((int) $process['pid'], SIGTERM, 'POLLER');
}
}
}
+10 -4
View File
@@ -88,7 +88,7 @@ function sig_handler($signo) : void {
if (cacti_sizeof($pids)) {
foreach ($pids as $pid) {
posix_kill($pid, SIGTERM);
cacti_process_kill((int) $pid, SIGTERM, 'AUTOM8');
}
}
@@ -109,7 +109,7 @@ function sig_handler($signo) : void {
if (cacti_sizeof($pids)) {
foreach ($pids as $pid) {
posix_kill($pid, SIGTERM);
cacti_process_kill((int) $pid, SIGTERM, 'AUTOM8');
}
}
@@ -366,7 +366,13 @@ if ($master === false && $thread == 0) {
[$network_id]);
if ($command == 'cancel') {
/* Terminating ourselves, so exit rather than depend on the signal
arriving: the sibling cancel path below already does, and without
it a refused or lost signal leaves this while (true) re-querying
for ever and the cancelled discovery never stops. */
killProcess(getmypid());
exit(0);
}
$running = db_fetch_cell_prepared('SELECT count(*)
@@ -982,11 +988,11 @@ function display_help() : void {
}
function isProcessRunning(int $pid) : bool {
return posix_kill($pid, 0);
return cacti_process_pid_exists($pid);
}
function killProcess(int $pid) : bool {
return posix_kill($pid, SIGTERM);
return cacti_process_kill((int) $pid, SIGTERM, 'AUTOM8');
}
function removeMyProcess(int $pid, int $network_id) : void {
+1 -1
View File
@@ -415,7 +415,7 @@ function boost_kill_running_processes() : void {
if (cacti_process_still_running((int) $p['pid'])) {
cacti_log(sprintf('WARNING: Killing Boost %s PID %d due to another boost process starting.', ucfirst($p['taskname']), $p['pid']), true, 'BOOST');
posix_kill($p['pid'], SIGTERM);
cacti_process_kill((int) $p['pid'], SIGTERM, 'BOOST');
}
unregister_process($p['tasktype'], $p['taskname'], $p['taskid'], $p['pid']);
+1 -1
View File
@@ -425,7 +425,7 @@ function commands_kill_running_processes() : void {
if (cacti_process_still_running((int) $p['pid'])) {
cacti_log(sprintf('WARNING: Killing Commands %s PID %d due to another due to signal or overrun.', ucfirst($p['taskname']), $p['pid']), false, 'CLEANUP');
posix_kill($p['pid'], SIGTERM);
cacti_process_kill((int) $p['pid'], SIGTERM, 'CLEANUP');
}
unregister_process($p['tasktype'], $p['taskname'], $p['taskid'], $p['pid']);
+12 -1
View File
@@ -170,7 +170,18 @@ $records_inserted = 0;
debug('About to start recovery processing');
if (!empty($recovery_pid)) {
$pid = posix_kill($recovery_pid, 0);
/* A stored pid wider than pid_t narrows to -1 inside posix_kill(), which
reads as "someone is running" and leaves the stale row in place forever;
on PHP 8.5 the same value throws instead. Either way recovery never runs
again.
This branch reads false as "stale, clear the row and run", so it needs a
probe where a pid we cannot signal counts as stale. That is the prior
posix_kill($pid, 0) semantics, kept, with the bound added.
cacti_process_pid_exists() reports EPERM as present by contract and
cacti_process_still_running() inherits that wherever /proc cannot
answer, so neither of those fits here. */
$pid = cacti_process_signalable((int) $recovery_pid);
if ($pid === false) {
// we found a stale PID, so we delete it from the table
@@ -0,0 +1,310 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/*
* The files below each read a pid out of a process table and signalled it
* directly. lib/poller.php's own two sites are not in this list because it
* legitimately calls posix_kill() inside the guard itself; they are asserted
* separately.
* A row holding 1 would reach init, and one holding 4294967295, the maximum an
* int(10) unsigned column takes, narrows inside posix_kill() to -1, which
* kill(2) reads as every process the caller owns. cacti_process_kill() is the
* shared bound those sites now go through.
*/
require_once dirname(__DIR__, 4) . '/include/global_constants.php';
require_once dirname(__DIR__, 4) . '/lib/poller.php';
/**
* Every file that reads a pid out of a process table and signals it.
*
* @return array<int, string> Repository-relative paths whose signal calls
* the guard replaced.
*/
function process_kill_guard_sites() : array {
return [
'cli/batchgapfix.php',
'cli/float_rrdfiles.php',
'cli/poller_reindex_hosts.php',
'cli/rebuild_poller_cache.php',
'lib/dsstats.php',
'lib/rrdcheck.php',
'poller.php',
'poller_automation.php',
'poller_boost.php',
'poller_commands.php',
];
}
/**
* The file's code with comments and literal strings removed, so the scan reads
* calls rather than prose. Matching raw text tripped on a docblock naming the
* function it forbids, and matching one argument shape missed the scalar form
* these files actually use.
*
* @param string $src The file contents.
*
* @return string The same file with comments and string literals dropped.
*/
function process_kill_guard_code(string $src) : string {
$code = '';
foreach (token_get_all($src) as $token) {
if (is_array($token)) {
if (in_array($token[0], [T_COMMENT, T_DOC_COMMENT, T_CONSTANT_ENCAPSED_STRING], true)) {
continue;
}
$code .= $token[1];
} else {
$code .= $token;
}
}
return $code;
}
/**
* Whether the file still reaches posix_kill() itself. These files have no
* business calling it at all now, whatever the argument looks like.
*
* @param string $src The file contents.
*
* @return bool True when a raw signal call survives in the code.
*/
function process_kill_guard_signals_directly(string $src) : bool {
return strpos(process_kill_guard_code($src), 'posix_kill(') !== false;
}
test('a pid that cannot name a process is refused', function () {
expect(cacti_process_kill(0, SIGTERM))->toBeFalse()
->and(cacti_process_kill(-1, SIGTERM))->toBeFalse();
});
test('the guard refuses init and carries no wider low pid floor', function () {
/* A floor at the reserved low range would also exclude Cacti's own
children inside a pid namespace, where they hold single and double digit
pids, and every caller unregisters the row whether or not the signal
lands. Refusing there would strand a live collector with no registry row
and let a second one start against the same RRDs. Asserting on the
source keeps that decision from being quietly reversed; the behaviour
itself cannot be tested without signalling a real low pid. */
$src = file_get_contents(dirname(__DIR__, 4) . '/lib/poller.php');
$start = strpos($src, 'function cacti_process_kill(');
expect($start)->not->toBeFalse();
/* Slice to the function's own closing brace; a fixed width truncates as
soon as the body grows. */
$body = substr($src, $start, strpos($src, "\n}\n", $start) - $start);
expect($body)->toContain('$pid <= 1')
->and($body)->toContain('$pid > cacti_process_pid_max()')
->and($body)->not->toContain('<= 100');
});
test('a pid wider than pid_t is refused', function () {
if (PHP_INT_SIZE < 8) {
test()->markTestSkipped('a 32 bit build cannot express a pid wider than pid_t');
}
/* Through PHP 8.4 posix_kill() narrows 4294967295 to -1 and signals every
process the caller owns; from PHP 8.5 the same call raises a ValueError
that ends the poller. Refusing ahead of the call covers both. */
expect(cacti_process_kill(4294967295, SIGTERM))->toBeFalse()
->and(cacti_process_kill(PHP_INT_MAX, SIGTERM))->toBeFalse();
});
test('the process id ceiling follows the platform signal adapter', function () {
$expected = PHP_OS_FAMILY === 'Windows' && PHP_INT_SIZE >= 8 ? 4294967295 : 2147483647;
expect(cacti_process_pid_max())->toBe($expected);
});
test('a pid inside pid_t that does not exist is left to the kernel', function () {
// Above every platform's pid_max but a real pid_t, so it must reach
// posix_kill() and come back ESRCH rather than be refused here.
expect(cacti_process_kill(999999999, SIGTERM))->toBeFalse();
});
test('an ordinary pid is still signalled', function () {
$pipes = [];
$handle = proc_open([PHP_BINARY, '-r', 'sleep(30);'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
/* proc_open() returns false when process creation is disabled, and
proc_get_status(false) then fails somewhere less obvious than here. */
expect($handle)->toBeResource();
$pid = (int) proc_get_status($handle)['pid'];
foreach ($pipes as $pipe) {
fclose($pipe);
}
expect(cacti_process_kill($pid, SIGTERM))->toBeTrue();
/* Wait on the child's own status rather than probing the pid. Until the
parent reaps it the pid still exists as a zombie, so posix_kill($pid, 0)
would report it alive well after the signal landed. */
$deadline = microtime(true) + 5;
$status = proc_get_status($handle);
while ($status['running'] && microtime(true) < $deadline) {
usleep(10000);
$status = proc_get_status($handle);
}
expect($status['running'])->toBeFalse();
if (PHP_OS_FAMILY !== 'Windows') {
expect($status['signaled'])->toBeTrue()
->and($status['termsig'])->toBe(SIGTERM);
}
proc_close($handle);
});
test('the guard logs the row it refused', function () {
$src = file_get_contents(dirname(__DIR__, 4) . '/lib/poller.php');
expect($src)->not->toBeFalse()
->and($src)->toContain('Refusing to signal PID');
/* ext-posix is optional outside Windows, so the guard must not fatal there.
Slice to the function's own closing brace rather than a fixed width,
which drifts as the body changes. */
$start = strpos($src, 'function cacti_process_kill(');
expect($start)->not->toBeFalse();
expect(substr($src, $start, strpos($src, "\n}\n", $start) - $start))
->toContain("function_exists('posix_kill')");
});
test('every process table kill site routes through the guard', function () {
$missing = [];
$unguarded = [];
foreach (process_kill_guard_sites() as $file) {
$src = file_get_contents(dirname(__DIR__, 4) . '/' . $file);
expect($src)->not->toBeFalse($file . ' must be readable');
if (strpos($src, 'cacti_process_kill(') === false) {
$missing[] = $file;
}
if (process_kill_guard_signals_directly($src)) {
$unguarded[] = $file;
}
}
expect($missing)->toBe([])
->and($unguarded)->toBe([]);
});
/**
* Files that only probe a stored pid with signal 0. They never deliver a
* signal, but an unbounded probe still narrows a wide pid to -1 and answers
* "running" for a row that names nothing, and on PHP 8.5 it throws instead.
*
* @return array<int, string> Repository-relative paths.
*/
function process_kill_guard_probe_sites() : array {
return [
'poller_automation.php',
'poller_recovery.php',
];
}
test('a stored pid is never probed with a raw signal 0 either', function () {
$raw = [];
foreach (process_kill_guard_probe_sites() as $file) {
$src = file_get_contents(dirname(__DIR__, 4) . '/' . $file);
expect($src)->not->toBeFalse($file . ' must be readable');
if (preg_match('/posix_kill\([^)]*,\s*0\s*\)/', process_kill_guard_code($src))) {
$raw[] = $file;
}
}
expect($raw)->toBe([]);
});
test('the bounded probe answers false for a wide pid instead of throwing', function () {
if (PHP_INT_SIZE < 8) {
test()->markTestSkipped('a 32 bit build cannot express a pid wider than pid_t');
}
/* poller_recovery treats a true answer as "another recovery owns this pid"
and leaves the stale settings row in place, so a wide pid answered true
would wedge recovery permanently rather than merely misreport once. */
expect(cacti_process_pid_exists(4294967295))->toBeFalse()
->and(cacti_process_pid_exists(PHP_INT_MAX))->toBeFalse();
});
test('the scan catches every shape of raw signal and ignores prose', function () {
/* Without this the ratchet's own coverage is unverified. The scalar form is
the one half these files use, and it is the shape an argument-matching
regex missed. */
$guarded = <<<'GUARDED'
<?php
/* posix_kill($pid, 0) is what this replaced. */
cacti_process_kill($p['pid'], SIGTERM, 'BOOST');
$note = 'posix_kill(';
GUARDED;
expect(process_kill_guard_signals_directly($guarded))->toBeFalse();
$shapes = [
'posix_kill($row[\'pid\'], SIGTERM);',
'posix_kill($pid, SIGTERM);',
'posix_kill((int) $r[\'pid\'], SIGTERM);',
'posix_kill($pid, 0);',
];
foreach ($shapes as $shape) {
expect(process_kill_guard_signals_directly("<?php\n" . $shape . "\n"))
->toBeTrue($shape . ' must be reported');
}
});
test('a pid that exists but cannot be signalled counts as stale, not live', 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');
}
/* The two contracts must stay apart. cacti_process_pid_exists() answers
"is it there", and EPERM means yes. poller_recovery asks "is the row
still mine", where EPERM means the pid was recycled by someone else and
the row is stale; reading it as live leaves settings.recovery_pid in
place and retires recovery for good. */
expect(cacti_process_pid_exists(1))->toBeTrue()
->and(cacti_process_signalable(1))->toBeFalse();
$src = file_get_contents(dirname(__DIR__, 4) . '/poller_recovery.php');
expect($src)->not->toBeFalse()
->and(process_kill_guard_code($src))->toContain('cacti_process_signalable(');
});
test('cacti_process_kill refuses init behaviourally, not just in source', function () {
// Signal 0 delivers nothing, so this asserts the refusal without touching init.
expect(cacti_process_kill(1, 0))->toBeFalse()
->and(cacti_process_signalable(getmypid()))->toBeTrue();
});
@@ -28,8 +28,8 @@
* check, using the command name under /proc where that exists.
*/
require_once dirname(__DIR__, 2) . '/include/global_constants.php';
require_once dirname(__DIR__, 2) . '/lib/poller.php';
require_once dirname(__DIR__, 4) . '/include/global_constants.php';
require_once dirname(__DIR__, 4) . '/lib/poller.php';
/**
* Starts a process running a different program and returns its pid.
@@ -73,6 +73,32 @@ test('a pid that cannot be valid is never reported running', function () {
->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();
});
@@ -151,9 +177,9 @@ test('without /proc the check falls back to plain existence', function () {
});
test('every registry kill site checks the pid before signalling it', function () {
$poller = file_get_contents(dirname(__DIR__, 2) . '/lib/poller.php');
$dsstats = file_get_contents(dirname(__DIR__, 2) . '/lib/dsstats.php');
$batchgapfix = file_get_contents(dirname(__DIR__, 2) . '/cli/batchgapfix.php');
$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')
@@ -173,7 +199,7 @@ test('every registry kill site checks the pid before signalling it', function ()
* 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__, 2) . '/lib/poller.php');
$body = file_get_contents(dirname(__DIR__, 4) . '/lib/poller.php');
$start = strpos($body, 'function cacti_process_still_running(');
expect($start)->not->toBeFalse();