fix(shutdown): kill entire process tree to prevent orphaned Claude processes

Merge polecat/dementus-mkddymu6: Improves KillSessionWithProcesses to
recursively find and kill all descendant processes, not just direct
children. This prevents orphaned Claude processes when the process
tree is deeper than one level.

Adds getAllDescendants() helper and TestGetAllDescendants test.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/refinery
2026-01-13 18:37:36 -08:00
committed by beads/crew/emma
2 changed files with 64 additions and 9 deletions

View File

@@ -139,12 +139,13 @@ func (t *Tmux) KillSession(name string) error {
// //
// Process: // Process:
// 1. Get the pane's main process PID // 1. Get the pane's main process PID
// 2. Send SIGTERM to all child processes (pkill -TERM -P <pid>) // 2. Find all descendant processes recursively (not just direct children)
// 3. Wait 100ms for graceful shutdown // 3. Send SIGTERM to all descendants (deepest first)
// 4. Send SIGKILL to any remaining children (pkill -KILL -P <pid>) // 4. Wait 100ms for graceful shutdown
// 5. Kill the tmux session // 5. Send SIGKILL to any remaining descendants
// 6. Kill the tmux session
// //
// This ensures Claude processes are properly terminated even if they ignore SIGHUP. // This ensures Claude processes and all their children are properly terminated.
func (t *Tmux) KillSessionWithProcesses(name string) error { func (t *Tmux) KillSessionWithProcesses(name string) error {
// Get the pane PID // Get the pane PID
pid, err := t.GetPanePID(name) pid, err := t.GetPanePID(name)
@@ -154,20 +155,49 @@ func (t *Tmux) KillSessionWithProcesses(name string) error {
} }
if pid != "" { if pid != "" {
// Send SIGTERM to child processes // Get all descendant PIDs recursively (returns deepest-first order)
_ = exec.Command("pkill", "-TERM", "-P", pid).Run() descendants := getAllDescendants(pid)
// Send SIGTERM to all descendants (deepest first to avoid orphaning)
for _, dpid := range descendants {
_ = exec.Command("kill", "-TERM", dpid).Run()
}
// Wait for graceful shutdown // Wait for graceful shutdown
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
// Send SIGKILL to any remaining children // Send SIGKILL to any remaining descendants
_ = exec.Command("pkill", "-KILL", "-P", pid).Run() for _, dpid := range descendants {
_ = exec.Command("kill", "-KILL", dpid).Run()
}
} }
// Kill the tmux session // Kill the tmux session
return t.KillSession(name) return t.KillSession(name)
} }
// getAllDescendants recursively finds all descendant PIDs of a process.
// Returns PIDs in deepest-first order so killing them doesn't orphan grandchildren.
func getAllDescendants(pid string) []string {
var result []string
// Get direct children using pgrep
out, err := exec.Command("pgrep", "-P", pid).Output()
if err != nil {
return result
}
children := strings.Fields(strings.TrimSpace(string(out)))
for _, child := range children {
// First add grandchildren (recursively) - deepest first
result = append(result, getAllDescendants(child)...)
// Then add this child
result = append(result, child)
}
return result
}
// KillServer terminates the entire tmux server and all sessions. // KillServer terminates the entire tmux server and all sessions.
func (t *Tmux) KillServer() error { func (t *Tmux) KillServer() error {
_, err := t.run("kill-server") _, err := t.run("kill-server")

View File

@@ -527,3 +527,28 @@ func TestHasClaudeChild(t *testing.T) {
t.Error("hasClaudeChild should return false for nonexistent PID") t.Error("hasClaudeChild should return false for nonexistent PID")
} }
} }
func TestGetAllDescendants(t *testing.T) {
// Test the getAllDescendants helper function
// Test with nonexistent PID - should return empty slice
got := getAllDescendants("999999999")
if len(got) != 0 {
t.Errorf("getAllDescendants(nonexistent) = %v, want empty slice", got)
}
// Test with PID 1 (init/launchd) - should find some descendants
// Note: We can't test exact PIDs, just that the function doesn't panic
// and returns reasonable results
descendants := getAllDescendants("1")
t.Logf("getAllDescendants(\"1\") found %d descendants", len(descendants))
// Verify returned PIDs are all numeric strings
for _, pid := range descendants {
for _, c := range pid {
if c < '0' || c > '9' {
t.Errorf("getAllDescendants returned non-numeric PID: %q", pid)
}
}
}
}