aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/cli/internal/process
diff options
context:
space:
mode:
Diffstat (limited to 'cli/internal/process')
-rw-r--r--cli/internal/process/child.go406
-rw-r--r--cli/internal/process/child_nix_test.go190
-rw-r--r--cli/internal/process/child_test.go193
-rw-r--r--cli/internal/process/manager.go120
-rw-r--r--cli/internal/process/manager_test.go94
-rw-r--r--cli/internal/process/sys_nix.go23
-rw-r--r--cli/internal/process/sys_windows.go17
7 files changed, 1043 insertions, 0 deletions
diff --git a/cli/internal/process/child.go b/cli/internal/process/child.go
new file mode 100644
index 0000000..1c3e6e7
--- /dev/null
+++ b/cli/internal/process/child.go
@@ -0,0 +1,406 @@
+package process
+
+/**
+ * Code in this file is based on the source code at
+ * https://github.com/hashicorp/consul-template/tree/3ea7d99ad8eff17897e0d63dac86d74770170bb8/child/child.go
+ *
+ * Major changes include removing the ability to restart a child process,
+ * requiring a fully-formed exec.Cmd to be passed in, and including cmd.Dir
+ * in the description of a child process.
+ */
+
+import (
+ "errors"
+ "fmt"
+ "math/rand"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/hashicorp/go-hclog"
+)
+
+func init() {
+ // Seed the default rand Source with current time to produce better random
+ // numbers used with splay
+ rand.Seed(time.Now().UnixNano())
+}
+
+var (
+ // ErrMissingCommand is the error returned when no command is specified
+ // to run.
+ ErrMissingCommand = errors.New("missing command")
+
+ // ExitCodeOK is the default OK exit code.
+ ExitCodeOK = 0
+
+ // ExitCodeError is the default error code returned when the child exits with
+ // an error without a more specific code.
+ ExitCodeError = 127
+)
+
+// Child is a wrapper around a child process which can be used to send signals
+// and manage the processes' lifecycle.
+type Child struct {
+ sync.RWMutex
+
+ timeout time.Duration
+
+ killSignal os.Signal
+ killTimeout time.Duration
+
+ splay time.Duration
+
+ // cmd is the actual child process under management.
+ cmd *exec.Cmd
+
+ // exitCh is the channel where the processes exit will be returned.
+ exitCh chan int
+
+ // stopLock is the mutex to lock when stopping. stopCh is the circuit breaker
+ // to force-terminate any waiting splays to kill the process now. stopped is
+ // a boolean that tells us if we have previously been stopped.
+ stopLock sync.RWMutex
+ stopCh chan struct{}
+ stopped bool
+
+ // whether to set process group id or not (default on)
+ setpgid bool
+
+ Label string
+
+ logger hclog.Logger
+}
+
+// NewInput is input to the NewChild function.
+type NewInput struct {
+ // Cmd is the unstarted, preconfigured command to run
+ Cmd *exec.Cmd
+
+ // Timeout is the maximum amount of time to allow the command to execute. If
+ // set to 0, the command is permitted to run infinitely.
+ Timeout time.Duration
+
+ // KillSignal is the signal to send to gracefully kill this process. This
+ // value may be nil.
+ KillSignal os.Signal
+
+ // KillTimeout is the amount of time to wait for the process to gracefully
+ // terminate before force-killing.
+ KillTimeout time.Duration
+
+ // Splay is the maximum random amount of time to wait before sending signals.
+ // This option helps reduce the thundering herd problem by effectively
+ // sleeping for a random amount of time before sending the signal. This
+ // prevents multiple processes from all signaling at the same time. This value
+ // may be zero (which disables the splay entirely).
+ Splay time.Duration
+
+ // Logger receives debug log lines about the process state and transitions
+ Logger hclog.Logger
+}
+
+// New creates a new child process for management with high-level APIs for
+// sending signals to the child process, restarting the child process, and
+// gracefully terminating the child process.
+func newChild(i NewInput) (*Child, error) {
+ // exec.Command prepends the command to be run to the arguments list, so
+ // we only need the arguments here, it will include the command itself.
+ label := fmt.Sprintf("(%v) %v", i.Cmd.Dir, strings.Join(i.Cmd.Args, " "))
+ child := &Child{
+ cmd: i.Cmd,
+ timeout: i.Timeout,
+ killSignal: i.KillSignal,
+ killTimeout: i.KillTimeout,
+ splay: i.Splay,
+ stopCh: make(chan struct{}, 1),
+ setpgid: true,
+ Label: label,
+ logger: i.Logger.Named(label),
+ }
+
+ return child, nil
+}
+
+// ExitCh returns the current exit channel for this child process. This channel
+// may change if the process is restarted, so implementers must not cache this
+// value.
+func (c *Child) ExitCh() <-chan int {
+ c.RLock()
+ defer c.RUnlock()
+ return c.exitCh
+}
+
+// Pid returns the pid of the child process. If no child process exists, 0 is
+// returned.
+func (c *Child) Pid() int {
+ c.RLock()
+ defer c.RUnlock()
+ return c.pid()
+}
+
+// Command returns the human-formatted command with arguments.
+func (c *Child) Command() string {
+ return c.Label
+}
+
+// Start starts and begins execution of the child process. A buffered channel
+// is returned which is where the command's exit code will be returned upon
+// exit. Any errors that occur prior to starting the command will be returned
+// as the second error argument, but any errors returned by the command after
+// execution will be returned as a non-zero value over the exit code channel.
+func (c *Child) Start() error {
+ // log.Printf("[INFO] (child) spawning: %s", c.Command())
+ c.Lock()
+ defer c.Unlock()
+ return c.start()
+}
+
+// Signal sends the signal to the child process, returning any errors that
+// occur.
+func (c *Child) Signal(s os.Signal) error {
+ c.logger.Debug("receiving signal %q", s.String())
+ c.RLock()
+ defer c.RUnlock()
+ return c.signal(s)
+}
+
+// Kill sends the kill signal to the child process and waits for successful
+// termination. If no kill signal is defined, the process is killed with the
+// most aggressive kill signal. If the process does not gracefully stop within
+// the provided KillTimeout, the process is force-killed. If a splay was
+// provided, this function will sleep for a random period of time between 0 and
+// the provided splay value to reduce the thundering herd problem. This function
+// does not return any errors because it guarantees the process will be dead by
+// the return of the function call.
+func (c *Child) Kill() {
+ c.logger.Debug("killing process")
+ c.Lock()
+ defer c.Unlock()
+ c.kill(false)
+}
+
+// Stop behaves almost identical to Kill except it suppresses future processes
+// from being started by this child and it prevents the killing of the child
+// process from sending its value back up the exit channel. This is useful
+// when doing a graceful shutdown of an application.
+func (c *Child) Stop() {
+ c.internalStop(false)
+}
+
+// StopImmediately behaves almost identical to Stop except it does not wait
+// for any random splay if configured. This is used for performing a fast
+// shutdown of consul-template and its children when a kill signal is received.
+func (c *Child) StopImmediately() {
+ c.internalStop(true)
+}
+
+func (c *Child) internalStop(immediately bool) {
+ c.Lock()
+ defer c.Unlock()
+
+ c.stopLock.Lock()
+ defer c.stopLock.Unlock()
+ if c.stopped {
+ return
+ }
+ c.kill(immediately)
+ close(c.stopCh)
+ c.stopped = true
+}
+
+func (c *Child) start() error {
+ setSetpgid(c.cmd, c.setpgid)
+ if err := c.cmd.Start(); err != nil {
+ return err
+ }
+
+ // Create a new exitCh so that previously invoked commands (if any) don't
+ // cause us to exit, and start a goroutine to wait for that process to end.
+ exitCh := make(chan int, 1)
+ go func() {
+ var code int
+ // It's possible that kill is called before we even
+ // manage to get here. Make sure we still have a valid
+ // cmd before waiting on it.
+ c.RLock()
+ var cmd = c.cmd
+ c.RUnlock()
+ var err error
+ if cmd != nil {
+ err = cmd.Wait()
+ }
+ if err == nil {
+ code = ExitCodeOK
+ } else {
+ code = ExitCodeError
+ if exiterr, ok := err.(*exec.ExitError); ok {
+ if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
+ code = status.ExitStatus()
+ }
+ }
+ }
+
+ // If the child is in the process of killing, do not send a response back
+ // down the exit channel.
+ c.stopLock.RLock()
+ defer c.stopLock.RUnlock()
+ if !c.stopped {
+ select {
+ case <-c.stopCh:
+ case exitCh <- code:
+ }
+ }
+
+ close(exitCh)
+ }()
+
+ c.exitCh = exitCh
+
+ // If a timeout was given, start the timer to wait for the child to exit
+ if c.timeout != 0 {
+ select {
+ case code := <-exitCh:
+ if code != 0 {
+ return fmt.Errorf(
+ "command exited with a non-zero exit status:\n"+
+ "\n"+
+ " %s\n"+
+ "\n"+
+ "This is assumed to be a failure. Please ensure the command\n"+
+ "exits with a zero exit status.",
+ c.Command(),
+ )
+ }
+ case <-time.After(c.timeout):
+ // Force-kill the process
+ c.stopLock.Lock()
+ defer c.stopLock.Unlock()
+ if c.cmd != nil && c.cmd.Process != nil {
+ c.cmd.Process.Kill()
+ }
+
+ return fmt.Errorf(
+ "command did not exit within %q:\n"+
+ "\n"+
+ " %s\n"+
+ "\n"+
+ "Commands must exit in a timely manner in order for processing to\n"+
+ "continue. Consider using a process supervisor or utilizing the\n"+
+ "built-in exec mode instead.",
+ c.timeout,
+ c.Command(),
+ )
+ }
+ }
+
+ return nil
+}
+
+func (c *Child) pid() int {
+ if !c.running() {
+ return 0
+ }
+ return c.cmd.Process.Pid
+}
+
+func (c *Child) signal(s os.Signal) error {
+ if !c.running() {
+ return nil
+ }
+
+ sig, ok := s.(syscall.Signal)
+ if !ok {
+ return fmt.Errorf("bad signal: %s", s)
+ }
+ pid := c.cmd.Process.Pid
+ if c.setpgid {
+ // kill takes negative pid to indicate that you want to use gpid
+ pid = -(pid)
+ }
+ // cross platform way to signal process/process group
+ p, err := os.FindProcess(pid)
+ if err != nil {
+ return err
+ }
+ return p.Signal(sig)
+}
+
+// kill sends the signal to kill the process using the configured signal
+// if set, else the default system signal
+func (c *Child) kill(immediately bool) {
+
+ if !c.running() {
+ c.logger.Debug("Kill() called but process dead; not waiting for splay.")
+ return
+ } else if immediately {
+ c.logger.Debug("Kill() called but performing immediate shutdown; not waiting for splay.")
+ } else {
+ c.logger.Debug("Kill(%v) called", immediately)
+ select {
+ case <-c.stopCh:
+ case <-c.randomSplay():
+ }
+ }
+
+ var exited bool
+ defer func() {
+ if !exited {
+ c.logger.Debug("PKill")
+ c.cmd.Process.Kill()
+ }
+ c.cmd = nil
+ }()
+
+ if c.killSignal == nil {
+ return
+ }
+
+ if err := c.signal(c.killSignal); err != nil {
+ c.logger.Debug("Kill failed: %s", err)
+ if processNotFoundErr(err) {
+ exited = true // checked in defer
+ }
+ return
+ }
+
+ killCh := make(chan struct{}, 1)
+ go func() {
+ defer close(killCh)
+ c.cmd.Process.Wait()
+ }()
+
+ select {
+ case <-c.stopCh:
+ case <-killCh:
+ exited = true
+ case <-time.After(c.killTimeout):
+ c.logger.Debug("timeout")
+ }
+}
+
+func (c *Child) running() bool {
+ select {
+ case <-c.exitCh:
+ return false
+ default:
+ }
+ return c.cmd != nil && c.cmd.Process != nil
+}
+
+func (c *Child) randomSplay() <-chan time.Time {
+ if c.splay == 0 {
+ return time.After(0)
+ }
+
+ ns := c.splay.Nanoseconds()
+ offset := rand.Int63n(ns)
+ t := time.Duration(offset)
+
+ c.logger.Debug("waiting %.2fs for random splay", t.Seconds())
+
+ return time.After(t)
+}
diff --git a/cli/internal/process/child_nix_test.go b/cli/internal/process/child_nix_test.go
new file mode 100644
index 0000000..7311d18
--- /dev/null
+++ b/cli/internal/process/child_nix_test.go
@@ -0,0 +1,190 @@
+//go:build !windows
+// +build !windows
+
+package process
+
+/**
+ * Code in this file is based on the source code at
+ * https://github.com/hashicorp/consul-template/tree/3ea7d99ad8eff17897e0d63dac86d74770170bb8/child/child_test.go
+ *
+ * Tests in this file use signals or pgid features not available on windows
+ */
+
+import (
+ "os/exec"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/hashicorp/go-gatedio"
+)
+
+func TestSignal(t *testing.T) {
+
+ c := testChild(t)
+ cmd := exec.Command("sh", "-c", "trap 'echo one; exit' USR1; while true; do sleep 0.2; done")
+ c.cmd = cmd
+
+ out := gatedio.NewByteBuffer()
+ c.cmd.Stdout = out
+
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer c.Stop()
+
+ // For some reason bash doesn't start immediately
+ time.Sleep(fileWaitSleepDelay)
+
+ if err := c.Signal(syscall.SIGUSR1); err != nil {
+ t.Fatal(err)
+ }
+
+ // Give time for the file to flush
+ time.Sleep(fileWaitSleepDelay)
+
+ expected := "one\n"
+ if out.String() != expected {
+ t.Errorf("expected %q to be %q", out.String(), expected)
+ }
+}
+
+func TestStop_childAlreadyDead(t *testing.T) {
+ c := testChild(t)
+ c.cmd = exec.Command("sh", "-c", "exit 1")
+ c.splay = 100 * time.Second
+ c.killSignal = syscall.SIGTERM
+
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+
+ // For some reason bash doesn't start immediately
+ time.Sleep(fileWaitSleepDelay)
+
+ killStartTime := time.Now()
+ c.Stop()
+ killEndTime := time.Now()
+
+ if killEndTime.Sub(killStartTime) > fileWaitSleepDelay {
+ t.Error("expected not to wait for splay")
+ }
+}
+
+func TestSignal_noProcess(t *testing.T) {
+
+ c := testChild(t)
+ if err := c.Signal(syscall.SIGUSR1); err != nil {
+ // Just assert there is no error
+ t.Fatal(err)
+ }
+}
+
+func TestKill_signal(t *testing.T) {
+
+ c := testChild(t)
+ cmd := exec.Command("sh", "-c", "trap 'echo one; exit' USR1; while true; do sleep 0.2; done")
+ c.killSignal = syscall.SIGUSR1
+
+ out := gatedio.NewByteBuffer()
+ cmd.Stdout = out
+ c.cmd = cmd
+
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer c.Stop()
+
+ // For some reason bash doesn't start immediately
+ time.Sleep(fileWaitSleepDelay)
+
+ c.Kill()
+
+ // Give time for the file to flush
+ time.Sleep(fileWaitSleepDelay)
+
+ expected := "one\n"
+ if out.String() != expected {
+ t.Errorf("expected %q to be %q", out.String(), expected)
+ }
+}
+
+func TestKill_noProcess(t *testing.T) {
+ c := testChild(t)
+ c.killSignal = syscall.SIGUSR1
+ c.Kill()
+}
+
+func TestStop_noWaitForSplay(t *testing.T) {
+ c := testChild(t)
+ c.cmd = exec.Command("sh", "-c", "trap 'echo one; exit' USR1; while true; do sleep 0.2; done")
+ c.splay = 100 * time.Second
+ c.killSignal = syscall.SIGUSR1
+
+ out := gatedio.NewByteBuffer()
+ c.cmd.Stdout = out
+
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+
+ // For some reason bash doesn't start immediately
+ time.Sleep(fileWaitSleepDelay)
+
+ killStartTime := time.Now()
+ c.StopImmediately()
+ killEndTime := time.Now()
+
+ expected := "one\n"
+ if out.String() != expected {
+ t.Errorf("expected %q to be %q", out.String(), expected)
+ }
+
+ if killEndTime.Sub(killStartTime) > fileWaitSleepDelay {
+ t.Error("expected not to wait for splay")
+ }
+}
+
+func TestSetpgid(t *testing.T) {
+ t.Run("true", func(t *testing.T) {
+ c := testChild(t)
+ c.cmd = exec.Command("sh", "-c", "while true; do sleep 0.2; done")
+ // default, but to be explicit for the test
+ c.setpgid = true
+
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer c.Stop()
+
+ // when setpgid is true, the pid and gpid should be the same
+ gpid, err := syscall.Getpgid(c.Pid())
+ if err != nil {
+ t.Fatal("Getpgid error:", err)
+ }
+
+ if c.Pid() != gpid {
+ t.Fatal("pid and gpid should match")
+ }
+ })
+ t.Run("false", func(t *testing.T) {
+ c := testChild(t)
+ c.cmd = exec.Command("sh", "-c", "while true; do sleep 0.2; done")
+ c.setpgid = false
+
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer c.Stop()
+
+ // when setpgid is true, the pid and gpid should be the same
+ gpid, err := syscall.Getpgid(c.Pid())
+ if err != nil {
+ t.Fatal("Getpgid error:", err)
+ }
+
+ if c.Pid() == gpid {
+ t.Fatal("pid and gpid should NOT match")
+ }
+ })
+}
diff --git a/cli/internal/process/child_test.go b/cli/internal/process/child_test.go
new file mode 100644
index 0000000..63dee22
--- /dev/null
+++ b/cli/internal/process/child_test.go
@@ -0,0 +1,193 @@
+package process
+
+/**
+ * Code in this file is based on the source code at
+ * https://github.com/hashicorp/consul-template/tree/3ea7d99ad8eff17897e0d63dac86d74770170bb8/child/child_test.go
+ *
+ * Major changes include supporting api changes in child.go and removing
+ * tests for reloading, which was removed in child.go
+ */
+
+import (
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/hashicorp/go-gatedio"
+ "github.com/hashicorp/go-hclog"
+)
+
+const fileWaitSleepDelay = 150 * time.Millisecond
+
+func testChild(t *testing.T) *Child {
+ cmd := exec.Command("echo", "hello", "world")
+ cmd.Stdout = ioutil.Discard
+ cmd.Stderr = ioutil.Discard
+ c, err := newChild(NewInput{
+ Cmd: cmd,
+ KillSignal: os.Kill,
+ KillTimeout: 2 * time.Second,
+ Splay: 0 * time.Second,
+ Logger: hclog.Default(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return c
+}
+
+func TestNew(t *testing.T) {
+
+ stdin := gatedio.NewByteBuffer()
+ stdout := gatedio.NewByteBuffer()
+ stderr := gatedio.NewByteBuffer()
+ command := "echo"
+ args := []string{"hello", "world"}
+ env := []string{"a=b", "c=d"}
+ killSignal := os.Kill
+ killTimeout := fileWaitSleepDelay
+ splay := fileWaitSleepDelay
+
+ cmd := exec.Command(command, args...)
+ cmd.Stdin = stdin
+ cmd.Stderr = stderr
+ cmd.Stdout = stdout
+ cmd.Env = env
+ c, err := newChild(NewInput{
+ Cmd: cmd,
+ KillSignal: killSignal,
+ KillTimeout: killTimeout,
+ Splay: splay,
+ Logger: hclog.Default(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if c.killSignal != killSignal {
+ t.Errorf("expected %q to be %q", c.killSignal, killSignal)
+ }
+
+ if c.killTimeout != killTimeout {
+ t.Errorf("expected %q to be %q", c.killTimeout, killTimeout)
+ }
+
+ if c.splay != splay {
+ t.Errorf("expected %q to be %q", c.splay, splay)
+ }
+
+ if c.stopCh == nil {
+ t.Errorf("expected %#v to be", c.stopCh)
+ }
+}
+
+func TestExitCh_noProcess(t *testing.T) {
+
+ c := testChild(t)
+ ch := c.ExitCh()
+ if ch != nil {
+ t.Errorf("expected %#v to be nil", ch)
+ }
+}
+
+func TestExitCh(t *testing.T) {
+
+ c := testChild(t)
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+ println("Started")
+ defer c.Stop()
+
+ ch := c.ExitCh()
+ if ch == nil {
+ t.Error("expected ch to exist")
+ }
+}
+
+func TestPid_noProcess(t *testing.T) {
+
+ c := testChild(t)
+ pid := c.Pid()
+ if pid != 0 {
+ t.Errorf("expected %q to be 0", pid)
+ }
+}
+
+func TestPid(t *testing.T) {
+
+ c := testChild(t)
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer c.Stop()
+
+ pid := c.Pid()
+ if pid == 0 {
+ t.Error("expected pid to not be 0")
+ }
+}
+
+func TestStart(t *testing.T) {
+
+ c := testChild(t)
+
+ // Set our own reader and writer so we can verify they are wired to the child.
+ stdin := gatedio.NewByteBuffer()
+ stdout := gatedio.NewByteBuffer()
+ stderr := gatedio.NewByteBuffer()
+ // Custom env and command
+ env := []string{"a=b", "c=d"}
+ cmd := exec.Command("env")
+ cmd.Stdin = stdin
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ cmd.Env = env
+ c.cmd = cmd
+
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer c.Stop()
+
+ select {
+ case <-c.ExitCh():
+ case <-time.After(fileWaitSleepDelay):
+ t.Fatal("process should have exited")
+ }
+
+ output := stdout.String()
+ for _, envVar := range env {
+ if !strings.Contains(output, envVar) {
+ t.Errorf("expected to find %q in %q", envVar, output)
+ }
+ }
+}
+
+func TestKill_noSignal(t *testing.T) {
+
+ c := testChild(t)
+ c.cmd = exec.Command("sh", "-c", "while true; do sleep 0.2; done")
+ c.killTimeout = 20 * time.Millisecond
+ c.killSignal = nil
+
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer c.Stop()
+
+ // For some reason bash doesn't start immediately
+ time.Sleep(fileWaitSleepDelay)
+
+ c.Kill()
+
+ // Give time for the file to flush
+ time.Sleep(fileWaitSleepDelay)
+
+ if c.cmd != nil {
+ t.Errorf("expected cmd to be nil")
+ }
+}
diff --git a/cli/internal/process/manager.go b/cli/internal/process/manager.go
new file mode 100644
index 0000000..0488a29
--- /dev/null
+++ b/cli/internal/process/manager.go
@@ -0,0 +1,120 @@
+package process
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "sync"
+ "time"
+
+ "github.com/hashicorp/go-hclog"
+)
+
+// ErrClosing is returned when the process manager is in the process of closing,
+// meaning that no more child processes can be Exec'd, and existing, non-failed
+// child processes will be stopped with this error.
+var ErrClosing = errors.New("process manager is already closing")
+
+// ChildExit is returned when a child process exits with a non-zero exit code
+type ChildExit struct {
+ ExitCode int
+ Command string
+}
+
+func (ce *ChildExit) Error() string {
+ return fmt.Sprintf("command %s exited (%d)", ce.Command, ce.ExitCode)
+}
+
+// Manager tracks all of the child processes that have been spawned
+type Manager struct {
+ done bool
+ children map[*Child]struct{}
+ mu sync.Mutex
+ doneCh chan struct{}
+ logger hclog.Logger
+}
+
+// NewManager creates a new properly-initialized Manager instance
+func NewManager(logger hclog.Logger) *Manager {
+ return &Manager{
+ children: make(map[*Child]struct{}),
+ doneCh: make(chan struct{}),
+ logger: logger,
+ }
+}
+
+// Exec spawns a child process to run the given command, then blocks
+// until it completes. Returns a nil error if the child process finished
+// successfully, ErrClosing if the manager closed during execution, and
+// a ChildExit error if the child process exited with a non-zero exit code.
+func (m *Manager) Exec(cmd *exec.Cmd) error {
+ m.mu.Lock()
+ if m.done {
+ m.mu.Unlock()
+ return ErrClosing
+ }
+
+ child, err := newChild(NewInput{
+ Cmd: cmd,
+ // Run forever by default
+ Timeout: 0,
+ // When it's time to exit, give a 10 second timeout
+ KillTimeout: 10 * time.Second,
+ // Send SIGINT to stop children
+ KillSignal: os.Interrupt,
+ Logger: m.logger,
+ })
+ if err != nil {
+ return err
+ }
+
+ m.children[child] = struct{}{}
+ m.mu.Unlock()
+ err = child.Start()
+ if err != nil {
+ m.mu.Lock()
+ delete(m.children, child)
+ m.mu.Unlock()
+ return err
+ }
+ err = nil
+ exitCode, ok := <-child.ExitCh()
+ if !ok {
+ err = ErrClosing
+ } else if exitCode != ExitCodeOK {
+ err = &ChildExit{
+ ExitCode: exitCode,
+ Command: child.Command(),
+ }
+ }
+
+ m.mu.Lock()
+ delete(m.children, child)
+ m.mu.Unlock()
+ return err
+}
+
+// Close sends SIGINT to all child processes if it hasn't been done yet,
+// and in either case blocks until they all exit or timeout
+func (m *Manager) Close() {
+ m.mu.Lock()
+ if m.done {
+ m.mu.Unlock()
+ <-m.doneCh
+ return
+ }
+ wg := sync.WaitGroup{}
+ m.done = true
+ for child := range m.children {
+ child := child
+ wg.Add(1)
+ go func() {
+ child.Stop()
+ wg.Done()
+ }()
+ }
+ m.mu.Unlock()
+ wg.Wait()
+ close(m.doneCh)
+}
diff --git a/cli/internal/process/manager_test.go b/cli/internal/process/manager_test.go
new file mode 100644
index 0000000..fb40ffa
--- /dev/null
+++ b/cli/internal/process/manager_test.go
@@ -0,0 +1,94 @@
+package process
+
+import (
+ "errors"
+ "os/exec"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/hashicorp/go-gatedio"
+ "github.com/hashicorp/go-hclog"
+)
+
+func newManager() *Manager {
+ return NewManager(hclog.Default())
+}
+
+func TestExec_simple(t *testing.T) {
+ mgr := newManager()
+
+ out := gatedio.NewByteBuffer()
+ cmd := exec.Command("env")
+ cmd.Stdout = out
+
+ err := mgr.Exec(cmd)
+ if err != nil {
+ t.Errorf("expected %q to be nil", err)
+ }
+
+ output := out.String()
+ if output == "" {
+ t.Error("expected output from running 'env', got empty string")
+ }
+}
+
+func TestClose(t *testing.T) {
+ mgr := newManager()
+
+ wg := sync.WaitGroup{}
+ tasks := 4
+ errors := make([]error, tasks)
+ start := time.Now()
+ for i := 0; i < tasks; i++ {
+ wg.Add(1)
+ go func(index int) {
+ cmd := exec.Command("sleep", "0.5")
+ err := mgr.Exec(cmd)
+ if err != nil {
+ errors[index] = err
+ }
+ wg.Done()
+ }(i)
+ }
+ // let processes kick off
+ time.Sleep(50 * time.Millisecond)
+ mgr.Close()
+ end := time.Now()
+ wg.Wait()
+ duration := end.Sub(start)
+ if duration >= 500*time.Millisecond {
+ t.Errorf("expected to close, total time was %q", duration)
+ }
+ for _, err := range errors {
+ if err != ErrClosing {
+ t.Errorf("expected manager closing error, found %q", err)
+ }
+ }
+}
+
+func TestClose_alreadyClosed(t *testing.T) {
+ mgr := newManager()
+ mgr.Close()
+
+ // repeated closing does not error
+ mgr.Close()
+
+ err := mgr.Exec(exec.Command("sleep", "1"))
+ if err != ErrClosing {
+ t.Errorf("expected manager closing error, found %q", err)
+ }
+}
+
+func TestExitCode(t *testing.T) {
+ mgr := newManager()
+
+ err := mgr.Exec(exec.Command("ls", "doesnotexist"))
+ exitErr := &ChildExit{}
+ if !errors.As(err, &exitErr) {
+ t.Errorf("expected a ChildExit err, got %q", err)
+ }
+ if exitErr.ExitCode == 0 {
+ t.Error("expected non-zero exit code , got 0")
+ }
+}
diff --git a/cli/internal/process/sys_nix.go b/cli/internal/process/sys_nix.go
new file mode 100644
index 0000000..0e6c003
--- /dev/null
+++ b/cli/internal/process/sys_nix.go
@@ -0,0 +1,23 @@
+//go:build !windows
+// +build !windows
+
+package process
+
+/**
+ * Code in this file is based on the source code at
+ * https://github.com/hashicorp/consul-template/tree/3ea7d99ad8eff17897e0d63dac86d74770170bb8/child/sys_nix.go
+ */
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+func setSetpgid(cmd *exec.Cmd, value bool) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: value}
+}
+
+func processNotFoundErr(err error) bool {
+ // ESRCH == no such process, ie. already exited
+ return err == syscall.ESRCH
+}
diff --git a/cli/internal/process/sys_windows.go b/cli/internal/process/sys_windows.go
new file mode 100644
index 0000000..c626c22
--- /dev/null
+++ b/cli/internal/process/sys_windows.go
@@ -0,0 +1,17 @@
+//go:build windows
+// +build windows
+
+package process
+
+/**
+ * Code in this file is based on the source code at
+ * https://github.com/hashicorp/consul-template/tree/3ea7d99ad8eff17897e0d63dac86d74770170bb8/child/sys_windows.go
+ */
+
+import "os/exec"
+
+func setSetpgid(cmd *exec.Cmd, value bool) {}
+
+func processNotFoundErr(err error) bool {
+ return false
+}