diff --git a/pmuxlib/pmuxlib.go b/pmuxlib/pmuxlib.go index 6012b4d..b9d6f7e 100644 --- a/pmuxlib/pmuxlib.go +++ b/pmuxlib/pmuxlib.go @@ -13,7 +13,9 @@ type Config struct { Processes []ProcessConfig `yaml:"processes"` } -// Run runs the given configuration as if this was a real pmux process. +// Run runs the given configuration as if this was a real pmux process. It will +// block until the context is canceled and all child processes have been cleaned +// up. func Run(ctx context.Context, cfg Config) { stdoutLogger := newLogger(os.Stdout, logSepStdout, cfg.TimeFormat) diff --git a/pmuxlib/proc.go b/pmuxlib/proc.go index af8823b..30a77c8 100644 --- a/pmuxlib/proc.go +++ b/pmuxlib/proc.go @@ -10,6 +10,7 @@ import ( "os/exec" "strings" "sync" + "syscall" "time" ) @@ -66,8 +67,26 @@ func (cfg ProcessConfig) withDefaults() ProcessConfig { return cfg } +func sigProcessGroup(sysLogger Logger, proc *os.Process, sig syscall.Signal) { + sysLogger.Printf("sending %v signal", sig) + + // Because we use Setpgid when starting child processes, child processes + // will have the same PGID as their PID. To send a signal to all processes + // in a group, you send the signal to the negation of the PGID, which in + // this case is equivalent to -PID. + // + // POSIX is a fucking joke. + if err := syscall.Kill(-proc.Pid, sig); err != nil { + + panic(fmt.Errorf( + "failed to send %v signal to %d: %w", + sig, -proc.Pid, err, + )) + } +} + // RunProcessOnce runs the process described by the ProcessConfig (though it -// doesn't use all fields from the ProcessConfig). The process is killed if the +// doesn't use all fields from the ProcessConfig). The process is killed iff the // context is canceled. The exit status of the process is returned, or -1 if the // process was never started. // @@ -110,8 +129,16 @@ func RunProcessOnce( cmd := exec.Command(cfg.Cmd, cfg.Args...) cmd.Dir = cfg.Dir - cmd.Env = os.Environ() + cmd.SysProcAttr = &syscall.SysProcAttr{ + // Indicates that the child process should be a part of a separate + // process group than the parent, so that it does not receive signals + // that the parent receives. This is what ensures that context + // cancellation is the only way to interrupt the child processes. + Setpgid: true, + } + + cmd.Env = os.Environ() for k, v := range cfg.Env { cmd.Env = append(cmd.Env, k+"="+v) } @@ -138,18 +165,20 @@ func RunProcessOnce( stopCh := make(chan struct{}) go func(proc *os.Process) { + select { case <-ctx.Done(): + sigProcessGroup(sysLogger, proc, syscall.SIGINT) case <-stopCh: return } select { case <-time.After(cfg.SigKillWait): - sysLogger.Println("forcefully killing process") - _ = proc.Signal(os.Kill) + sigProcessGroup(sysLogger, proc, syscall.SIGKILL) case <-stopCh: } + }(cmd.Process) wg.Wait() @@ -166,12 +195,12 @@ func RunProcessOnce( return exitCode, nil } -// RunProcess is a process (configured by ProcessConfig) until the context is +// RunProcess runs a process (configured by ProcessConfig) until the context is // canceled, at which point the process is killed and RunProcess returns. // // The process will be restarted if it exits of its own accord. There will be a -// brief wait time between each restart, with an exponential backup mechanism so -// that the wait time increases upon repeated restarts. +// brief wait time between each restart, with an exponential backoff mechanism +// so that the wait time increases upon repeated restarts. // // The stdout and stderr of the process will be written to the corresponding // Loggers. Various runtime events will be written to the sysLogger.