Files
gastown/internal/tmux/tmux_test.go
Julian Knutsen f82477d6a6 fix(tmux): prevent gt done from killing itself during session cleanup (#821)
When gt done runs inside a tmux session (e.g., after polecat task
completion), calling KillSessionWithProcesses would kill the gt done
process itself before it could complete cleanup operations like writing
handoff state.

Add KillSessionWithProcessesExcluding() function that accepts a list of
PIDs to exclude from the kill sequence. Update selfKillSession to pass
its own PID, ensuring gt done completes before the session is destroyed.

Also fix both Kill*WithProcesses functions to ignore "session not found"
errors from KillSession - when we kill all processes in a session, tmux
may automatically destroy it before we explicitly call KillSession.

Co-authored-by: julianknutsen <julianknutsen@users.noreply.github>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:34:20 -08:00

760 lines
19 KiB
Go

package tmux
import (
"os/exec"
"regexp"
"strings"
"testing"
)
func hasTmux() bool {
_, err := exec.LookPath("tmux")
return err == nil
}
func TestListSessionsNoServer(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessions, err := tm.ListSessions()
// Should not error even if no server running
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
// Result may be nil or empty slice
_ = sessions
}
func TestHasSessionNoServer(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
has, err := tm.HasSession("nonexistent-session-xyz")
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if has {
t.Error("expected session to not exist")
}
}
func TestSessionLifecycle(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-session-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Verify exists
has, err := tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if !has {
t.Error("expected session to exist after creation")
}
// List should include it
sessions, err := tm.ListSessions()
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
found := false
for _, s := range sessions {
if s == sessionName {
found = true
break
}
}
if !found {
t.Error("session not found in list")
}
// Kill session
if err := tm.KillSession(sessionName); err != nil {
t.Fatalf("KillSession: %v", err)
}
// Verify 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 kill")
}
}
func TestDuplicateSession(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-dup-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Try to create duplicate
err := tm.NewSession(sessionName, "")
if err != ErrSessionExists {
t.Errorf("expected ErrSessionExists, got %v", err)
}
}
func TestSendKeysAndCapture(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-keys-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Send echo command
if err := tm.SendKeys(sessionName, "echo HELLO_TEST_MARKER"); err != nil {
t.Fatalf("SendKeys: %v", err)
}
// Give it a moment to execute
// In real tests you'd wait for output, but for basic test we just capture
output, err := tm.CapturePane(sessionName, 50)
if err != nil {
t.Fatalf("CapturePane: %v", err)
}
// Should contain our marker (might not if shell is slow, but usually works)
if !strings.Contains(output, "echo HELLO_TEST_MARKER") {
t.Logf("captured output: %s", output)
// Don't fail, just note - timing issues possible
}
}
func TestGetSessionInfo(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-info-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
info, err := tm.GetSessionInfo(sessionName)
if err != nil {
t.Fatalf("GetSessionInfo: %v", err)
}
if info.Name != sessionName {
t.Errorf("Name = %q, want %q", info.Name, sessionName)
}
if info.Windows < 1 {
t.Errorf("Windows = %d, want >= 1", info.Windows)
}
}
func TestWrapError(t *testing.T) {
tm := NewTmux()
tests := []struct {
stderr string
want error
}{
{"no server running on /tmp/tmux-...", ErrNoServer},
{"error connecting to /tmp/tmux-...", ErrNoServer},
{"no current target", ErrNoServer},
{"duplicate session: test", ErrSessionExists},
{"session not found: test", ErrSessionNotFound},
{"can't find session: test", ErrSessionNotFound},
}
for _, tt := range tests {
err := tm.wrapError(nil, tt.stderr, []string{"test"})
if err != tt.want {
t.Errorf("wrapError(%q) = %v, want %v", tt.stderr, err, tt.want)
}
}
}
func TestEnsureSessionFresh_NoExistingSession(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-fresh-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// EnsureSessionFresh should create a new session
if err := tm.EnsureSessionFresh(sessionName, ""); err != nil {
t.Fatalf("EnsureSessionFresh: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Verify session exists
has, err := tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if !has {
t.Error("expected session to exist after EnsureSessionFresh")
}
}
func TestEnsureSessionFresh_ZombieSession(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-zombie-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create a zombie session (session exists but no Claude/node running)
// A normal tmux session with bash/zsh is a "zombie" for our purposes
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Verify it's a zombie (not running Claude/node)
if tm.IsClaudeRunning(sessionName) {
t.Skip("session unexpectedly has Claude running - can't test zombie case")
}
// Verify generic agent check also treats it as not running (shell session)
if tm.IsAgentRunning(sessionName) {
t.Fatalf("expected IsAgentRunning(%q) to be false for a fresh shell session", sessionName)
}
// EnsureSessionFresh should kill the zombie and create fresh session
// This should NOT error with "session already exists"
if err := tm.EnsureSessionFresh(sessionName, ""); err != nil {
t.Fatalf("EnsureSessionFresh on zombie: %v", err)
}
// Session should still exist
has, err := tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if !has {
t.Error("expected session to exist after EnsureSessionFresh on zombie")
}
}
func TestEnsureSessionFresh_IdempotentOnZombie(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-idem-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Call EnsureSessionFresh multiple times - should work each time
for i := 0; i < 3; i++ {
if err := tm.EnsureSessionFresh(sessionName, ""); err != nil {
t.Fatalf("EnsureSessionFresh attempt %d: %v", i+1, err)
}
}
defer func() { _ = tm.KillSession(sessionName) }()
// Session should exist
has, err := tm.HasSession(sessionName)
if err != nil {
t.Fatalf("HasSession: %v", err)
}
if !has {
t.Error("expected session to exist after multiple EnsureSessionFresh calls")
}
}
func TestIsAgentRunning(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-agent-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session (will run default shell)
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Get the current pane command (should be bash/zsh/etc)
cmd, err := tm.GetPaneCommand(sessionName)
if err != nil {
t.Fatalf("GetPaneCommand: %v", err)
}
tests := []struct {
name string
processNames []string
wantRunning bool
}{
{
name: "empty process list",
processNames: []string{},
wantRunning: false,
},
{
name: "matching shell process",
processNames: []string{cmd}, // Current shell
wantRunning: true,
},
{
name: "claude agent (node) - not running",
processNames: []string{"node"},
wantRunning: cmd == "node", // Only true if shell happens to be node
},
{
name: "gemini agent - not running",
processNames: []string{"gemini"},
wantRunning: cmd == "gemini",
},
{
name: "cursor agent - not running",
processNames: []string{"cursor-agent"},
wantRunning: cmd == "cursor-agent",
},
{
name: "multiple process names with match",
processNames: []string{"nonexistent", cmd, "also-nonexistent"},
wantRunning: true,
},
{
name: "multiple process names without match",
processNames: []string{"nonexistent1", "nonexistent2"},
wantRunning: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tm.IsAgentRunning(sessionName, tt.processNames...)
if got != tt.wantRunning {
t.Errorf("IsAgentRunning(%q, %v) = %v, want %v (current cmd: %q)",
sessionName, tt.processNames, got, tt.wantRunning, cmd)
}
})
}
}
func TestIsAgentRunning_NonexistentSession(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
// IsAgentRunning on nonexistent session should return false, not error
got := tm.IsAgentRunning("nonexistent-session-xyz", "node", "gemini", "cursor-agent")
if got {
t.Error("IsAgentRunning on nonexistent session should return false")
}
}
func TestIsClaudeRunning(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-claude-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session (will run default shell, not Claude)
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// IsClaudeRunning should be false (shell is running, not node/claude)
cmd, _ := tm.GetPaneCommand(sessionName)
wantRunning := cmd == "node" || cmd == "claude"
if got := tm.IsClaudeRunning(sessionName); got != wantRunning {
t.Errorf("IsClaudeRunning() = %v, want %v (pane cmd: %q)", got, wantRunning, cmd)
}
}
func TestIsClaudeRunning_VersionPattern(t *testing.T) {
// Test the version pattern regex matching directly
// Since we can't easily mock the pane command, test the pattern logic
tests := []struct {
cmd string
want bool
}{
{"node", true},
{"claude", true},
{"2.0.76", true},
{"1.2.3", true},
{"10.20.30", true},
{"bash", false},
{"zsh", false},
{"", false},
{"v2.0.76", false}, // version with 'v' prefix shouldn't match
{"2.0", false}, // incomplete version
}
for _, tt := range tests {
t.Run(tt.cmd, func(t *testing.T) {
// Check if it matches node/claude directly
isKnownCmd := tt.cmd == "node" || tt.cmd == "claude"
// Check version pattern
matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+`, tt.cmd)
got := isKnownCmd || matched
if got != tt.want {
t.Errorf("IsClaudeRunning logic for %q = %v, want %v", tt.cmd, got, tt.want)
}
})
}
}
func TestIsClaudeRunning_ShellWithNodeChild(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-shell-child-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session with "bash -c" running a node process
// Use a simple node command that runs for a few seconds
cmd := `node -e "setTimeout(() => {}, 10000)"`
if err := tm.NewSessionWithCommand(sessionName, "", cmd); err != nil {
t.Fatalf("NewSessionWithCommand: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Give the node process time to start
// WaitForCommand waits until NOT running bash/zsh/sh
shellsToExclude := []string{"bash", "zsh", "sh"}
err := tm.WaitForCommand(sessionName, shellsToExclude, 2000*1000000) // 2 second timeout
if err != nil {
// If we timeout waiting, it means the pane command is still a shell
// This is the case we're testing - shell with a node child
paneCmd, _ := tm.GetPaneCommand(sessionName)
t.Logf("Pane command is %q - testing shell+child detection", paneCmd)
}
// Now test IsClaudeRunning - it should detect node as a child process
paneCmd, _ := tm.GetPaneCommand(sessionName)
if paneCmd == "node" {
// Direct node detection should work
if !tm.IsClaudeRunning(sessionName) {
t.Error("IsClaudeRunning should return true when pane command is 'node'")
}
} else {
// Pane is a shell (bash/zsh) with node as child
// The new child process detection should catch this
got := tm.IsClaudeRunning(sessionName)
t.Logf("Pane command: %q, IsClaudeRunning: %v", paneCmd, got)
// Note: This may or may not detect depending on how tmux runs the command.
// On some systems, tmux runs the command directly; on others via a shell.
}
}
func TestHasClaudeChild(t *testing.T) {
// Test the hasClaudeChild helper function directly
// This uses the current process as a test subject
// Get current process PID as string
currentPID := "1" // init/launchd - should have children but not claude/node
// hasClaudeChild should return false for init (no node/claude children)
got := hasClaudeChild(currentPID)
if got {
t.Logf("hasClaudeChild(%q) = true - init has claude/node child?", currentPID)
}
// Test with a definitely nonexistent PID
got = hasClaudeChild("999999999")
if got {
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)
}
}
}
}
func TestKillSessionWithProcesses(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-killproc-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session with a long-running process
cmd := `sleep 300`
if err := tm.NewSessionWithCommand(sessionName, "", cmd); err != nil {
t.Fatalf("NewSessionWithCommand: %v", err)
}
// 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
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 TestKillSessionWithProcesses_NonexistentSession(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
// Killing nonexistent session should not panic, just return error or nil
err := tm.KillSessionWithProcesses("nonexistent-session-xyz-12345")
// We don't care about the error value, just that it doesn't panic
_ = err
}
func TestKillSessionWithProcessesExcluding(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-killexcl-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session with a long-running process
cmd := `sleep 300`
if err := tm.NewSessionWithCommand(sessionName, "", cmd); err != nil {
t.Fatalf("NewSessionWithCommand: %v", err)
}
// 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 empty excludePIDs (should behave like KillSessionWithProcesses)
if err := tm.KillSessionWithProcessesExcluding(sessionName, nil); err != nil {
t.Fatalf("KillSessionWithProcessesExcluding: %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 KillSessionWithProcessesExcluding")
_ = tm.KillSession(sessionName) // cleanup
}
}
func TestKillSessionWithProcessesExcluding_WithExcludePID(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-killexcl2-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create session with a long-running process
cmd := `sleep 300`
if err := tm.NewSessionWithCommand(sessionName, "", cmd); err != nil {
t.Fatalf("NewSessionWithCommand: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Get the pane PID
panePID, err := tm.GetPanePID(sessionName)
if err != nil {
t.Fatalf("GetPanePID: %v", err)
}
if panePID == "" {
t.Skip("could not get pane PID")
}
// Kill with the pane PID excluded - the function should still kill the session
// but should not kill the excluded PID before the session is destroyed
err = tm.KillSessionWithProcessesExcluding(sessionName, []string{panePID})
if err != nil {
t.Fatalf("KillSessionWithProcessesExcluding: %v", err)
}
// Session should be gone (the final KillSession always happens)
has, _ := tm.HasSession(sessionName)
if has {
t.Error("expected session to not exist after KillSessionWithProcessesExcluding")
}
}
func TestKillSessionWithProcessesExcluding_NonexistentSession(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
// Killing nonexistent session should not panic
err := tm.KillSessionWithProcessesExcluding("nonexistent-session-xyz-12345", []string{"12345"})
// We don't care about the error value, just that it doesn't panic
_ = err
}
func TestSessionSet(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-sessionset-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create a test session
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Get the session set
set, err := tm.GetSessionSet()
if err != nil {
t.Fatalf("GetSessionSet: %v", err)
}
// Test Has() for existing session
if !set.Has(sessionName) {
t.Errorf("SessionSet.Has(%q) = false, want true", sessionName)
}
// Test Has() for non-existing session
if set.Has("nonexistent-session-xyz-12345") {
t.Error("SessionSet.Has(nonexistent) = true, want false")
}
// Test nil safety
var nilSet *SessionSet
if nilSet.Has("anything") {
t.Error("nil SessionSet.Has() = true, want false")
}
// Test Names() returns the session
names := set.Names()
found := false
for _, n := range names {
if n == sessionName {
found = true
break
}
}
if !found {
t.Errorf("SessionSet.Names() doesn't contain %q", sessionName)
}
}