Sybren A. Stüvel ced826581a Worker: make sure long lines are broken on character boundaries
When a command (like `blender` or `ffmpeg`) outputs lines that are longer
than our buffer, they are broken into buffer-sized chunks. Extra code has
been added to ensure those chunks consist of valid UTF-8 characters.
2022-11-22 17:31:47 +01:00

137 lines
3.8 KiB
Go

package cli_runner
// SPDX-License-Identifier: GPL-3.0-or-later
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"github.com/rs/zerolog"
)
// The buffer size used to read stdout/stderr output from subprocesses, in
// bytes. Effectively this determines the maximum line length that can be
// handled in one go. Lines that are longer will be broken up.
const StdoutBufferSize = 40 * 1024
// CLIRunner is a wrapper around exec.CommandContext() to allow mocking.
type CLIRunner struct {
}
func NewCLIRunner() *CLIRunner {
return &CLIRunner{}
}
func (cli *CLIRunner) CommandContext(ctx context.Context, name string, arg ...string) *exec.Cmd {
return exec.CommandContext(ctx, name, arg...)
}
// RunWithTextOutput runs a command and sends its output line-by-line to the
// lineChannel. Stdout and stderr are combined.
// Before returning. RunWithTextOutput() waits for the subprocess, to ensure it
// doesn't become defunct.
func (cli *CLIRunner) RunWithTextOutput(
ctx context.Context,
logger zerolog.Logger,
execCmd *exec.Cmd,
logChunker LogChunker,
lineChannel chan<- string,
) error {
outPipe, err := execCmd.StdoutPipe()
if err != nil {
return err
}
execCmd.Stderr = execCmd.Stdout // Redirect stderr to stdout.
if err := execCmd.Start(); err != nil {
logger.Error().Err(err).Msg("error starting CLI execution")
return err
}
blenderPID := execCmd.Process.Pid
logger = logger.With().Int("pid", blenderPID).Logger()
reader := bufio.NewReaderSize(outPipe, StdoutBufferSize)
// returnErr determines which error is returned to the caller. More important
// errors overwrite less important ones. This is done via a variable instead
// of simply returning, because the function must be run to completion in
// order to wait for processes (and not create defunct ones).
var returnErr error = nil
// If a line longer than our buffer is received, it will be trimmed to the
// bufffer length. This means that it may not end on a valid character
// boundary. Any leftover bytes are collected here, and prepended to the next
// line.
leftovers := []byte{}
readloop:
for {
lineBytes, isPrefix, readErr := reader.ReadLine()
switch {
case readErr == io.EOF:
break readloop
case readErr != nil:
logger.Error().Err(err).Msg("error reading stdout/err")
returnErr = readErr
break readloop
}
// Prepend any leftovers from the previous line to the received bytes.
if len(leftovers) > 0 {
lineBytes = append(leftovers, lineBytes...)
leftovers = []byte{}
}
// Make sure long lines are broken on character boundaries.
lineBytes, leftovers = splitOnCharacterBoundary(lineBytes)
line := string(lineBytes)
if isPrefix {
prefix := []rune(line)
if len(prefix) > 256 {
prefix = prefix[:256]
}
logger.Warn().
Str("line", fmt.Sprintf("%s...", string(prefix))).
Int("bytesRead", len(lineBytes)).
Msg("unexpectedly long line read, will be split up")
}
logger.Debug().Msg(line)
if lineChannel != nil {
lineChannel <- line
}
if err := logChunker.Append(ctx, fmt.Sprintf("pid=%d > %s", blenderPID, line)); err != nil {
returnErr = fmt.Errorf("appending log entry to log chunker: %w", err)
break readloop
}
}
if err := logChunker.Flush(ctx); err != nil {
// any readErr is less important, as these are likely caused by other
// issues, which will surface on the Wait() and Success() calls.
returnErr = fmt.Errorf("flushing log chunker: %w", err)
}
if err := execCmd.Wait(); err != nil {
logger.Error().
Int("exitCode", execCmd.ProcessState.ExitCode()).
Msg("command exited abnormally")
returnErr = fmt.Errorf("command exited abnormally with code %d", execCmd.ProcessState.ExitCode())
}
if returnErr != nil {
logger.Error().Err(err).
Int("exitCode", execCmd.ProcessState.ExitCode()).
Msg("command exited abnormally")
return returnErr
}
logger.Info().Msg("command exited succesfully")
return nil
}