paperclip/scripts/assert-orphan-reaping.sh

91 lines
3.1 KiB
Bash
Executable File

#!/bin/sh
# Assert that PID 1 in this container reaps orphans the kernel re-parents onto it.
#
# Regression test for the zombie-exhaustion outage: with node as PID 1 and no
# init, orphaned descendants of agent runs (git, claude, esbuild, sh, ...) are
# never wait()ed, so they pin as zombies at ~79/h until the cgroup pid limit is
# exhausted and *every* fork() in the container fails.
#
# Designed to run inside the image under its real ENTRYPOINT, so PID 1 is
# exactly what a production container gets:
#
# docker run --rm -i <image> sh -s < scripts/assert-orphan-reaping.sh
#
# Exits 0 when the orphan is reaped, non-zero (with the observed state) when it
# is left in Z.
set -e
init_comm="$(cat /proc/1/comm)"
echo "PID 1 = $init_comm"
if [ "$init_comm" != "tini" ]; then
echo "FAIL: PID 1 is '$init_comm', expected an init (tini)" >&2
exit 1
fi
pidfile="$(mktemp)"
trap 'rm -f "$pidfile"' EXIT
# Read a field of /proc/<pid>/stat past the comm field, so a command name
# containing spaces or parens cannot shift the offset. Field 1 of the remainder
# is the state, field 2 is the ppid. Empty output means the pid is gone.
stat_field() {
sed -e 's/^.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f"$2"
}
# The leader exits immediately, orphaning the grandchild onto PID 1. The
# grandchild publishes its own pid, then exits ~5s later. That lifetime is what
# keeps the adoption check below honest: it must always land on a live process,
# never on a corpse whose exit merely happened to outrun a slow leader.
sh -c 'sh -c '\''echo $$ > "$0"; exec sleep 5'\'' "$1" & exit 0' _ "$pidfile"
i=0
while [ ! -s "$pidfile" ]; do
i=$((i + 1))
if [ "$i" -gt 100 ]; then
echo "FAIL: grandchild never reported its pid" >&2
exit 1
fi
sleep 0.05
done
gpid="$(cat "$pidfile")"
# The kernel re-parents the grandchild only once the leader has finished
# exiting, and that can lag the grandchild's pid-file write. Sampling the ppid
# once would read a leader that is merely slow to exit as a re-parenting
# failure, so poll instead: the leader exits unconditionally, so ppid 1 is
# reached in bounded time whenever adoption works at all.
i=0
while :; do
ppid="$(stat_field "$gpid" 2)"
if [ "$ppid" = "1" ]; then
break
fi
if [ -z "$ppid" ]; then
echo "FAIL: grandchild pid $gpid vanished before adoption could be observed; the probe proved nothing" >&2
exit 1
fi
i=$((i + 1))
if [ "$i" -gt 20 ]; then
echo "FAIL: grandchild still parented to $ppid after 20 polls, never adopted by PID 1; the probe proved nothing" >&2
exit 1
fi
sleep 0.05
done
echo "orphaned grandchild pid=$gpid reparented to ppid=$ppid"
# Give an unreaped zombie a generous window to show itself before calling the
# reap successful -- a pass here must mean "reaped", never "checked too early".
i=0
while [ "$i" -lt 100 ]; do
if [ ! -e "/proc/$gpid" ]; then
echo "PASS: PID 1 reaped orphaned pid $gpid"
exit 0
fi
i=$((i + 1))
sleep 0.1
done
state="$(stat_field "$gpid" 1)"
echo "FAIL: pid $gpid still present after 100 polls (state=$state); PID 1 ('$init_comm') is not reaping adopted orphans" >&2
exit 1