-
Notifications
You must be signed in to change notification settings - Fork 116
Expand file tree
/
Copy pathtermexec.go
More file actions
192 lines (176 loc) · 5.89 KB
/
termexec.go
File metadata and controls
192 lines (176 loc) · 5.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
package termexec
import (
"context"
"errors"
"io"
"log/slog"
"os"
"os/exec"
"sync"
"syscall"
"time"
"github.com/ActiveState/termtest/xpty"
"github.com/coder/agentapi/lib/logctx"
"github.com/coder/agentapi/lib/util"
"github.com/coder/quartz"
"golang.org/x/xerrors"
)
type Process struct {
xp *xpty.Xpty
execCmd *exec.Cmd
screenUpdateLock sync.RWMutex
lastScreenUpdate time.Time
clock quartz.Clock
}
type StartProcessConfig struct {
Program string
Args []string
TerminalWidth uint16
TerminalHeight uint16
Clock quartz.Clock
}
func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error) {
logger := logctx.From(ctx)
clock := args.Clock
if clock == nil {
clock = quartz.NewReal()
}
xp, err := xpty.New(args.TerminalWidth, args.TerminalHeight, false)
if err != nil {
return nil, err
}
execCmd := exec.Command(args.Program, args.Args...)
// vt100 is the terminal type that the vt10x library emulates.
// Setting this signals to the process that it should only use compatible
// escape sequences.
execCmd.Env = append(os.Environ(), "TERM=vt100")
if err := xp.StartProcessInTerminal(execCmd); err != nil {
return nil, err
}
process := &Process{xp: xp, execCmd: execCmd, clock: clock}
go func() {
// HACK: Working around xpty concurrency limitations
//
// Problem:
// 1. We need to track when the terminal screen was last updated (for ReadScreen)
// 2. xpty only updates terminal state through xp.ReadRune()
// 3. xp.ReadRune() has a bug - it panics when SetReadDeadline is used
// 4. Without deadlines, ReadRune blocks until the process outputs data
//
// Why this matters:
// If we wrapped ReadRune + lastScreenUpdate in a mutex, this goroutine would
// hold the lock while waiting for process output. Since ReadRune blocks indefinitely,
// ReadScreen callers would be locked out until new output arrives. Even worse,
// after output arrives, this goroutine could immediately reacquire the lock
// for the next ReadRune call, potentially starving ReadScreen callers indefinitely.
//
// Solution:
// Instead of using xp.ReadRune(), we directly use its internal components:
// - pp.ReadRune() - handles the blocking read from the process
// - xp.Term.WriteRune() - updates the terminal state
//
// This lets us apply the mutex only around the terminal update and timestamp,
// keeping reads non-blocking while maintaining thread safety.
//
// Warning: This depends on xpty internals and may break if xpty changes.
// A proper fix would require forking xpty or getting upstream changes.
pp := util.GetUnexportedField(xp, "pp").(*xpty.PassthroughPipe)
for {
r, _, err := pp.ReadRune()
if err != nil {
if err != io.EOF {
logger.Error("Error reading from pseudo terminal", "error", err)
}
// TODO: handle this error better. if this happens, the terminal
// state will never be updated anymore and the process will appear
// unresponsive.
return
}
process.screenUpdateLock.Lock()
// writing to the terminal updates its state. without it,
// xp.State will always return an empty string
xp.Term.WriteRune(r)
process.lastScreenUpdate = clock.Now()
process.screenUpdateLock.Unlock()
}
}()
return process, nil
}
func (p *Process) Signal(sig os.Signal) error {
return p.execCmd.Process.Signal(sig)
}
// ReadScreen returns the contents of the terminal window.
// It waits for the terminal to be stable for 16ms before
// returning, or 48 ms since it's called, whichever is sooner.
//
// This logic acts as a kind of vsync. Agents regularly redraw
// parts of the screen. If we naively snapshotted the screen,
// we'd often capture it while it's being updated. This would
// result in a malformed agent message being returned to the
// user.
func (p *Process) ReadScreen() string {
for range 3 {
p.screenUpdateLock.RLock()
if p.clock.Since(p.lastScreenUpdate) >= 16*time.Millisecond {
state := p.xp.State.String()
p.screenUpdateLock.RUnlock()
return state
}
p.screenUpdateLock.RUnlock()
t := p.clock.NewTimer(16 * time.Millisecond)
<-t.C
t.Stop()
}
return p.xp.State.String()
}
// Write sends input to the process via the pseudo terminal.
func (p *Process) Write(data []byte) (int, error) {
return p.xp.TerminalInPipe().Write(data)
}
// Close closes the process using a SIGINT signal or forcefully killing it if the process
// does not exit after the timeout. It then closes the pseudo terminal.
func (p *Process) Close(logger *slog.Logger, timeout time.Duration) error {
logger.Info("Closing process")
if err := p.execCmd.Process.Signal(os.Interrupt); err != nil {
return xerrors.Errorf("failed to send SIGINT to process: %w", err)
}
exited := make(chan error, 1)
go func() {
_, err := p.execCmd.Process.Wait()
exited <- err
close(exited)
}()
timeoutTimer := p.clock.NewTimer(timeout)
defer timeoutTimer.Stop()
var exitErr error
select {
case <-timeoutTimer.C:
if err := p.execCmd.Process.Kill(); err != nil {
exitErr = xerrors.Errorf("failed to forcefully kill the process: %w", err)
}
// don't wait for the process to exit to avoid hanging indefinitely
// if the process never exits
case err := <-exited:
var pathErr *os.SyscallError
// ECHILD is expected if the process has already exited
if err != nil && !(errors.As(err, &pathErr) && errors.Is(pathErr.Err, syscall.ECHILD)) {
exitErr = xerrors.Errorf("process exited with error: %w", err)
}
}
if err := p.xp.Close(); err != nil {
return xerrors.Errorf("failed to close pseudo terminal: %w, exitErr: %w", err, exitErr)
}
return exitErr
}
var ErrNonZeroExitCode = xerrors.New("non-zero exit code")
// Wait waits for the process to exit.
func (p *Process) Wait() error {
state, err := p.execCmd.Process.Wait()
if err != nil {
return xerrors.Errorf("process exited with error: %w", err)
}
if state.ExitCode() != 0 {
return ErrNonZeroExitCode
}
return nil
}