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 }