@@ -7,17 +7,21 @@ import (
77 "log/slog"
88 "os"
99 "os/exec"
10+ "sync"
1011 "syscall"
1112 "time"
1213
1314 "github.com/ActiveState/termtest/xpty"
1415 "github.com/coder/agentapi/lib/logctx"
16+ "github.com/coder/agentapi/lib/util"
1517 "golang.org/x/xerrors"
1618)
1719
1820type 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
2125}
2226
2327type StartProcessConfig struct {
@@ -42,11 +46,38 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
4246 return nil , err
4347 }
4448
49+ process := & Process {xp : xp , execCmd : execCmd }
50+
4551 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 )
4678 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 {
5081 if err != io .EOF {
5182 logger .Error ("Error reading from pseudo terminal" , "error" , err )
5283 }
@@ -55,18 +86,42 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
5586 // unresponsive.
5687 return
5788 }
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 ()
5895 }
5996 }()
6097
61- return & Process { xp : xp , execCmd : execCmd } , nil
98+ return process , nil
6299}
63100
64101func (p * Process ) Signal (sig os.Signal ) error {
65102 return p .execCmd .Process .Signal (sig )
66103}
67104
68105// 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.
69114func (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+ }
70125 return p .xp .State .String ()
71126}
72127
0 commit comments