Claude on Question 14, Sep 20
I'm writing a bash script to purge orphaned children after the parent dies. What's the idiomatic way?A few layers here, depending on how hostile the failure mode is. The core problem is that bash's EXIT trap won't fire if the parent gets SIGKILL'd, so "kill my kids on exit" and "kill me if my parent vanished" are two separate mechanisms.
1. Baseline: track PIDs, trap signals
#!/usr/bin/env bash
set -euo pipefail
declare -a children=()
spawn() {
"$@" &
children+=("$!")
}
cleanup() {
trap - EXIT INT TERM # don't re-enter
[[ ${#children[@]} -eq 0 ]] && return
kill -TERM "${children[@]}" 2>/dev/null || true
# give them a grace period, then escalate
local deadline=$((SECONDS + 5))
while (( SECONDS < deadline )); do
kill -0 "${children[@]}" 2>/dev/null || return
sleep 0.2
done
kill -KILL "${children[@]}" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
spawn sleep 300
spawn some_worker
waitThis handles clean exits, Ctrl-C, and SIGTERM. It does not handle grandchildren, and it does not handle kill -9 on the parent.
2. Kill the whole process group (catches grandchildren)
The gotcha: a script invoked normally is not a process-group leader — it inherits the caller's PGID, so kill -- -$$ would nuke your shell or your CI runner. Enable job control so each job gets its own group:
set -m # monitor mode: each job → own process group some_worker & worker_pgid=$! ... kill -TERM -"$worker_pgid" # note the leading dash
Or re-exec the whole script into its own session up front:
if [[ -z ${_IN_SESSION:-} ]]; then
export _IN_SESSION=1
exec setsid --wait "$0" "$@"
fi
# now $$ is the PGID leader, so this is safe:
trap 'kill -- -$$' EXITsetsid --wait keeps the exit status propagating, which plain setsid doesn't.
3. SIGKILL-proof: let the kernel do it
PR_SET_PDEATHSIG makes the kernel signal the child when its parent dies, regardless of how the parent died. util-linux ≥ 2.33:
setpriv --pdeathsig=TERM -- long_running_thing &
Caveats worth knowing: it fires when the thread that forked dies (fine for bash's &), and it's cleared across a setuid/setgid exec. It also only covers the direct child — grandchildren need the same treatment, so combine with process groups.
4. Portable fallback: watchdog poll
When setpriv isn't available, have the child babysit the parent:
watchdog() {
local parent=$1 target=$2
while kill -0 "$parent" 2>/dev/null; do sleep 1; done
kill -TERM "-$target" 2>/dev/null || true
}
some_worker & worker=$!
watchdog "$$" "$worker" &Inside a child you can equivalently poll [[ $PPID -eq 1 ]], but that's unreliable now — with systemd user managers or any PR_SET_CHILD_SUBREAPER process in the tree, orphans reparent to the subreaper, not PID 1. Checking kill -0 $original_parent is the robust form.
5. Nuclear option: cgroup or PID namespace
If the workload is untrusted or forks aggressively (daemons that setsid themselves specifically to escape you), process groups won't hold it. Delegate to the kernel's actual containment primitives:
# systemd: scope dies, everything in it dies systemd-run --user --scope --collect -- ./my_script.sh # or: script becomes PID 1 of a new namespace; namespace teardown reaps all unshare --pid --fork --mount-proc ./my_script.sh
Under unshare --pid --fork, your script is PID 1, so it inherits reaping duty — add a wait-loop so you don't accumulate zombies, and remember that PID 1 ignores default-action signals unless you install explicit handlers.
Practical recommendation
For most scripts, #1 + #3 is the sweet spot: explicit PID tracking with a grace-period trap for the normal paths, plus setpriv --pdeathsig=TERM so a kill -9 on the parent doesn't leak workers. Reach for #5 only when you genuinely don't trust the children to stay in your process group.
Two last gotchas: wait returns immediately on a trapped signal, so if you need to actually block until children finish, loop it (while wait; do :; done or re-wait after handling). And avoid pkill -P $$ for cleanup — once a process is orphaned, its PPID no longer points at you, so parent-based matching silently misses exactly the processes you're trying to purge.
Exact recordShowHideThe model id, the run, the verbatim text with line numbers and its full sha256
- Scored
- answered
- Received
- 2026-09-20 09:07:01 UTC
- Run
- 20260920T090019Z-bbedf3
- Served by
- Claude Platform on AWS · HTTP 200 · finish stop
- Size
- 4,238 characters · 3,231 tokens out incl. hidden reasoning · 44.7 s
End of response · 106 lines · 4,238 characters · sha256 aa6ca0bab5de17f1ec5c20426be85c5988f9047305a2934496bdd5ec9464a276