mirror of
https://github.com/Cacti/spine.git
synced 2026-10-23 03:27:10 +09:00
fix: reap script/PHP children without blocking the poller, drop USING_TPOPEN (#633)
## What - `nft_pclose()` (`nft_popen.c`) and `php_terminate_and_reap()` (`php.c`) no longer escalate through a spin/sleep/SIGTERM/SIGKILL sequence. Each now does one bounded, non-blocking check (~20ms allowance for normal fork/exec/exit scheduling latency), kills anything still running, and hands it to the existing abandoned-pid sweep instead of waiting on it in the calling (polling) thread. - Removed the `USING_TPOPEN`/`--enable-popen` native-`popen()` fallback in `exec_poll()` (`poller.c`, `configure.ac`). It could not track or kill a timed-out child (no pid available from a real `popen()`), so a timeout there could only ever abandon the descriptor without terminating the process. - Extended `tests/regression/test_child_process_safety.sh` with structural guards against reintroducing the blocking spin/sleep escalation in either function. - Updated `tests/unit/test_linked.c`'s `nft_pclose()` coverage for the new, faster contract (the old test asserted a graceful-SIGTERM-then-wait exit code that the new design intentionally no longer provides). ## Why Investigating a reported poller slowdown surfaced that `nft_pclose()` (added by #614) could block a poller thread for up to ~5 seconds per script whose process hadn't exited by the time its pipe was closed, and `php_terminate_and_reap()` had the same shape (up to ~2s), reachable mid-poll-cycle via `php_fail_read()`, not just at shutdown. Separately, the `USING_TPOPEN` fallback's inability to kill a timed-out child was flagged as out of scope in #574. Tracked in #632; design principles written up in #630. ## What was tested - `./bootstrap && ./configure --enable-warnings && make -j` — clean build, no new warnings. - `make check` — all 11 suites pass (unit, regression, integration), including the extended `test_child_process_safety.sh` guard and the updated `test_linked` case. - `cppcheck --enable=all --std=c11` on the three changed `.c` files — no new errors (pre-existing style-only notes unrelated to this change). Signed-off-by: TheWitness <thewitness@cacti.net>
This commit is contained in:
1 parent
86a062dd09
commit
08e2ae0b42
7 files changed
+89
-133
No files matched your search
@@ -63,6 +63,8 @@ The Cacti Group | spine
|
||||
-feature: Add the unit, fuzz and integration test suites
|
||||
-feature: Make spine work with Stream feature
|
||||
-feature: Seed spine.conf from spine.conf.dist on first install without overwriting an existing config
|
||||
-issue#632: Reap nft_pclose() and php_terminate_and_reap() with a single bounded non-blocking check, killing and handing off to the abandoned-pid sweep instead of a blocking spin/SIGTERM/SIGKILL escalation that could pin a poller thread for seconds
|
||||
-issue#632: Remove the USING_TPOPEN/--enable-popen native popen() fallback in exec_poll(), which could not track or kill a timed-out child
|
||||
|
||||
1.2.31
|
||||
-issue#365: Removing Backtrace Support
|
||||
|
||||
@@ -438,21 +438,6 @@ AC_ARG_WITH(max-mysql-buffer,
|
||||
AC_DEFINE_UNQUOTED(MAX_MYSQL_BUF_SIZE, $max_mysql_buffer, The maximum MySQL buffer size to insert)
|
||||
AC_MSG_RESULT(checking for the maximum MySQL buffer size... $max_mysql_buffer)
|
||||
|
||||
# ****************** Traditional Popen Check ***********************
|
||||
# If we should use the system popen or nifty popen
|
||||
AC_MSG_CHECKING(whether we are using traditional popen)
|
||||
AC_ARG_ENABLE(popen,
|
||||
[ --enable-popen Enable the traditional popen implementation of nifty popen (default: disabled)],
|
||||
[ ENABLED_TPOPEN=$enableval ],
|
||||
[ ENABLED_TPOPEN=no ]
|
||||
)
|
||||
if test "$ENABLED_TPOPEN" = "yes"; then
|
||||
AC_MSG_RESULT([yes])
|
||||
AC_DEFINE(USING_TPOPEN, 1, If traditional popen should be enabled by default)
|
||||
else
|
||||
AC_MSG_RESULT([no])
|
||||
fi
|
||||
|
||||
# ****************** Force Net-SNMP Version Checks ***********************
|
||||
# If we should use the system popen or nifty popen
|
||||
AC_MSG_CHECKING(whether to verify net-snmp library vs header versions)
|
||||
|
||||
+30
-42
@@ -145,28 +145,19 @@ static __attribute__((noinline)) struct pid *pid_list_close_and_take(int fd)
|
||||
|
||||
return cur;
|
||||
}
|
||||
|
||||
/* nft_pclose() must not block a poller thread indefinitely. A script that
|
||||
writes its value and then lingers, or that ignores SIGPIPE, would otherwise
|
||||
pin the thread across polling cycles while holding its available_scripts
|
||||
token. Poll with WNOHANG, then escalate to SIGKILL. */
|
||||
/* Budget to SIGKILL is about five seconds on both platforms. The counts differ
|
||||
* because the granularity does: everywhere else in the tree usleep() is simply
|
||||
* skipped under SOLAR_THREAD rather than replaced, so the coarsest wait
|
||||
* available there is a whole second and the attempt count scales to match.
|
||||
* Leaving the counts equal made the Solaris path roughly a hundred seconds,
|
||||
* longer than a polling cycle, while nft_pclose() holds an available_scripts
|
||||
* token throughout. */
|
||||
/* nft_pclose() must not block a poller thread: a script that writes its
|
||||
value and then lingers, or that ignores signals, would otherwise pin the
|
||||
thread across polling cycles while holding its available_scripts token.
|
||||
It gets a brief (~20ms), non-escalating WNOHANG allowance via
|
||||
spine_reap_child_bounded() below - just enough to absorb ordinary
|
||||
fork/exec/exit scheduling latency (measured flaky under virtualized/loaded
|
||||
CI at the original 2ms budget), not to wait out a script that is actually
|
||||
still working. Anything still running past that is killed outright and
|
||||
handed to nft_abandon_child() so nft_sweep_abandoned() reaps it on a
|
||||
later, unrelated nft_popen() call instead of this thread waiting for it. */
|
||||
#define NFT_PCLOSE_REAP_USEC 50000
|
||||
#define NFT_PCLOSE_SPIN_USEC 200
|
||||
#define NFT_PCLOSE_SPIN_ATTEMPTS 10
|
||||
#ifndef SOLAR_THREAD
|
||||
#define NFT_PCLOSE_TERM_ATTEMPTS 100
|
||||
#define NFT_PCLOSE_KILL_ATTEMPTS 20
|
||||
#else
|
||||
#define NFT_PCLOSE_TERM_ATTEMPTS 5
|
||||
#define NFT_PCLOSE_KILL_ATTEMPTS 2
|
||||
#endif
|
||||
#define NFT_PCLOSE_SPIN_ATTEMPTS 100
|
||||
|
||||
int spine_set_cloexec(int fd) {
|
||||
int flags;
|
||||
@@ -303,15 +294,13 @@ int spine_reap_child_bounded(pid_t pid, int *pstat, int attempts) {
|
||||
}
|
||||
|
||||
/* The delay is load-bearing: without it the attempts are spent in
|
||||
nanoseconds and SIGKILL lands before the child can exit.
|
||||
nanoseconds and the caller's kill lands before the child can exit.
|
||||
|
||||
Starting at the full 50ms charged that to every script that exits a
|
||||
moment after closing stdout, which is the common case for anything
|
||||
that flushes or tears down an interpreter. nft_pclose() runs while
|
||||
the caller still holds an available_scripts token, so that delay
|
||||
costs poller capacity rather than one thread. Spin briefly first,
|
||||
then settle. The attempt count and so the time to SIGKILL are
|
||||
unchanged. */
|
||||
that flushes or tears down an interpreter. Spin briefly first, then
|
||||
settle, so a caller that opts into this bounded wait does not pay
|
||||
the full interval on every reap. */
|
||||
#ifndef SOLAR_THREAD
|
||||
if (attempt < NFT_PCLOSE_SPIN_ATTEMPTS) {
|
||||
usleep(NFT_PCLOSE_SPIN_USEC);
|
||||
@@ -609,7 +598,10 @@ int nft_pchild(int fd) {
|
||||
*
|
||||
* nft_pclose
|
||||
*
|
||||
* Close the pipe and wait for the status of the child process.
|
||||
* Close the pipe and check the child's status with a brief (~20ms),
|
||||
* non-escalating bounded waitpid(). A child still running past that point
|
||||
* is killed and handed to the abandoned-pid sweep rather than waited for
|
||||
* here, so this call never blocks the caller on a lingering script.
|
||||
*
|
||||
* On success, the exit status of the child process is returned.
|
||||
* On failure, nft_pclose() returns -1, with errno set to:
|
||||
@@ -646,26 +638,22 @@ nft_pclose(int fd)
|
||||
|
||||
pthread_setcancelstate(cancel_state, NULL);
|
||||
|
||||
/* Give a child a brief chance to observe pipe EOF, then request graceful
|
||||
* termination before escalating to SIGKILL. */
|
||||
/* The script already had its one chance to write and its pipe is now
|
||||
* closed. Give it a brief (~20ms), non-escalating allowance to catch the
|
||||
* common case where it has already exited or is about to on seeing EOF -
|
||||
* without it, ordinary fork/exec/exit scheduling latency would flag a
|
||||
* script that is not actually misbehaving. Anything still running past
|
||||
* that is killed and handed to the abandoned-pid sweep instead of this
|
||||
* thread waiting for it. */
|
||||
switch (spine_reap_child_bounded(cur->pid, &pstat, NFT_PCLOSE_SPIN_ATTEMPTS)) {
|
||||
case 0:
|
||||
pid = cur->pid;
|
||||
break;
|
||||
case 1:
|
||||
(void)kill(cur->pid, SIGTERM);
|
||||
if (spine_reap_child_bounded(cur->pid, &pstat, NFT_PCLOSE_TERM_ATTEMPTS) == 0) {
|
||||
pid = cur->pid;
|
||||
} else {
|
||||
(void)kill(cur->pid, SIGKILL);
|
||||
if (spine_reap_child_bounded(cur->pid, &pstat, NFT_PCLOSE_KILL_ATTEMPTS) == 0) {
|
||||
pid = cur->pid;
|
||||
} else {
|
||||
nft_abandon_child(cur->pid, "kill budget expired");
|
||||
errno = ETIMEDOUT;
|
||||
pid = -1;
|
||||
}
|
||||
}
|
||||
(void)kill(cur->pid, SIGKILL);
|
||||
nft_abandon_child(cur->pid, "did not exit before pipe close");
|
||||
errno = ETIMEDOUT;
|
||||
pid = -1;
|
||||
break;
|
||||
default:
|
||||
nft_abandon_child(cur->pid, "waitpid failed");
|
||||
|
||||
@@ -801,38 +801,26 @@ cleanup:
|
||||
return rc;
|
||||
}
|
||||
static int php_terminate_and_reap(pid_t pid) {
|
||||
int attempts;
|
||||
int phase;
|
||||
int status;
|
||||
int signal_number = SIGTERM;
|
||||
pid_t waited;
|
||||
|
||||
for (phase = 0; phase < 2; phase++) {
|
||||
if (kill(pid, signal_number) < 0 && errno != ESRCH) {
|
||||
SPINE_LOG(("WARNING: Unable to signal PHP Script Server PID[%ld]: %s", (long)pid, strerror(errno)));
|
||||
}
|
||||
do {
|
||||
waited = waitpid(pid, &status, WNOHANG);
|
||||
} while (waited < 0 && errno == EINTR);
|
||||
|
||||
for (attempts = 0; attempts < 20; attempts++) {
|
||||
do {
|
||||
waited = waitpid(pid, &status, WNOHANG);
|
||||
} while (waited < 0 && errno == EINTR);
|
||||
if (waited == pid || (waited < 0 && errno == ECHILD)) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (waited == pid || (waited < 0 && errno == ECHILD)) {
|
||||
return TRUE;
|
||||
}
|
||||
if (waited < 0) {
|
||||
SPINE_LOG(("WARNING: Unable to reap PHP Script Server PID[%ld]: %s", (long)pid, strerror(errno)));
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (waited < 0) {
|
||||
SPINE_LOG(("WARNING: Unable to reap PHP Script Server PID[%ld]: %s", (long)pid, strerror(errno)));
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* The delay is load-bearing: without it both phases burn twenty
|
||||
* WNOHANG polls in nanoseconds, so SIGKILL lands immediately and
|
||||
* the child is never reaped. */
|
||||
usleep(50000);
|
||||
}
|
||||
|
||||
signal_number = SIGKILL;
|
||||
/* Still running after its grace period: kill outright rather than
|
||||
* waiting to see whether a gentler signal works. */
|
||||
if (kill(pid, SIGKILL) < 0 && errno != ESRCH) {
|
||||
SPINE_LOG(("WARNING: Unable to signal PHP Script Server PID[%ld]: %s", (long)pid, strerror(errno)));
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
@@ -847,8 +835,11 @@ static int php_terminate_and_reap(pid_t pid) {
|
||||
* information is will close and/or terminate the child PHP Script Server
|
||||
* process and then return to the calling function.
|
||||
*
|
||||
* Child shutdown is bounded: SIGTERM receives a grace period before SIGKILL.
|
||||
* Both phases use non-blocking reaping so a wedged child cannot hang Spine.
|
||||
* Shutdown gives the server one grace period (the "quit" write plus a
|
||||
* fixed delay below) to exit on its own, then checks once with a
|
||||
* non-blocking waitpid(). A child still running at that point is killed
|
||||
* outright and handed to the abandoned-pid sweep instead of waiting for
|
||||
* it here.
|
||||
*/
|
||||
void php_close(int php_process) {
|
||||
int i;
|
||||
|
||||
@@ -2422,11 +2422,6 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) {
|
||||
int cmd_fd;
|
||||
int pid;
|
||||
|
||||
#ifdef USING_TPOPEN
|
||||
FILE *fd;
|
||||
int close_fd = TRUE;
|
||||
#endif
|
||||
|
||||
int bytes_read;
|
||||
fd_set fds;
|
||||
double begin_time = 0;
|
||||
@@ -2518,22 +2513,12 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) {
|
||||
SPINE_LOG_DEBUG(("The executable is '%s' in \'%s\'", executable, proc_command));
|
||||
|
||||
if (access(executable, X_OK | F_OK) != -1) {
|
||||
#ifdef USING_TPOPEN
|
||||
fd = popen((char *)proc_command, "r");
|
||||
cmd_fd = fileno(fd);
|
||||
if (is_debug_device(current_host->id)) {
|
||||
SPINE_LOG(("Device[%i] DEBUG: The POPEN returned the following File Descriptor %i", current_host->id, cmd_fd));
|
||||
} else {
|
||||
SPINE_LOG_DEBUG(("Device[%i] DEBUG: The POPEN returned the following File Descriptor %i", current_host->id, cmd_fd));
|
||||
}
|
||||
#else
|
||||
cmd_fd = nft_popen(proc_command, "r");
|
||||
if (is_debug_device(current_host->id)) {
|
||||
SPINE_LOG(("Device[%i] DEBUG: The NIFTY POPEN returned the following File Descriptor %i", current_host->id, cmd_fd));
|
||||
} else {
|
||||
SPINE_LOG_DEBUG(("Device[%i] DEBUG: The NIFTY POPEN returned the following File Descriptor %i", current_host->id, cmd_fd));
|
||||
}
|
||||
#endif
|
||||
|
||||
if (cmd_fd > 0) {
|
||||
retry:
|
||||
@@ -2550,10 +2535,6 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) {
|
||||
SPINE_LOG(("Device[%i] ERROR: One or more of the file descriptor sets specified a file descriptor that is not a valid open file descriptor.", current_host->id));
|
||||
SET_UNDEFINED(result_string);
|
||||
|
||||
#ifdef USING_TPOPEN
|
||||
close_fd = FALSE;
|
||||
#endif
|
||||
|
||||
break;
|
||||
case EINTR:
|
||||
#ifndef SOLAR_THREAD
|
||||
@@ -2581,34 +2562,20 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) {
|
||||
} else {
|
||||
SPINE_LOG(("WARNING: A script timed out while processing EINTR's."));
|
||||
SET_UNDEFINED(result_string);
|
||||
#ifdef USING_TPOPEN
|
||||
close_fd = FALSE;
|
||||
#endif
|
||||
}
|
||||
break;
|
||||
case EINVAL:
|
||||
SPINE_LOG(("Device[%i] ERROR: Possible invalid timeout specified in select() statement.", current_host->id));
|
||||
SET_UNDEFINED(result_string);
|
||||
#ifdef USING_TPOPEN
|
||||
close_fd = FALSE;
|
||||
#endif
|
||||
break;
|
||||
default:
|
||||
SPINE_LOG(("Device[%i] ERROR: The script/command select() failed", current_host->id));
|
||||
SET_UNDEFINED(result_string);
|
||||
#ifdef USING_TPOPEN
|
||||
close_fd = FALSE;
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case 0:
|
||||
#ifdef USING_TPOPEN
|
||||
SPINE_LOG_MEDIUM(("Device[%i] ERROR: The POPEN timed out", current_host->id));
|
||||
|
||||
close_fd = FALSE;
|
||||
#else
|
||||
SPINE_LOG_MEDIUM(("Device[%i] ERROR: The NIFTY POPEN timed out", current_host->id));
|
||||
|
||||
pid = nft_pchild(cmd_fd);
|
||||
@@ -2617,7 +2584,6 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) {
|
||||
} else {
|
||||
SPINE_LOG(("Device[%i] ERROR: Unable to find the timed-out POPEN child", current_host->id));
|
||||
}
|
||||
#endif
|
||||
|
||||
SET_UNDEFINED(result_string);
|
||||
break;
|
||||
@@ -2637,14 +2603,7 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) {
|
||||
}
|
||||
|
||||
/* close pipe */
|
||||
#ifdef USING_TPOPEN
|
||||
/* we leave the old fd open if it timed out. It will have to exit on it's own */
|
||||
if (close_fd) {
|
||||
pclose(fd);
|
||||
}
|
||||
#else
|
||||
nft_pclose(cmd_fd);
|
||||
#endif
|
||||
} else {
|
||||
SPINE_LOG(("Device[%i] ERROR: Problem executing POPEN [%s]: '%s'", current_host->id, current_host->hostname, command));
|
||||
SET_UNDEFINED(result_string);
|
||||
|
||||
@@ -48,6 +48,23 @@ if grep -q 'waitpid(phpp->php_pid, &wstatus, 0)' php.c; then
|
||||
fail "php_close() must not block in waitpid()"
|
||||
fi
|
||||
|
||||
# PR #614 replaced the blocking waitpid() above with a bounded-but-still-
|
||||
# blocking spin/sleep/SIGTERM/SIGKILL escalation, which reproduced the same
|
||||
# poller slowdown this guard exists to prevent. nft_pclose() must reap with a
|
||||
# single non-blocking check and hand anything still running to the abandoned-
|
||||
# pid sweep instead of spinning/sleeping in the calling thread.
|
||||
nft_pclose_body=$(awk '/^nft_pclose\(int fd\)/,/^}/' nft_popen.c)
|
||||
|
||||
printf '%s\n' "$nft_pclose_body" | grep -qE 'usleep|NFT_PCLOSE_(TERM|KILL)_ATTEMPTS' &&
|
||||
fail "nft_pclose() must not spin/sleep waiting for a child to exit"
|
||||
|
||||
# php_terminate_and_reap() must hold the same invariant: one non-blocking
|
||||
# check, then kill and move on.
|
||||
php_terminate_body=$(awk '/^static int php_terminate_and_reap/,/^}/' php.c)
|
||||
|
||||
printf '%s\n' "$php_terminate_body" | grep -qE 'usleep|for \(' &&
|
||||
fail "php_terminate_and_reap() must not spin/sleep waiting for a child to exit"
|
||||
|
||||
echo "PASS: child process safety invariants"
|
||||
|
||||
# php_init() has one cleanup path that closes every descriptor it still holds,
|
||||
|
||||
@@ -1042,19 +1042,33 @@ static void test_reap_reports_an_already_reaped_child(void **state) {
|
||||
assert_int_equal(pstat, 0);
|
||||
}
|
||||
|
||||
static void test_nft_pclose_requests_graceful_termination_before_kill(void **state) {
|
||||
static void test_nft_pclose_does_not_block_on_a_lingering_child(void **state) {
|
||||
char ready[6] = {0};
|
||||
int fd;
|
||||
int status;
|
||||
double start;
|
||||
int attempts;
|
||||
|
||||
(void) state;
|
||||
fd = nft_popen("trap 'exit 0' TERM; printf ready; while :; do sleep 1; done", "r");
|
||||
fd = nft_popen("printf ready; while :; do sleep 1; done", "r");
|
||||
assert_true(fd >= 0);
|
||||
assert_int_equal(read(fd, ready, 5), 5);
|
||||
assert_string_equal(ready, "ready");
|
||||
status = nft_pclose(fd);
|
||||
assert_true(WIFEXITED(status));
|
||||
assert_int_equal(WEXITSTATUS(status), 0);
|
||||
|
||||
start = get_time_as_double();
|
||||
assert_int_equal(nft_pclose(fd), -1);
|
||||
|
||||
/* The removed escalating spin/sleep/SIGTERM/SIGKILL sequence took up to
|
||||
* ~5 seconds here. nft_pclose() must kill the child and hand it to the
|
||||
* abandoned-pid sweep instead of blocking waiting for it to exit. */
|
||||
assert_true(get_time_as_double() - start < 0.5);
|
||||
|
||||
for (attempts = 0; attempts < 50; attempts++) {
|
||||
if (nft_abandoned_pending() == 0) {
|
||||
break;
|
||||
}
|
||||
usleep(20000);
|
||||
}
|
||||
assert_int_equal(nft_abandoned_pending(), 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
@@ -1113,7 +1127,7 @@ int main(void) {
|
||||
cmocka_unit_test(test_reap_returns_still_running_rather_than_blocking),
|
||||
cmocka_unit_test(test_reap_collects_an_exited_child),
|
||||
cmocka_unit_test(test_reap_reports_an_already_reaped_child),
|
||||
cmocka_unit_test(test_nft_pclose_requests_graceful_termination_before_kill),
|
||||
cmocka_unit_test(test_nft_pclose_does_not_block_on_a_lingering_child),
|
||||
cmocka_unit_test(test_abandoned_children_are_swept_and_capacity_is_bounded),
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user