Files
kandev/scripts/kandev-kill
T

322 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# kandev-kill — terminate exactly ONE kandev instance: the backend listening on
# a given port, plus its agentctl child(ren) and its matching web/next server.
#
# This NEVER does a blanket `pkill kandev`. It resolves the backend PID from the
# port, confirms the process is actually a kandev backend, computes the exact
# set of PIDs it will signal, PRINTS that set, and only then (after confirmation)
# sends SIGTERM, escalating to SIGKILL after a short grace period.
#
# Safety:
# - Refuses to act unless you explicitly pass a port or a --pidfile.
# - GUARDED PORTS (the well-known production ports the user's live instance
# uses — see GUARDED_PORTS below) are protected: killing one requires an
# explicit --force. The guard is applied to the RESOLVED backend port, so it
# holds no matter HOW you reached it — by `<port>` OR via a `--pidfile` whose
# process happens to listen on a guarded port. There is no bypass path.
# - Prints exactly what will be killed and asks for confirmation, unless --yes
# is given (for scripted teardown).
# - Only signals a process group (kill -- -<pid>) when the target PID is
# actually the group leader; otherwise it kills the PID + its discovered
# descendants individually, never a group it doesn't own.
#
# Usage:
# scripts/kandev-kill <backend_port> [--yes] [--force]
# scripts/kandev-kill --pidfile /tmp/kandev-dev-isolated-<port>.pid [--yes]
#
# Examples:
# scripts/kandev-kill 48429 --yes
# scripts/kandev-kill --pidfile /tmp/kandev-dev-isolated-48429.pid --yes
#
set -euo pipefail
# Ports we refuse to kill without --force (the user's live production instance).
# Extend by adding entries — the guard runs on the RESOLVED port, so every entry
# is protected through both the <port> and --pidfile entry paths.
GUARDED_PORTS=(38429)
GRACE_SECONDS=5
# True if $1 is one of the guarded ports.
is_guarded_port() {
local p="$1" g
for g in "${GUARDED_PORTS[@]}"; do
[[ "$p" == "$g" ]] && return 0
done
return 1
}
PORT=""
PIDFILE=""
ASSUME_YES=0
FORCE=0
usage() {
sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-2}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--pidfile)
PIDFILE="${2:?--pidfile requires a path}"
shift 2
;;
--yes|-y)
ASSUME_YES=1
shift
;;
--force)
FORCE=1
shift
;;
-h|--help)
usage 0
;;
--*)
echo "kandev-kill: unknown flag '$1'" >&2
usage 2
;;
*)
if [[ -z "$PORT" ]]; then
PORT="$1"
else
echo "kandev-kill: unexpected argument '$1'" >&2
usage 2
fi
shift
;;
esac
done
# Resolve the backend PID listening on a TCP port.
pid_for_port() {
local port="$1" pid=""
if command -v ss >/dev/null 2>&1; then
pid="$(ss -tlnpH "sport = :$port" 2>/dev/null |
grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)"
fi
if [[ -z "$pid" ]] && command -v lsof >/dev/null 2>&1; then
pid="$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | head -1)"
fi
printf '%s\n' "$pid"
}
# List every TCP port a PID is listening on, one per line.
listening_ports_for_pid() {
local pid="$1"
if command -v ss >/dev/null 2>&1; then
ss -tlnpH 2>/dev/null | grep "pid=$pid," |
grep -oE ':[0-9]+ ' | tr -d ': ' | sort -nu
elif command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP -sTCP:LISTEN -a -p "$pid" 2>/dev/null |
awk 'NR>1 {n=split($9,a,":"); print a[n]}' | sort -nu
fi
}
# Confirm a PID is a kandev backend (exe basename or cmdline mentions kandev).
is_kandev_backend() {
local pid="$1" exe cmd
[[ -d "/proc/$pid" ]] || return 1
exe="$(readlink "/proc/$pid/exe" 2>/dev/null || true)"
[[ "$(basename "$exe" 2>/dev/null)" == "kandev" ]] && return 0
cmd="$(tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null || true)"
case "$cmd" in
*/kandev*|*bin/kandev*) return 0 ;;
esac
return 1
}
# --- Resolve the target backend PID + port ---
BACKEND_PID=""
if [[ -n "$PIDFILE" ]]; then
[[ -f "$PIDFILE" ]] || { echo "kandev-kill: pidfile not found: $PIDFILE" >&2; exit 1; }
BACKEND_PID="$(tr -d '[:space:]' <"$PIDFILE")"
[[ "$BACKEND_PID" =~ ^[0-9]+$ ]] || { echo "kandev-kill: pidfile has no numeric PID: $PIDFILE" >&2; exit 1; }
if [[ ! -d "/proc/$BACKEND_PID" ]]; then
echo "kandev-kill: process $BACKEND_PID from pidfile is already gone; nothing to do." >&2
rm -f "$PIDFILE" 2>/dev/null || true
exit 0
fi
elif [[ -n "$PORT" ]]; then
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "kandev-kill: port must be numeric, got '$PORT'" >&2; exit 2; }
BACKEND_PID="$(pid_for_port "$PORT")"
[[ -n "$BACKEND_PID" ]] || { echo "kandev-kill: no listening process found on port $PORT" >&2; exit 1; }
else
echo "kandev-kill: refusing to run without an explicit <port> or --pidfile." >&2
usage 2
fi
# --- Resolve EVERY listening port of the target PID (for guard + display) ---
# The guard must hold no matter how the target was supplied. We derive all
# listening ports from the running process itself, so a --pidfile pointing at a
# guarded port is refused exactly like `kandev-kill <guarded_port>`.
mapfile -t TARGET_PORTS < <(listening_ports_for_pid "$BACKEND_PID")
# Fail closed: in --pidfile mode the guard relies entirely on resolving the
# target's listening ports. If neither ss nor lsof is available (empty list and
# no explicit port supplied), we can't prove the target isn't a guarded
# production instance — refuse rather than kill blind, unless --force.
if [[ -n "$PIDFILE" && -z "$PORT" && ${#TARGET_PORTS[@]} -eq 0 && "$FORCE" -ne 1 ]]; then
echo "kandev-kill: cannot resolve listening ports for PID $BACKEND_PID (need ss or lsof)." >&2
echo " Refusing a --pidfile kill without --force — the guarded-port check can't run." >&2
exit 3
fi
# Prefer the explicitly supplied port for display; else the lowest listening one.
if [[ -z "$PORT" && ${#TARGET_PORTS[@]} -gt 0 ]]; then
PORT="${TARGET_PORTS[0]}"
fi
# --- Safety guards ---
# Refuse if ANY resolved listening port (or the supplied port) is guarded.
guarded_hit=""
if [[ -n "${PORT:-}" ]] && is_guarded_port "$PORT"; then
guarded_hit="$PORT"
fi
for p in "${TARGET_PORTS[@]:-}"; do
[[ -z "$p" ]] && continue
if is_guarded_port "$p"; then
guarded_hit="$p"
break
fi
done
if [[ -n "$guarded_hit" && "$FORCE" -ne 1 ]]; then
echo "kandev-kill: port $guarded_hit is a guarded production port (the user's live instance)." >&2
echo " PID $BACKEND_PID listens on it — refusing to kill without --force." >&2
exit 3
fi
if ! is_kandev_backend "$BACKEND_PID"; then
echo "kandev-kill: PID $BACKEND_PID (port ${PORT:-?}) does not look like a kandev backend. Aborting." >&2
exit 4
fi
# --- Compute the kill set: backend + descendants (agentctl + web if children) ---
# Collect the full descendant tree of the backend PID.
collect_descendants() {
local root="$1"
local -a queue=("$root")
local -a out=()
while [[ ${#queue[@]} -gt 0 ]]; do
local cur="${queue[0]}"
queue=("${queue[@]:1}")
out+=("$cur")
local kids
kids="$(pgrep -P "$cur" 2>/dev/null || true)"
for k in $kids; do
queue+=("$k")
done
done
printf '%s\n' "${out[@]}"
}
mapfile -t TREE < <(collect_descendants "$BACKEND_PID")
# The web/next server is typically a sibling (spawned by the launcher, not a
# child of the backend). If a pidfile sidecar lists it, include it.
WEB_PID=""
if [[ -n "$PIDFILE" && -f "${PIDFILE%.pid}.web.pid" ]]; then
WEB_PID="$(tr -d '[:space:]' <"${PIDFILE%.pid}.web.pid" 2>/dev/null || true)"
if [[ "$WEB_PID" =~ ^[0-9]+$ && -d "/proc/$WEB_PID" ]]; then
TREE+=("$WEB_PID")
else
WEB_PID=""
fi
fi
# Describe the kill set.
echo "kandev-kill: will terminate the following processes (backend port ${PORT:-?}):"
for p in "${TREE[@]}"; do
cmd="$( (tr '\0' ' ' <"/proc/$p/cmdline") 2>/dev/null | cut -c1-90 || true)"
echo " PID $p ${cmd:-<gone>}"
done
if [[ "$ASSUME_YES" -ne 1 ]]; then
printf 'Proceed? [y/N] '
read -r answer
case "$answer" in
y|Y|yes|YES) ;;
*) echo "kandev-kill: aborted by user."; exit 0 ;;
esac
fi
# Is the backend PID its own process-group leader? Only then is it safe to
# signal the whole group (kill -- -<pid>); otherwise that negative PID would
# target some OTHER process's group. `dev-isolated` launches via setsid (leader),
# but a kandev started another way may not be.
pid_is_pgleader() {
local pid="$1" pgid
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
[[ -n "$pgid" && "$pgid" == "$pid" ]]
}
BACKEND_IS_LEADER=0
if pid_is_pgleader "$BACKEND_PID"; then
BACKEND_IS_LEADER=1
fi
# A PID is "alive" only if it exists AND is not a zombie. A zombie (state Z) has
# already exited; it just hasn't been reaped by its parent. When kandev-kill
# targets a non-leader backend whose parent is a sleeping launcher (not us), the
# dead process lingers as a zombie — treat that as success, not failure.
pid_is_alive() {
local pid="$1" stat state
[[ -d "/proc/$pid" ]] || return 1
stat="$(cat "/proc/$pid/stat" 2>/dev/null || true)"
[[ -n "$stat" ]] || return 1
# The state char is the first field after the comm field, which is wrapped in
# parens and may itself contain spaces — strip through the LAST ')' first.
state="${stat##*) }"
state="${state%% *}"
[[ "$state" == "Z" ]] && return 1
return 0
}
# --- Terminate: SIGTERM first (cascades to agent children), then any stragglers
# individually, then SIGKILL after the grace period. ---
signal_tree() {
local sig="$1"
# If the backend leads its own process group, signal the whole group so
# detached children cascade. Never signal a negative PID we don't lead.
if [[ "$BACKEND_IS_LEADER" -eq 1 ]]; then
kill -"$sig" -- -"$BACKEND_PID" 2>/dev/null || true
fi
# Always signal each discovered PID individually (the fallback when we don't
# own the group, and a belt-and-suspenders pass when we do).
for p in "${TREE[@]}"; do
kill -"$sig" "$p" 2>/dev/null || true
done
}
signal_tree TERM
# Wait up to GRACE_SECONDS for the backend to exit.
waited=0
while pid_is_alive "$BACKEND_PID" && [[ "$waited" -lt "$GRACE_SECONDS" ]]; do
sleep 1
waited=$((waited + 1))
done
if pid_is_alive "$BACKEND_PID"; then
echo "kandev-kill: backend still alive after ${GRACE_SECONDS}s, sending SIGKILL."
signal_tree KILL
sleep 1
fi
# Reap any remaining tracked PIDs with SIGKILL.
for p in "${TREE[@]}"; do
if pid_is_alive "$p"; then
kill -KILL "$p" 2>/dev/null || true
fi
done
if pid_is_alive "$BACKEND_PID"; then
echo "kandev-kill: WARNING — backend PID $BACKEND_PID is still present." >&2
exit 5
fi
# Clean up pidfile(s).
if [[ -n "$PIDFILE" ]]; then
rm -f "$PIDFILE" "${PIDFILE%.pid}.web.pid" 2>/dev/null || true
fi
echo "kandev-kill: done. Backend on port ${PORT:-?} (PID $BACKEND_PID) terminated."