diff --git a/helper/helper.go b/helper/helper.go index d9bcc7f..6f52922 100644 --- a/helper/helper.go +++ b/helper/helper.go @@ -2,3 +2,183 @@ Package helper runs external helpers and manages their status and args FDs. */ package helper + +import ( + "errors" + "io" + "os" + "os/exec" + "sync" +) + +var ( + ErrStatusFault = errors.New("generic status pipe fault") + ErrStatusRead = errors.New("unexpected status response") +) + +// Helper wraps *exec.Cmd and manages status and args fd. +// Args is always 3 and status if set is always 4. +type Helper struct { + lock sync.RWMutex + args io.WriterTo + + statP [2]*os.File + argsP [2]*os.File + + ready chan error + + // ExtraFiles specifies additional open files to be inherited by the + // new process. It does not include standard input, standard output, or + // standard error. If non-nil, entry i becomes file descriptor 5+i. + ExtraFiles []*os.File + + *exec.Cmd +} + +func (h *Helper) StartNotify(ready chan error) error { + h.lock.Lock() + defer h.lock.Unlock() + + // Check for doubled Start calls before we defer failure cleanup. If the prior + // call to Start succeeded, we don't want to spuriously close its pipes. + if h.Cmd.Process != nil { + return errors.New("exec: already started") + } + + // create pipes + if pr, pw, err := os.Pipe(); err != nil { + return err + } else { + h.argsP[0], h.argsP[1] = pr, pw + } + // create status pipes if ready signal is requested + if ready != nil { + if pr, pw, err := os.Pipe(); err != nil { + return err + } else { + h.statP[0], h.statP[1] = pr, pw + } + } + + // prepare extra files + el := len(h.ExtraFiles) + if ready != nil { + el += 2 + } else { + el++ + } + ef := make([]*os.File, 0, el) + ef = append(ef, h.argsP[0]) + if ready != nil { + ef = append(ef, h.statP[1]) + } + ef = append(ef, h.ExtraFiles...) + + // prepare and start process + h.Cmd.ExtraFiles = ef + if err := h.Cmd.Start(); err != nil { + return err + } + + statsP, argsP := h.statP[0], h.argsP[1] + + // write arguments and close args pipe + if _, err := h.args.WriteTo(argsP); err != nil { + if err1 := h.Cmd.Process.Kill(); err1 != nil { + panic(err1) + } + return err + } else { + if err = argsP.Close(); err != nil { + if err1 := h.Cmd.Process.Kill(); err1 != nil { + panic(err1) + } + return err + } + } + + if ready != nil { + h.ready = ready + + // monitor stat pipe + go func() { + n, err := statsP.Read(make([]byte, 1)) + switch n { + case -1: + if err1 := h.Cmd.Process.Kill(); err1 != nil { + panic(err1) + } + // ensure error is not nil + if err == nil { + err = ErrStatusFault + } + ready <- err + case 0: + // ensure error is not nil + if err == nil { + err = ErrStatusRead + } + ready <- err + case 1: + ready <- nil + default: + panic("unreachable") // unexpected read count + } + }() + } + + return nil +} + +func (h *Helper) Wait() error { + h.lock.RLock() + defer h.lock.RUnlock() + + if h.Cmd.Process == nil { + return errors.New("exec: not started") + } + if h.Cmd.ProcessState != nil { + return errors.New("exec: Wait was already called") + } + + // ensure pipe close + defer func() { + if err := h.argsP[0].Close(); err != nil && !errors.Is(err, os.ErrClosed) { + panic(err) + } + if err := h.argsP[1].Close(); err != nil && !errors.Is(err, os.ErrClosed) { + panic(err) + } + + if h.ready != nil { + if err := h.statP[0].Close(); err != nil && !errors.Is(err, os.ErrClosed) { + panic(err) + } + if err := h.statP[1].Close(); err != nil && !errors.Is(err, os.ErrClosed) { + panic(err) + } + } + }() + + return h.Cmd.Wait() +} + +func (h *Helper) Close() error { + if h.ready == nil { + panic("attempted to close helper with no status pipe") + } + + return h.statP[0].Close() +} + +func (h *Helper) Start() error { + return h.StartNotify(nil) +} + +func New(wt io.WriterTo, name string, arg ...string) *Helper { + if wt == nil { + panic("attempted to create helper with nil argument writer") + } + + return &Helper{args: wt, Cmd: exec.Command(name, arg...)} +}