@@ -7,17 +7,21 @@ import (
7
7
"log/slog"
8
8
"os"
9
9
"os/exec"
10
+ "sync"
10
11
"syscall"
11
12
"time"
12
13
13
14
"github.com/ActiveState/termtest/xpty"
14
15
"github.com/coder/agentapi/lib/logctx"
16
+ "github.com/coder/agentapi/lib/util"
15
17
"golang.org/x/xerrors"
16
18
)
17
19
18
20
type Process struct {
19
- xp * xpty.Xpty
20
- execCmd * exec.Cmd
21
+ xp * xpty.Xpty
22
+ execCmd * exec.Cmd
23
+ screenUpdateLock sync.RWMutex
24
+ lastScreenUpdate time.Time
21
25
}
22
26
23
27
type StartProcessConfig struct {
@@ -42,11 +46,38 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
42
46
return nil , err
43
47
}
44
48
49
+ process := & Process {xp : xp , execCmd : execCmd }
50
+
45
51
go func () {
52
+ // HACK: Working around xpty concurrency limitations
53
+ //
54
+ // Problem:
55
+ // 1. We need to track when the terminal screen was last updated (for ReadScreen)
56
+ // 2. xpty only updates terminal state through xp.ReadRune()
57
+ // 3. xp.ReadRune() has a bug - it panics when SetReadDeadline is used
58
+ // 4. Without deadlines, ReadRune blocks until the process outputs data
59
+ //
60
+ // Why this matters:
61
+ // If we wrapped ReadRune + lastScreenUpdate in a mutex, this goroutine would
62
+ // hold the lock while waiting for process output. Since ReadRune blocks indefinitely,
63
+ // ReadScreen callers would be locked out until new output arrives. Even worse,
64
+ // after output arrives, this goroutine could immediately reacquire the lock
65
+ // for the next ReadRune call, potentially starving ReadScreen callers indefinitely.
66
+ //
67
+ // Solution:
68
+ // Instead of using xp.ReadRune(), we directly use its internal components:
69
+ // - pp.ReadRune() - handles the blocking read from the process
70
+ // - xp.Term.WriteRune() - updates the terminal state
71
+ //
72
+ // This lets us apply the mutex only around the terminal update and timestamp,
73
+ // keeping reads non-blocking while maintaining thread safety.
74
+ //
75
+ // Warning: This depends on xpty internals and may break if xpty changes.
76
+ // A proper fix would require forking xpty or getting upstream changes.
77
+ pp := util .GetUnexportedField (xp , "pp" ).(* xpty.PassthroughPipe )
46
78
for {
47
- // calling ReadRune updates the terminal state. without it,
48
- // xp.State will always return an empty string
49
- if _ , _ , err := xp .ReadRune (); err != nil {
79
+ r , _ , err := pp .ReadRune ()
80
+ if err != nil {
50
81
if err != io .EOF {
51
82
logger .Error ("Error reading from pseudo terminal" , "error" , err )
52
83
}
@@ -55,18 +86,42 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
55
86
// unresponsive.
56
87
return
57
88
}
89
+ process .screenUpdateLock .Lock ()
90
+ // writing to the terminal updates its state. without it,
91
+ // xp.State will always return an empty string
92
+ xp .Term .WriteRune (r )
93
+ process .lastScreenUpdate = time .Now ()
94
+ process .screenUpdateLock .Unlock ()
58
95
}
59
96
}()
60
97
61
- return & Process { xp : xp , execCmd : execCmd } , nil
98
+ return process , nil
62
99
}
63
100
64
101
func (p * Process ) Signal (sig os.Signal ) error {
65
102
return p .execCmd .Process .Signal (sig )
66
103
}
67
104
68
105
// ReadScreen returns the contents of the terminal window.
106
+ // It waits for the terminal to be stable for 16ms before
107
+ // returning, or 48 ms since it's called, whichever is sooner.
108
+ //
109
+ // This logic acts as a kind of vsync. Agents regularly redraw
110
+ // parts of the screen. If we naively snapshotted the screen,
111
+ // we'd often capture it while it's being updated. This would
112
+ // result in a malformed agent message being returned to the
113
+ // user.
69
114
func (p * Process ) ReadScreen () string {
115
+ for range 3 {
116
+ p .screenUpdateLock .RLock ()
117
+ if time .Since (p .lastScreenUpdate ) >= 16 * time .Millisecond {
118
+ state := p .xp .State .String ()
119
+ p .screenUpdateLock .RUnlock ()
120
+ return state
121
+ }
122
+ p .screenUpdateLock .RUnlock ()
123
+ time .Sleep (16 * time .Millisecond )
124
+ }
70
125
return p .xp .State .String ()
71
126
}
72
127
0 commit comments