fix(tmux): kill process group to prevent orphaned processes

KillSession was leaving orphaned Claude/node processes because pgrep -P
only finds direct children. Processes that reparent to init (PID 1) were
missed.

Changes:
- Kill entire process group first using kill -TERM/-KILL -<pgid>
- Add getProcessGroupID() and getProcessGroupMembers() helpers
- Update KillSessionWithProcesses, KillSessionWithProcessesExcluding,
  and KillPaneProcesses to use process group killing
- Fix EnsureSessionFresh to use KillSessionWithProcesses instead of
  basic KillSession

Fixes: gt-w1dcvq

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dag
2026-01-22 21:42:38 -08:00
committed by beads/crew/emma
parent bebf425ac5
commit be96bb0050
2 changed files with 199 additions and 25 deletions

View File

@@ -1,10 +1,13 @@
package tmux
import (
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"testing"
"time"
)
func hasTmux() bool {
@@ -705,6 +708,97 @@ func TestKillSessionWithProcessesExcluding_NonexistentSession(t *testing.T) {
_ = err
}
func TestGetProcessGroupID(t *testing.T) {
// Test with current process
pid := fmt.Sprintf("%d", os.Getpid())
pgid := getProcessGroupID(pid)
if pgid == "" {
t.Error("expected non-empty PGID for current process")
}
// PGID should not be 0 or 1 for a normal process
if pgid == "0" || pgid == "1" {
t.Errorf("unexpected PGID %q for current process", pgid)
}
// Test with nonexistent PID
pgid = getProcessGroupID("999999999")
if pgid != "" {
t.Errorf("expected empty PGID for nonexistent process, got %q", pgid)
}
}
func TestGetProcessGroupMembers(t *testing.T) {
// Get current process's PGID
pid := fmt.Sprintf("%d", os.Getpid())
pgid := getProcessGroupID(pid)
if pgid == "" {
t.Skip("could not get PGID for current process")
}
members := getProcessGroupMembers(pgid)
// Current process should be in the list
found := false
for _, m := range members {
if m == pid {
found = true
break
}
}
if !found {
t.Errorf("current process %s not found in process group %s members: %v", pid, pgid, members)
}
}
func TestKillSessionWithProcesses_KillsProcessGroup(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-killpg-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session that spawns a child process
// The child will stay in the same process group as the shell
cmd := `sleep 300 & sleep 300`
if err := tm.NewSessionWithCommand(sessionName, "", cmd); err != nil {
t.Fatalf("NewSessionWithCommand: %v", err)
}
// Give processes time to start
time.Sleep(200 * time.Millisecond)
// Verify session exists
has, err := tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if !has {
t.Fatal("expected session to exist after creation")
}
// Kill with processes (should kill the entire process group)
if err := tm.KillSessionWithProcesses(sessionName); err != nil {
t.Fatalf("KillSessionWithProcesses: %v", err)
}
// Verify session is gone
has, err = tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession after kill: %v", err)
}
if has {
t.Error("expected session to not exist after KillSessionWithProcesses")
_ = tm.KillSession(sessionName) // cleanup
}
}
func TestSessionSet(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")