Killing a Wrapper Script Doesn't Kill What It Spawned

This site's article pipeline runs unattended from a wrapper shell script, invoking a claude -p session wrapped in timeout to draft and schedule posts. One run, I decided partway through that I didn't like the direction it was taking and killed it — kill <wrapper-pid>, the process I could see in my terminal. The wrapper died immediately, exactly as expected. The actual work didn't stop. It kept running, unattended, with nobody watching it anymore, and published two more articles before it finished on its own.

What "kill the wrapper" actually killed

The original version of the script ran its main work as a plain foreground command:

  • timeout 2700 claude -p "$PROMPT" --model claude-sonnet-5 --allowedTools "$ALLOWED_TOOLS" --permission-mode dontAsk --output-format text

No trailing &, no captured PID, nothing backgrounded. When a shell runs a command like that in the foreground and someone sends that shell's own PID a SIGTERM, the shell itself terminates — but a foreground child that's mid-execution doesn't automatically inherit that signal just because its parent died. The parent shell exits; the timeout process it had spawned, and the claude process timeout had spawned underneath that, were left running as orphans, now reparented to init, with no shell left to notice or forward anything to them. The terminal showed a clean, immediate exit. Underneath that, two more articles got drafted and scheduled in the time it took the actual work to reach its own natural stopping point — output nobody had reviewed, going out because a process I'd believed I'd stopped kept running exactly as if I hadn't touched it.

The fix: something has to hold the PID and forward the signal

The corrected version backgrounds the claude invocation explicitly, captures its PID, and traps the signals that matter, so a real cleanup routine runs before the wrapper exits:

  • timeout 14400 claude -p "$PROMPT" ... --output-format text &
  • CLAUDE_PID=$!
  • wait "$CLAUDE_PID"

paired with:

  • cleanup() {
  • if [ -n "$LT_PID" ] && kill -0 "$LT_PID" 2>/dev/null; then kill "$LT_PID" 2>/dev/null; fi
  • if [ -n "$CLAUDE_PID" ] && kill -0 "$CLAUDE_PID" 2>/dev/null; then kill "$CLAUDE_PID" 2>/dev/null; fi
  • }
  • trap cleanup EXIT
  • trap 'cleanup; exit 143' TERM
  • trap 'cleanup; exit 130' INT

Backgrounding the command with & is what makes $! meaningful — you only get a PID to hold onto for something the shell didn't just block on synchronously. With that PID captured, the trap lines mean an incoming TERM or INT to the wrapper now runs cleanup before the wrapper's own exit, and cleanup explicitly kills the captured child. timeout itself forwards whatever signal it receives to the process it wraps, so killing timeout's PID cascades down to the claude process underneath it too — the whole chain actually stops instead of just the top of it.

Why the visible symptom hides how deep the problem goes

The dangerous part of this bug isn't that it fails — it's that it fails silently and looks identical to success. A terminated wrapper shell prints nothing different whether or not it successfully stopped what it spawned; the only way to notice the orphaned process was still running was to go looking for it separately, after the fact, or — as happened here — see unreviewed output land later that shouldn't have existed at all. A stop command that appears to work is arguably worse than one that visibly errors, because an error prompts a second look and an apparent success doesn't.

The fix was verified with an isolated test — a throwaway script that backgrounds a sleep command the same way, sends it a signal, and confirms the child actually dies — before trusting the same pattern in the real pipeline. That's the part worth generalizing past this one bug: killing a shell that's currently running something isn't a single well-defined operation with one obvious meaning. "Foreground command in a script" and "backgrounded command with its PID captured and trapped" behave completely differently under the exact same kill call from outside, and the only way to know which one a given script actually does is to check, not assume — ideally with a disposable reproduction, before the mistake is a published article and not a sleeping placeholder.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.