blob: ca5dee9bb0baaecee808083c7beeb16565cb526a [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package subprocess
import (
"context"
"errors"
"io"
"os"
"os/exec"
"syscall"
"time"
"go.fuchsia.dev/fuchsia/tools/lib/clock"
"go.fuchsia.dev/fuchsia/tools/lib/logger"
)
const (
// cleanupGracePeriod is the time period we allow the subprocess to complete in
// after we send a SIGTERM.
cleanupGracePeriod = 10 * time.Second
)
// Runner is a Runner that runs commands as local subprocesses.
type Runner struct {
// Dir is the working directory of the subprocesses; if unspecified, that
// of the current process will be used.
Dir string
// Env is the environment of the subprocess, following the usual convention of a list of
// strings of the form "<environment variable name>=<value>".
Env []string
}
// RunOptions represents all the optional fields that may be passed into Run().
type RunOptions struct {
// Stdout is the writer to which the subprocess's stdout should be directed.
// It defaults to os.Stdout.
Stdout io.Writer
// Stderr is the writer to which the subprocess's stderr should be directed.
// It defaults to os.Stderr.
Stderr io.Writer
// Stderr is the reader to which the subprocess's stdin should be connected.
// It is unset by default.
Stdin io.Reader
// Env is the environment of the subprocess, appended to Runner.Env.
Env []string
// Dir is the directory in which the subprocess should be run. It inherits
// Runner.Dir if unset.
Dir string
}
// Command returns an *exec.Cmd from the provided command args and run options.
func (r *Runner) Command(command []string, options RunOptions) *exec.Cmd {
cmd := exec.Command(command[0], command[1:]...)
if options.Stdout == nil {
options.Stdout = os.Stdout
}
cmd.Stdout = options.Stdout
if options.Stderr == nil {
options.Stderr = os.Stderr
}
cmd.Stderr = options.Stderr
// Don't inherit stdin by default because the majority of subprocesses don't
// require access to stdin, and using os.Stdin results in any grandchildren
// processes not being cleaned up due to the pgid logic below.
if options.Stdin != nil {
cmd.Stdin = options.Stdin
}
if options.Dir == "" {
options.Dir = r.Dir
}
cmd.Dir = options.Dir
// Inherit the parent process's environment. `exec.Command` inherits the
// parent's environment if `Env` is nil, so unconditionally inheriting the
// parent's environment means that adding a single environment variable to a
// command that previously didn't have any will not implicitly remove the
// inheritance.
cmd.Env = append(os.Environ(), r.Env...)
cmd.Env = append(cmd.Env, options.Env...)
// For some reason, adding the child to the same process group as the
// current process disconnects it from stdin. So don't do it if we're
// running a potentially interactive command that has access to stdin. Those
// cases are less likely to involve chains of subprocesses anyway, so it's
// not as important to be able to kill the entire chain.
if cmd.Stdin != os.Stdin {
cmd.SysProcAttr = &syscall.SysProcAttr{
// Set a process group ID so we can kill the entire group, meaning
// the process and any of its children.
Setpgid: true,
}
}
return cmd
}
// Run runs a command generated from the provided command args and run options.
func (r *Runner) Run(ctx context.Context, command []string, options RunOptions) error {
cmd := r.Command(command, options)
return r.RunCommand(ctx, cmd)
}
// RunCommand runs a command until completion or until a context is canceled, in
// which case the subprocess is killed so that no subprocesses it spun up are
// orphaned.
func (r *Runner) RunCommand(ctx context.Context, cmd *exec.Cmd) error {
if len(cmd.Env) > 0 {
logger.Tracef(ctx, "environment of subprocess: %v", cmd.Env)
}
// Ensure that the context still exists before running the subprocess.
if ctx.Err() != nil {
logger.Debugf(ctx, "context exited before starting subprocess")
return ctx.Err()
}
logger.Debugf(ctx, "starting: %v", cmd.Args)
if err := cmd.Start(); err != nil {
return err
}
return WaitForCmd(ctx, cmd)
}
// WaitForCmd waits for the command to finish and sends a SIGTERM and SIGKILL
// if the command doesn't complete on its own.
func WaitForCmd(ctx context.Context, cmd *exec.Cmd) error {
errs := make(chan error)
go func() {
errs <- cmd.Wait()
}()
pgidSet := cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid
select {
case err := <-errs:
// Process is done so no need to worry about cleanup. Just exit.
return err
case <-ctx.Done():
logger.Debugf(ctx, "sending SIGTERM to process %d", cmd.Process.Pid)
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
logger.Debugf(ctx, "exited cmd %v with error: %s", cmd.Args, err)
}
// Wait up to `cleanupGracePeriod` for the subprocess to exit on its
// own. If it takes too long we'll SIGKILL it.
select {
case <-errs:
// The command has completed but it may still have child processes
// running that we would like to clean up if possible. Sending a
// SIGKILL to clean up the entire process group will only work if
// the pgid is set.
if pgidSet {
killProcess(ctx, cmd, pgidSet)
}
case <-clock.After(ctx, cleanupGracePeriod):
killProcess(ctx, cmd, pgidSet)
// Wait for the subprocess to complete after killing it.
<-errs
}
// Return the context error instead of the error returned by cmd.Wait()
// to indicate to the caller that the command failed as a result of a
// context cancellation; in this case the error returned by cmd.Wait()
// will generally be more confusing than meaningful.
return ctx.Err()
}
}
// killProcess makes a best-effort attempt at killing the subprocess specified
// by `cmd`, along with all of its child processes if `pgidSet` is true.
func killProcess(ctx context.Context, cmd *exec.Cmd, pgidSet bool) {
logger.Debugf(ctx, "killing process %d", cmd.Process.Pid)
pgid := cmd.Process.Pid
if pgidSet {
// Negating the process ID means interpret it as a process group ID, so
// we kill the subprocess and all of its children.
pgid = -pgid
}
if err := syscall.Kill(pgid, syscall.SIGKILL); err != nil {
// ESRCH is "no such process", meaning the process has already exited.
if !errors.Is(err, syscall.ESRCH) {
logger.Debugf(ctx, "killed cmd %v with error: %s", cmd.Args, err)
}
}
}