11package worktree
22
33import (
4+ "context"
45 "encoding/json"
56 "fmt"
67 "os"
@@ -30,12 +31,50 @@ type paneCache struct {
3031 ttl time.Duration
3132}
3233
34+ type captureCoordinator struct {
35+ mu sync.Mutex
36+ inFlight bool
37+ cond * sync.Cond
38+ }
39+
40+ func newCaptureCoordinator () * captureCoordinator {
41+ cc := & captureCoordinator {}
42+ cc .cond = sync .NewCond (& cc .mu )
43+ return cc
44+ }
45+
46+ // runBatch executes fn if no batch is currently running. If a batch is in-flight,
47+ // it waits for completion and returns ran=false so callers can re-check cache.
48+ func (c * captureCoordinator ) runBatch (fn func () (map [string ]string , error )) (outputs map [string ]string , err error , ran bool ) {
49+ c .mu .Lock ()
50+ if c .inFlight {
51+ for c .inFlight {
52+ c .cond .Wait ()
53+ }
54+ c .mu .Unlock ()
55+ return nil , nil , false
56+ }
57+ c .inFlight = true
58+ c .mu .Unlock ()
59+
60+ outputs , err = fn ()
61+
62+ c .mu .Lock ()
63+ c .inFlight = false
64+ c .cond .Broadcast ()
65+ c .mu .Unlock ()
66+
67+ return outputs , err , true
68+ }
69+
3370// Global cache instance for pane captures
3471var globalPaneCache = & paneCache {
3572 entries : make (map [string ]paneCacheEntry ),
3673 ttl : 300 * time .Millisecond , // Cache valid for 300ms
3774}
3875
76+ var globalCaptureCoordinator = newCaptureCoordinator ()
77+
3978// get returns cached output if valid, or empty string if expired/missing
4079func (c * paneCache ) get (session string ) (string , bool ) {
4180 c .mu .Lock ()
@@ -84,6 +123,10 @@ const (
84123 // We only need recent output for status detection and display
85124 captureLineCount = 600
86125
126+ // Timeout for tmux capture commands to avoid blocking on hung sessions
127+ tmuxCaptureTimeout = 2 * time .Second
128+ tmuxBatchCaptureTimeout = 3 * time .Second
129+
87130 // Polling intervals - adaptive based on agent status
88131 // Conservative values to reduce CPU with multiple worktrees while maintaining responsiveness
89132 pollIntervalInitial = 500 * time .Millisecond // First poll after agent starts
@@ -570,14 +613,21 @@ func capturePane(sessionName string) (string, error) {
570613 return output , nil
571614 }
572615
573- // Cache miss - batch capture all sidecar sessions
574- outputs , err := batchCaptureAllSessions ()
616+ // Cache miss - batch capture all sidecar sessions (singleflight)
617+ outputs , err , ran := globalCaptureCoordinator .runBatch (batchCaptureAllSessions )
618+ if ! ran {
619+ // Another goroutine captured; re-check cache
620+ if output , ok := globalPaneCache .get (sessionName ); ok {
621+ return output , nil
622+ }
623+ return capturePaneDirect (sessionName )
624+ }
575625 if err != nil {
576626 // Fall back to single capture on batch error
577627 return capturePaneDirect (sessionName )
578628 }
579629
580- // Cache all results
630+ // Cache all results from batch
581631 globalPaneCache .setAll (outputs )
582632
583633 // Return requested session's output
@@ -592,8 +642,13 @@ func capturePane(sessionName string) (string, error) {
592642// capturePaneDirect captures a single pane without caching.
593643func capturePaneDirect (sessionName string ) (string , error ) {
594644 startLine := fmt .Sprintf ("-%d" , captureLineCount )
595- cmd := exec .Command ("tmux" , "capture-pane" , "-p" , "-e" , "-J" , "-S" , startLine , "-t" , sessionName )
645+ ctx , cancel := context .WithTimeout (context .Background (), tmuxCaptureTimeout )
646+ defer cancel ()
647+ cmd := exec .CommandContext (ctx , "tmux" , "capture-pane" , "-p" , "-e" , "-J" , "-S" , startLine , "-t" , sessionName )
596648 output , err := cmd .Output ()
649+ if ctx .Err () == context .DeadlineExceeded {
650+ return "" , fmt .Errorf ("capture-pane: timeout after %s" , tmuxCaptureTimeout )
651+ }
597652 if err != nil {
598653 return "" , fmt .Errorf ("capture-pane: %w" , err )
599654 }
@@ -612,8 +667,13 @@ for session in $(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep '^%s
612667done
613668` , tmuxSessionPrefix , captureLineCount )
614669
615- cmd := exec .Command ("bash" , "-c" , script )
670+ ctx , cancel := context .WithTimeout (context .Background (), tmuxBatchCaptureTimeout )
671+ defer cancel ()
672+ cmd := exec .CommandContext (ctx , "bash" , "-c" , script )
616673 output , err := cmd .Output ()
674+ if ctx .Err () == context .DeadlineExceeded {
675+ return nil , fmt .Errorf ("batch capture: timeout after %s" , tmuxBatchCaptureTimeout )
676+ }
617677 if err != nil {
618678 return nil , fmt .Errorf ("batch capture: %w" , err )
619679 }
0 commit comments