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  }