Files
DDNS/tests/scripts/test-docker-zombies.sh
e102ce5039 fix(docker): prevent zombie process accumulation (#711)
* fix(docker): prevent zombie process accumulation

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(docker): forward signals to startup processes

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-08 01:05:22 +08:00

212 lines
5.9 KiB
Bash
Executable File

#!/bin/sh
# Verify that regex IP detection neither launches an intermediate shell nor
# leaves zombie processes when crond is the container's PID 1.
set -eu
if [ "${DDNS_SIGNAL_TEST_IP:-}" = "1" ]; then
trap ': > /signal/terminated; exit 0' TERM INT
: > /signal/started
while :; do
sleep 1
done
fi
TEST_ROOT=/tmp/ddns-zombie-test
TEST_BIN="$TEST_ROOT/bin"
PARENT_LOG="$TEST_ROOT/ip-parents"
READY_FILE="$TEST_ROOT/ready"
setup_container() {
mkdir -p "$TEST_BIN"
cat > "$TEST_BIN/ip" <<'EOF'
#!/bin/sh
set -eu
if [ "$#" -ne 1 ] || [ "$1" != "address" ]; then
echo "Unexpected ip arguments: $*" >&2
exit 64
fi
cat "/proc/$PPID/comm" >> /tmp/ddns-zombie-test/ip-parents
cat <<'EOF_IP_OUTPUT'
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
inet6 2001:db8::42/64 scope global
EOF_IP_OUTPUT
EOF
chmod 755 "$TEST_BIN/ip"
: > "$PARENT_LOG"
: > "$READY_FILE"
}
check_process_tree() {
init_name=$(cat /proc/1/comm)
if [ "$init_name" != "tini" ]; then
echo "Expected tini as PID 1, got $init_name" >&2
return 1
fi
crond_parent=$(ps -o pid,ppid,stat,comm | awk 'NR > 1 && $4 == "crond" { print $2; exit }')
if [ "$crond_parent" != "1" ]; then
echo "Expected crond to be a direct child of tini, got PPID ${crond_parent:-missing}" >&2
return 1
fi
}
check_container() {
expected_runs=$1
actual_runs=$(wc -l < "$PARENT_LOG")
check_process_tree
if [ "$actual_runs" -ne "$expected_runs" ]; then
echo "Expected $expected_runs ip executions, got $actual_runs" >&2
cat "$PARENT_LOG" >&2
exit 1
fi
if grep -Eq "^(sh|ash|busybox)$" "$PARENT_LOG"; then
echo "Regex IP detection launched ip through an intermediate shell" >&2
cat "$PARENT_LOG" >&2
exit 1
fi
zombies=$(ps -o pid,ppid,stat,comm | awk 'NR > 1 && $3 ~ /^Z/ { print }')
if [ -n "$zombies" ]; then
echo "Zombie processes found after regex IP detection:" >&2
echo "$zombies" >&2
exit 1
fi
echo "Regex IP detection completed $actual_runs runs without shell intermediates or zombies"
}
test_startup_signal_forwarding() {
docker run --detach \
--name "$signal_container" \
--platform "$platform" \
--env "PATH=/tmp:$base_path" \
--env DDNS_DNS=debug \
--env DDNS_IPV6=zombie-test.example \
--env 'DDNS_INDEX6=regex:2001:db8:.*' \
--env DDNS_CACHE=false \
--env DDNS_SIGNAL_TEST_IP=1 \
--volume "$script_path:/tmp/ip:ro" \
--volume "$signal_dir:/signal" \
"$image" >/dev/null
attempt=0
until [ -f "$signal_dir/started" ]; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
echo "Timed out waiting for the initial DDNS update" >&2
docker logs "$signal_container" >&2
exit 1
fi
sleep 1
done
docker stop --time 5 "$signal_container" >/dev/null
if [ ! -f "$signal_dir/terminated" ]; then
echo "Tini did not forward SIGTERM to the initial DDNS process group" >&2
exit 1
fi
exit_code=$(docker inspect --format '{{.State.ExitCode}}' "$signal_container")
if [ "$exit_code" -eq 137 ]; then
echo "Initial DDNS shutdown required SIGKILL" >&2
exit 1
fi
echo "Tini forwarded SIGTERM during the initial DDNS update"
}
case "${1:-}" in
--setup)
setup_container
exit 0
;;
--check)
check_container "$2"
exit 0
;;
--ready)
test -f "$READY_FILE"
check_process_tree
exit 0
;;
esac
image=${1:?Usage: test-docker-zombies.sh IMAGE [PLATFORM]}
platform=${2:-linux/amd64}
runs=${DDNS_ZOMBIE_TEST_RUNS:-8}
script_dir=$(cd "$(dirname "$0")" && pwd)
script_path="$script_dir/$(basename "$0")"
container="ddns-zombie-test-$$"
signal_container="${container}-signal"
signal_dir="${TMPDIR:-/tmp}/${signal_container}"
base_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
mkdir -p "$signal_dir"
cleanup() {
docker rm -f "$container" >/dev/null 2>&1 || true
docker rm -f "$signal_container" >/dev/null 2>&1 || true
rm -rf "$signal_dir"
}
trap cleanup 0 1 2 15
expected_entrypoint='["/sbin/tini","-g","--","/bin/entrypoint.sh"]'
actual_entrypoint=$(docker image inspect --format '{{json .Config.Entrypoint}}' "$image")
if [ "$actual_entrypoint" != "$expected_entrypoint" ]; then
echo "Unexpected image entrypoint: $actual_entrypoint" >&2
exit 1
fi
docker run --detach \
--name "$container" \
--platform "$platform" \
--entrypoint /sbin/tini \
--volume "$script_dir:/tests:ro" \
"$image" \
-g -- /bin/sh -c "/bin/sh /tests/test-docker-zombies.sh --setup; exec crond -f" >/dev/null
attempt=0
until docker exec "$container" /bin/sh /tests/test-docker-zombies.sh --ready 2>/dev/null; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
echo "Timed out waiting for the zombie test container" >&2
docker logs "$container" >&2
exit 1
fi
sleep 1
done
test_path="$TEST_BIN:$base_path"
run=0
while [ "$run" -lt "$runs" ]; do
output=$(docker exec --env "PATH=$test_path" "$container" \
/bin/ddns \
--dns=debug \
'--index6=regex:2001:db8:.*' \
--ipv6=zombie-test.example \
--cache=false)
if ! printf '%s\n' "$output" | grep -q '^\[IPv6\] 2001:db8::42$'; then
echo "Regex IPv6 result did not reach the debug provider:" >&2
echo "$output" >&2
exit 1
fi
run=$((run + 1))
done
sleep 1
docker exec "$container" /bin/sh /tests/test-docker-zombies.sh --check "$runs"
docker stop --time 5 "$container" >/dev/null
exit_code=$(docker inspect --format '{{.State.ExitCode}}' "$container")
if [ "$exit_code" -eq 137 ]; then
echo "Container required SIGKILL instead of a graceful Tini shutdown" >&2
exit 1
fi
echo "Tini forwarded shutdown with container exit code $exit_code"
test_startup_signal_forwarding