blob: 1be4c9f3eaa4a5b28c5006b82591328db605b949 [file] [edit]
// 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"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"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
// Whether to set a new process group ID so that we can kill the subprocess
// and any of its children.
Setpgid bool
}
// 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 && options.Setpgid {
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)
}
// procInfo contains information about a process and its children.
type procInfo struct {
// pid is the process ID.
pid int
// ppid is the parent process ID.
ppid int
// comm is the name of the executable.
comm string
// cmdline is the command line arguments of the process.
cmdline string
// children are the child processes.
children []*procInfo
}
// render generates an ASCII representation of the process tree.
func (p *procInfo) render(prefix string, isLast bool, isRoot bool) []string {
var connector string
if !isRoot {
if isLast {
connector = "└── "
} else {
connector = "├── "
}
}
line := fmt.Sprintf("%s%sPID: %d, comm: %q, cmdline: %q", prefix, connector, p.pid, p.comm, p.cmdline)
lines := []string{line}
var childPrefix string
if !isRoot {
if isLast {
childPrefix = prefix + " "
} else {
childPrefix = prefix + "│ "
}
}
sort.Slice(p.children, func(i, j int) bool {
return p.children[i].pid < p.children[j].pid
})
for i, child := range p.children {
lines = append(lines, child.render(childPrefix, i == len(p.children)-1, false)...)
}
return lines
}
// processSet represents a collection of processes.
type processSet struct {
procs map[int]*procInfo
}
// roots returns the root processes in the set.
func (s *processSet) roots() []*procInfo {
for _, p := range s.procs {
p.children = nil
}
var roots []*procInfo
for _, p := range s.procs {
if parent, ok := s.procs[p.ppid]; ok && p.ppid != p.pid {
parent.children = append(parent.children, p)
} else {
roots = append(roots, p)
}
}
sort.Slice(roots, func(i, j int) bool {
return roots[i].pid < roots[j].pid
})
return roots
}
// render generates the entire ASCII tree as a slice of strings.
func (s *processSet) render() []string {
roots := s.roots()
var lines []string
for i, root := range roots {
lines = append(lines, root.render("", i == len(roots)-1, true)...)
}
return lines
}
// loadProcessSet reads the /proc filesystem to collect information about all running processes.
func loadProcessSet(ctx context.Context) (*processSet, error) {
dir, err := os.Open("/proc")
if err != nil {
return nil, err
}
defer dir.Close()
files, err := dir.Readdirnames(0)
if err != nil {
return nil, err
}
procs := make(map[int]*procInfo)
for _, file := range files {
pid, err := strconv.Atoi(file)
if err != nil {
continue
}
p := &procInfo{pid: pid}
if comm, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)); err == nil {
p.comm = strings.TrimSpace(string(comm))
} else if !errors.Is(err, os.ErrNotExist) {
logger.Debugf(ctx, "failed to read /proc/%d/comm: %v", pid, err)
}
if cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)); err == nil {
p.cmdline = strings.TrimSpace(strings.ReplaceAll(string(cmdline), "\x00", " "))
} else if !errors.Is(err, os.ErrNotExist) {
logger.Debugf(ctx, "failed to read /proc/%d/cmdline: %v", pid, err)
}
if status, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)); err == nil {
for _, line := range strings.Split(string(status), "\n") {
if strings.HasPrefix(line, "PPid:") {
ppidStr := strings.TrimSpace(strings.TrimPrefix(line, "PPid:"))
if ppid, err := strconv.Atoi(ppidStr); err == nil {
p.ppid = ppid
}
break
}
}
} else if !errors.Is(err, os.ErrNotExist) {
logger.Debugf(ctx, "failed to read /proc/%d/status: %v", pid, err)
}
procs[pid] = p
}
return &processSet{procs: procs}, nil
}
// This is an inhouse implementation of `ps` for Linux which isn't guaranteed to
// be available on all the systems we run on.
func logRunningProcesses(ctx context.Context) {
set, err := loadProcessSet(ctx)
if err != nil {
logger.Errorf(ctx, "failed to load processes: %v", err)
return
}
if lines := set.render(); len(lines) > 0 {
logger.Warningf(ctx, "Running processes:\n%s", strings.Join(lines, "\n"))
}
}
// 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()
if cmd.ProcessState != nil {
retcode := cmd.ProcessState.ExitCode()
logger.Debugf(ctx, "Subprocess completed with exit code %d: %v", retcode, cmd.Args)
}
}()
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():
logRunningProcesses(ctx)
logger.Debugf(ctx, "sending SIGTERM to process %d: %v", cmd.Process.Pid, cmd.Args)
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)
}
}
}