@@ -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,34 @@ 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
+ // This is a hack to work around a concurrency issue in the xpty
53
+ // library. The only way the xpty library allows the user to update
54
+ // the terminal state is to call xp.ReadRune. Ideally, we'd just use it here.
55
+ // However, we need to atomically update the terminal state and set p.lastScreenUpdate.
56
+ // p.ReadScreen depends on it.
57
+ // xp.ReadRune has a bug which makes it impossible to use xp.SetReadDeadline -
58
+ // ReadRune panics if the deadline is set. So xp.ReadRune will block until the
59
+ // underlying process produces new output.
60
+ // So if we naively wrapped ReadRune and lastScreenUpdate in a mutex,
61
+ // we'd have to wait for the underlying process to produce new output.
62
+ // And that would block p.ReadScreen. That's no good.
63
+ //
64
+ // Internally, xp.ReadRune calls pp.ReadRune, which is what's doing the waiting,
65
+ // and then xp.Term.WriteRune, which is what's updating the terminal state.
66
+ // Below, we do the same things xp.ReadRune does, but we wrap only the terminal
67
+ // state update in a mutex. As a result, p.ReadScreen is not blocked.
68
+ //
69
+ // It depends on the implementation details of the xpty library, and is prone
70
+ // to break if xpty is updated.
71
+ // The proper way to fix it would be to fork xpty and make changes there, but
72
+ // I don't want to maintain a fork now.
73
+ pp := util .GetUnexportedField (xp , "pp" ).(* xpty.PassthroughPipe )
46
74
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 {
75
+ r , _ , err := pp .ReadRune ()
76
+ if err != nil {
50
77
if err != io .EOF {
51
78
logger .Error ("Error reading from pseudo terminal" , "error" , err )
52
79
}
@@ -55,18 +82,42 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
55
82
// unresponsive.
56
83
return
57
84
}
85
+ process .screenUpdateLock .Lock ()
86
+ // writing to the terminal updates its state. without it,
87
+ // xp.State will always return an empty string
88
+ xp .Term .WriteRune (r )
89
+ process .lastScreenUpdate = time .Now ()
90
+ process .screenUpdateLock .Unlock ()
58
91
}
59
92
}()
60
93
61
- return & Process { xp : xp , execCmd : execCmd } , nil
94
+ return process , nil
62
95
}
63
96
64
97
func (p * Process ) Signal (sig os.Signal ) error {
65
98
return p .execCmd .Process .Signal (sig )
66
99
}
67
100
68
101
// ReadScreen returns the contents of the terminal window.
102
+ // It waits for the terminal to be stable for 16ms before
103
+ // returning, or 48 ms since it's called, whichever is sooner.
104
+ //
105
+ // This logic acts as a kind of vsync. Agents regularly redraw
106
+ // parts of the screen. If we naively snapshotted the screen,
107
+ // we'd often capture it while it's being updated. This would
108
+ // result in a malformed agent message being returned to the
109
+ // user.
69
110
func (p * Process ) ReadScreen () string {
111
+ for range 3 {
112
+ p .screenUpdateLock .RLock ()
113
+ if time .Since (p .lastScreenUpdate ) >= 16 * time .Millisecond {
114
+ state := p .xp .State .String ()
115
+ p .screenUpdateLock .RUnlock ()
116
+ return state
117
+ }
118
+ p .screenUpdateLock .RUnlock ()
119
+ time .Sleep (16 * time .Millisecond )
120
+ }
70
121
return p .xp .State .String ()
71
122
}
72
123
0 commit comments