-
Notifications
You must be signed in to change notification settings - Fork 301
/
execer.go
266 lines (223 loc) · 6.16 KB
/
execer.go
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
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
package localexec
import (
"bytes"
"context"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"syscall"
"testing"
"github.com/tilt-dev/tilt/pkg/logger"
"github.com/tilt-dev/tilt/pkg/model"
"github.com/tilt-dev/tilt/pkg/procutil"
)
// OneShotResult includes details about command execution.
type OneShotResult struct {
// ExitCode from the process
ExitCode int
// Stdout from the process
Stdout []byte
// Stderr from the process
Stderr []byte
}
type RunIO struct {
// Stdin for the process
Stdin io.Reader
// Stdout for the process
Stdout io.Writer
// Stderr for the process
Stderr io.Writer
}
type Execer interface {
// Run executes a command and waits for it to complete.
//
// If the context is canceled before the process terminates, the process will be killed.
Run(ctx context.Context, cmd model.Cmd, runIO RunIO) (int, error)
}
func OneShot(ctx context.Context, execer Execer, cmd model.Cmd) (OneShotResult, error) {
var stdout, stderr bytes.Buffer
runIO := RunIO{
Stdout: &stdout,
Stderr: &stderr,
}
exitCode, err := execer.Run(ctx, cmd, runIO)
if err != nil {
return OneShotResult{}, err
}
return OneShotResult{
ExitCode: exitCode,
Stdout: stdout.Bytes(),
Stderr: stderr.Bytes(),
}, nil
}
func OneShotToLogger(ctx context.Context, execer Execer, cmd model.Cmd) error {
l := logger.Get(ctx)
out := l.Writer(logger.InfoLvl)
runIO := RunIO{Stdout: out, Stderr: out}
l.Infof("Running cmd: %s", cmd.String())
exitCode, err := execer.Run(ctx, cmd, runIO)
if err == nil && exitCode != 0 {
err = fmt.Errorf("exit status %d", exitCode)
}
return err
}
type ProcessExecer struct {
env *Env
}
var _ Execer = &ProcessExecer{}
func NewProcessExecer(env *Env) *ProcessExecer {
return &ProcessExecer{env: env}
}
func (p ProcessExecer) Run(ctx context.Context, cmd model.Cmd, runIO RunIO) (int, error) {
osCmd, err := p.env.ExecCmd(cmd, logger.Get(ctx))
if err != nil {
return -1, err
}
osCmd.SysProcAttr = &syscall.SysProcAttr{}
procutil.SetOptNewProcessGroup(osCmd.SysProcAttr)
osCmd.Stdin = runIO.Stdin
osCmd.Stdout = runIO.Stdout
osCmd.Stderr = runIO.Stderr
if err := osCmd.Start(); err != nil {
return -1, err
}
// monitor context cancel in a background goroutine and forcibly kill the process group if it's exceeded
// (N.B. an exit code of 137 is forced; otherwise, it's possible for the main process to exit with 0 after
// its children are killed, which is misleading)
// the sync.Once provides synchronization with the main function that's blocked on Cmd::Wait()
var exitCode int
var handleProcessExit sync.Once
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
<-ctx.Done()
handleProcessExit.Do(
func() {
procutil.KillProcessGroup(osCmd)
exitCode = 137
})
}()
// this WILL block on child processes, but that's ok since we handle the timeout termination in a goroutine above
// and it's preferable vs using Process::Wait() since that complicates I/O handling (Cmd::Wait() will
// ensure all I/O is complete before returning)
err = osCmd.Wait()
if exitErr, ok := err.(*exec.ExitError); ok {
handleProcessExit.Do(
func() {
exitCode = exitErr.ExitCode()
})
err = nil
} else if err != nil {
handleProcessExit.Do(
func() {
exitCode = -1
})
} else {
// explicitly consume the sync.Once to prevent a data race with the goroutine waiting on the context
// (since process completed successfully, exit code is 0, so no need to set anything)
handleProcessExit.Do(func() {})
}
return exitCode, err
}
type fakeCmdResult struct {
exitCode int
err error
stdout []byte
stderr []byte
}
type FakeCall struct {
Cmd model.Cmd
ExitCode int
Error error
}
func (f FakeCall) String() string {
return fmt.Sprintf("cmd=%q exitCode=%d err=%v", f.Cmd.String(), f.ExitCode, f.Error)
}
type FakeExecer struct {
t testing.TB
mu sync.Mutex
cmds map[string]fakeCmdResult
calls []FakeCall
}
var _ Execer = &FakeExecer{}
func NewFakeExecer(t testing.TB) *FakeExecer {
return &FakeExecer{
t: t,
cmds: make(map[string]fakeCmdResult),
}
}
func (f *FakeExecer) Run(ctx context.Context, cmd model.Cmd, runIO RunIO) (exitCode int, err error) {
f.t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
defer func() {
f.calls = append(f.calls, FakeCall{
Cmd: cmd,
ExitCode: exitCode,
Error: err,
})
}()
ctxErr := ctx.Err()
if ctxErr != nil {
return -1, ctxErr
}
if r, ok := f.cmds[cmd.String()]; ok {
if r.err != nil {
return -1, r.err
}
if runIO.Stdout != nil && len(r.stdout) != 0 {
if _, err := runIO.Stdout.Write(r.stdout); err != nil {
return -1, fmt.Errorf("error writing to stdout: %v", err)
}
}
if runIO.Stderr != nil && len(r.stderr) != 0 {
if _, err := runIO.Stderr.Write(r.stderr); err != nil {
return -1, fmt.Errorf("error writing to stderr: %v", err)
}
}
return r.exitCode, nil
}
return 0, nil
}
func (f *FakeExecer) RegisterCommandError(cmd string, err error) {
f.t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
f.cmds[cmd] = fakeCmdResult{
err: err,
}
}
// RegisterCommandBytes adds or replaces a command to the FakeExecer.
//
// The output values will be used exactly as-is, so can be used to simulate processes that do not newline terminate etc.
func (f *FakeExecer) RegisterCommandBytes(cmd string, exitCode int, stdout []byte, stderr []byte) {
f.registerCommand(cmd, exitCode, stdout, stderr)
}
// RegisterCommand adds or replaces a command to the FakeExecer.
//
// If the output strings are not newline terminated, a newline will automatically be added.
// If this behavior is not desired, use `RegisterCommandBytes`.
func (f *FakeExecer) RegisterCommand(cmd string, exitCode int, stdout string, stderr string) {
if stdout != "" && !strings.HasSuffix(stdout, "\n") {
stdout += "\n"
}
if stderr != "" && !strings.HasSuffix(stderr, "\n") {
stderr += "\n"
}
f.registerCommand(cmd, exitCode, []byte(stdout), []byte(stderr))
}
func (f *FakeExecer) Calls() []FakeCall {
return f.calls
}
func (f *FakeExecer) registerCommand(cmd string, exitCode int, stdout []byte, stderr []byte) {
f.t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
f.cmds[cmd] = fakeCmdResult{
exitCode: exitCode,
stdout: stdout,
stderr: stderr,
}
}