MODELDRIFT.WATCH

Question 14 · Sep 21 · Day 12

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 TERM

setsid --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_exit

More 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 TERM

Two 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.

sha256 43078e70c9ba… · 40 tokens in · 2,958 out · 41.8 s · via Claude Platform on AWS · stopped on its own

Sep 21, the whole dayQuestion 14, every day

Exact recordShowHideThe model id, the run, the verbatim text with line numbers and its full sha256
Model
anthropic/claude-opus-5
pinned anthropic/claude-opus-5-20260723; alias resolved to anthropic/claude-opus-5-20260723 at 2026-09-21 09:00:20 UTC (matches the pin)
Date
2026-09-21 · run 20260921T090021Z-989a7c · scorer v1
Scored
answered
sha256
43078e70c9ba82cb94aee0a0e1322f8d7aa18f5b49426b443fb0b61e669d1cde
Permalink
https://modeldrift.watch/r/2026-09-21/R5/anthropic/claude-opus-5/
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
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 `SIGKILL`ed, so the robustness ladder matters.
## Level 1: trap + kill the process group
```bash
#!/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:
```bash
#!/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 TERM
```
`setsid --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:
```bash
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:
```bash
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:
```bash
# parent
exec 3>&1
coproc LIFELINE { ./child.sh; }
# child.sh
while read -r _ <&3; do :; done # returns on EOF
cleanup_and_exit
```
More 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:
```bash
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 TERM
```
## Two 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 `wait`ed 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.

End of response · 103 lines · 3,653 characters · sha256 43078e70c9ba82cb94aee0a0e1322f8d7aa18f5b49426b443fb0b61e669d1cde