mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
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:
1 parent
a2c8df612e
commit
3f377531b7
15 files changed
+473
-23
No files matched your search
@@ -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
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
+32
-6
@@ -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();
|
||||
Reference in new issue
Block a user