Skip to content

Commit 7a13f63

Browse files
authored
fix: prevent frozen output from transient tmux errors (#572)
Previously, any non-timeout error from tmux display-message or has-session would cause the capture loop to treat it as "session doesn't exist", setting running=false and stopping all output updates. Now only definitive "session gone" errors cause this behavior: - "error connecting to" - socket doesn't exist - "can't find session:" - session not found - "no server running" - tmux server not running - "executable file not found" - tmux not installed All other errors (broken pipe, signal killed, generic exit status) are assumed to be transient, and the capture loop continues retrying. Also fixed checkSessionExists to use CombinedOutput() instead of Run() to properly capture stderr for error message detection.
1 parent 39181fe commit 7a13f63

File tree

3 files changed

+152
-9
lines changed

3 files changed

+152
-9
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Frozen Output from Transient Tmux Errors** - Fixed a critical bug where tmux sessions would appear frozen (no output updates, input not showing) due to transient tmux errors being misinterpreted as "session doesn't exist". Previously, any non-timeout error from tmux `display-message` or `has-session` commands would cause the capture loop to think the session had ended, setting `running = false` and stopping all output updates. Now, only definitive "session gone" errors (socket doesn't exist, session not found, no server running, tmux not installed) are treated as terminal—all other errors (broken pipe, signal killed, generic exit status) are assumed to be transient and the capture loop continues retrying. Also fixed `checkSessionExists` to properly capture stderr using `CombinedOutput()` for accurate error message detection.
13+
1014
## [0.12.6] - 2026-01-22
1115

1216
### Fixed

internal/instance/manager.go

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -824,20 +824,53 @@ func (m *Manager) getSessionStatus(sessionName string) sessionStatus {
824824
cmd := m.tmuxCmdCtx(ctx, "display-message", "-t", sessionName, "-p", "#{history_size}|#{window_bell_flag}")
825825
output, err := cmd.Output()
826826
if err != nil {
827+
m.mu.RLock()
828+
logger := m.logger
829+
m.mu.RUnlock()
830+
827831
// Check if this was a timeout - session may still exist
828832
if ctx.Err() == context.DeadlineExceeded {
829-
m.mu.RLock()
830-
logger := m.logger
831-
m.mu.RUnlock()
832833
if logger != nil {
833834
logger.Warn("tmux display-message timed out, will retry next tick",
834835
"session_name", sessionName)
835836
}
836837
return sessionStatus{historySize: -1, bellActive: false, sessionExists: true}
837838
}
838839

839-
// Any non-timeout error means session doesn't exist or can't be verified
840-
return sessionStatus{historySize: -1, bellActive: false, sessionExists: false}
840+
// Check if this is a definitive "session doesn't exist" error.
841+
// Only treat session as non-existent for specific error patterns:
842+
// - "error connecting to" - socket doesn't exist
843+
// - "can't find session:" - session not found
844+
// - "no server running" - tmux server not running
845+
// - "executable file not found" - tmux not installed
846+
// For all other errors (transient failures, pipe errors, etc.),
847+
// assume the session still exists and retry on next tick.
848+
errStr := err.Error()
849+
if exitErr, ok := err.(*exec.ExitError); ok {
850+
// Include stderr in the error check
851+
errStr = string(exitErr.Stderr)
852+
}
853+
sessionGone := strings.Contains(errStr, "error connecting to") ||
854+
strings.Contains(errStr, "can't find session:") ||
855+
strings.Contains(errStr, "no server running") ||
856+
strings.Contains(errStr, "executable file not found")
857+
858+
if sessionGone {
859+
if logger != nil {
860+
logger.Debug("tmux session confirmed not found",
861+
"session_name", sessionName,
862+
"error", errStr)
863+
}
864+
return sessionStatus{historySize: -1, bellActive: false, sessionExists: false}
865+
}
866+
867+
// For other errors (transient), assume session exists and retry
868+
if logger != nil {
869+
logger.Warn("tmux display-message failed with transient error, will retry",
870+
"session_name", sessionName,
871+
"error", errStr)
872+
}
873+
return sessionStatus{historySize: -1, bellActive: false, sessionExists: true}
841874
}
842875

843876
// Parse the response using the extracted helper
@@ -881,7 +914,8 @@ func (m *Manager) checkSessionExists(sessionName string) bool {
881914
defer cancel()
882915

883916
cmd := m.tmuxCmdCtx(ctx, "has-session", "-t", sessionName)
884-
err := cmd.Run()
917+
// Use CombinedOutput to capture stderr which contains the error message
918+
output, err := cmd.CombinedOutput()
885919

886920
// If timeout, assume session still exists (retry on next heartbeat)
887921
if ctx.Err() == context.DeadlineExceeded {
@@ -896,9 +930,43 @@ func (m *Manager) checkSessionExists(sessionName string) bool {
896930
}
897931

898932
if err != nil {
899-
// Any error means session doesn't exist or can't be verified
900-
// This is consistent with TmuxSessionExists() which uses `cmd.Run() == nil`
901-
return false
933+
m.mu.RLock()
934+
logger := m.logger
935+
m.mu.RUnlock()
936+
937+
// Check if this is a definitive "session doesn't exist" error.
938+
// Only return false for specific error patterns:
939+
// - "error connecting to" - socket doesn't exist
940+
// - "can't find session:" - session not found
941+
// - "no server running" - tmux server not running
942+
// - "executable file not found" - tmux not installed
943+
// For transient errors, assume the session still exists.
944+
// Note: With CombinedOutput, error messages are in the output bytes, not ExitError.Stderr
945+
errStr := string(output)
946+
if errStr == "" {
947+
errStr = err.Error()
948+
}
949+
sessionGone := strings.Contains(errStr, "error connecting to") ||
950+
strings.Contains(errStr, "can't find session:") ||
951+
strings.Contains(errStr, "no server running") ||
952+
strings.Contains(errStr, "executable file not found")
953+
954+
if sessionGone {
955+
if logger != nil {
956+
logger.Debug("tmux session confirmed not found during heartbeat",
957+
"session_name", sessionName,
958+
"error", errStr)
959+
}
960+
return false
961+
}
962+
963+
// For transient errors, assume session exists and retry
964+
if logger != nil {
965+
logger.Warn("tmux has-session failed with transient error, assuming session exists",
966+
"session_name", sessionName,
967+
"error", errStr)
968+
}
969+
return true
902970
}
903971

904972
return true

internal/instance/manager_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,77 @@ func TestManager_CheckSessionExists_NoSession(t *testing.T) {
558558
}
559559
}
560560

561+
// TestSessionGoneErrorPatterns documents which tmux error messages indicate
562+
// that a session is definitively gone vs transient errors that should be retried.
563+
// This test verifies our understanding of tmux error messages.
564+
func TestSessionGoneErrorPatterns(t *testing.T) {
565+
// These error patterns indicate the session is definitively gone
566+
sessionGonePatterns := []struct {
567+
name string
568+
errorMsg string
569+
}{
570+
{
571+
name: "socket doesn't exist",
572+
errorMsg: "error connecting to /tmp/tmux-501/claudio-test (No such file or directory)",
573+
},
574+
{
575+
name: "session not found",
576+
errorMsg: "can't find session: test-session",
577+
},
578+
{
579+
name: "no server running on socket",
580+
errorMsg: "no server running on /tmp/tmux-501/claudio-test",
581+
},
582+
{
583+
name: "tmux not installed",
584+
errorMsg: `exec: "tmux": executable file not found in $PATH`,
585+
},
586+
}
587+
588+
for _, tt := range sessionGonePatterns {
589+
t.Run(tt.name, func(t *testing.T) {
590+
sessionGone := strings.Contains(tt.errorMsg, "error connecting to") ||
591+
strings.Contains(tt.errorMsg, "can't find session:") ||
592+
strings.Contains(tt.errorMsg, "no server running") ||
593+
strings.Contains(tt.errorMsg, "executable file not found")
594+
if !sessionGone {
595+
t.Errorf("expected %q to be recognized as session-gone error", tt.errorMsg)
596+
}
597+
})
598+
}
599+
600+
// These errors should NOT indicate the session is gone (transient errors)
601+
transientPatterns := []struct {
602+
name string
603+
errorMsg string
604+
}{
605+
{
606+
name: "generic exit status",
607+
errorMsg: "exit status 1",
608+
},
609+
{
610+
name: "broken pipe",
611+
errorMsg: "write: broken pipe",
612+
},
613+
{
614+
name: "signal killed",
615+
errorMsg: "signal: killed",
616+
},
617+
}
618+
619+
for _, tt := range transientPatterns {
620+
t.Run("transient/"+tt.name, func(t *testing.T) {
621+
sessionGone := strings.Contains(tt.errorMsg, "error connecting to") ||
622+
strings.Contains(tt.errorMsg, "can't find session:") ||
623+
strings.Contains(tt.errorMsg, "no server running") ||
624+
strings.Contains(tt.errorMsg, "executable file not found")
625+
if sessionGone {
626+
t.Errorf("expected %q to NOT be recognized as session-gone error", tt.errorMsg)
627+
}
628+
})
629+
}
630+
}
631+
561632
func TestListClaudioTmuxSessions_NoTmuxServer(t *testing.T) {
562633
// This test may return nil or an empty list depending on whether tmux is running
563634
// The important thing is it should not error in a way that causes a panic

0 commit comments

Comments
 (0)