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  }