#!/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 ---------------------------------------------------------- # # The SC2034 directives below are per-line on purpose, not file-level: each # constant is consumed by a later task, and its directive is deleted at that # point. A file-level disable would silence SC2034 for the whole file forever, # hiding a genuinely dead variable added later. By the final task none of these # directives should remain. HEALTH_TIMEOUT=6 # seconds to wait on `docker info` before calling it wedged # shellcheck disable=SC2034 GRACEFUL_WAIT=8 # seconds to let Docker quit politely # shellcheck disable=SC2034 TERM_WAIT=5 # seconds to wait after SIGTERM before SIGKILL # shellcheck disable=SC2034 RESTART_TIMEOUT=90 # seconds to wait for the daemon to come back # shellcheck disable=SC2034 POLL_INTERVAL=2 # seconds between daemon health polls # shellcheck disable=SC2034 SAMPLE_DURATION=2 # seconds of stack sampling per process # shellcheck disable=SC2034 SAMPLE_TIMEOUT=10 # hard cap on a single `sample` invocation # shellcheck disable=SC2034 DUMP_RETENTION=10 # how many dump directories to keep # shellcheck disable=SC2034 LOG_TAIL_LINES=2000 # lines of Docker log to copy (keeps dumps small) DOCKER_APP="/Applications/Docker.app" # shellcheck disable=SC2034 DOCKER_LOG_DIR="$HOME/Library/Containers/com.docker.docker/Data/log" # shellcheck disable=SC2034 DOCKER_RAW="$HOME/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw" # shellcheck disable=SC2034 CRASH_DIR="$HOME/Library/Logs/DiagnosticReports" # Overridable so tests never touch real state. # shellcheck disable=SC2034 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; } # 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" } # 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 comm { # `pgrep -f "$DOCKER_APP"` matches any process whose *command line* # mentions the bundle path — e.g. `tail -f /Applications/Docker.app/...` # matches even though that process is plainly not Docker. Post-filter # each candidate by its actual executable so only processes truly # launched from the bundle survive. Wrapped in `|| true`: under # `set -o pipefail`, an empty `pgrep -f` match (exit 1) would otherwise # make this whole statement fail and abort the function under `set -e`. pgrep -f "$DOCKER_APP" 2>/dev/null | while read -r pid; do if [ -z "$pid" ]; then continue; fi # A dead PID (process exited between pgrep and ps) or a `ps` failure # must yield an empty comm, which matches no case branch below — # never a crash, never a false positive. # NOTE: `ps -o comm=` reports the executable path as invoked, not # canonicalized — a Docker helper ever exec'd by relative path or bare # name would report a non-bundle comm here and be silently dropped. comm="$(ps -o comm= -p "$pid" 2>/dev/null)" || true case "$comm" in "$DOCKER_APP"/*) printf '%s\n' "$pid" ;; esac done || true # Exact-name matches are already precise — do NOT apply the comm # filter here. One job of this branch is catching a hung # /usr/local/bin/docker CLI, which legitimately lives outside the # bundle; filtering by $DOCKER_APP prefix would break that. 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 } # 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" } 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// 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 # shellcheck disable=SC2034 # OPT_* are read by main once it is wired up 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