Files
spine/tests/integration/test_script_server_reap.sh
Thomas VincentandTheWitness 90da94b94f fix(process): unify subprocess ownership and child lifecycle safety (#614)
## Summary

- make subprocess-registry detach atomic so exactly one closer owns each
entry
- make cancellation cleanup release only the detached node it owns
- replace `vfork`/`execve` child setup with `posix_spawn` and explicit
file actions
- mark every pipe and stdio-collision duplicate close-on-exec
- restore the child's default SIGPIPE disposition while preserving the
parent's handling
- bound TERM/KILL reaping and park unreaped children in a fixed-capacity
sweep list
- harden PHP script-server slot ownership, startup, restart, signal,
descriptor, and cleanup paths
- retire and restart failed or oversized reads so stale response tails
cannot poison later data sources
- revalidate every command retry and cap recovery handshakes at two
seconds

This folds the child-process lifecycle portion of #597 into the narrower
ownership PR. It does not absorb unrelated poll-host, SQL, SNMP, ICMP,
or build-infrastructure work.

## Review-driven corrections

- close-on-exec is explicitly restored after `dup()` in both read and
write stdio-collision branches
- the source invariant check now uses portable POSIX character classes
- direct tests cover duplicate flags, both collision branches, failure
after pipe creation, abandoned-child sweeping, and capacity overflow
- collision cleanup preserves current-child redirects even when an older
registry entry occupies fd 0 or 1
- script children receive SIGTERM before SIGKILL and unreaped PHP
children transfer to the bounded sweep registry
- the Docker integration suite now starts a real PHP script server,
verifies its startup protocol and database result, and checks that
shutdown leaves no zombie PHP process

## Validation

- Ubuntu 24.04 container with GCC and GNU ld: `make check`, 6/6 targets
passed with no skips, including `test_php_runtime`
- real PHP script-server Docker integration: 6/6 checks passed; exact
value `42` reached `poller_output` and no zombie remained
- macOS Homebrew dependency build passed
- macOS `make check`: 5/5 available binaries passed; GNU `--wrap`
runtime suite is Linux-only
- PHP fixture syntax checked through mise PHP 8.3
- actionlint, shellcheck, and `git diff --check` passed

Closes #610.

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-09-22 17:24:04 -04:00

133 lines
4.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Integration test for the PHP script-server reap fix in php_close().
#
# Bug: php_close() sent SIGTERM but never waitpid()'d the child, so each
# script-server shutdown left a <defunct> zombie. Fix: reap with waitpid()
# and reset php_pid. This test drives a real poll against a script-server
# data source (poller_item.action = POLLER_ACTION_PHP_SCRIPT_SERVER = 2) and
# asserts no zombie php child survives the run.
#
# Requires docker compose. The checked-in fixture supplies PHP and the minimal
# script-server protocol needed to exercise the real Spine process lifecycle.
#
# Usage: ./tests/integration/test_script_server_reap.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMPOSE=(docker compose -f "$REPO_ROOT/tests/snmpv3/docker-compose.yml")
PASS=0
FAIL=0
pass() { echo " PASS: $*"; PASS=$((PASS+1)); }
fail() { echo " FAIL: $*"; FAIL=$((FAIL+1)); }
# ---------------------------------------------------------------------------
# Preconditions: docker, the compose fixture, and a PHP-capable spine image.
# ---------------------------------------------------------------------------
command -v docker >/dev/null 2>&1 || { echo "FAIL: docker not installed"; exit 1; }
docker compose version >/dev/null 2>&1 || { echo "FAIL: docker compose plugin not available"; exit 1; }
echo ""
echo "=== Setup: build spine image and probe for PHP ==="
"${COMPOSE[@]}" build spine >/dev/null 2>&1 \
|| { echo "FAIL: spine image failed to build"; exit 1; }
# The script server execs PHP; without it the action=2 path cannot run.
if ! "${COMPOSE[@]}" run --rm --no-deps --entrypoint sh spine \
-c 'command -v php >/dev/null 2>&1'; then
echo "FAIL: spine runtime image has no PHP" >&2
exit 1
fi
cleanup() {
echo ""
echo "=== Cleanup ==="
"${COMPOSE[@]}" down -v --remove-orphans 2>/dev/null || true
}
trap cleanup EXIT
"${COMPOSE[@]}" up -d db snmpd >/dev/null 2>&1
# Wait for seed data.
elapsed=0
while [[ $elapsed -lt 120 ]]; do
count=$("${COMPOSE[@]}" exec -T db mariadb -uspine -pspine cacti \
-N -e "SELECT COUNT(*) FROM host;" 2>/dev/null || echo "0")
[[ "$count" -gt 0 ]] && break
sleep 3; elapsed=$((elapsed + 3))
done
[[ "$count" -gt 0 ]] || { fail "database did not start"; exit 1; }
pass "infrastructure ready"
"${COMPOSE[@]}" exec -T db mariadb -uspine -pspine cacti -e "
INSERT INTO settings (name, value) VALUES
('path_webroot', '/opt/cacti'),
('path_php_binary', '/usr/bin/php')
ON DUPLICATE KEY UPDATE value = VALUES(value);
" 2>/dev/null
pass "PHP script-server settings seeded"
# ---------------------------------------------------------------------------
# Seed a script-server data source (action=2) for host 1.
# ---------------------------------------------------------------------------
echo ""
echo "=== Seed script-server poller_item (action=2) ==="
"${COMPOSE[@]}" exec -T db mariadb -uspine -pspine cacti -e "
INSERT IGNORE INTO poller_item (
local_data_id, host_id, action, hostname,
rrd_name, rrd_path, rrd_num, rrd_step, arg1, deleted, poller_id
) VALUES (
900, 1, 2, 'localhost',
'ss', '/dev/null', 1, 300, 'ss_test.php ss_value 1', '', 1
);" 2>/dev/null
pass "script-server poller_item seeded"
# ---------------------------------------------------------------------------
# Poll: spine starts the script server, then php_close() must reap it.
# Run inside one container so we can inspect its process table afterward.
# ---------------------------------------------------------------------------
echo ""
echo "=== Poll and check for zombie php children ==="
set +e
poll_out=$("${COMPOSE[@]}" run --rm --entrypoint sh spine -c '
/usr/local/bin/spine --conf=/etc/spine/spine.conf -f 1 -l 1 -S
echo "---PROCTABLE---"
ps -eo pid,ppid,stat,comm 2>/dev/null || true
' 2>&1)
poll_status=$?
set -e
echo "$poll_out"
if [[ $poll_status -ne 0 ]]; then
fail "spine exited with status $poll_status during script-server poll"
elif echo "$poll_out" | grep -qi "segfault\|SIGSEGV\|Aborted"; then
fail "spine crashed during script-server poll"
else
pass "spine completed script-server poll without crash"
fi
# A reaped child leaves no <defunct>/Z-state php entry.
proctable=$(echo "$poll_out" | sed -n '/---PROCTABLE---/,$p')
if echo "$proctable" | grep -Ei '<defunct>|[[:space:]]Z[[:space:]+]*[[:space:]]php'; then
fail "zombie php child remained after poll (php_close reap regression)"
else
pass "no zombie php child after poll"
fi
value=$("${COMPOSE[@]}" exec -T db mariadb -uspine -pspine cacti -N -B \
-e "SELECT output FROM poller_output WHERE local_data_id = 900 LIMIT 1;" 2>/dev/null)
if [[ $value == 42 ]]; then
pass "script-server command returned and stored 42"
else
fail "script-server command did not store 42 (got '$value')"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "=== Results: ${PASS} passed, ${FAIL} failed ==="
[[ $FAIL -eq 0 ]]