Files
absolute-dotfiles/.local/bin/test-docker-nuke.sh
T
David Ibia 74a718efa8 fix(docker-nuke): give accurate recovery advice when the daemon does not return
The failure path advised --deep and a disk-space check regardless of cause.
On the first live run that was wrong on both counts: 101 GiB were free and
--deep was irrelevant. Docker Desktop had crashed on startup, and the actual
fix was launching it from the Dock.

Branch the advice on whether any Docker process survived the relaunch. No
survivors means the app did not stay up, which no programmatic relaunch can
work around; survivors mean it is up but not answering yet.
2026-07-20 16:51:16 +01:00

1291 lines
54 KiB
Bash
Executable File

#!/usr/bin/env bash
# Test harness for docker-nuke. No external dependencies.
# Run: ./test-docker-nuke.sh
set -uo pipefail # deliberately not -e: a failing assertion must not abort the run
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TESTS_RUN=0
TESTS_FAILED=0
TMPROOT=""
setup_tmp() {
TMPROOT="$(mktemp -d)"
export DOCKER_NUKE_DUMP_ROOT="$TMPROOT/dumps"
}
teardown_tmp() {
if [ -n "$TMPROOT" ] && [ -d "$TMPROOT" ]; then
rm -rf "$TMPROOT"
fi
TMPROOT=""
}
# Shared test infrastructure: creates a stub `docker` executable in DIR so
# tests never depend on the real daemon's state. MODE: healthy | broken | hang.
# Reused by this task's docker_healthy tests and by task 7's recovery-polling
# tests, so it lives here with the other harness helpers rather than nested
# inside a single test function.
make_docker_stub() {
local dir="$1" mode="$2"
mkdir -p "$dir"
case "$mode" in
healthy)
cat >"$dir/docker" <<'STUB'
#!/usr/bin/env bash
printf 'Server Version: 99.0.0\n'
exit 0
STUB
;;
broken)
cat >"$dir/docker" <<'STUB'
#!/usr/bin/env bash
printf 'Cannot connect to the Docker daemon\n' >&2
exit 1
STUB
;;
hang)
cat >"$dir/docker" <<'STUB'
#!/usr/bin/env bash
sleep 300
STUB
;;
esac
chmod +x "$dir/docker"
}
pass() {
TESTS_RUN=$((TESTS_RUN + 1))
printf ' ok %s\n' "$1"
}
fail() {
TESTS_RUN=$((TESTS_RUN + 1))
TESTS_FAILED=$((TESTS_FAILED + 1))
printf ' FAIL %s\n %s\n' "$1" "$2"
}
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then
pass "$desc"
else
fail "$desc" "expected [$expected] got [$actual]"
fi
}
assert_contains() {
local desc="$1" haystack="$2" needle="$3"
case "$haystack" in
*"$needle"*) pass "$desc" ;;
*) fail "$desc" "expected to contain [$needle] got [$haystack]" ;;
esac
}
assert_not_contains() {
local desc="$1" haystack="$2" needle="$3"
case "$haystack" in
*"$needle"*) fail "$desc" "expected NOT to contain [$needle] got [$haystack]" ;;
*) pass "$desc" ;;
esac
}
# Source the script under test. Its trailing BASH_SOURCE/$0 guard means main
# runs only when the script is executed directly, so sourcing it here defines
# the functions without triggering a real nuke.
setup_tmp
# shellcheck source=/dev/null
source "$SCRIPT_DIR/docker-nuke"
set +e # the sourced script sets -e; undo it for the harness
printf '\n=== argument parsing ===\n'
test_parse_args() {
parse_args --force
assert_eq "--force sets OPT_FORCE" "1" "$OPT_FORCE"
parse_args -n
assert_eq "-n sets OPT_NO_RESTART" "1" "$OPT_NO_RESTART"
parse_args --dry-run
assert_eq "--dry-run sets OPT_DRY_RUN" "1" "$OPT_DRY_RUN"
parse_args --deep
assert_eq "--deep sets OPT_DEEP" "1" "$OPT_DEEP"
parse_args
assert_eq "no flags leaves OPT_FORCE off" "0" "$OPT_FORCE"
parse_args --bogus >/dev/null 2>&1
assert_eq "unknown flag returns 2" "2" "$?"
local help_text
help_text="$(usage)"
assert_contains "usage mentions --dry-run" "$help_text" "--dry-run"
assert_contains "usage mentions --deep" "$help_text" "--deep"
}
test_parse_args
printf '\n=== run_timeout ===\n'
test_run_timeout() {
local out rc
out="$(run_timeout 5 printf 'hello')"
assert_eq "returns command stdout" "hello" "$out"
run_timeout 5 true
assert_eq "propagates success" "0" "$?"
run_timeout 5 false
assert_eq "propagates failure" "1" "$?"
rc=0
run_timeout 1 sleep 5 || rc=$?
assert_eq "returns 124 on timeout" "124" "$rc"
# Force the pure-bash fallback by making bin_exists report nothing available.
# The override is scoped to a subshell so later tests still see the real one.
rc=0
(
# shellcheck disable=SC2329 # invoked indirectly by run_timeout name lookup
bin_exists() { return 1; }
run_timeout 1 sleep 5
) || rc=$?
assert_eq "fallback path also returns 124" "124" "$rc"
rc=0
out="$(
# shellcheck disable=SC2329 # invoked indirectly by run_timeout name lookup
bin_exists() { return 1; }
run_timeout 5 printf 'fallback-ok'
)" || rc=$?
assert_eq "fallback path returns stdout" "fallback-ok" "$out"
}
test_run_timeout
printf '\n=== docker_pids ===\n'
test_docker_pids() {
# Keep both forms: newline for counting, space-padded for substring checks.
# A space-padded haystack is required — searching newline-separated output
# for " <pid> " can never match, which would make these assertions vacuous.
local pids_nl pids_sp
pids_nl="$(docker_pids)"
pids_sp=" $(printf '%s' "$pids_nl" | tr '\n' ' ') "
# $$ and $PPID are never real candidates in live output (the harness's own
# command line does not contain $DOCKER_APP, and its comm is "bash", which
# matches no exact name), so asserting they are absent from live output
# proves nothing — it would pass even if the self/parent exclusion were
# deleted entirely. Kept below only as an additional sanity check; the
# real safety test stubs discovery so $$ and $PPID genuinely ARE
# candidates, which exercises the exclusion filter itself.
assert_not_contains "sanity: live output has no own PID" "$pids_sp" " $$ "
assert_not_contains "sanity: live output has no parent PID" "$pids_sp" " $PPID "
local filtered
filtered="$(
# shellcheck disable=SC2329 # invoked indirectly by docker_pids name lookup
pgrep() { printf '%s\n%s\n%s\n' "$$" "$PPID" 99999; }
# shellcheck disable=SC2329 # invoked indirectly by docker_pids name lookup
ps() { printf '%s\n' "$DOCKER_APP/Contents/MacOS/com.docker.backend"; }
docker_pids | tr '\n' ' '
)"
assert_eq "excludes own and parent PID, keeps others" "99999 " "$filtered"
# Guard against a vacuous suite: if Docker is running there must be matches.
local count
count="$(printf '%s\n' "$pids_nl" | grep -c . || true)"
if pgrep -x com.docker.backend >/dev/null 2>&1; then
if [ "$count" -gt 0 ]; then
pass "finds Docker processes while Docker is running"
else
fail "finds Docker processes while Docker is running" "got 0 pids"
fi
fi
# Every returned PID must be a live process.
local pid bad=""
for pid in $pids_nl; do
if ! kill -0 "$pid" 2>/dev/null; then
bad="$bad $pid"
fi
done
assert_eq "all returned PIDs are live" "" "$bad"
# Output must be numeric and deduped.
local nonnum=""
for pid in $pids_nl; do
case "$pid" in
''|*[!0-9]*) nonnum="$nonnum [$pid]" ;;
esac
done
assert_eq "all output is numeric" "" "$nonnum"
local uniq
uniq="$(printf '%s\n' "$pids_nl" | sort -u | grep -c . || true)"
assert_eq "output is deduped" "$count" "$uniq"
# A decoy process whose name contains "docker" but is not Docker Desktop
# must not be matched.
local decoy="$TMPROOT/docker-nuke-decoy"
cat >"$decoy" <<'STUB'
#!/usr/bin/env bash
sleep 30
STUB
chmod +x "$decoy"
# The decoy's command line must mention $DOCKER_APP so pgrep -f "$DOCKER_APP"
# actually matches it (the script ignores its args and just sleeps); its
# comm stays "bash", which the comm filter must then reject. Without this,
# pgrep -f never matches the decoy at all and the comm filter is untested.
"$decoy" "$DOCKER_APP/Contents/MacOS/not-really-docker" &
local decoy_pid=$!
sleep 1
pids_sp=" $(docker_pids | tr '\n' ' ') "
assert_not_contains "ignores decoy named docker-nuke-*" "$pids_sp" " $decoy_pid "
# Guard against a vacuous pass: with Docker stopped, docker_pids returns
# empty output and the assertion above passes for the wrong reason.
# Reuses the anti-vacuous guard pattern used above.
if pgrep -x com.docker.backend >/dev/null 2>&1; then
if [ -n "$(printf '%s' "$pids_sp" | tr -d '[:space:]')" ]; then
pass "decoy check ran against non-empty output while Docker is running"
else
fail "decoy check ran against non-empty output while Docker is running" "got empty pids"
fi
fi
kill -9 "$decoy_pid" 2>/dev/null || true
wait "$decoy_pid" 2>/dev/null || true
}
test_docker_pids
printf '\n=== docker_pids errexit contract ===\n'
test_docker_pids_errexit() {
# Every call site in docker-nuke invokes docker_pids inside $( ), where bash
# never applies errexit to the failing command that triggered it — so a
# regression that lets docker_pids abort under `set -e` would go completely
# undetected by every other test in this file. Prove the contract with a
# real script, invoked directly (not inside $( )), that calls docker_pids
# as a plain statement under `set -euo pipefail`.
#
# The specific failure mode is `pgrep -f "$DOCKER_APP"` matching zero
# processes (pipefail + no trailing `|| true` -> the pipeline itself exits
# non-zero -> set -e aborts before the exact-name branch ever runs). On a
# machine where Docker Desktop happens to be live, the real $DOCKER_APP
# always has matches, so calling docker_pids unmodified would never
# exercise that zero-match branch and the mutation would go undetected
# regardless of host state. Override DOCKER_APP after sourcing to a path
# guaranteed to match nothing, so the zero-match code path is exercised
# deterministically no matter whether Docker is running on this host.
local probe="$TMPROOT/errexit-probe.sh"
cat >"$probe" <<PROBE
#!/usr/bin/env bash
set -euo pipefail
source "$SCRIPT_DIR/docker-nuke"
DOCKER_APP="/nonexistent/docker-nuke-test-probe-guard"
docker_pids >/dev/null
printf 'survived\n'
PROBE
chmod +x "$probe"
local probe_out probe_rc=0
probe_out="$("$probe" 2>&1)" || probe_rc=$?
assert_eq "docker_pids does not abort a set -euo pipefail caller" "0" "$probe_rc"
assert_contains "set -e probe ran to completion" "$probe_out" "survived"
}
test_docker_pids_errexit
printf '\n=== bash 3.2 syntax compatibility ===\n'
test_bash32_syntax() {
# bash 3.2 (stock macOS /bin/bash) has a $( ) scanner that does not skip
# comments, so an apostrophe inside a comment inside a command substitution
# opens an unterminated quote and the whole file fails to parse. Guard
# against that regressing silently by parse-checking both files with the
# real system bash on every run.
local out rc
rc=0
out="$(/bin/bash -n "$SCRIPT_DIR/docker-nuke" 2>&1)" || rc=$?
assert_eq "docker-nuke parses under /bin/bash -n" "0" "$rc"
assert_eq "docker-nuke -n produces no output" "" "$out"
rc=0
out="$(/bin/bash -n "$SCRIPT_DIR/test-docker-nuke.sh" 2>&1)" || rc=$?
assert_eq "test-docker-nuke.sh parses under /bin/bash -n" "0" "$rc"
assert_eq "test-docker-nuke.sh -n produces no output" "" "$out"
}
test_bash32_syntax
printf '\n=== docker_healthy ===\n'
test_docker_healthy() {
local stub="$TMPROOT/stub" oldpath="$PATH" rc
make_docker_stub "$stub" healthy
PATH="$stub:$oldpath"
rc=0; docker_healthy || rc=$?
assert_eq "healthy daemon returns 0" "0" "$rc"
make_docker_stub "$stub" broken
rc=0; docker_healthy || rc=$?
assert_eq "broken daemon returns non-zero" "1" "$rc"
make_docker_stub "$stub" hang
HEALTH_TIMEOUT=1
rc=0; docker_healthy || rc=$?
assert_eq "hanging daemon returns 124" "124" "$rc"
# shellcheck disable=SC2034 # restores the value docker_healthy (sourced from docker-nuke) reads; the source=/dev/null directive above hides that cross-file read from this analysis
HEALTH_TIMEOUT=6
PATH="$oldpath"
}
test_docker_healthy
printf '\n=== dump ===\n'
test_dump() {
local d rc
# DUMP_DIR and CAPTURE_FAILURES are mutated below with no restore below
# this point in the original code -- the identical leak Task 7 already
# fixed one block below in test_restart_and_summary (Fix I3, final
# review). The stale path then propagates through every later block in
# this file. Save/restore the same way test_restart_and_summary does.
local old_dump_dir="$DUMP_DIR" old_capture_failures="$CAPTURE_FAILURES"
d="$(new_dump_dir)"
assert_eq "dump dir is created" "yes" "$([ -d "$d" ] && printf 'yes' || printf 'no')"
assert_contains "dump dir lives under DUMP_ROOT" "$d" "$DUMP_ROOT"
# shellcheck disable=SC2034 # DUMP_DIR is read by capture() (sourced from docker-nuke); the source=/dev/null directive above hides that cross-file read from this analysis
DUMP_DIR="$d"
CAPTURE_FAILURES=""
capture "ok.txt" 5 printf 'captured'
assert_eq "successful capture writes its file" "captured" "$(cat "$d/ok.txt")"
assert_eq "successful capture records no failure" "" "$CAPTURE_FAILURES"
rc=0
capture "slow.txt" 1 sleep 5 || rc=$?
assert_eq "timed-out capture still returns 0" "0" "$rc"
assert_contains "timed-out capture is recorded" "$CAPTURE_FAILURES" "slow.txt"
assert_contains "timed-out capture notes failure in file" "$(cat "$d/slow.txt")" "failed or timed out"
rc=0
capture "missing.txt" 5 this-command-does-not-exist || rc=$?
assert_eq "failed capture still returns 0" "0" "$rc"
assert_contains "failed capture is recorded" "$CAPTURE_FAILURES" "missing.txt"
# Regression coverage for the headline run_timeout fix: an external
# timeout/gtimeout binary can never exec a shell function directly (execvp
# fails with rc=127), so run_timeout must route shell functions to its
# pure-bash poller instead. Every capture test above passes an external
# command (printf, sleep, this-command-does-not-exist), so without this
# assertion that routing has zero coverage and could regress silently.
# shellcheck disable=SC2329 # invoked indirectly by capture -> run_timeout name lookup
that_function() { printf 'shell-function-output\n'; }
capture "fn.txt" 5 that_function
assert_contains "capture of a shell function produces its real output" \
"$(cat "$d/fn.txt")" "shell-function-output"
assert_not_contains "capture of a shell function is not a failure banner" \
"$(cat "$d/fn.txt")" "failed or timed out"
# Retention: 12 dumps in, 10 newest survive.
rm -rf "${DUMP_ROOT:?}"
mkdir -p "$DUMP_ROOT"
local i
for i in 01 02 03 04 05 06 07 08 09 10 11 12; do
mkdir -p "$DUMP_ROOT/202601${i}T000000Z"
done
prune_dumps 10
# shellcheck disable=SC2012 # dump dir names are our own generated timestamps, never contain newlines; a plain count needs plain lines, not find -print0
assert_eq "keeps exactly 10 dumps" "10" "$(ls -1 "$DUMP_ROOT" | wc -l | tr -d ' ')"
assert_eq "prunes the oldest" "no" \
"$([ -d "$DUMP_ROOT/20260101T000000Z" ] && printf 'yes' || printf 'no')"
assert_eq "keeps the newest" "yes" \
"$([ -d "$DUMP_ROOT/20260112T000000Z" ] && printf 'yes' || printf 'no')"
DUMP_DIR="$old_dump_dir"
CAPTURE_FAILURES="$old_capture_failures"
}
test_dump
printf '\n=== kill escalation ===\n'
test_kill() {
local a b c d rc alive
sleep 60 & a=$!
sleep 60 & b=$!
sleep 1
alive="$(pids_alive "$a" "$b")"
assert_contains "pids_alive reports first live pid" " $alive " " $a "
assert_contains "pids_alive reports second live pid" " $alive " " $b "
kill_pids TERM "$a"
sleep 1
alive="$(pids_alive "$a" "$b")"
assert_not_contains "SIGTERM kills the target" " $alive " " $a "
assert_contains "SIGTERM leaves others alone" " $alive " " $b "
rc=0; wait_for_exit 3 "$a" || rc=$?
assert_eq "wait_for_exit returns 0 for a dead pid" "0" "$rc"
rc=0; wait_for_exit 2 "$b" || rc=$?
assert_eq "wait_for_exit returns 1 while pid lives" "1" "$rc"
# A third pid, still alive at this point, pairs with the emptiness check
# below (Fix 5): a pids_alive that always prints nothing would make the
# emptiness assertion pass for the wrong reason, but this positive
# assertion over the same call would then fail, since c is genuinely
# alive right now.
sleep 60 & c=$!
kill_pids KILL "$b"
sleep 1
assert_eq "SIGKILL clears the last pid" "" "$(pids_alive "$a" "$b")"
assert_contains "pids_alive still detects a live pid alongside dead ones" \
" $(pids_alive "$a" "$b" "$c") " " $c "
# Fix 4: invoking kill_pids inside a `||` list (as this assertion previously
# did) suppresses set -e throughout the whole function body, so it can
# never prove the `|| true` guard on the real kill call inside kill_pids
# does anything at all. A bare invocation under genuinely active set -e is
# required. Pairing it with a real target in the same call (Fix 5) proves
# kill_pids is not simply a no-op that trivially "succeeds": if it were, d
# would still be alive afterward.
sleep 60 & d=$!
rc=0
( set -e; kill_pids TERM 999999 "$d" ); rc=$?
assert_eq "killing a nonexistent pid alongside a real one is not an error" "0" "$rc"
sleep 1
assert_not_contains "kill_pids still kills the real target in the same call" \
" $(pids_alive "$d") " " $d "
wait "$a" 2>/dev/null || true
wait "$b" 2>/dev/null || true
kill -9 "$c" 2>/dev/null || true
wait "$c" 2>/dev/null || true
kill -9 "$d" 2>/dev/null || true
wait "$d" 2>/dev/null || true
}
test_kill
printf '\n=== pids_alive zombie handling (Fix 7) ===\n'
test_pids_alive_zombie() {
# A real zombie is too transient to depend on in a portable test harness
# (it is reaped almost immediately once bash notices SIGCHLD), so this
# exercises the same branch deterministically by shadowing `kill` and `ps`
# inside a subshell rather than relying on a real defunct process. `kill`
# is an ordinary builtin here (not a special one), so a same-named shell
# function shadows it for calls made from inside this subshell only.
local alive
alive="$(
# shellcheck disable=SC2329 # invoked indirectly by pids_alive name lookup
kill() {
if [ "$1" = "-0" ]; then
return 0
fi
command kill "$@"
}
# shellcheck disable=SC2329 # invoked indirectly by pids_alive name lookup
ps() { printf 'Z+\n'; }
pids_alive 424242
)"
assert_eq "pids_alive treats a zombie as dead" "" "$alive"
# Pairs with the assertion above (Fix 5): a pids_alive that always prints
# nothing would make the zombie assertion pass for the wrong reason, but
# this positive assertion over the same shadowed setup, differing only in
# the reported process state, would then fail.
alive="$(
# shellcheck disable=SC2329 # invoked indirectly by pids_alive name lookup
kill() {
if [ "$1" = "-0" ]; then
return 0
fi
command kill "$@"
}
# shellcheck disable=SC2329 # invoked indirectly by pids_alive name lookup
ps() { printf 'R\n'; }
pids_alive 424242
)"
assert_contains "pids_alive still reports a genuinely running process" " $alive " " 424242 "
}
test_pids_alive_zombie
printf '\n=== OPT_DEEP unbound safety (Fix 2) ===\n'
test_opt_deep_unbound() {
# [ "$OPT_DEEP" -eq 1 ] aborts under set -u the moment OPT_DEEP is unset --
# which nuke_docker reaches only AFTER SIGKILL has already been sent. Under
# the old code this scenario would kill the throwaway process below, then
# blow up on the OPT_DEEP read before ever logging completion. Prove the
# fixed read (${OPT_DEEP:-0}) lets nuke_docker run to its normal finish
# with OPT_DEEP genuinely unset, not merely zero.
local out rc
out="$(
set -u
unset OPT_DEEP
GRACEFUL_WAIT=1
TERM_WAIT=1
p1=""
bash -c 'trap "" TERM; sleep 60' & p1=$!
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
docker_pids() {
if kill -0 "$p1" 2>/dev/null; then printf '%s\n' "$p1"; fi
}
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
osascript() { return 0; }
nuke_docker
rc_inner=$?
kill -9 "$p1" 2>/dev/null || true
wait "$p1" 2>/dev/null || true
exit "$rc_inner"
)"
rc=$?
assert_eq "nuke_docker survives OPT_DEEP unset under set -u" "0" "$rc"
assert_contains "nuke_docker still logs completion with OPT_DEEP unset" "$out" "all Docker processes terminated"
}
test_opt_deep_unbound
printf '\n=== sudo non-interactive guard (Fix 3) ===\n'
test_sudo_noninteractive() {
# Simulating an actual hung, non-cached sudo prompt is not something this
# harness can safely or deterministically drive. This is a static
# regression guard instead: stop_privileged_helpers must call `sudo -n`
# (never a plain `sudo`), so a missing credential fails fast into the
# existing `|| true`-style guard rather than blocking forever on a hidden
# /dev/tty prompt after Docker has already been SIGKILLed.
local src
src="$(cat "$SCRIPT_DIR/docker-nuke")"
assert_contains "stop_privileged_helpers uses sudo -n for vmnetd" "$src" "sudo -n launchctl stop com.docker.vmnetd"
assert_contains "stop_privileged_helpers uses sudo -n for socket" "$src" "sudo -n launchctl stop com.docker.socket"
assert_not_contains "stop_privileged_helpers never calls a plain sudo" "$src" $'\nsudo launchctl'
}
test_sudo_noninteractive
printf '\n=== --deep help text and upfront credential check (Fix I2) ===\n'
test_deep_upfront_check() {
# Task 6 correctly moved stop_privileged_helpers to `sudo -n` so a missing
# credential fails fast instead of hanging on a hidden prompt after Docker
# is already dead (Fix 3, above). But until this fix, usage() still claimed
# "needs sudo" (never prompts) and main() never checked for a cached
# credential until AFTER the kill, when it is too late for the user to do
# anything about it. This is a static regression guard: usage() text must
# be corrected, and main() must check `sudo -n true` before anything is
# killed.
local help_text src
help_text="$(usage)"
assert_not_contains "usage no longer claims --deep just needs sudo" "$help_text" "needs sudo"
assert_contains "usage explains --deep requires a cached credential" "$help_text" "cached sudo credential"
assert_contains "usage tells the user how to cache it" "$help_text" "sudo -v"
src="$(cat "$SCRIPT_DIR/docker-nuke")"
assert_contains "main checks sudo -n true up front" "$src" "sudo -n true"
}
test_deep_upfront_check
printf '\n=== nuke_docker escalation orchestration (Fix 6) ===\n'
test_nuke_docker() {
local out rc
# 1. No processes found -> returns 0, logs accordingly. Nothing spawned,
# nothing to reap.
out="$(
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
docker_pids() { :; }
nuke_docker
)"
rc=$?
assert_eq "no processes: returns 0" "0" "$rc"
assert_contains "no processes: logs the no-processes message" "$out" "no Docker processes found"
# 2. All processes exit at the polite-quit stage -> returns 0. The
# shadowed osascript stands in for Docker actually honoring the quit
# request; docker_pids re-checks real liveness on every call, just like
# the real pgrep-backed implementation.
out="$(
GRACEFUL_WAIT=2
p1=""; p2=""
sleep 60 & p1=$!
sleep 60 & p2=$!
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
docker_pids() {
local pid
for pid in "$p1" "$p2"; do
if kill -0 "$pid" 2>/dev/null; then printf '%s\n' "$pid"; fi
done
}
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
osascript() {
kill -TERM "$p1" "$p2" 2>/dev/null || true
return 0
}
nuke_docker
rc_inner=$?
wait "$p1" 2>/dev/null || true
wait "$p2" 2>/dev/null || true
exit "$rc_inner"
)"
rc=$?
assert_eq "polite quit: returns 0" "0" "$rc"
assert_contains "polite quit: logs clean exit" "$out" "Docker exited cleanly"
# 3. Survivors reach SIGTERM -> returns 0. osascript no-ops (Docker ignores
# the polite quit); the throwaway sleeps have no TERM trap, so nuke_docker's
# own SIGTERM kills them for real.
out="$(
GRACEFUL_WAIT=1
TERM_WAIT=2
p1=""; p2=""
sleep 60 & p1=$!
sleep 60 & p2=$!
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
docker_pids() {
local pid
for pid in "$p1" "$p2"; do
if kill -0 "$pid" 2>/dev/null; then printf '%s\n' "$pid"; fi
done
}
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
osascript() { return 0; }
nuke_docker
rc_inner=$?
wait "$p1" 2>/dev/null || true
wait "$p2" 2>/dev/null || true
exit "$rc_inner"
)"
rc=$?
assert_eq "SIGTERM stage: returns 0" "0" "$rc"
assert_contains "SIGTERM stage: logs post-SIGTERM exit" "$out" "Docker exited after SIGTERM"
# 4. Survivors reach SIGKILL -> returns 0, and stop_privileged_helpers is
# NOT called when OPT_DEEP=0 (Fix 6's last bullet). Each throwaway process
# traps and ignores SIGTERM so only SIGKILL can end it.
out="$(
OPT_DEEP=0
GRACEFUL_WAIT=1
TERM_WAIT=1
p1=""; p2=""
bash -c 'trap "" TERM; sleep 60' & p1=$!
bash -c 'trap "" TERM; sleep 60' & p2=$!
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
docker_pids() {
local pid
for pid in "$p1" "$p2"; do
if kill -0 "$pid" 2>/dev/null; then printf '%s\n' "$pid"; fi
done
}
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
osascript() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
stop_privileged_helpers() { printf 'STOP_PRIVILEGED_CALLED\n'; }
nuke_docker
rc_inner=$?
kill -9 "$p1" "$p2" 2>/dev/null || true
wait "$p1" 2>/dev/null || true
wait "$p2" 2>/dev/null || true
exit "$rc_inner"
)"
rc=$?
assert_eq "SIGKILL stage: returns 0" "0" "$rc"
assert_contains "SIGKILL stage: logs full termination" "$out" "all Docker processes terminated"
assert_not_contains "SIGKILL stage: stop_privileged_helpers not called when OPT_DEEP=0" "$out" "STOP_PRIVILEGED_CALLED"
# 4b. Same shape, but OPT_DEEP=1 -- pairs with 4's negative assertion (Fix
# 5): a stop_privileged_helpers call that is unconditionally skipped (or
# unconditionally run) would fail one of these two, never both.
out="$(
OPT_DEEP=1
GRACEFUL_WAIT=1
TERM_WAIT=1
p1=""; p2=""
bash -c 'trap "" TERM; sleep 60' & p1=$!
bash -c 'trap "" TERM; sleep 60' & p2=$!
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
docker_pids() {
local pid
for pid in "$p1" "$p2"; do
if kill -0 "$pid" 2>/dev/null; then printf '%s\n' "$pid"; fi
done
}
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
osascript() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
stop_privileged_helpers() { printf 'STOP_PRIVILEGED_CALLED\n'; }
nuke_docker
rc_inner=$?
kill -9 "$p1" "$p2" 2>/dev/null || true
wait "$p1" 2>/dev/null || true
wait "$p2" 2>/dev/null || true
exit "$rc_inner"
)"
rc=$?
assert_eq "SIGKILL stage with --deep: returns 0" "0" "$rc"
assert_contains "SIGKILL stage with --deep: stop_privileged_helpers is called" "$out" "STOP_PRIVILEGED_CALLED"
# 5. Something survives everything -> returns 1, and does NOT log success.
# docker_pids is stubbed to unconditionally keep reporting a fixed,
# nonexistent pid as live, modeling a process nothing here can actually
# kill, so the bounded retry loop is guaranteed to exhaust its rounds.
out="$(
OPT_DEEP=0
GRACEFUL_WAIT=1
TERM_WAIT=1
KILL_RETRY_ROUNDS=2
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
docker_pids() { printf '%s\n' 555555; }
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
osascript() { return 0; }
nuke_docker 2>&1
)"
rc=$?
assert_eq "unkillable survivor: returns 1" "1" "$rc"
assert_not_contains "unkillable survivor: never logs Docker exited cleanly" "$out" "Docker exited cleanly"
assert_not_contains "unkillable survivor: never logs post-SIGTERM exit" "$out" "Docker exited after SIGTERM"
assert_not_contains "unkillable survivor: never logs full termination" "$out" "all Docker processes terminated"
assert_contains "unkillable survivor: warns that processes survived" "$out" "processes survived SIGKILL"
# 6. Fix 1's respawn scenario. docker_pids returns the real entry-snapshot
# pids on its FIRST call only; every call after that returns a different,
# fixed, nonexistent pid, modeling a helper that respawned under a brand
# new pid after the snapshot pids died. The snapshot pids are killed
# BEFORE nuke_docker even runs, so wait_for_exit sees them as gone
# immediately at the very first stage -- exactly the shape that fooled the
# old stale-snapshot success check. Assert nuke_docker does not report
# success at any stage.
out="$(
OPT_DEEP=0
# shellcheck disable=SC2034 # read by wait_for_exit (sourced from docker-nuke); the source=/dev/null directive above hides that cross-file read from this analysis
GRACEFUL_WAIT=1
# shellcheck disable=SC2034 # read by wait_for_exit (sourced from docker-nuke); the source=/dev/null directive above hides that cross-file read from this analysis
TERM_WAIT=1
# shellcheck disable=SC2034 # read by the nuke_docker retry loop (sourced from docker-nuke); the source=/dev/null directive above hides that cross-file read from this analysis
KILL_RETRY_ROUNDS=2
p1=""; p2=""
sleep 60 & p1=$!
sleep 60 & p2=$!
kill -9 "$p1" "$p2" 2>/dev/null || true
wait "$p1" 2>/dev/null || true
wait "$p2" 2>/dev/null || true
# docker_pids is always invoked as the first stage of a pipeline inside
# nuke_docker (`docker_pids | tr ...`), and bash runs every non-last
# pipeline stage in a forked subshell. A plain shell-variable call
# counter would therefore reset to its parent value on every single
# call and never actually advance, silently defeating this stub. A
# counter file survives across those forks since it lives on disk, not
# in process memory.
dp_call_marker="$TMPROOT/respawn-dp-calls"
printf '0' >"$dp_call_marker"
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
docker_pids() {
local n
n="$(cat "$dp_call_marker" 2>/dev/null)"
n=$((n + 1))
printf '%s' "$n" >"$dp_call_marker"
if [ "$n" -eq 1 ]; then
printf '%s\n%s\n' "$p1" "$p2"
else
printf '%s\n' 666666
fi
}
# shellcheck disable=SC2329 # invoked indirectly by nuke_docker name lookup
osascript() { return 0; }
nuke_docker 2>&1
)"
rc=$?
assert_eq "respawn scenario: returns 1, not success" "1" "$rc"
assert_not_contains "respawn scenario: never logs Docker exited cleanly" "$out" "Docker exited cleanly"
assert_not_contains "respawn scenario: never logs post-SIGTERM exit" "$out" "Docker exited after SIGTERM"
assert_not_contains "respawn scenario: never logs full termination" "$out" "all Docker processes terminated"
# Positive counterpart (Fix 5): proves the stub genuinely produced a
# DIFFERENT pid after the snapshot died, rather than this test vacuously
# passing because the entry-snapshot pids simply stayed non-empty text for
# an unrelated reason.
assert_contains "respawn scenario: nuke_docker discovers the NEW respawned pid" "$out" "666666"
}
test_nuke_docker
printf '\n=== restart and summary ===\n'
test_restart_and_summary() {
local stub="$TMPROOT/stub2" oldpath="$PATH" rc
# DUMP_DIR and CAPTURE_FAILURES are mutated below like PATH is above.
# Restore all three on the way out (Fix 4, review round 1): this is the
# last test block in the file today, so nothing currently leaks, but
# Task 8 appends its own block immediately after this one and would
# silently inherit whatever non-empty DUMP_DIR/CAPTURE_FAILURES this test
# left behind if they were not restored, exactly like PATH and
# HEALTH_TIMEOUT already are elsewhere in this file.
local old_dump_dir="$DUMP_DIR" old_capture_failures="$CAPTURE_FAILURES"
make_docker_stub "$stub" healthy
PATH="$stub:$oldpath"
rc=0; wait_for_daemon 6 || rc=$?
assert_eq "wait_for_daemon returns 0 when healthy" "0" "$rc"
make_docker_stub "$stub" broken
rc=0; wait_for_daemon 4 || rc=$?
assert_eq "wait_for_daemon returns 1 when never healthy" "1" "$rc"
PATH="$oldpath"
DUMP_DIR="$(new_dump_dir)"
CAPTURE_FAILURES=" slow.txt"
write_summary "recovered" "42"
local s
s="$(cat "$DUMP_DIR/summary.txt")"
assert_contains "summary records the verdict" "$s" "recovered"
assert_contains "summary records elapsed time" "$s" "42"
assert_contains "summary records capture failures" "$s" "slow.txt"
assert_not_contains "capture failures line has no double space (Fix 5)" "$s" "failures: "
DUMP_DIR="$old_dump_dir"
CAPTURE_FAILURES="$old_capture_failures"
}
test_restart_and_summary
printf '\n=== wait_for_daemon wall-clock deadline (Fix 2) ===\n'
test_wait_for_daemon_deadline() {
# Regression coverage for the headline wait_for_daemon fix: the old loop
# counted only sleeps, never docker_healthy's own cost, so against a
# daemon that never answers it silently overshoots the requested timeout
# by roughly (HEALTH_TIMEOUT / POLL_INTERVAL)x. HEALTH_TIMEOUT and
# POLL_INTERVAL are scaled down here (2s / 1s instead of the real 6s / 2s)
# purely so this test stays fast; the ratio between them -- the thing that
# actually exposes the bug -- is preserved.
local stub="$TMPROOT/stub3" oldpath="$PATH" rc start elapsed
local old_health="$HEALTH_TIMEOUT" old_poll="$POLL_INTERVAL"
make_docker_stub "$stub" hang
PATH="$stub:$oldpath"
HEALTH_TIMEOUT=2
POLL_INTERVAL=1
start="$(date +%s)"
rc=0; wait_for_daemon 4 || rc=$?
elapsed=$(( $(date +%s) - start ))
assert_eq "wait_for_daemon returns 1 against a hung daemon" "1" "$rc"
# Requested 4s. New deadline-based code bounds the overshoot to at most one
# in-flight docker_healthy call (~2s here), landing around 6s. The old
# counting-sleeps code discounted every docker_healthy call and would land
# around 12s for these constants (see the manual revert-and-measure proof
# run alongside this fix). 8s cleanly separates "new" from "old" while
# leaving headroom for process-spawn/date-granularity jitter.
if [ "$elapsed" -le 8 ]; then
pass "wait_for_daemon honors the wall-clock deadline against a hung daemon (elapsed=${elapsed}s, requested=4s)"
else
fail "wait_for_daemon honors the wall-clock deadline against a hung daemon" \
"elapsed=${elapsed}s, requested=4s, expected <=8s"
fi
HEALTH_TIMEOUT="$old_health"
POLL_INTERVAL="$old_poll"
PATH="$oldpath"
}
test_wait_for_daemon_deadline
printf '\n=== write_summary survives an unwritable DUMP_DIR (Fix 1) ===\n'
test_write_summary_survives() {
# write_summary was previously only ever called with a fresh, valid,
# writable DUMP_DIR (see test_restart_and_summary above), so the
# "unset/missing -> no-op" branch -- the entire reason the guard at the
# top of write_summary exists -- was never exercised, and the write
# itself (the actual Fix 1 bug: an existing-but-now-unwritable-or-full
# DUMP_DIR) had zero coverage at all. Each case below runs write_summary
# in a subshell so a mutated DUMP_DIR/CAPTURE_FAILURES never escapes into
# the rest of the suite.
local rc out
rc=0
out="$(
unset DUMP_DIR CAPTURE_FAILURES
set -u
write_summary "recovered" "1"
printf 'survived\n'
)" || rc=$?
assert_eq "write_summary no-ops when DUMP_DIR is unset (under set -u)" "0" "$rc"
assert_contains "caller survives write_summary with DUMP_DIR unset" "$out" "survived"
rc=0
out="$(
DUMP_DIR=""
write_summary "recovered" "1"
printf 'survived\n'
)" || rc=$?
assert_eq "write_summary no-ops when DUMP_DIR is empty" "0" "$rc"
assert_contains "caller survives write_summary with DUMP_DIR empty" "$out" "survived"
rc=0
out="$(
DUMP_DIR="$TMPROOT/write-summary-does-not-exist"
write_summary "recovered" "1"
printf 'survived\n'
)" || rc=$?
assert_eq "write_summary no-ops when DUMP_DIR does not exist" "0" "$rc"
assert_contains "caller survives write_summary with DUMP_DIR missing" "$out" "survived"
# The actual Fix 1 bug: DUMP_DIR is a real, valid, existing directory (the
# guard's "unset or not a directory" check passes it straight through) but
# is unwritable -- isomorphic to the disk filling between new_dump_dir
# succeeding and write_summary running. set -e is turned on explicitly
# inside the subshell so this reproduces the real script's own
# set -euo pipefail context, not just the harness's relaxed `set +e`.
local ro_dir="$TMPROOT/write-summary-ro"
mkdir -p "$ro_dir"
chmod 555 "$ro_dir"
rc=0
out="$(
set -e
DUMP_DIR="$ro_dir"
CAPTURE_FAILURES=""
write_summary "recovered" "1"
printf 'survived\n'
)" || rc=$?
chmod 755 "$ro_dir"
assert_eq "write_summary returns 0 on an unwritable DUMP_DIR" "0" "$rc"
assert_contains "caller survives write_summary on an unwritable DUMP_DIR (Fix 1)" "$out" "survived"
assert_eq "unwritable DUMP_DIR: no summary.txt was created" "no" \
"$([ -f "$ro_dir/summary.txt" ] && printf 'yes' || printf 'no')"
}
test_write_summary_survives
printf '\n=== dry run ===\n'
test_dry_run() {
local out rc before
# shellcheck disable=SC2012 # dump dir names are our own generated timestamps, never contain newlines; a plain count needs plain lines, not find -print0
before="$(ls -1 "$DUMP_ROOT" 2>/dev/null | wc -l | tr -d ' ')"
rc=0
out="$("$SCRIPT_DIR/docker-nuke" --dry-run 2>&1)" || rc=$?
assert_eq "dry run exits 0" "0" "$rc"
assert_contains "dry run says it changed nothing" "$out" "no changes made"
assert_contains "dry run lists processes it would kill" "$out" "would kill"
# shellcheck disable=SC2012 # dump dir names are our own generated timestamps, never contain newlines; a plain count needs plain lines, not find -print0
assert_eq "dry run writes no dump" "$before" \
"$(ls -1 "$DUMP_ROOT" 2>/dev/null | wc -l | tr -d ' ')"
rc=0
out="$("$SCRIPT_DIR/docker-nuke" --help 2>&1)" || rc=$?
assert_eq "--help exits 0" "0" "$rc"
assert_contains "--help prints usage" "$out" "Usage:"
rc=0
"$SCRIPT_DIR/docker-nuke" --nonsense >/dev/null 2>&1 || rc=$?
assert_eq "unknown flag exits 2" "2" "$rc"
}
test_dry_run
printf '\n=== main() integration (Fix I1) ===\n'
# Before this block, the only entrypoint coverage was --dry-run / --help /
# --bogus (test_dry_run above), all of which return before run_dump is ever
# called -- so main()'s own composition of run_dump, nuke_docker,
# start_docker, and wait_for_daemon had zero automated coverage. That is
# exactly why the C1 (bare `run_dump` under set -e aborting recovery) and C2
# (a failed nuke_docker silently reported as success) review findings were
# never caught by any earlier per-task test.
#
# Every case below shadows run_dump/nuke_docker/start_docker/wait_for_daemon/
# docker_healthy (never the real Docker) per the standing safety rule for
# this file, plus docker_pids for deterministic survivor lists. Each
# subshell explicitly re-enables `set -euo pipefail` to reproduce the real
# script's own top-of-file directive -- the harness's `set +e` (line 5) would
# otherwise mask the exact class of bug C1 fixes (a bare failing command
# aborting the caller under set -e). stdin is always either piped or
# redirected from /dev/null so a wrong turn in the code under test can never
# block on a real terminal prompt.
printf '\n--- healthy daemon, declines the prompt ---\n'
test_main_healthy_decline() {
local out rc=0
out="$(
set -euo pipefail
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_healthy name lookup
docker_healthy() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via run_dump name lookup
run_dump() { printf 'RUN_DUMP_CALLED\n'; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via nuke_docker name lookup
nuke_docker() { printf 'NUKE_DOCKER_CALLED\n'; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via start_docker name lookup
start_docker() { printf 'START_DOCKER_CALLED\n'; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via wait_for_daemon name lookup
wait_for_daemon() { return 0; }
printf 'n\n' | main 2>&1
)"
# Deliberately NOT `)" || rc=$?` (final review fix): per the bash manual,
# "If a compound command or shell function executes in a context where -e
# is being ignored, none of the commands executed within [it] will be
# affected by the -e setting, even if -e is set" -- and a command
# substitution used as the left operand of `||` is exactly such a context.
# Attaching `|| rc=$?` directly to this assignment would silently defeat
# the `set -euo pipefail` above for the ENTIRE subshell, no matter how
# explicitly it is set inside. Capturing $? on its own line next avoids
# that trap; see test_nuke_docker above for the same established pattern.
rc=$?
assert_eq "healthy+decline: rc 2" "2" "$rc"
assert_not_contains "healthy+decline: run_dump never called" "$out" "RUN_DUMP_CALLED"
assert_not_contains "healthy+decline: nuke_docker never called" "$out" "NUKE_DOCKER_CALLED"
assert_not_contains "healthy+decline: start_docker never called" "$out" "START_DOCKER_CALLED"
}
test_main_healthy_decline
printf '\n--- healthy daemon with --force: proceeds without prompting ---\n'
test_main_healthy_force() {
local out rc=0 dump_dir="$TMPROOT/main-force-dump"
out="$(
set -euo pipefail
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_healthy name lookup
docker_healthy() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via run_dump name lookup
run_dump() { DUMP_DIR="$dump_dir"; mkdir -p "$DUMP_DIR"; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via nuke_docker name lookup
nuke_docker() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via start_docker name lookup
start_docker() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via wait_for_daemon name lookup
wait_for_daemon() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_pids name lookup
docker_pids() { :; }
main --force </dev/null 2>&1
)"
rc=$? # not `|| rc=$?` -- see the note on the first main() test above
assert_eq "healthy+force: rc 0" "0" "$rc"
assert_not_contains "healthy+force: does not print the confirmation prompt" "$out" "Proceed anyway"
}
test_main_healthy_force
printf '\n--- wedged daemon: proceeds with no prompt ---\n'
test_main_wedged_no_prompt() {
local out rc=0 dump_dir="$TMPROOT/main-wedged-dump"
out="$(
set -euo pipefail
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_healthy name lookup
docker_healthy() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via run_dump name lookup
run_dump() { DUMP_DIR="$dump_dir"; mkdir -p "$DUMP_DIR"; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via nuke_docker name lookup
nuke_docker() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via start_docker name lookup
start_docker() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via wait_for_daemon name lookup
wait_for_daemon() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_pids name lookup
docker_pids() { :; }
main </dev/null 2>&1
)"
rc=$? # not `|| rc=$?` -- see the note on the first main() test above
assert_eq "wedged: rc 0" "0" "$rc"
assert_not_contains "wedged: does not print the confirmation prompt" "$out" "Proceed anyway"
}
test_main_wedged_no_prompt
printf '\n--- C1: run_dump failure never aborts recovery ---\n'
test_main_c1_dump_failure_does_not_abort() {
# THE headline regression test for Fix C1. Under the pre-fix code, `run_dump`
# was called bare on its own line under set -euo pipefail; run_dump returns
# 1 when new_dump_dir fails (dump root unwritable or full), and a bare
# failing command aborts the whole script right there via set -e -- Docker
# is never killed, never restarted. Proving this requires `set -e` to
# genuinely be active in this subshell (see the block-level comment above),
# not just the harness's relaxed `set +e`.
local out rc=0
out="$(
set -euo pipefail
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_healthy name lookup
docker_healthy() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via run_dump name lookup
run_dump() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via nuke_docker name lookup
nuke_docker() { printf 'NUKE_DOCKER_CALLED\n'; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via start_docker name lookup
start_docker() { printf 'START_DOCKER_CALLED\n'; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via wait_for_daemon name lookup
wait_for_daemon() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_pids name lookup
docker_pids() { :; }
main </dev/null 2>&1
)"
rc=$? # not `|| rc=$?` -- see the note on the first main() test above; this
# one matters most of all, since it is what proves C1
assert_eq "C1: main still completes (rc 0) after a dump failure" "0" "$rc"
assert_contains "C1: nuke_docker is still called after a dump failure" "$out" "NUKE_DOCKER_CALLED"
assert_contains "C1: start_docker is still called after a dump failure" "$out" "START_DOCKER_CALLED"
assert_contains "C1: warns that diagnostics capture failed" "$out" "diagnostics capture failed"
}
test_main_c1_dump_failure_does_not_abort
printf '\n--- C2: a failed kill is never reported as success ---\n'
test_main_c2_failed_kill_no_restart() {
# THE headline regression test for Fix C2, --no-restart path. nuke_docker
# returns 1 only after KILL_RETRY_ROUNDS is exhausted with Docker processes
# still alive. Under the pre-fix code this was discarded with `|| true` and
# never consulted again: --no-restart's verdict said "killed, not
# restarted" and returned 0 even though nothing was actually killed.
local out rc=0 dump_dir="$TMPROOT/main-c2-norestart-dump"
out="$(
set -euo pipefail
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_healthy name lookup
docker_healthy() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via run_dump name lookup
run_dump() { DUMP_DIR="$dump_dir"; mkdir -p "$DUMP_DIR"; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via nuke_docker name lookup
nuke_docker() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via start_docker name lookup
start_docker() { printf 'START_DOCKER_CALLED\n'; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via wait_for_daemon name lookup
wait_for_daemon() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_pids name lookup
docker_pids() { printf '%s\n' 777777; }
main --no-restart </dev/null 2>&1
)"
rc=$? # not `|| rc=$?` -- see the note on the first main() test above
assert_eq "C2 --no-restart: exit code is non-zero when the kill fails" "1" "$rc"
assert_not_contains "C2 --no-restart: start_docker is never called" "$out" "START_DOCKER_CALLED"
assert_contains "C2 --no-restart: the survivor pid reaches the log" "$out" "777777"
assert_contains "C2 --no-restart: summary.txt records the failed kill" \
"$(cat "$dump_dir/summary.txt" 2>/dev/null)" "777777"
}
test_main_c2_failed_kill_no_restart
test_main_c2_failed_kill_restart() {
# C2's restart-path counterpart: even when wait_for_daemon subsequently
# reports the daemon healthy again (a genuinely possible outcome -- a fresh
# instance can start responding while old zombies from the failed kill
# still linger), a kill that did not complete must still surface as a
# failure, not get overwritten by the later "recovered" success.
local out rc=0 dump_dir="$TMPROOT/main-c2-restart-dump"
out="$(
set -euo pipefail
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_healthy name lookup
docker_healthy() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via run_dump name lookup
run_dump() { DUMP_DIR="$dump_dir"; mkdir -p "$DUMP_DIR"; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via nuke_docker name lookup
nuke_docker() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via start_docker name lookup
start_docker() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via wait_for_daemon name lookup
wait_for_daemon() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_pids name lookup
docker_pids() { printf '%s\n' 888888; }
main </dev/null 2>&1
)"
rc=$? # not `|| rc=$?` -- see the note on the first main() test above
assert_eq "C2 restart path: exit code is non-zero despite the daemon coming back" "1" "$rc"
assert_contains "C2 restart path: the survivor pid reaches the log" "$out" "888888"
assert_contains "C2 restart path: summary.txt records the failed kill" \
"$(cat "$dump_dir/summary.txt" 2>/dev/null)" "888888"
}
test_main_c2_failed_kill_restart
printf '\n--- --no-restart: start_docker never called (success path) ---\n'
test_main_no_restart_success() {
local out rc=0 dump_dir="$TMPROOT/main-norestart-ok-dump"
out="$(
set -euo pipefail
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_healthy name lookup
docker_healthy() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via run_dump name lookup
run_dump() { DUMP_DIR="$dump_dir"; mkdir -p "$DUMP_DIR"; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via nuke_docker name lookup
nuke_docker() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via start_docker name lookup
start_docker() { printf 'START_DOCKER_CALLED\n'; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via wait_for_daemon name lookup
wait_for_daemon() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_pids name lookup
docker_pids() { :; }
main --no-restart </dev/null 2>&1
)"
rc=$? # not `|| rc=$?` -- see the note on the first main() test above
assert_eq "no-restart success: rc 0" "0" "$rc"
assert_not_contains "no-restart success: start_docker never called" "$out" "START_DOCKER_CALLED"
}
test_main_no_restart_success
printf '\n--- daemon never returns: rc 1 ---\n'
test_main_daemon_never_returns() {
local out rc=0 dump_dir="$TMPROOT/main-timeout-dump"
out="$(
set -euo pipefail
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_healthy name lookup
docker_healthy() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via run_dump name lookup
run_dump() { DUMP_DIR="$dump_dir"; mkdir -p "$DUMP_DIR"; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via nuke_docker name lookup
nuke_docker() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via start_docker name lookup
start_docker() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via wait_for_daemon name lookup
wait_for_daemon() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_pids name lookup
docker_pids() { :; }
main </dev/null 2>&1
)"
rc=$? # not `|| rc=$?` -- see the note on the first main() test above
assert_eq "daemon never returns: rc 1" "1" "$rc"
assert_contains "daemon never returns: warns it did not return" "$out" "did not return"
# No surviving Docker processes means the app was relaunched but did not stay
# up, so the advice must point at a manual Dock launch -- NOT at --deep or at
# disk space, which is what a real run got wrong in the field.
assert_contains "no survivors: says Docker did not stay up" "$out" "did not stay up"
assert_contains "no survivors: advises launching from the Dock" "$out" "Dock or Spotlight"
assert_not_contains "no survivors: does NOT suggest --deep" "$out" "--deep"
}
test_main_daemon_never_returns
test_main_daemon_never_returns_with_survivors() {
local rc out dump_dir="$TMPROOT/main-survivors"
out="$(
set -euo pipefail
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_healthy name lookup
docker_healthy() { return 1; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via run_dump name lookup
run_dump() { DUMP_DIR="$dump_dir"; mkdir -p "$DUMP_DIR"; return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via nuke_docker name lookup
nuke_docker() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via start_docker name lookup
start_docker() { return 0; }
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via wait_for_daemon name lookup
wait_for_daemon() { return 1; }
# Docker processes ARE alive: it is up but not answering, a different case.
# shellcheck disable=SC2329 # invoked indirectly by main (sourced from docker-nuke) via docker_pids name lookup
docker_pids() { printf '4242\n'; }
main </dev/null 2>&1
)"
rc=$? # not `|| rc=$?` -- see the note on the first main() test above
assert_eq "survivors: rc 1" "1" "$rc"
assert_contains "survivors: reports the surviving pid" "$out" "4242"
assert_contains "survivors: suggests --deep for this case" "$out" "--deep"
assert_not_contains "survivors: does NOT advise a Dock launch" "$out" "Dock or Spotlight"
}
test_main_daemon_never_returns_with_survivors
printf '\n=== summary ===\n'
teardown_tmp
printf 'ran %d, failed %d\n' "$TESTS_RUN" "$TESTS_FAILED"
[ "$TESTS_FAILED" -eq 0 ]