github.com/haraldrudell/parl@v0.4.176/pexec/exec-stream-full.go (about) 1 /* 2 © 2021–present Harald Rudell <harald.rudell@gmail.com> (https://haraldrudell.github.io/haraldrudell/) 3 ISC License 4 */ 5 6 // Package pexec provides streaming, context-cancelable system command execution 7 package pexec 8 9 import ( 10 "context" 11 "io" 12 "os" 13 "os/exec" 14 "sync" 15 "syscall" 16 17 "github.com/haraldrudell/parl" 18 "github.com/haraldrudell/parl/perrors" 19 "golang.org/x/sys/unix" 20 ) 21 22 type StartCallback func(execCmd *exec.Cmd, err error) 23 24 // ExecStreamFull executes a system command using the exec.Cmd type and flexible streaming. 25 // - ExecStreamFull blocks during command execution 26 // - ExecStreamFull returns any error occurring during launch or execution including 27 // errors in copy threads 28 // - successful exit is: statusCode == 0, isCancel == false, err == nil 29 // - statusCode may be set by the process but is otherwise: 30 // - — 0 successful exit 31 // - — -1 process was killed by signal such as ^C or SIGTERM 32 // - context cancel exit is: statusCode == -1, isCancel == true, err == nil 33 // - failure exit is: statusCode != 0, isCancel == false, err != nil 34 // - — 35 // - args is the command followed by arguments. 36 // - args[0] must specify an executable in the file system. 37 // env.PATH is used to resolve the command executable 38 // - if stdin stdout or stderr are nil, the are /dev/null 39 // Additional threads are used to copy data when stdin stdout or stderr are non-nil 40 // - os.Stdin os.Stdout os.Stderr can be provided 41 // - for stdout and stderr pio has usable types: 42 // - — pio.NewWriteCloserToString 43 // - — pio.NewWriteCloserToChan 44 // - — pio.NewWriteCloserToChanLine 45 // - — pio.NewReadWriteCloserSlice 46 // - any stream provided is not closed. However, upon return from ExecStream all i/o operations 47 // have completed and streams may be closed as the case may be 48 // - ctx is used to kill the process (by calling os.Process.Kill) if the context becomes 49 // done before the command completes on its own 50 // - startCallback is invoked immediately after cmd.Exec.Start returns with 51 // its result. To not use a callback, set startCallback to nil 52 // - If env is nil, the new process uses the current process’ environment 53 // - use ExecStreamFull with parl.EchoModerator 54 // - if system commands slow down or lock-up, too many (dozens) invoking goroutines 55 // may cause increased memory consumption, thrashing or exhaust of file handles, ie. 56 // an uncontrollable host state 57 func ExecStreamFull(stdin io.Reader, stdout io.WriteCloser, stderr io.WriteCloser, 58 env []string, ctx context.Context, startCallback StartCallback, extraFiles []*os.File, 59 args ...string) (statusCode int, isCancel bool, err error) { 60 if len(args) == 0 { 61 err = perrors.ErrorfPF("%w", ErrArgsListEmpty) 62 return 63 } 64 65 // execCtx allows for local cancel, ie. failing copyThreads 66 var execCtx = parl.NewCancelContext(ctx) 67 68 // thread management: waitgroup and thread-safe error store 69 var wg sync.WaitGroup 70 defer parl.Debug("waitgroup complete") 71 defer wg.Wait() 72 var errs perrors.ParlError 73 defer func() { 74 err = perrors.AppendError(err, errs.GetError()) 75 }() 76 77 // close if we are aborting 78 var closers []io.Closer 79 isStart := false 80 defer parl.Debug("closers complete") 81 defer func() { 82 if isStart { 83 return // do nothing: if exec.Cmd.Start succeeded, exe.Cmd close the streams 84 } 85 for _, c := range closers { 86 if e := c.Close(); e != nil { 87 err = perrors.AppendError(err, perrors.ErrorfPF("stream Close %w", e)) 88 } 89 } 90 }() 91 92 // get Cmd structure, possibly resolve args[0] using environment PATH 93 var execCmd *exec.Cmd = exec.CommandContext(execCtx, args[0], args[1:]...) 94 95 // possibly replace current process's environment os.Environ() 96 if env != nil { 97 execCmd.Env = env 98 } 99 100 // pipe stdin to process 101 if stdin != nil { 102 if stdin == os.Stdin { 103 execCmd.Stdin = stdin 104 } else { 105 var ioWriteCloser io.WriteCloser 106 if ioWriteCloser, err = execCmd.StdinPipe(); err != nil { 107 err = perrors.ErrorfPF("execCmd.StdinPipe %w", err) 108 return // pipe error return 109 } 110 wg.Add(1) 111 go copyThread("stdin", stdin, ioWriteCloser, errs.AddErrorProc, execCtx, &wg) 112 } 113 } 114 115 // pipe stdout to process 116 if stdout != nil { 117 if stdout == os.Stdout || stdout == os.Stderr { 118 execCmd.Stdout = stdout 119 } else { 120 var ioReadCloser io.ReadCloser 121 if ioReadCloser, err = execCmd.StdoutPipe(); err != nil { 122 err = perrors.ErrorfPF("execCmd.StdoutPipe %w", err) 123 return // pipe error return 124 } 125 wg.Add(1) 126 go copyThread("stdout", ioReadCloser, stdout, errs.AddErrorProc, execCtx, &wg) 127 } 128 } 129 130 // pipe stderr to process 131 if stderr != nil { 132 if stderr == os.Stdout || stderr == os.Stderr { 133 execCmd.Stderr = stderr 134 } else { 135 var ioReadCloser io.ReadCloser 136 if ioReadCloser, err = execCmd.StderrPipe(); err != nil { 137 err = perrors.ErrorfPF("execCmd.StderrPipe %w", err) 138 return // pipe error return 139 } 140 wg.Add(1) 141 go copyThread("stderr", ioReadCloser, stderr, errs.AddErrorProc, execCtx, &wg) 142 } 143 } 144 145 if len(extraFiles) > 0 { 146 execCmd.ExtraFiles = extraFiles 147 } 148 149 // execute 150 parl.Debug("Start") 151 if err = execCmd.Start(); err != nil { 152 err = perrors.ErrorfPF("execCmd.Start %w", err) 153 } 154 isStart = true 155 if startCallback != nil { 156 var e error 157 if e = invokeStart(startCallback, execCmd, err); e != nil { 158 err = perrors.AppendError(err, perrors.ErrorfPF("startCallback %w", e)) 159 } 160 } 161 if err != nil { 162 return // command Start error return 163 } 164 165 parl.Debug("Wait") 166 if err = execCmd.Wait(); err != nil { 167 err = perrors.ErrorfPF("execCmd.Wait %w", err) 168 } 169 parl.Debug("Wait complete") 170 if err != nil { 171 var hasStatusCode bool 172 var signal syscall.Signal 173 hasStatusCode, statusCode, signal, _ = ExitError(err) 174 175 // was the context canceled? 176 if execCtx.Err() != nil && 177 hasStatusCode && // there was an exec.ExitError 178 statusCode == TerminatedBySignal && // the process was terminated by a signal 179 signal == unix.SIGKILL { // in fact SIGKILL 180 // if it was SIGKILL, ignore it: it was caused by context cancelation 181 err = nil // ignore the error 182 isCancel = true 183 } 184 185 return // Wait() error return 186 } 187 return // command completed successfully return 188 } 189 190 func invokeStart(startCallback StartCallback, execCmd *exec.Cmd, e error) (err error) { 191 defer parl.RecoverErr(func() parl.DA { return parl.A() }, &err) 192 193 startCallback(execCmd, e) 194 195 return 196 }