package gotenberg import ( "bufio" "context" "errors" "fmt" "io" "os/exec" "strings" "syscall" "go.uber.org/zap" ) // Cmd wraps an exec.Cmd. type Cmd struct { ctx context.Context logger *zap.Logger process *exec.Cmd } // Command creates a Cmd without a context. It configures the internal // exec.Cmd of Cmd so that we may kill its unix process and all its children // without creating orphans. // // See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. func Command(logger *zap.Logger, binPath string, args ...string) Cmd { cmd := exec.Command(binPath, args...) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} return Cmd{ ctx: nil, logger: logger.Named(strings.ReplaceAll(binPath, "/", "")), process: cmd, } } // CommandContext creates a Cmd with a context. It configures the internal // exec.Cmd of Cmd so that we may kill its unix process and all its children // without creating orphans. // // See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, args ...string) (Cmd, error) { if ctx == nil { return Cmd{}, errors.New("nil context") } cmd := exec.CommandContext(ctx, binPath, args...) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} return Cmd{ ctx: ctx, logger: logger.Named(strings.ReplaceAll(binPath, "/", "")), process: cmd, }, nil } // Start starts the command but does not wait for its completion. func (cmd Cmd) Start() error { err := cmd.pipeOutput() if err != nil { return fmt.Errorf("pipe unix process output: %w", err) } cmd.logger.Debug(fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " "))) err = cmd.process.Start() if err != nil { return fmt.Errorf("start unix process: %w", err) } return nil } // Wait waits for the command to complete. It should be called when using the // Start method, so that the command does not leak zombies. func (cmd Cmd) Wait() error { err := cmd.process.Wait() if err != nil { return fmt.Errorf("wait for unix process: %w", err) } return nil } // Exec executes the command and wait for its completion or until the context // is done. In any case, it kills the unix process and all its children. func (cmd Cmd) Exec() (int, error) { if cmd.ctx == nil { return 10, errors.New("nil context") } err := cmd.Start() if err != nil { if cmd.process.ProcessState == nil { return 131, fmt.Errorf("start command: %w", err) } return cmd.process.ProcessState.ExitCode(), fmt.Errorf("start command: %w", err) } errChan := make(chan error, 1) go func() { errChan <- cmd.Wait() }() select { case err = <-errChan: errProc := cmd.Kill() if errProc != nil { cmd.logger.Error(errProc.Error()) } if err == nil { return 0, nil } if cmd.process.ProcessState == nil { return 131, fmt.Errorf("unix process error: %w", err) } return cmd.process.ProcessState.ExitCode(), fmt.Errorf("unix process error: %w", err) case <-cmd.ctx.Done(): errProc := cmd.Kill() if errProc != nil { cmd.logger.Error(errProc.Error()) } return 62, fmt.Errorf("context done: %w", cmd.ctx.Err()) } } // pipeOutput creates logs entries according to the process stdout and stderr. // It does nothing if the logging level is not debug. func (cmd Cmd) pipeOutput() error { checkedEntry := cmd.logger.Check(zap.DebugLevel, "check for debug level before piping unix process output") if checkedEntry == nil { return nil } stdout, err := cmd.process.StdoutPipe() if err != nil { return fmt.Errorf("pipe unix process stdout: %w", err) } stderr, err := cmd.process.StderrPipe() if err != nil { return fmt.Errorf("unix process sdterr: %w", err) } // logCommandOutput creates logs entries according to a reader // (either stdout or stderr). logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) { r := bufio.NewReader(reader) defer reader.Close() for { line, _, err := r.ReadLine() if err != nil { if err != io.EOF && !strings.Contains(err.Error(), "file already closed") { logger.Error(fmt.Sprintf("pipe unix process output error: %s", err)) } break } if len(line) != 0 { logger.Debug(string(line)) } } } go logCommandOutput(cmd.logger.Named("stdout"), stdout) go logCommandOutput(cmd.logger.Named("stderr"), stderr) return nil } // Kill kills the unix process and all its children without creating orphans. // // See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. func (cmd Cmd) Kill() error { if cmd.process == nil { // We cannot use the logger here, because for whatever reason using it // result to a panic. // cmd.logger.Debug("no process, skip killing") return nil } err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL) if err == nil { cmd.logger.Debug("unix process killed") return nil } // If the process does not exist anymore, the error is irrelevant. if strings.Contains(err.Error(), "no such process") { cmd.logger.Debug("unix process already killed") return nil } return fmt.Errorf("kill unix process: %w", err) }