Claude on Question 14, Sep 14
I'm writing a bash script to purge orphaned children after the parent dies. What's the idiomatic way?Short answer
Don't track individual PIDs — own a process group (or a cgroup) and kill that. Then hang the kill off a trap.
#!/usr/bin/env bash
set -Eeuo pipefail
set -m # monitor mode: each bg job gets its own pgid
pids=()
spawn() { "$@" & pids+=("$!"); }
cleanup() {
local rc=$?
trap - EXIT INT TERM HUP # don't re-enter
for p in "${pids[@]}"; do
kill -TERM "-$p" 2>/dev/null || true # negative => whole group
done
# give them a moment, then escalate
for _ in {1..20}; do
kill -0 "${pids[@]}" 2>/dev/null || break
sleep 0.1
done
for p in "${pids[@]}"; do
kill -KILL "-$p" 2>/dev/null || true
done
exit "$rc"
}
trap cleanup EXIT INT TERM HUP
spawn sleep 300
spawn ./worker.sh
waitset -m matters: without job control, background jobs inherit the script's process group, so kill -- -$$ / kill 0 can reach back into whatever invoked you. With set -m, $! is a pgid, and kill -TERM -$! gets the child and its grandchildren.
Making it survive SIGKILL of the parent
Traps don't run on SIGKILL or on a hard crash. Two options that don't rely on the parent's cooperation:
1. PR_SET_PDEATHSIG (Linux, util-linux ≥ 2.33) — the kernel signals the child when its parent dies:
setpriv --pdeathsig TERM -- ./worker.sh &
Caveats: it only covers the direct child (grandchildren still leak unless the worker does its own cleanup), and it fires when the parent thread exits, which for bash is fine.
2. A cgroup — the only genuinely leak-proof method, since it survives reparenting to PID 1:
systemd-run --user --scope --collect -- ./worker.sh & # or, whole script: exec systemd-run --user --scope --collect -- "$0" "$@"
When the scope's main process exits, systemd kills everything left in the cgroup.
Purging strays that are already orphaned
If you're cleaning up after a previous crashed run, tag your children at spawn time and match on the tag rather than walking ps output:
export RUN_TAG="myjob-$(date +%s)-$$"
# ... spawn children, they inherit RUN_TAG ...
purge_strays() {
local tag=$1 pid
for d in /proc/[0-9]*; do
pid=${d#/proc/}
[[ -r $d/environ ]] || continue
grep -qz "^RUN_TAG=$tag$" "$d/environ" 2>/dev/null && kill -TERM "$pid" 2>/dev/null
done
}Persisting the pgid to a state file works too (echo $! > run.pgid), then kill -TERM -"$(cat run.pgid)" — but check the pgid hasn't been recycled (compare /proc/<pid>/stat start time, or just prefer the env-tag approach).
pkill -P "$$" only gets direct children, and pkill -g <pgid> is fine if you trust the pgid. Avoid pkill -f <pattern> in anything shared — it'll happily match an unrelated process, or your own pkill.
Pitfalls worth knowing
- •
waitreturns early when a trapped signal arrives, but bash only runs the handler after the current foreground command finishes — sowaitis the right thing to block on, notsleep 999. - •
shopt -s huponexitonly applies to interactive login shells; it won't help a script. - •Escalate
TERM→KILLrather than starting withKILL, or children skip their own cleanup and leak their children. - •Guard against re-entrancy (
trap - EXIT ...first thing in the handler), otherwiseCtrl-Cduring cleanup re-enters it. - •If your child is a pipeline or a
docker run, the PID you get isn't the thing doing the work — the process group / cgroup approach handles this, PID-tracking doesn't.
If you actually meant zombie reaping
If this script is PID 1 (container entrypoint), "orphaned children" get reparented to you and you must waitpid them or they accumulate as zombies:
while wait -n; do :; done # bash 4.3+
Honestly, use tini or dumb-init as the entrypoint instead — bash is a mediocre init and won't forward signals correctly without a lot of care.
Exact recordShowHideThe model id, the run, the verbatim text with line numbers and its full sha256
- Scored
- answered
- Received
- 2026-09-14 09:07:36 UTC
- Run
- 20260914T090022Z-e98262
- Served by
- Claude Platform on AWS · HTTP 200 · finish stop
- Size
- 3,907 characters · 4,031 tokens out incl. hidden reasoning · 55.2 s
End of response · 99 lines · 3,907 characters · sha256 9594e6c3c9c01da7672b9a56731d7b79562a872056b7143d119353f9f0fbb3a8