@@ -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,34 @@ 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+ // 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 )
4674 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 {
5077 if err != io .EOF {
5178 logger .Error ("Error reading from pseudo terminal" , "error" , err )
5279 }
@@ -55,18 +82,42 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
5582 // unresponsive.
5683 return
5784 }
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 ()
5891 }
5992 }()
6093
61- return & Process { xp : xp , execCmd : execCmd } , nil
94+ return process , nil
6295}
6396
6497func (p * Process ) Signal (sig os.Signal ) error {
6598 return p .execCmd .Process .Signal (sig )
6699}
67100
68101// 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.
69110func (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+ }
70121 return p .xp .State .String ()
71122}
72123
0 commit comments