Claude on Question 14, Sep 21
I'm writing a bash script to purge orphaned children after the parent dies. What's the idiomatic way?Short answer: put your children in their own process group and kill the group from an EXIT trap. But traps can't help if the parent is SIGKILLed, so the robustness ladder matters.
Level 1: trap + kill the process group
#!/usr/bin/env bash set -euo pipefail trap 'trap - EXIT INT TERM; kill 0' EXIT INT TERM sleep 300 & sleep 300 & wait
kill 0 signals every process in the caller's process group. The trap - EXIT INT TERM first prevents re-entry when the signal comes back around to you.
The gotcha: a script invoked from a non-interactive parent shares that parent's process group, so kill 0 will take out your caller too. Interactive shells put each job in its own pgroup, which is why this pattern looks fine in testing and then wrecks a CI runner.
Level 2: guarantee your own process group
Re-exec under setsid so the pgroup is unambiguously yours:
#!/usr/bin/env bash
set -euo pipefail
if [[ -z ${_UNDER_SETSID:-} ]]; then
export _UNDER_SETSID=1
exec setsid --wait "$0" "$@"
fi
trap 'trap - EXIT; kill -- -$$ 2>/dev/null' EXIT INT TERMsetsid --wait (util-linux ≥ 2.24) forwards the exit status back. Now $$ is the pgid, and kill -- -$$ hits the whole tree. Note the -- and the leading - on the pid.
Level 3: PDEATHSIG for the SIGKILL case
Traps run in the shell, so kill -9 on the parent leaves children orphaned and reparented to init. Linux's PR_SET_PDEATHSIG is the kernel-side fix:
setpriv --pdeathsig TERM -- ./long_running_thing &
Caveats worth knowing:
- •Fires when the thread that forked exits, not the whole process — usually the same thing in bash, but not for threaded parents.
- •Cleared across a setuid/setgid
exec. - •Only protects the direct child; grandchildren need their own.
Level 4: cgroup containment
The only genuinely airtight option, because escape isn't possible regardless of how the parent dies:
systemd-run --user --scope --collect -- ./my_script.sh
When the scope's last process exits — or when you systemctl --user stop it — the whole cgroup is reaped. This is what you want for anything long-lived or production-facing.
Portable alternative: pipe-as-lifeline
No Linux-specific calls. The parent holds the write end of a pipe; the child blocks reading it and gets EOF the instant the parent dies, however it died:
# parent
exec 3>&1
coproc LIFELINE { ./child.sh; }
# child.sh
while read -r _ <&3; do :; done # returns on EOF
cleanup_and_exitMore commonly written with an explicit FIFO or by passing the fd through exec {fd}<>. Works on macOS and BSDs where setpriv doesn't exist.
Graceful shutdown with escalation
A polite TERM, then KILL for whatever ignored it:
cleanup() {
trap - EXIT INT TERM
local pgid=$$
kill -TERM -- "-$pgid" 2>/dev/null || true
for _ in {1..20}; do
kill -0 -- "-$pgid" 2>/dev/null || return 0
sleep 0.25
done
kill -KILL -- "-$pgid" 2>/dev/null || true
}
trap cleanup EXIT INT TERMTwo things that bite people
jobs -p only sees direct children. It misses grandchildren entirely, so kill $(jobs -p) leaves a subtree behind whenever a child forks. Process-group or cgroup kills don't have this problem.
Zombies aren't orphans. If ps shows <defunct>, those are already-dead children you haven't waited on — they hold a PID slot and nothing else. Signaling them does nothing; only wait clears them, and they vanish on their own when your script exits.
My default for anything non-trivial: setsid re-exec plus a kill -- -$$ trap, upgraded to a systemd scope if it runs unattended.
Exact recordShowHideThe model id, the run, the verbatim text with line numbers and its full sha256
- Scored
- answered
- Received
- 2026-09-21 09:07:17 UTC
- Run
- 20260921T090021Z-989a7c
- Served by
- Claude Platform on AWS · HTTP 200 · finish stop
- Size
- 3,653 characters · 2,958 tokens out incl. hidden reasoning · 41.8 s
End of response · 103 lines · 3,653 characters · sha256 43078e70c9ba82cb94aee0a0e1322f8d7aa18f5b49426b443fb0b61e669d1cde