Claude on Question 14, Sep 27
I'm writing a bash script to purge orphaned children after the parent dies. What's the idiomatic way?Short answer
There isn't one — it depends on how hard the parent's death is. Three tiers:
Tier 1: trap + kill the job list (graceful exits)
#!/usr/bin/env bash
set -euo pipefail
cleanup() {
trap - EXIT INT TERM HUP # disarm to avoid re-entry
local pids
pids=$(jobs -rp) # running background jobs only
[[ -n $pids ]] && kill $pids 2>/dev/null
wait
}
trap cleanup EXIT INT TERM HUP
sleep 300 &
sleep 300 &
waitTwo gotchas people hit:
- •You must list
INT TERM HUPexplicitly. An untrapped fatal signal kills bash via default disposition and theEXITtrap never fires. - •
jobs -ponly knows direct background jobs. Grandchildren survive.
Tier 2: kill the whole process group
This is the idiomatic way to get grandchildren too. Put yourself in a fresh process group so you don't nuke your caller:
#!/usr/bin/env bash
set -euo pipefail
# Re-exec into our own session/process group exactly once
if [[ -z ${_IN_SESSION:-} ]]; then
export _IN_SESSION=1
exec setsid --wait "$0" "$@"
fi
cleanup() {
trap - EXIT INT TERM HUP
kill -- -$$ 2>/dev/null # negative pid == process group
}
trap cleanup EXIT INT TERM HUPkill -- -$$ is only valid when $$ is the group leader. When a script is launched from an interactive shell, job control already gives it its own pgid — but from another script it inherits the caller's, and kill -- -$$ then either errors or hits the wrong group. setsid removes the ambiguity. (--wait needs util-linux ≥ 2.24; without it, use setsid "$0" "$@" & wait.)
Note that setsid detaches from the controlling terminal, so Ctrl-C from the tty no longer reaches you — you're relying on your TERM trap instead.
Tier 3: parent gets SIGKILL / segfaults
No trap will ever run, and the orphans are immediately reparented to PID 1, so you can't find them by PPID afterwards. pkill -P $$ is useless here. You need the kernel to do it:
PR_SET_PDEATHSIG — the child asks to be signalled when its parent dies:
setpriv --pdeathsig TERM -- long_running_thing &
Caveats: it fires when the thread that forked exits (surprising with threaded parents), and it's cleared across a privilege-changing exec. Also it only protects the immediate child, so each level has to opt in.
cgroups — the actually-bulletproof option on systemd systems:
systemd-run --user --scope --collect -- long_running_thing
Everything in the scope dies together regardless of reparenting; --collect cleans up the unit on failure.
Subreaper — if you're writing a supervisor, PR_SET_CHILD_SUBREAPER makes orphaned descendants reparent to you instead of PID 1, so you keep seeing their SIGCHLD. Not reachable from pure bash; setpriv doesn't expose it, so this means a small C wrapper or a Python/Go supervisor.
Practical recommendation
For most scripts, Tier 2 is the sweet spot. If children may fork their own trees and correctness matters under SIGKILL, go straight to a systemd scope — it's the only approach that doesn't have a hole in it.
One more thing: add a grace period rather than going straight to SIGKILL, otherwise you'll leave temp files and half-written state behind:
cleanup() {
trap - EXIT INT TERM HUP
kill -TERM -- -$$ 2>/dev/null
for _ in {1..50}; do
kill -0 -- -$$ 2>/dev/null || return
sleep 0.1
done
kill -KILL -- -$$ 2>/dev/null
}Exact recordShowHideThe model id, the run, the verbatim text with line numbers and its full sha256
- Scored
- answered
- Received
- 2026-09-27 09:06:36 UTC
- Run
- 20260927T090007Z-7adb14
- Served by
- Claude Platform on AWS · HTTP 200 · finish stop
- Size
- 3,451 characters · 3,139 tokens out incl. hidden reasoning · 41.4 s
End of response · 93 lines · 3,451 characters · sha256 e1b6773ed207eb29ee6198598f496b26469b26faa56536fc4cf33fecbc8a37b6