// Package process runs synchronous tools with owned child-process lifetimes. package process import ( "bytes" "context" "errors" "os/exec" "sync" "time" ) // Run joins the command and its output copies, and terminates remaining children // on cancellation or exit. It is for synchronous tools, not detached app launches. func Run(ctx context.Context, cmd *exec.Cmd) error { if err := ctx.Err(); err != nil { return err } scope, err := newScope(cmd) if err != nil { return err } defer scope.close() var mu sync.Mutex started, cancelled := false, false kill := func() error { mu.Lock() defer mu.Unlock() cancelled = true if started { return scope.kill() } return nil } // Retain a CommandContext caller's deadline, replacing only its direct-child // cancellation. Plain exec.Command calls use the owner-context watcher below. if cmd.Cancel != nil { cmd.Cancel = kill } if cmd.WaitDelay == 0 || cmd.WaitDelay > 2*time.Second { cmd.WaitDelay = 2 * time.Second } if err := cmd.Start(); err != nil { return err } mu.Lock() started = true err = scope.attach() if err == nil && !cancelled && ctx.Err() == nil { err = scope.resume() } if err != nil || cancelled || ctx.Err() != nil { _ = scope.kill() } mu.Unlock() if err != nil { _ = cmd.Process.Kill() // attach failures must not leave a suspended child _ = cmd.Wait() return err } done, joined := make(chan struct{}), make(chan struct{}) go func() { defer close(joined) select { case <-ctx.Done(): _ = kill() case <-done: } }() waitErr := cmd.Wait() close(done) <-joined mu.Lock() cleanupErr := scope.kill() mu.Unlock() return errors.Join(waitErr, ctx.Err(), cleanupErr) } type boundedBuffer struct { data bytes.Buffer truncated bool } func (b *boundedBuffer) Write(p []byte) (int, error) { const limit = 1024 * 1024 n := len(p) keep := min(n, limit-b.data.Len()) _, _ = b.data.Write(p[:keep]) b.truncated = b.truncated || keep < n return n, nil // keep draining so a noisy tool cannot block on its pipe } // Output captures bounded stdout; stderr is drained into the same bounded budget // independently. Parsing callers receive an error if either stream overflowed. var ErrOutputLimit = errors.New("tool output exceeded 1 MiB per stream") // Output captures bounded stdout and preserves bounded stderr on ExitError. // Parsing callers receive ErrOutputLimit when either stream overflowed. func Output(ctx context.Context, cmd *exec.Cmd) ([]byte, error) { var stdout, stderr boundedBuffer cmd.Stdout, cmd.Stderr = &stdout, &stderr err := Run(ctx, cmd) var exitErr *exec.ExitError if errors.As(err, &exitErr) { exitErr.Stderr = stderr.data.Bytes() } if stdout.truncated || stderr.truncated { err = errors.Join(err, ErrOutputLimit) } return stdout.data.Bytes(), err }