Files
absolute-dotfiles/docs/plans/2026-07-19-docker-nuke.md

1413 lines
43 KiB
Markdown

# docker-nuke Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build `docker-nuke`, a macOS command that captures diagnostics from a wedged Docker Desktop, force-kills it, and restarts it back to a responsive daemon.
**Architecture:** A single self-contained bash script in the dotfiles repo, symlinked into `~/.local/bin`. Every unit of logic is a small named function; `main` runs only when the script is executed, not when sourced, so the test harness can source the file and unit-test functions without triggering a real nuke. Side-effecting behaviour is made testable through injection: `DOCKER_NUKE_DUMP_ROOT` redirects dump output to a temp dir, and a stub `docker` on `PATH` simulates healthy/failing/hanging daemons. Process-killing functions take an explicit PID list so tests can exercise them against throwaway `sleep` processes.
**Tech Stack:** Bash, `pgrep`/`kill`, `sample`, `osascript`, `open`. Lint via `shellcheck`. Tests are a dependency-free bash harness (no `bats` on this machine).
## Global Constraints
- Script style follows `~/dotfiles/.local/bin/claude-tmux`: `#!/usr/bin/env bash`, `set -euo pipefail`, 2-space indent, `snake_case` functions, `printf` over `echo`, `bin_exists()` guard, heredoc help text, `case` dispatch.
- **Write bash 3.2-compatible code.** `/bin/bash` on this machine is 3.2.57. No `mapfile`, no `declare -A`, no `${var,,}`.
- **Never use `pgrep -f docker`.** It matches this script's own name and command line, so the script would kill itself. Match only the app bundle path and exact process names.
- **Never write absolute personal paths (`/Users/<name>/...`) into committed files.** Repo rule from `~/dotfiles/AGENTS.md`. Use `$HOME` or `~`.
- **Never `git add -A` or `git add .`.** The dotfiles repo has unrelated uncommitted changes (modified nvim/zsh/tmux configs, deleted opencode plugins). Stage only the exact files named in each task.
- Commit messages use conventional commits with a scope, matching repo history: `feat(docker-nuke): ...`.
- Exit codes: `0` success, `1` daemon did not return, `2` usage error or user abort.
- Every diagnostic capture runs under its own timeout and records failure instead of aborting. The dump must never block recovery.
---
## File Structure
| File | Responsibility |
|---|---|
| `~/dotfiles/.local/bin/docker-nuke` | The entire tool: config, helpers, dump, kill, restart, `main` |
| `~/dotfiles/.local/bin/test-docker-nuke.sh` | Test harness: assertions, stubs, all unit tests |
| `~/.local/bin/docker-nuke` | Symlink to the dotfiles copy, puts it on `PATH` |
| `~/dotfiles/docs/specs/2026-07-19-docker-nuke-design.md` | Approved spec, moved into the repo |
| `~/dotfiles/docs/plans/2026-07-19-docker-nuke.md` | This plan |
The script stays one file because it must be self-contained to run from `PATH` as a single symlink. Testability comes from the source guard, not from splitting into libraries.
---
### Task 1: Repo scaffold, help text, and argument parsing
**Files:**
- Create: `~/dotfiles/.local/bin/docker-nuke`
- Create: `~/dotfiles/.local/bin/test-docker-nuke.sh`
- Create: `~/dotfiles/docs/specs/2026-07-19-docker-nuke-design.md` (moved from `~/docs/superpowers/specs/`)
**Interfaces:**
- Consumes: nothing
- Produces: `usage()` (prints help to stdout); `parse_args "$@"` setting globals `OPT_FORCE`, `OPT_NO_RESTART`, `OPT_DRY_RUN`, `OPT_DEEP` to `0`/`1`, returning `2` on an unknown flag; `bin_exists NAME`
- [ ] **Step 1: Move the spec into the repo, stripping absolute paths**
```bash
mkdir -p ~/dotfiles/docs/specs ~/dotfiles/docs/plans
mv ~/docs/superpowers/specs/2026-07-19-docker-nuke-design.md \
~/dotfiles/docs/specs/2026-07-19-docker-nuke-design.md
rmdir -p ~/docs/superpowers/specs 2>/dev/null || true
sed -i '' "s#/Users/[^/]*/#~/#g" ~/dotfiles/docs/specs/2026-07-19-docker-nuke-design.md
grep -c "/Users/" ~/dotfiles/docs/specs/2026-07-19-docker-nuke-design.md
```
Expected: prints `0`.
- [ ] **Step 2: Write the failing test**
Create `~/dotfiles/.local/bin/test-docker-nuke.sh`:
```bash
#!/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=""
}
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. DOCKER_NUKE_LIB stops main from running.
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=== summary ===\n'
teardown_tmp
printf 'ran %d, failed %d\n' "$TESTS_RUN" "$TESTS_FAILED"
[ "$TESTS_FAILED" -eq 0 ]
```
```bash
chmod +x ~/dotfiles/.local/bin/test-docker-nuke.sh
```
- [ ] **Step 3: Run test to verify it fails**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: FAIL — `docker-nuke: No such file or directory`
- [ ] **Step 4: Write minimal implementation**
Create `~/dotfiles/.local/bin/docker-nuke`:
```bash
#!/usr/bin/env bash
# docker-nuke — capture diagnostics from a wedged Docker Desktop, then
# force-kill and restart it.
#
# Written for bash 3.2 compatibility (macOS /bin/bash).
set -euo pipefail
# --- configuration ----------------------------------------------------------
HEALTH_TIMEOUT=6 # seconds to wait on `docker info` before calling it wedged
GRACEFUL_WAIT=8 # seconds to let Docker quit politely
TERM_WAIT=5 # seconds to wait after SIGTERM before SIGKILL
RESTART_TIMEOUT=90 # seconds to wait for the daemon to come back
POLL_INTERVAL=2 # seconds between daemon health polls
SAMPLE_DURATION=2 # seconds of stack sampling per process
SAMPLE_TIMEOUT=10 # hard cap on a single `sample` invocation
DUMP_RETENTION=10 # how many dump directories to keep
LOG_TAIL_LINES=2000 # lines of Docker log to copy (keeps dumps small)
DOCKER_APP="/Applications/Docker.app"
DOCKER_LOG_DIR="$HOME/Library/Containers/com.docker.docker/Data/log"
DOCKER_RAW="$HOME/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw"
CRASH_DIR="$HOME/Library/Logs/DiagnosticReports"
# Overridable so tests never touch real state.
DUMP_ROOT="${DOCKER_NUKE_DUMP_ROOT:-$HOME/.local/state/docker-nuke}"
OPT_FORCE=0
OPT_NO_RESTART=0
OPT_DRY_RUN=0
OPT_DEEP=0
# --- helpers ----------------------------------------------------------------
bin_exists() { command -v "$1" >/dev/null 2>&1; }
log() { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }
usage() {
cat <<'EOF'
docker-nuke: force-restart a wedged Docker Desktop, capturing diagnostics first
Usage:
docker-nuke [options]
Options:
-f, --force Skip the confirmation shown when Docker looks healthy
-n, --no-restart Kill Docker but do not relaunch it
--dry-run Report what would be killed and dumped; change nothing
--deep Also stop root launchd helpers (com.docker.vmnetd); needs sudo
-h, --help Show this help
Diagnostics are written to ~/.local/state/docker-nuke/<timestamp>/ before
anything is killed. The most recent 10 dumps are kept.
Exit codes:
0 Docker killed and confirmed responsive again
1 Daemon did not come back within the restart timeout
2 Usage error, or aborted at the healthy-daemon prompt
EOF
}
parse_args() {
OPT_FORCE=0
OPT_NO_RESTART=0
OPT_DRY_RUN=0
OPT_DEEP=0
while [ "$#" -gt 0 ]; do
case "$1" in
-f|--force) OPT_FORCE=1 ;;
-n|--no-restart) OPT_NO_RESTART=1 ;;
--dry-run) OPT_DRY_RUN=1 ;;
--deep) OPT_DEEP=1 ;;
-h|--help) usage; return 3 ;;
*)
warn "docker-nuke: unknown option '$1'"
usage >&2
return 2
;;
esac
shift
done
return 0
}
# --- entrypoint -------------------------------------------------------------
main() {
local rc=0
parse_args "$@" || rc=$?
if [ "$rc" -eq 3 ]; then return 0; fi
if [ "$rc" -ne 0 ]; then return "$rc"; fi
log "docker-nuke: not yet implemented"
return 0
}
# Only run when executed, not when sourced by the test harness.
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
main "$@"
fi
```
```bash
chmod +x ~/dotfiles/.local/bin/docker-nuke
```
- [ ] **Step 5: Run test to verify it passes**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: PASS — `ran 8, failed 0`
- [ ] **Step 6: Lint**
Run: `shellcheck ~/dotfiles/.local/bin/docker-nuke ~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: no output (clean). Fix any warnings before committing.
- [ ] **Step 7: Commit**
```bash
cd ~/dotfiles
git add .local/bin/docker-nuke .local/bin/test-docker-nuke.sh docs/specs/2026-07-19-docker-nuke-design.md
git commit -m "feat(docker-nuke): add script scaffold, arg parsing, and test harness"
```
---
### Task 2: `run_timeout` helper
Everything that touches a wedged Docker must be time-bounded. This wraps `timeout` when available and falls back to a pure-bash implementation, so the tool still works if coreutils disappears.
**Files:**
- Modify: `~/dotfiles/.local/bin/docker-nuke` (add after `bin_exists`)
- Modify: `~/dotfiles/.local/bin/test-docker-nuke.sh` (add test section)
**Interfaces:**
- Consumes: `bin_exists` from Task 1
- Produces: `run_timeout SECONDS CMD [ARGS...]` — runs CMD, returns its exit code, or `124` if it exceeded SECONDS. Callers must invoke it in a conditional (`if run_timeout ...`) so `set -e` does not abort on a non-zero return.
- [ ] **Step 1: Write the failing test**
Add to `test-docker-nuke.sh`, immediately before the `=== summary ===` block:
```bash
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
(
bin_exists() { return 1; }
run_timeout 1 sleep 5
) || rc=$?
assert_eq "fallback path also returns 124" "124" "$rc"
rc=0
out="$(
bin_exists() { return 1; }
run_timeout 5 printf 'fallback-ok'
)" || rc=$?
assert_eq "fallback path returns stdout" "fallback-ok" "$out"
}
test_run_timeout
```
- [ ] **Step 2: Run test to verify it fails**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: FAIL — `run_timeout: command not found`
- [ ] **Step 3: Write minimal implementation**
In `docker-nuke`, insert directly after the `bin_exists` definition:
```bash
# Run a command with a hard time limit. Returns 124 if it timed out.
# Call inside a conditional so `set -e` does not abort on a non-zero return.
run_timeout() {
local secs="$1"
shift
if bin_exists timeout; then
timeout "$secs" "$@"
return $?
fi
if bin_exists gtimeout; then
gtimeout "$secs" "$@"
return $?
fi
# Pure-bash fallback: run in background and poll.
"$@" &
local pid=$!
local waited=0
while kill -0 "$pid" 2>/dev/null; do
if [ "$waited" -ge "$secs" ]; then
kill -9 "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
return 124
fi
sleep 1
waited=$((waited + 1))
done
local rc=0
wait "$pid" || rc=$?
return "$rc"
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: PASS — `ran 14, failed 0`
- [ ] **Step 5: Lint and commit**
```bash
shellcheck ~/dotfiles/.local/bin/docker-nuke ~/dotfiles/.local/bin/test-docker-nuke.sh
cd ~/dotfiles
git add .local/bin/docker-nuke .local/bin/test-docker-nuke.sh
git commit -m "feat(docker-nuke): add run_timeout helper with pure-bash fallback"
```
---
### Task 3: Process discovery (safety-critical)
This is the task most likely to cause real damage if wrong. A naive `pgrep -f docker` matches this script's own name, so `docker-nuke` would kill itself mid-run. The tests exist specifically to prove it does not.
**Files:**
- Modify: `~/dotfiles/.local/bin/docker-nuke`
- Modify: `~/dotfiles/.local/bin/test-docker-nuke.sh`
**Interfaces:**
- Consumes: `DOCKER_APP` from Task 1
- Produces: `docker_pids` — prints one PID per line, sorted, deduped, never including the current process or its parent
- [ ] **Step 1: Write the failing test**
Add before `=== summary ===`:
```bash
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' ' ') "
assert_not_contains "never returns own PID" "$pids_sp" " $$ "
assert_not_contains "never returns parent PID" "$pids_sp" " $PPID "
# 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"
"$decoy" &
local decoy_pid=$!
sleep 1
pids_sp=" $(docker_pids | tr '\n' ' ') "
assert_not_contains "ignores decoy named docker-nuke-*" "$pids_sp" " $decoy_pid "
kill -9 "$decoy_pid" 2>/dev/null || true
wait "$decoy_pid" 2>/dev/null || true
}
test_docker_pids
```
- [ ] **Step 2: Run test to verify it fails**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: FAIL — `docker_pids: command not found`
- [ ] **Step 3: Write minimal implementation**
Add to `docker-nuke` after `run_timeout`:
```bash
# Exact process names to target, in addition to anything under Docker.app.
# NOTE: never use `pgrep -f docker` here — it matches this script's own
# command line and the tool would kill itself.
DOCKER_PROC_NAMES="com.docker.backend com.docker.build com.docker.krun \
com.docker.vpnkit com.docker.virtualization vpnkit docker"
docker_pids() {
local self="$$"
local parent="${PPID:-0}"
local name pid
{
pgrep -f "$DOCKER_APP" 2>/dev/null || true
for name in $DOCKER_PROC_NAMES; do
pgrep -x "$name" 2>/dev/null || true
done
} | sort -un | while read -r pid; do
if [ -z "$pid" ]; then continue; fi
if [ "$pid" = "$self" ]; then continue; fi
if [ "$pid" = "$parent" ]; then continue; fi
printf '%s\n' "$pid"
done
}
```
Note the explicit `if ... then continue; fi` form. The shorter `[ "$pid" = "$self" ] && continue` returns 1 when the test fails, which `set -e` treats as a fatal error.
- [ ] **Step 4: Run test to verify it passes**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: PASS — `ran 20, failed 0`
- [ ] **Step 5: Manually confirm it finds the real Docker processes**
Run: `bash -c 'source ~/dotfiles/.local/bin/docker-nuke; docker_pids | wc -l'`
Expected: a non-zero count while Docker Desktop is running (roughly 9-12 on this machine).
Cross-check against the raw list:
Run: `pgrep -f '/Applications/Docker.app' | wc -l`
Expected: same order of magnitude. If `docker_pids` returns 0 while Docker is running, the matching is broken — stop and fix before continuing.
- [ ] **Step 6: Lint and commit**
```bash
shellcheck ~/dotfiles/.local/bin/docker-nuke ~/dotfiles/.local/bin/test-docker-nuke.sh
cd ~/dotfiles
git add .local/bin/docker-nuke .local/bin/test-docker-nuke.sh
git commit -m "feat(docker-nuke): add self-excluding Docker process discovery"
```
---
### Task 4: Health check
**Files:**
- Modify: `~/dotfiles/.local/bin/docker-nuke`
- Modify: `~/dotfiles/.local/bin/test-docker-nuke.sh`
**Interfaces:**
- Consumes: `run_timeout`, `HEALTH_TIMEOUT`
- Produces: `docker_healthy` — returns 0 if `docker info` answers within `HEALTH_TIMEOUT`, non-zero otherwise. `make_docker_stub DIR MODE` is a test-only helper (lives in the harness, not the script) where MODE is `healthy`, `broken`, or `hang`.
- [ ] **Step 1: Write the failing test**
Add before `=== summary ===`:
```bash
printf '\n=== docker_healthy ===\n'
# Creates a stub `docker` in DIR. MODE: healthy | broken | hang
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"
}
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"
HEALTH_TIMEOUT=6
PATH="$oldpath"
}
test_docker_healthy
```
- [ ] **Step 2: Run test to verify it fails**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: FAIL — `docker_healthy: command not found`
- [ ] **Step 3: Write minimal implementation**
Add to `docker-nuke` after `docker_pids`:
```bash
# Returns 0 if the daemon answers within HEALTH_TIMEOUT, else non-zero
# (124 specifically means it hung rather than errored).
docker_healthy() {
local rc=0
run_timeout "$HEALTH_TIMEOUT" docker info >/dev/null 2>&1 || rc=$?
return "$rc"
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: PASS — `ran 23, failed 0`
- [ ] **Step 5: Lint and commit**
```bash
shellcheck ~/dotfiles/.local/bin/docker-nuke ~/dotfiles/.local/bin/test-docker-nuke.sh
cd ~/dotfiles
git add .local/bin/docker-nuke .local/bin/test-docker-nuke.sh
git commit -m "feat(docker-nuke): add timeout-bounded daemon health check"
```
---
### Task 5: Diagnostic dump and retention
**Files:**
- Modify: `~/dotfiles/.local/bin/docker-nuke`
- Modify: `~/dotfiles/.local/bin/test-docker-nuke.sh`
**Interfaces:**
- Consumes: `run_timeout`, `docker_pids`, `DUMP_ROOT`, `DUMP_RETENTION`, `LOG_TAIL_LINES`, `SAMPLE_DURATION`, `SAMPLE_TIMEOUT`, `DOCKER_LOG_DIR`, `DOCKER_RAW`, `CRASH_DIR`
- Produces: `new_dump_dir` (creates and prints a timestamped dir); `capture NAME SECS CMD...` (writes `$DUMP_DIR/NAME`, records failures in `CAPTURE_FAILURES`, always returns 0); `run_dump` (populates the dump, sets global `DUMP_DIR`); `prune_dumps [KEEP]`
- [ ] **Step 1: Write the failing test**
Add before `=== summary ===`:
```bash
printf '\n=== dump ===\n'
test_dump() {
local d rc
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"
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"
# 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/2026010${i}T000000Z"
done
prune_dumps 10
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')"
}
test_dump
```
- [ ] **Step 2: Run test to verify it fails**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: FAIL — `new_dump_dir: command not found`
- [ ] **Step 3: Write minimal implementation**
Add to `docker-nuke` after `docker_healthy`:
```bash
DUMP_DIR=""
CAPTURE_FAILURES=""
new_dump_dir() {
local stamp
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
local dir="$DUMP_ROOT/$stamp"
mkdir -p "$dir"
printf '%s\n' "$dir"
}
# capture NAME SECONDS CMD... — write CMD output to $DUMP_DIR/NAME.
# Never fails: a timeout or error is recorded and execution continues, so a
# broken capture can never block recovery.
capture() {
local name="$1" secs="$2"
shift 2
local out="$DUMP_DIR/$name"
local rc=0
run_timeout "$secs" "$@" >"$out" 2>&1 || rc=$?
if [ "$rc" -ne 0 ]; then
printf '\n[docker-nuke] capture "%s" failed or timed out (rc=%s)\n' "$name" "$rc" >>"$out"
CAPTURE_FAILURES="$CAPTURE_FAILURES $name"
fi
return 0
}
# Sample user-space stacks of the daemon-side processes. This is the one
# signal that cannot be reconstructed after the processes are killed.
capture_samples() {
if ! bin_exists sample; then
printf 'sample(1) not available\n' >"$DUMP_DIR/samples-skipped.txt"
return 0
fi
local name pid
for name in com.docker.backend com.docker.krun; do
for pid in $(pgrep -x "$name" 2>/dev/null || true); do
capture "sample-$name-$pid.txt" "$SAMPLE_TIMEOUT" \
sample "$pid" "$SAMPLE_DURATION" -mayDie
done
done
}
capture_logs() {
mkdir -p "$DUMP_DIR/logs"
local f base
for f in "$DOCKER_LOG_DIR"/host/*.log "$DOCKER_LOG_DIR"/vm/*.log; do
if [ -f "$f" ]; then
base="$(basename "$f")"
tail -n "$LOG_TAIL_LINES" "$f" >"$DUMP_DIR/logs/$base" 2>/dev/null || true
fi
done
}
capture_crashes() {
mkdir -p "$DUMP_DIR/crashes"
if [ ! -d "$CRASH_DIR" ]; then
return 0
fi
local f
ls -1t "$CRASH_DIR" 2>/dev/null | grep -i docker | head -5 | while read -r f; do
cp "$CRASH_DIR/$f" "$DUMP_DIR/crashes/" 2>/dev/null || true
done
}
disk_report() {
df -h
printf '\n--- Docker.raw ---\n'
ls -lh "$DOCKER_RAW" 2>/dev/null || printf 'Docker.raw not found\n'
du -h -d0 "$DOCKER_RAW" 2>/dev/null || true
}
socket_report() {
local sock="$HOME/.docker/run/docker.sock"
printf 'socket: %s\n' "$sock"
ls -l "$sock" 2>/dev/null || printf 'socket does not exist\n'
printf '\n--- listeners ---\n'
lsof "$sock" 2>/dev/null || printf 'no processes hold the socket\n'
}
run_dump() {
DUMP_DIR="$(new_dump_dir)"
CAPTURE_FAILURES=""
log "docker-nuke: capturing diagnostics to $DUMP_DIR"
capture "ps.txt" 10 ps -eo pid,ppid,stat,%cpu,%mem,etime,command
capture "docker-info.txt" "$HEALTH_TIMEOUT" docker info
capture "docker-version.txt" "$HEALTH_TIMEOUT" docker version
capture "disk.txt" 15 disk_report
capture "memory.txt" 10 vm_stat
capture "socket.txt" 10 socket_report
capture_logs
capture_crashes
capture_samples
}
prune_dumps() {
local keep="${1:-$DUMP_RETENTION}"
if [ ! -d "$DUMP_ROOT" ]; then
return 0
fi
local d
ls -1 "$DUMP_ROOT" 2>/dev/null | sort -r | tail -n "+$((keep + 1))" | while read -r d; do
if [ -n "$d" ]; then
rm -rf "${DUMP_ROOT:?}/$d"
fi
done
}
```
`disk_report` and `socket_report` are defined as functions so `capture` can time-bound them as single units.
- [ ] **Step 4: Run test to verify it passes**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: PASS — `ran 34, failed 0`
- [ ] **Step 5: Confirm a real dump is well-formed**
Run:
```bash
DOCKER_NUKE_DUMP_ROOT=/tmp/dn-check bash -c \
'source ~/dotfiles/.local/bin/docker-nuke; run_dump; printf "failures:%s\n" "$CAPTURE_FAILURES"; ls -la "$DUMP_DIR"'
```
Expected: `ps.txt`, `docker-info.txt`, `disk.txt`, `memory.txt`, `socket.txt`, `logs/`, `crashes/`, and at least one `sample-*.txt`. `disk.txt` should show the near-full data volume. Sampling makes this take ~10-15s — that is the designed cost.
Then clean up: `rm -rf /tmp/dn-check`
- [ ] **Step 6: Lint and commit**
```bash
shellcheck ~/dotfiles/.local/bin/docker-nuke ~/dotfiles/.local/bin/test-docker-nuke.sh
cd ~/dotfiles
git add .local/bin/docker-nuke .local/bin/test-docker-nuke.sh
git commit -m "feat(docker-nuke): add pre-kill diagnostic dump with retention"
```
---
### Task 6: Kill escalation
Tested against throwaway `sleep` processes rather than real Docker, so the escalation logic is proven without bouncing containers.
**Files:**
- Modify: `~/dotfiles/.local/bin/docker-nuke`
- Modify: `~/dotfiles/.local/bin/test-docker-nuke.sh`
**Interfaces:**
- Consumes: `docker_pids`, `GRACEFUL_WAIT`, `TERM_WAIT`, `DOCKER_APP`
- Produces: `pids_alive PID...` (prints the subset still running, space-separated); `kill_pids SIGNAL PID...`; `wait_for_exit SECONDS PID...` (returns 0 once all are gone, 1 on timeout); `nuke_docker` (full escalation against `docker_pids`)
- [ ] **Step 1: Write the failing test**
Add before `=== summary ===`:
```bash
printf '\n=== kill escalation ===\n'
test_kill() {
local a b 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"
kill_pids KILL "$b"
sleep 1
assert_eq "SIGKILL clears the last pid" "" "$(pids_alive "$a" "$b")"
rc=0; kill_pids TERM 999999 || rc=$?
assert_eq "killing a nonexistent pid is not an error" "0" "$rc"
wait "$a" 2>/dev/null || true
wait "$b" 2>/dev/null || true
}
test_kill
```
- [ ] **Step 2: Run test to verify it fails**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: FAIL — `pids_alive: command not found`
- [ ] **Step 3: Write minimal implementation**
Add to `docker-nuke` after `prune_dumps`:
```bash
pids_alive() {
local pid alive=""
for pid in "$@"; do
if kill -0 "$pid" 2>/dev/null; then
alive="$alive $pid"
fi
done
printf '%s' "${alive# }"
}
kill_pids() {
local sig="$1"
shift
local pid
for pid in "$@"; do
kill "-$sig" "$pid" 2>/dev/null || true
done
return 0
}
# Returns 0 once every PID is gone, 1 if SECONDS elapses first.
wait_for_exit() {
local secs="$1"
shift
local waited=0
while [ "$waited" -lt "$secs" ]; do
if [ -z "$(pids_alive "$@")" ]; then
return 0
fi
sleep 1
waited=$((waited + 1))
done
if [ -z "$(pids_alive "$@")" ]; then
return 0
fi
return 1
}
# Stop root launchd helpers. Only called with --deep; prompts for sudo.
stop_privileged_helpers() {
log "docker-nuke: stopping privileged helpers (sudo required)"
sudo launchctl stop com.docker.vmnetd 2>/dev/null || true
sudo launchctl stop com.docker.socket 2>/dev/null || true
}
# Three-stage escalation: polite quit, SIGTERM, SIGKILL.
nuke_docker() {
local pids
pids="$(docker_pids | tr '\n' ' ')"
if [ -z "$pids" ]; then
log "docker-nuke: no Docker processes found"
return 0
fi
log "docker-nuke: asking Docker Desktop to quit"
osascript -e 'quit app "Docker Desktop"' >/dev/null 2>&1 || true
# shellcheck disable=SC2086
if wait_for_exit "$GRACEFUL_WAIT" $pids; then
log "docker-nuke: Docker exited cleanly"
return 0
fi
log "docker-nuke: sending SIGTERM"
# shellcheck disable=SC2086
kill_pids TERM $pids
# shellcheck disable=SC2086
if wait_for_exit "$TERM_WAIT" $pids; then
log "docker-nuke: Docker exited after SIGTERM"
return 0
fi
log "docker-nuke: sending SIGKILL"
# shellcheck disable=SC2086
local remaining
remaining="$(pids_alive $pids)"
# shellcheck disable=SC2086
kill_pids KILL $remaining
sleep 1
if [ "$OPT_DEEP" -eq 1 ]; then
stop_privileged_helpers
fi
# shellcheck disable=SC2086
local still
still="$(pids_alive $pids)"
if [ -n "$still" ]; then
warn "docker-nuke: processes survived SIGKILL: $still"
return 1
fi
log "docker-nuke: all Docker processes terminated"
return 0
}
```
The `SC2086` disables are deliberate: `$pids` must word-split into separate arguments here.
- [ ] **Step 4: Run test to verify it passes**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: PASS — `ran 41, failed 0`
- [ ] **Step 5: Lint and commit**
```bash
shellcheck ~/dotfiles/.local/bin/docker-nuke ~/dotfiles/.local/bin/test-docker-nuke.sh
cd ~/dotfiles
git add .local/bin/docker-nuke .local/bin/test-docker-nuke.sh
git commit -m "feat(docker-nuke): add three-stage kill escalation"
```
---
### Task 7: Restart, daemon polling, and summary
**Files:**
- Modify: `~/dotfiles/.local/bin/docker-nuke`
- Modify: `~/dotfiles/.local/bin/test-docker-nuke.sh`
**Interfaces:**
- Consumes: `docker_healthy`, `run_timeout`, `RESTART_TIMEOUT`, `POLL_INTERVAL`, `DUMP_DIR`, `CAPTURE_FAILURES`
- Produces: `start_docker`; `wait_for_daemon SECONDS` (0 when healthy, 1 on timeout); `write_summary VERDICT ELAPSED`
- [ ] **Step 1: Write the failing test**
Add before `=== summary ===`:
```bash
printf '\n=== restart and summary ===\n'
test_restart_and_summary() {
local stub="$TMPROOT/stub2" oldpath="$PATH" rc
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"
}
test_restart_and_summary
```
- [ ] **Step 2: Run test to verify it fails**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: FAIL — `wait_for_daemon: command not found`
- [ ] **Step 3: Write minimal implementation**
Add to `docker-nuke` after `nuke_docker`:
```bash
start_docker() {
log "docker-nuke: relaunching Docker Desktop"
open -a Docker >/dev/null 2>&1 || open "$DOCKER_APP" >/dev/null 2>&1 || true
}
wait_for_daemon() {
local secs="$1"
local waited=0
while [ "$waited" -lt "$secs" ]; do
if docker_healthy; then
return 0
fi
sleep "$POLL_INTERVAL"
waited=$((waited + POLL_INTERVAL))
done
return 1
}
write_summary() {
local verdict="$1" elapsed="$2"
if [ -z "$DUMP_DIR" ] || [ ! -d "$DUMP_DIR" ]; then
return 0
fi
{
printf 'docker-nuke summary\n'
printf '===================\n\n'
printf 'verdict: %s\n' "$verdict"
printf 'elapsed seconds: %s\n' "$elapsed"
printf 'dump directory: %s\n' "$DUMP_DIR"
printf 'capture failures: %s\n' "${CAPTURE_FAILURES:-none}"
printf '\nTriage hints\n------------\n'
printf '* ps.txt STAT column: U or D means stuck in the kernel, not user space\n'
printf '* disk.txt a nearly full data volume is a common cause of Docker hangs\n'
printf '* sample-* user-space stacks showing where the daemon was stuck\n'
printf '* logs/ Docker host and vm logs from just before the kill\n'
} >"$DUMP_DIR/summary.txt"
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: PASS — `ran 46, failed 0`
- [ ] **Step 5: Lint and commit**
```bash
shellcheck ~/dotfiles/.local/bin/docker-nuke ~/dotfiles/.local/bin/test-docker-nuke.sh
cd ~/dotfiles
git add .local/bin/docker-nuke .local/bin/test-docker-nuke.sh
git commit -m "feat(docker-nuke): add restart, daemon polling, and triage summary"
```
---
### Task 8: Wire up `main`, dry-run, and install the symlink
**Files:**
- Modify: `~/dotfiles/.local/bin/docker-nuke` (replace the placeholder `main`)
- Modify: `~/dotfiles/.local/bin/test-docker-nuke.sh`
- Create: `~/.local/bin/docker-nuke` (symlink)
- Modify: `~/dotfiles/README.md`
**Interfaces:**
- Consumes: everything from Tasks 1-7
- Produces: `confirm_healthy_nuke` (returns 0 to proceed, 2 to abort); `dry_run_report`; the finished `main`
- [ ] **Step 1: Write the failing test**
Add before `=== summary ===`:
```bash
printf '\n=== dry run ===\n'
test_dry_run() {
local out rc before
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"
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
```
- [ ] **Step 2: Run test to verify it fails**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: FAIL — dry run output does not contain `would kill`
- [ ] **Step 3: Write minimal implementation**
In `docker-nuke`, replace the placeholder `main` with:
```bash
confirm_healthy_nuke() {
local info
info="$(run_timeout "$HEALTH_TIMEOUT" docker info \
--format '{{.ServerVersion}} ({{.ContainersRunning}} running)' 2>/dev/null || true)"
log "docker-nuke: Docker looks healthy: ${info:-unknown}"
log "Nuking will stop running containers."
printf 'Proceed anyway? [y/N] '
local reply=""
read -r reply || true
case "$reply" in
y|Y|yes|YES) return 0 ;;
*) log "docker-nuke: aborted"; return 2 ;;
esac
}
dry_run_report() {
local pids
pids="$(docker_pids | tr '\n' ' ')"
log "docker-nuke: DRY RUN — no changes made"
if [ -z "$pids" ]; then
log " would kill: (no Docker processes found)"
else
log " would kill: $pids"
# shellcheck disable=SC2086
ps -o pid,stat,%cpu,%mem,etime,comm -p $pids 2>/dev/null || true
fi
log " would dump to: $DUMP_ROOT/<timestamp>"
if [ "$OPT_NO_RESTART" -eq 1 ]; then
log " would not restart Docker (--no-restart)"
else
log " would restart Docker and wait up to ${RESTART_TIMEOUT}s"
fi
}
main() {
local rc=0
parse_args "$@" || rc=$?
if [ "$rc" -eq 3 ]; then return 0; fi
if [ "$rc" -ne 0 ]; then return "$rc"; fi
if [ "$OPT_DRY_RUN" -eq 1 ]; then
dry_run_report
return 0
fi
if docker_healthy && [ "$OPT_FORCE" -eq 0 ]; then
confirm_healthy_nuke || return 2
fi
local started elapsed verdict
started="$(date +%s)"
run_dump
nuke_docker || true
if [ "$OPT_NO_RESTART" -eq 1 ]; then
verdict="killed, not restarted (--no-restart)"
elapsed=$(( $(date +%s) - started ))
write_summary "$verdict" "$elapsed"
prune_dumps
log "docker-nuke: $verdict"
log "docker-nuke: diagnostics in $DUMP_DIR"
return 0
fi
start_docker
if wait_for_daemon "$RESTART_TIMEOUT"; then
verdict="recovered"
elapsed=$(( $(date +%s) - started ))
write_summary "$verdict" "$elapsed"
prune_dumps
local info
info="$(run_timeout "$HEALTH_TIMEOUT" docker info \
--format '{{.ServerVersion}} ({{.ContainersRunning}} running)' 2>/dev/null || true)"
log "docker-nuke: Docker is back after ${elapsed}s — ${info:-ok}"
log "docker-nuke: diagnostics in $DUMP_DIR"
return 0
fi
verdict="daemon did not return within ${RESTART_TIMEOUT}s"
elapsed=$(( $(date +%s) - started ))
write_summary "$verdict" "$elapsed"
prune_dumps
warn "docker-nuke: $verdict"
warn "docker-nuke: still running: $(docker_pids | tr '\n' ' ')"
warn "docker-nuke: diagnostics in $DUMP_DIR"
warn "docker-nuke: try 'docker-nuke --deep', or check disk space in $DUMP_DIR/disk.txt"
return 1
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `~/dotfiles/.local/bin/test-docker-nuke.sh`
Expected: PASS — `ran 51, failed 0`
- [ ] **Step 5: Install the symlink and confirm it resolves**
```bash
ln -sfn ../../dotfiles/.local/bin/docker-nuke ~/.local/bin/docker-nuke
ls -l ~/.local/bin/docker-nuke
command -v docker-nuke
docker-nuke --help
```
Expected: the symlink resolves (matching the relative style used by `cl` and `brave-debug`), `command -v` prints `~/.local/bin/docker-nuke`, and help text prints.
- [ ] **Step 6: Confirm the healthy-daemon guard works**
Run: `printf 'n\n' | docker-nuke`
Expected: reports Docker looks healthy, prompts, then `aborted`. Exit code 2 (`echo $?`). **Docker must still be running afterwards** — verify with `docker info --format '{{.ServerVersion}}'`.
- [ ] **Step 7: Document it in the README**
Add an entry to `~/dotfiles/README.md` in whatever section lists the `.local/bin` scripts (match the file's existing format):
```markdown
- `docker-nuke` — force-restart a wedged Docker Desktop. Captures diagnostics to
`~/.local/state/docker-nuke/<timestamp>/` before killing anything. `--dry-run`
to preview, `--no-restart` to leave Docker down, `--deep` for root helpers.
```
- [ ] **Step 8: Lint and commit**
```bash
shellcheck ~/dotfiles/.local/bin/docker-nuke ~/dotfiles/.local/bin/test-docker-nuke.sh
cd ~/dotfiles
git add .local/bin/docker-nuke .local/bin/test-docker-nuke.sh README.md docs/plans/2026-07-19-docker-nuke.md
git commit -m "feat(docker-nuke): wire up main, dry-run mode, and docs"
```
---
### Task 9: Live end-to-end verification
**Requires explicit user go-ahead.** This bounces every running container. Do not run it unprompted.
**Files:** none modified.
**Interfaces:**
- Consumes: the installed `docker-nuke`
- Produces: evidence that a real nuke recovers the daemon
- [ ] **Step 1: Record pre-nuke state**
```bash
docker info --format 'version={{.ServerVersion}} running={{.ContainersRunning}} total={{.Containers}}'
docker ps --format '{{.Names}}' | sort >/tmp/dn-before.txt
wc -l </tmp/dn-before.txt
```
Expected: records the container names that were running, for comparison afterwards.
- [ ] **Step 2: Run the real nuke**
Run: `time docker-nuke --force`
Expected: dump path printed, kill stages logged, then `Docker is back after Ns`. Exit code 0.
- [ ] **Step 3: Verify recovery**
```bash
echo "exit: $?"
docker info --format 'version={{.ServerVersion}} running={{.ContainersRunning}}'
docker ps --format '{{.Names}}' | sort >/tmp/dn-after.txt
diff /tmp/dn-before.txt /tmp/dn-after.txt || true
```
Expected: the daemon responds. Containers with a restart policy are back; those without remain stopped — this is the documented non-goal, not a bug. Report the diff to the user rather than silently restarting anything.
- [ ] **Step 4: Verify the dump is useful**
```bash
ls -1t ~/.local/state/docker-nuke | head -1
cat ~/.local/state/docker-nuke/$(ls -1t ~/.local/state/docker-nuke | head -1)/summary.txt
```
Expected: `verdict: recovered`, a plausible elapsed time, `capture failures: none`, and the triage hints. Confirm `disk.txt` and at least one `sample-*.txt` are present and non-empty.
- [ ] **Step 5: Verify no stray processes and clean up**
```bash
rm -f /tmp/dn-before.txt /tmp/dn-after.txt
pgrep -x docker-nuke || echo "no docker-nuke processes left behind"
```
Expected: `no docker-nuke processes left behind` — proving the script did not kill or orphan itself.
---
## Self-Review
**Spec coverage:** Health check (Task 4, wired in 8) · pre-kill dump with all ten artifacts (Task 5) · dump-never-blocks-recovery (Task 5, `capture` always returns 0) · retention of 10 (Task 5) · three-stage kill escalation (Task 6) · restart and 90s poll (Task 7) · report with version, count, elapsed, dump path (Task 8) · process targeting incl. the self-kill hazard (Task 3) · all six flags (Tasks 1, 5, 6, 8) · exit codes 0/1/2 (Tasks 1, 8) · constants (Task 1) · `run_timeout` with fallback (Task 2) · `set -e`/`pgrep` care (Tasks 2, 3) · staged verification (Tasks 8, 9). No gaps.
**Non-goals honored:** no container snapshot/replay; no sudo outside `--deep`; no pruning or disk cleanup anywhere.
**Type consistency:** `DUMP_DIR` and `CAPTURE_FAILURES` are declared in Task 5 and consumed unchanged in Task 7. `capture NAME SECS CMD...` keeps that argument order at every call site. `wait_for_exit`/`wait_for_daemon` both take seconds first and return 0 on success, 1 on timeout. `pids_alive` returns a space-separated string in every use. `parse_args` returns 3 for `--help` and 2 for bad flags, handled identically in the Task 1 and Task 8 versions of `main`.