fix: Stop now preserves tmux window by tracking backend PID
The Stop() method was destroying the tmux window because it killed the bash shell (pane PID) instead of just the backend process. This commit fixes the issue by: 1. Added FindBackendPID() to find the actual backend process (child of bash) 2. Modified session creation to store the backend PID, not the shell PID 3. Updated stopInternal() to kill only the backend process: - Send Ctrl+C (handles SIGINT) - Send second Ctrl+C if still alive (some CLIs need 2x) - Use SIGTERM/SIGKILL on backend PID if unresponsive - Never kill the bash shell, preserving the tmux window 4. Fixed Restart() to properly clean up and recreate FIFO Tested successfully: - Stop kills backend but preserves window ✓ - Session state transitions to "stopped" ✓ - Restart reuses same window and works ✓ - Can send prompts after restart ✓ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e25d1658dd
commit
c445b21206
|
|
@ -117,6 +117,26 @@ func getPanePID(target string) (int, error) {
|
|||
return pid, err
|
||||
}
|
||||
|
||||
// FindBackendPID finds the backend process PID (direct child of bash)
|
||||
// This works for both claude and kiro-cli backends
|
||||
func FindBackendPID(panePID int) int {
|
||||
// Process tree: panePID (bash) -> backend (claude, kiro-cli, etc.)
|
||||
// Get first child process of the bash shell
|
||||
cmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", panePID))
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
// pgrep returns one PID per line; take the first one
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) == 0 || lines[0] == "" {
|
||||
return 0
|
||||
}
|
||||
var pid int
|
||||
fmt.Sscanf(lines[0], "%d", &pid)
|
||||
return pid
|
||||
}
|
||||
|
||||
// FindKiroChatPID traverses the process tree to find kiro-cli-chat
|
||||
func FindKiroChatPID(panePID int) int {
|
||||
// pane (kiro-cli-term) -> bash -> kiro-cli -> kiro-cli-chat
|
||||
|
|
|
|||
|
|
@ -522,45 +522,51 @@ func (s *Session) stopInternal(ctx context.Context) error {
|
|||
s.mu.Unlock()
|
||||
|
||||
// 1. Send Ctrl+C to interrupt any running operation
|
||||
// This sends SIGINT to the foreground process group (the backend process)
|
||||
if err := sendKeys(target, "C-c"); err != nil {
|
||||
debug.Log("[session %s] failed to send Ctrl+C: %v", sessionID, err)
|
||||
return fmt.Errorf("failed to interrupt: %w", err)
|
||||
}
|
||||
|
||||
// 2. Wait briefly for graceful shutdown
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
// 2. Wait for backend to respond to SIGINT and exit gracefully
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 3. Send Ctrl+D (EOF) to terminate process gracefully
|
||||
if err := sendKeys(target, "C-d"); err != nil {
|
||||
debug.Log("[session %s] failed to send Ctrl+D: %v", sessionID, err)
|
||||
return fmt.Errorf("failed to send EOF: %w", err)
|
||||
}
|
||||
|
||||
// 4. Wait for process to exit
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// 5. Verify process is gone (check PID)
|
||||
// 3. Check if backend process (pid) is still alive and force-kill if necessary
|
||||
// Note: pid is the backend process (claude/kiro-cli), NOT the bash shell
|
||||
if pid != 0 {
|
||||
// If still alive, send SIGTERM
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err == nil {
|
||||
debug.Log("[session %s] sent SIGTERM to PID %d", sessionID, pid)
|
||||
// Process exists, wait for it to die
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
// Check if process is still alive
|
||||
if err := syscall.Kill(pid, 0); err == nil {
|
||||
// Process still exists, send another Ctrl+C (some CLIs need 2 Ctrl+C to exit)
|
||||
debug.Log("[session %s] backend still alive after first Ctrl+C, sending second", sessionID)
|
||||
if err := sendKeys(target, "C-c"); err != nil {
|
||||
debug.Log("[session %s] failed to send second Ctrl+C: %v", sessionID, err)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// If STILL alive, send SIGKILL
|
||||
// Check if still alive after second Ctrl+C
|
||||
if err := syscall.Kill(pid, 0); err == nil {
|
||||
debug.Log("[session %s] process still alive, sending SIGKILL to PID %d", sessionID, pid)
|
||||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
||||
debug.Log("[session %s] SIGKILL failed: %v", sessionID, err)
|
||||
// Still alive, send SIGTERM
|
||||
debug.Log("[session %s] backend still alive, sending SIGTERM to PID %d", sessionID, pid)
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
debug.Log("[session %s] SIGTERM failed: %v", sessionID, err)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Last resort: SIGKILL
|
||||
if err := syscall.Kill(pid, 0); err == nil {
|
||||
debug.Log("[session %s] backend still alive, sending SIGKILL to PID %d", sessionID, pid)
|
||||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
||||
debug.Log("[session %s] SIGKILL failed: %v", sessionID, err)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
} else {
|
||||
debug.Log("[session %s] PID %d already dead: %v", sessionID, pid, err)
|
||||
debug.Log("[session %s] backend exited after Ctrl+C", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Verify tmux window still exists
|
||||
// 4. Verify tmux window still exists
|
||||
if !windowExists(tmuxSession, windowName) {
|
||||
debug.Log("[session %s] tmux window was unexpectedly destroyed", sessionID)
|
||||
s.mu.Lock()
|
||||
|
|
@ -569,7 +575,7 @@ func (s *Session) stopInternal(ctx context.Context) error {
|
|||
return fmt.Errorf("tmux window was unexpectedly destroyed")
|
||||
}
|
||||
|
||||
// 7. Update state
|
||||
// 5. Update state
|
||||
s.mu.Lock()
|
||||
s.state = "stopped"
|
||||
s.pid = 0
|
||||
|
|
@ -578,7 +584,8 @@ func (s *Session) stopInternal(ctx context.Context) error {
|
|||
|
||||
debug.Log("[session %s] stopped successfully", sessionID)
|
||||
|
||||
// Note: tmux window, FIFO, and reader goroutine remain active
|
||||
// Note: Shell, tmux window, FIFO, and reader goroutine remain active
|
||||
// This allows the session to be restarted without recreating the window
|
||||
// The reader stays in a wait loop until either Restart() or Close()
|
||||
return nil
|
||||
}
|
||||
|
|
@ -668,15 +675,28 @@ func (s *Session) Restart(ctx context.Context) error {
|
|||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// 3. Close existing pipe-pane
|
||||
if err := closePipePane(target); err != nil {
|
||||
debug.Log("[session %s] warning: failed to close pipe-pane: %v", s.id, err)
|
||||
}
|
||||
|
||||
// 4. Remove old FIFO file
|
||||
os.Remove(fifoPath)
|
||||
|
||||
// Small delay to ensure cleanup
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 3. Re-setup pipe-pane for output capture
|
||||
// 5. Recreate FIFO
|
||||
if err := syscall.Mkfifo(fifoPath, 0600); err != nil && !os.IsExist(err) {
|
||||
return fmt.Errorf("failed to create FIFO: %w", err)
|
||||
}
|
||||
|
||||
// 6. Re-setup pipe-pane for output capture
|
||||
if err := setupPipePane(target, fifoPath); err != nil {
|
||||
return fmt.Errorf("failed to re-setup pipe-pane: %w", err)
|
||||
}
|
||||
|
||||
// 4. Open FIFO for reading (this blocks until writer connects)
|
||||
// 7. Open FIFO for reading (this blocks until writer connects)
|
||||
fifoOpenCh := make(chan *os.File, 1)
|
||||
fifoErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
|
|
@ -688,7 +708,7 @@ func (s *Session) Restart(ctx context.Context) error {
|
|||
fifoOpenCh <- f
|
||||
}()
|
||||
|
||||
// 5. Change to working directory (may have changed in shell)
|
||||
// 8. Change to working directory (may have changed in shell)
|
||||
if err := sendKeys(target, fmt.Sprintf("cd \"%s\"", cwd), "C-m"); err != nil {
|
||||
return fmt.Errorf("failed to change directory: %w", err)
|
||||
}
|
||||
|
|
@ -696,19 +716,19 @@ func (s *Session) Restart(ctx context.Context) error {
|
|||
// Small delay for cd to complete
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 5.5. Restore environment variables
|
||||
// 9. Restore environment variables
|
||||
for k, v := range environment {
|
||||
if err := setEnvironment(target, k, v); err != nil {
|
||||
return fmt.Errorf("failed to set environment: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Send the original command to tmux
|
||||
// 10. Send the original command to tmux
|
||||
if err := sendKeys(target, cmdStr, "C-m"); err != nil {
|
||||
return fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
// 7. Wait for FIFO to open
|
||||
// 11. Wait for FIFO to open
|
||||
var fifo *os.File
|
||||
select {
|
||||
case fifo = <-fifoOpenCh:
|
||||
|
|
@ -719,12 +739,23 @@ func (s *Session) Restart(ctx context.Context) error {
|
|||
return fmt.Errorf("timeout opening FIFO")
|
||||
}
|
||||
|
||||
// 8. Get new PID
|
||||
// 12. Get new PID (find the backend process, not just the bash shell)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
pid, err := getPanePID(target)
|
||||
panePID, err := getPanePID(target)
|
||||
if err != nil {
|
||||
debug.Log("[session %s] warning: failed to get PID: %v", s.id, err)
|
||||
pid = 0
|
||||
debug.Log("[session %s] warning: failed to get pane PID: %v", s.id, err)
|
||||
panePID = 0
|
||||
}
|
||||
|
||||
// Find the actual backend process (child of bash shell)
|
||||
pid := 0
|
||||
if panePID != 0 {
|
||||
pid = FindBackendPID(panePID)
|
||||
if pid == 0 {
|
||||
debug.Log("[session %s] warning: backend process not found for pane PID %d", s.id, panePID)
|
||||
} else {
|
||||
debug.Log("[session %s] found backend PID %d (pane PID: %d)", s.id, pid, panePID)
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
|
|
@ -737,10 +768,10 @@ func (s *Session) Restart(ctx context.Context) error {
|
|||
s.readerGeneration++ // Increment to invalidate old reader
|
||||
s.mu.Unlock()
|
||||
|
||||
// 9. Start new reader goroutine
|
||||
// 13. Start new reader goroutine
|
||||
go s.reader()
|
||||
|
||||
// 10. Wait for ready (reuse existing waitForReady logic)
|
||||
// 14. Wait for ready (reuse existing waitForReady logic)
|
||||
if err := s.waitForReady(ctx, 30*time.Second); err != nil {
|
||||
s.mu.Lock()
|
||||
s.state = "error"
|
||||
|
|
|
|||
|
|
@ -230,11 +230,23 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio
|
|||
return nil, fmt.Errorf("timeout opening FIFO")
|
||||
}
|
||||
|
||||
// 9. Get PID
|
||||
pid, err := getPanePID(target)
|
||||
// 9. Get PID (find the backend process, not just the bash shell)
|
||||
panePID, err := getPanePID(target)
|
||||
if err != nil {
|
||||
debug.Log("[session %s] warning: failed to get PID: %v", id, err)
|
||||
pid = 0
|
||||
debug.Log("[session %s] warning: failed to get pane PID: %v", id, err)
|
||||
panePID = 0
|
||||
}
|
||||
|
||||
// Find the actual backend process (child of bash shell)
|
||||
pid := 0
|
||||
if panePID != 0 {
|
||||
time.Sleep(200 * time.Millisecond) // Brief delay for backend to start
|
||||
pid = FindBackendPID(panePID)
|
||||
if pid == 0 {
|
||||
debug.Log("[session %s] warning: backend process not found for pane PID %d", id, panePID)
|
||||
} else {
|
||||
debug.Log("[session %s] found backend PID %d (pane PID: %d)", id, pid, panePID)
|
||||
}
|
||||
}
|
||||
|
||||
sess := &Session{
|
||||
|
|
|
|||
Reference in New Issue