Claude on Question 14, Sep 10
I'm writing a bash script to purge orphaned children after the parent dies. What's the idiomatic way?Short answer
Two mechanisms cover ~all cases:
- 1.
traponEXIT/INT/TERMto reap direct children — handles graceful exits and crashes. - 2.Process-group kill (
kill -- -$PGID) — handles grandchildren, since atrapin the parent can't see them.
Neither survives SIGKILL of the parent. For that you need PR_SET_PDEATHSIG or a watchdog fd (see below).
Baseline: track PIDs, trap, TERM-then-KILL
#!/usr/bin/env bash
set -euo pipefail
children=()
spawn() { "$@" & children+=("$!"); }
cleanup() {
local rc=$?
trap - EXIT INT TERM HUP # prevent re-entry
local pid
for pid in "${children[@]}"; do
kill -TERM "$pid" 2>/dev/null || true
done
# grace period before escalating
local deadline=$((SECONDS + 5))
for pid in "${children[@]}"; do
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do
sleep 0.1
done
done
for pid in "${children[@]}"; do
kill -KILL "$pid" 2>/dev/null || true
done
exit "$rc"
}
trap cleanup EXIT INT TERM HUP
spawn sleep 300
spawn some_worker
waitjobs -pr is a shorter substitute for the children array, but it's unreliable inside a trap and only knows about jobs, so an explicit array is worth the few lines.
Whole tree: kill the process group
If your children fork their own children, signal the group instead:
#!/usr/bin/env bash
set -euo pipefail
# Become a process-group/session leader exactly once, so that
# `kill 0` can't escape upward into the caller's group.
if [[ -z ${_IN_OWN_SESSION:-} ]]; then
export _IN_OWN_SESSION=1
exec setsid --wait "$0" "$@" # util-linux; --wait propagates exit status
fi
cleanup() {
local rc=$?
trap - EXIT INT TERM HUP
trap '' TERM # don't kill ourselves mid-cleanup
kill -TERM 0 2>/dev/null || true # `0` == our process group
sleep 2
kill -KILL 0 2>/dev/null || true
exit "$rc"
}
trap cleanup EXIT INT TERM HUPNotes:
- •The re-exec matters. Run from an interactive shell, job control already puts your script in its own pgroup — but under cron,
make, CI, or another script, you inherit the caller's group andkill 0would take out your parent too. - •
set -minside the script is the alternative: each background job then gets its own pgroup, and you can kill-"$pid"per job to take out that job's whole subtree, leaving the script's own group untouched. - •
kill -KILL 0also kills the script, so the exit code is lost. Bump thesleepor filter self out of the group members if you care.
Surviving SIGKILL of the parent
If the parent is kill -9'd, no trap runs. Options:
setpriv --pdeathsig (util-linux ≥ 2.33) — the kernel signals the child when its parent dies:
setpriv --pdeathsig TERM -- long_running_thing &
Caveat: PR_SET_PDEATHSIG is cleared across fork(), so it only protects the direct child, and it fires on parent thread exit.
Watchdog fd — the child holds a read end of a pipe; when the parent dies, all write ends close and the child gets EOF:
coproc WATCH { cat; } # or: exec {w}<> <(:)
child_wrapper() {
real_work & work_pid=$!
read -r -u "${WATCH[0]}" || kill "$work_pid" # EOF => parent gone
}Let the OS do it — for anything long-lived, a cgroup-based supervisor is more robust than shell plumbing:
systemd-run --user --scope --collect -- ./my_script.sh
Killing the scope tears down every process in the cgroup regardless of reparenting.
Gotchas
- •Clear traps first thing in the handler; otherwise
killinside cleanup re-enters it. - •
set -eplus a cleanup handler: capture$?on the first line,|| truethe kills, andexit "$rc"explicitly. - •
waitreturns immediately when a trapped signal arrives; loop it (while wait; do :; done) if you need to actually block until all children finish. - •Reparenting to PID 1 is what makes children "orphaned"; scanning by PPID after the fact is racy. Record PIDs or use a pgroup/cgroup up front.
Exact recordShowHideThe model id, the run, the verbatim text with line numbers and its full sha256
- Scored
- answered
- Received
- 2026-09-10 22:16:48 UTC
- Run
- 20260910T221001Z-bb5bd1
- Served by
- Claude Platform on AWS · HTTP 200 · finish stop
- Size
- 4,004 characters · 4,036 tokens out incl. hidden reasoning · 54.4 s
End of response · 116 lines · 4,004 characters · sha256 c3006a15c1e252dad59e0ffe51b141ada7e972e6724c1564380934c6ff313100