github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/localexec/execer.go (about) 1 package localexec 2 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "io" 8 "os/exec" 9 "strings" 10 "sync" 11 "syscall" 12 "testing" 13 14 "github.com/tilt-dev/tilt/pkg/logger" 15 "github.com/tilt-dev/tilt/pkg/model" 16 "github.com/tilt-dev/tilt/pkg/procutil" 17 ) 18 19 // OneShotResult includes details about command execution. 20 type OneShotResult struct { 21 // ExitCode from the process 22 ExitCode int 23 // Stdout from the process 24 Stdout []byte 25 // Stderr from the process 26 Stderr []byte 27 } 28 29 type RunIO struct { 30 // Stdin for the process 31 Stdin io.Reader 32 // Stdout for the process 33 Stdout io.Writer 34 // Stderr for the process 35 Stderr io.Writer 36 } 37 38 type Execer interface { 39 // Run executes a command and waits for it to complete. 40 // 41 // If the context is canceled before the process terminates, the process will be killed. 42 Run(ctx context.Context, cmd model.Cmd, runIO RunIO) (int, error) 43 } 44 45 func OneShot(ctx context.Context, execer Execer, cmd model.Cmd) (OneShotResult, error) { 46 var stdout, stderr bytes.Buffer 47 runIO := RunIO{ 48 Stdout: &stdout, 49 Stderr: &stderr, 50 } 51 exitCode, err := execer.Run(ctx, cmd, runIO) 52 if err != nil { 53 return OneShotResult{}, err 54 } 55 56 return OneShotResult{ 57 ExitCode: exitCode, 58 Stdout: stdout.Bytes(), 59 Stderr: stderr.Bytes(), 60 }, nil 61 } 62 63 func OneShotToLogger(ctx context.Context, execer Execer, cmd model.Cmd) error { 64 l := logger.Get(ctx) 65 out := l.Writer(logger.InfoLvl) 66 67 runIO := RunIO{Stdout: out, Stderr: out} 68 69 l.Infof("Running cmd: %s", cmd.String()) 70 exitCode, err := execer.Run(ctx, cmd, runIO) 71 if err == nil && exitCode != 0 { 72 err = fmt.Errorf("exit status %d", exitCode) 73 } 74 return err 75 } 76 77 type ProcessExecer struct { 78 env *Env 79 } 80 81 var _ Execer = &ProcessExecer{} 82 83 func NewProcessExecer(env *Env) *ProcessExecer { 84 return &ProcessExecer{env: env} 85 } 86 87 func (p ProcessExecer) Run(ctx context.Context, cmd model.Cmd, runIO RunIO) (int, error) { 88 osCmd, err := p.env.ExecCmd(cmd, logger.Get(ctx)) 89 if err != nil { 90 return -1, err 91 } 92 93 osCmd.SysProcAttr = &syscall.SysProcAttr{} 94 procutil.SetOptNewProcessGroup(osCmd.SysProcAttr) 95 96 osCmd.Stdin = runIO.Stdin 97 osCmd.Stdout = runIO.Stdout 98 osCmd.Stderr = runIO.Stderr 99 100 if err := osCmd.Start(); err != nil { 101 return -1, err 102 } 103 104 // monitor context cancel in a background goroutine and forcibly kill the process group if it's exceeded 105 // (N.B. an exit code of 137 is forced; otherwise, it's possible for the main process to exit with 0 after 106 // its children are killed, which is misleading) 107 // the sync.Once provides synchronization with the main function that's blocked on Cmd::Wait() 108 var exitCode int 109 var handleProcessExit sync.Once 110 ctx, cancel := context.WithCancel(ctx) 111 defer cancel() 112 go func() { 113 <-ctx.Done() 114 handleProcessExit.Do( 115 func() { 116 procutil.KillProcessGroup(osCmd) 117 exitCode = 137 118 }) 119 }() 120 121 // this WILL block on child processes, but that's ok since we handle the timeout termination in a goroutine above 122 // and it's preferable vs using Process::Wait() since that complicates I/O handling (Cmd::Wait() will 123 // ensure all I/O is complete before returning) 124 err = osCmd.Wait() 125 if exitErr, ok := err.(*exec.ExitError); ok { 126 handleProcessExit.Do( 127 func() { 128 exitCode = exitErr.ExitCode() 129 }) 130 err = nil 131 } else if err != nil { 132 handleProcessExit.Do( 133 func() { 134 exitCode = -1 135 }) 136 } else { 137 // explicitly consume the sync.Once to prevent a data race with the goroutine waiting on the context 138 // (since process completed successfully, exit code is 0, so no need to set anything) 139 handleProcessExit.Do(func() {}) 140 } 141 return exitCode, err 142 } 143 144 type fakeCmdResult struct { 145 exitCode int 146 err error 147 stdout []byte 148 stderr []byte 149 } 150 151 type FakeCall struct { 152 Cmd model.Cmd 153 ExitCode int 154 Error error 155 } 156 157 func (f FakeCall) String() string { 158 return fmt.Sprintf("cmd=%q exitCode=%d err=%v", f.Cmd.String(), f.ExitCode, f.Error) 159 } 160 161 type FakeExecer struct { 162 t testing.TB 163 mu sync.Mutex 164 165 cmds map[string]fakeCmdResult 166 167 calls []FakeCall 168 } 169 170 var _ Execer = &FakeExecer{} 171 172 func NewFakeExecer(t testing.TB) *FakeExecer { 173 return &FakeExecer{ 174 t: t, 175 cmds: make(map[string]fakeCmdResult), 176 } 177 } 178 179 func (f *FakeExecer) Run(ctx context.Context, cmd model.Cmd, runIO RunIO) (exitCode int, err error) { 180 f.t.Helper() 181 f.mu.Lock() 182 defer f.mu.Unlock() 183 184 defer func() { 185 f.calls = append(f.calls, FakeCall{ 186 Cmd: cmd, 187 ExitCode: exitCode, 188 Error: err, 189 }) 190 }() 191 192 ctxErr := ctx.Err() 193 if ctxErr != nil { 194 return -1, ctxErr 195 } 196 197 if r, ok := f.cmds[cmd.String()]; ok { 198 if r.err != nil { 199 return -1, r.err 200 } 201 202 if runIO.Stdout != nil && len(r.stdout) != 0 { 203 if _, err := runIO.Stdout.Write(r.stdout); err != nil { 204 return -1, fmt.Errorf("error writing to stdout: %v", err) 205 } 206 } 207 208 if runIO.Stderr != nil && len(r.stderr) != 0 { 209 if _, err := runIO.Stderr.Write(r.stderr); err != nil { 210 return -1, fmt.Errorf("error writing to stderr: %v", err) 211 } 212 } 213 214 return r.exitCode, nil 215 } 216 217 return 0, nil 218 } 219 220 func (f *FakeExecer) RegisterCommandError(cmd string, err error) { 221 f.t.Helper() 222 f.mu.Lock() 223 defer f.mu.Unlock() 224 f.cmds[cmd] = fakeCmdResult{ 225 err: err, 226 } 227 } 228 229 // RegisterCommandBytes adds or replaces a command to the FakeExecer. 230 // 231 // The output values will be used exactly as-is, so can be used to simulate processes that do not newline terminate etc. 232 func (f *FakeExecer) RegisterCommandBytes(cmd string, exitCode int, stdout []byte, stderr []byte) { 233 f.registerCommand(cmd, exitCode, stdout, stderr) 234 } 235 236 // RegisterCommand adds or replaces a command to the FakeExecer. 237 // 238 // If the output strings are not newline terminated, a newline will automatically be added. 239 // If this behavior is not desired, use `RegisterCommandBytes`. 240 func (f *FakeExecer) RegisterCommand(cmd string, exitCode int, stdout string, stderr string) { 241 if stdout != "" && !strings.HasSuffix(stdout, "\n") { 242 stdout += "\n" 243 } 244 245 if stderr != "" && !strings.HasSuffix(stderr, "\n") { 246 stderr += "\n" 247 } 248 249 f.registerCommand(cmd, exitCode, []byte(stdout), []byte(stderr)) 250 } 251 252 func (f *FakeExecer) Calls() []FakeCall { 253 return f.calls 254 } 255 256 func (f *FakeExecer) registerCommand(cmd string, exitCode int, stdout []byte, stderr []byte) { 257 f.t.Helper() 258 f.mu.Lock() 259 defer f.mu.Unlock() 260 261 f.cmds[cmd] = fakeCmdResult{ 262 exitCode: exitCode, 263 stdout: stdout, 264 stderr: stderr, 265 } 266 }