github.com/benchkram/bob@v0.0.0-20240314204020-b7a57f2f9be9/pkg/execctl/cmd.go (about)

     1  package execctl
     2  
     3  import (
     4  	"errors"
     5  	"io"
     6  	"os"
     7  	"os/exec"
     8  	"strings"
     9  	"sync"
    10  
    11  	"github.com/benchkram/bob/pkg/ctl"
    12  	"github.com/benchkram/bob/pkg/usererror"
    13  )
    14  
    15  var (
    16  	ErrCmdAlreadyStarted = errors.New("cmd already started")
    17  )
    18  
    19  // assert Cmd implements the Command interface
    20  var _ ctl.Command = (*Cmd)(nil)
    21  
    22  // Cmd allows to control a process started through os.Exec with additional start, stop and restart capabilities, and
    23  // provides readers/writers for the command's outputs and input, respectively.
    24  type Cmd struct {
    25  	mux         sync.Mutex
    26  	cmd         *exec.Cmd
    27  	name        string
    28  	exe         string
    29  	args        []string
    30  	stdout      pipe
    31  	stderr      pipe
    32  	stdin       pipe
    33  	running     bool
    34  	interrupted bool
    35  	err         chan error
    36  	lastErr     error
    37  	env         []string
    38  }
    39  
    40  type pipe struct {
    41  	r *os.File
    42  	w *os.File
    43  }
    44  
    45  // NewCmd creates a new Cmd, ready to be started
    46  func NewCmd(name string, exe string, opts ...Option) (c *Cmd, err error) {
    47  	c = &Cmd{
    48  		name: name,
    49  		exe:  exe,
    50  		err:  make(chan error, 1),
    51  	}
    52  
    53  	for _, opt := range opts {
    54  		if opt == nil {
    55  			continue
    56  		}
    57  		opt(c)
    58  	}
    59  
    60  	// create pipes for stdout, stderr and stdin
    61  	c.stdout.r, c.stdout.w, err = os.Pipe()
    62  	if err != nil {
    63  		return nil, err
    64  	}
    65  
    66  	c.stderr.r, c.stderr.w, err = os.Pipe()
    67  	if err != nil {
    68  		return nil, err
    69  	}
    70  
    71  	c.stdin.r, c.stdin.w, err = os.Pipe()
    72  	if err != nil {
    73  		return nil, err
    74  	}
    75  
    76  	return c, nil
    77  }
    78  
    79  func (c *Cmd) Name() string {
    80  	c.mux.Lock()
    81  	defer c.mux.Unlock()
    82  
    83  	return c.name
    84  }
    85  
    86  func (c *Cmd) Running() bool {
    87  	c.mux.Lock()
    88  	defer c.mux.Unlock()
    89  
    90  	return c.running
    91  }
    92  
    93  // Start starts the command if it's not already running. It will be a noop if it is.
    94  // It also spins up a goroutine that will receive any error occurred during the command's exit.
    95  func (c *Cmd) Start() error {
    96  	c.mux.Lock()
    97  	defer c.mux.Unlock()
    98  
    99  	if c.running {
   100  		return nil
   101  	}
   102  
   103  	c.running = true
   104  	c.interrupted = false
   105  	c.lastErr = nil
   106  
   107  	// create the command with the found executable and the its args
   108  	cmd := exec.Command(c.exe, c.args...)
   109  	c.cmd = cmd
   110  	c.cmd.Env = c.env
   111  	// assign the pipes to the command
   112  	c.cmd.Stdout = c.stdout.w
   113  	c.cmd.Stderr = c.stderr.w
   114  	c.cmd.Stdin = c.stdin.r
   115  
   116  	// start the command
   117  	err := c.cmd.Start()
   118  	if err != nil {
   119  		return usererror.Wrapm(err, "Command execution failed")
   120  	}
   121  
   122  	go func() {
   123  		err = cmd.Wait()
   124  
   125  		c.err <- err
   126  
   127  		c.mux.Lock()
   128  
   129  		c.running = false
   130  
   131  		c.mux.Unlock()
   132  	}()
   133  
   134  	return nil
   135  }
   136  
   137  // Stop stops the running command with an os.Interrupt signal. It does not return an error if the command has
   138  // already exited gracefully.
   139  func (c *Cmd) Stop() error {
   140  	err := c.stop()
   141  	if err != nil {
   142  		return err
   143  	}
   144  
   145  	return c.Wait()
   146  }
   147  
   148  // Restart first interrupts the command if it's already running, and then re-runs the command.
   149  func (c *Cmd) Restart() error {
   150  	err := c.stop()
   151  	if err != nil {
   152  		return err
   153  	}
   154  
   155  	err = c.Wait()
   156  	if err != nil {
   157  		return err
   158  	}
   159  
   160  	return c.Start()
   161  }
   162  
   163  // Stdout returns a reader to the command's stdout. The reader will return an io.EOF error if the command exits.
   164  func (c *Cmd) Stdout() io.Reader {
   165  	c.mux.Lock()
   166  	defer c.mux.Unlock()
   167  
   168  	return c.stdout.r
   169  }
   170  
   171  // Stderr returns a reader to the command's stderr. The reader will return an io.EOF error if the command exits.
   172  func (c *Cmd) Stderr() io.Reader {
   173  	c.mux.Lock()
   174  	defer c.mux.Unlock()
   175  
   176  	return c.stderr.r
   177  }
   178  
   179  // Stdin returns a writer to the command's stdin. The writer will be closed if the command has exited by the time this
   180  // function is called.
   181  func (c *Cmd) Stdin() io.Writer {
   182  	c.mux.Lock()
   183  	defer c.mux.Unlock()
   184  
   185  	return c.stdin.w
   186  }
   187  
   188  // Wait awaits for the command to stop running (either to gracefully exit or be interrupted).
   189  // If the command has already finished when Wait is invoked, it returns the error that was returned when the command
   190  // exited, if any.
   191  func (c *Cmd) Wait() error {
   192  	c.mux.Lock()
   193  
   194  	running := c.running
   195  	errChan := c.err
   196  	lastErr := c.lastErr
   197  	interrupted := c.interrupted
   198  
   199  	if !running {
   200  		c.mux.Unlock()
   201  
   202  		return lastErr
   203  	}
   204  
   205  	c.mux.Unlock()
   206  
   207  	err := <-errChan
   208  
   209  	c.mux.Lock()
   210  
   211  	if err != nil && interrupted && strings.Contains(err.Error(), "signal: interrupt") {
   212  		err = nil
   213  	} else if err != nil {
   214  		c.lastErr = err
   215  	}
   216  
   217  	c.mux.Unlock()
   218  
   219  	return err
   220  }
   221  
   222  // stop requests for the command to stop, if it has already started.
   223  func (c *Cmd) stop() error {
   224  	c.mux.Lock()
   225  
   226  	running := c.running
   227  	cmd := c.cmd
   228  	c.interrupted = true
   229  
   230  	c.mux.Unlock()
   231  
   232  	if !running {
   233  		return nil
   234  	}
   235  
   236  	if cmd != nil && cmd.Process != nil {
   237  		// send an interrupt signal to the command
   238  		err := cmd.Process.Signal(os.Interrupt)
   239  		if err != nil && !strings.Contains(err.Error(), "os: process already finished") {
   240  			return err
   241  		}
   242  	}
   243  
   244  	return nil
   245  }
   246  
   247  // Shutdown stops the cmd
   248  func (c *Cmd) Shutdown() error {
   249  	return c.Stop()
   250  }
   251  
   252  func (c *Cmd) Done() <-chan struct{} {
   253  	done := make(chan struct{})
   254  	go func() {
   255  		_ = c.Wait()
   256  		close(done)
   257  	}()
   258  	return done
   259  }