Add piped child process support

This commit is contained in:
2026-10-09 01:17:58 +00:00
parent 41e58ef894
commit 618a7c9dd3
4 changed files with 267 additions and 10 deletions

View File

@@ -10,6 +10,7 @@ import (
"os/exec"
"sort"
"strings"
"sync"
)
// Invocation describes one child process. UnsetEnv removes inherited values
@@ -27,16 +28,11 @@ type Invocation struct {
// Run starts the command in the requested directory and waits for it to exit.
// Errors preserve the underlying exec error, including *exec.ExitError.
func Run(ctx context.Context, invocation Invocation) error {
if len(invocation.Args) == 0 || invocation.Args[0] == "" {
return errors.New("process invocation requires a command")
command, err := newCommand(ctx, invocation)
if err != nil {
return err
}
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
command.Dir = invocation.Dir
command.Stdin = invocation.Stdin
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
}
command.Stdout = invocation.Stdout
command.Stderr = invocation.Stderr
@@ -49,6 +45,102 @@ func Run(ctx context.Context, invocation Invocation) error {
return nil
}
// PipedProcess owns the command started by StartPiped. Callers close Stdin,
// read Stdout to EOF, and call Wait once after a normal exchange. On an abort,
// close active pipes, call Cancel, and then Wait to reap the child.
type PipedProcess struct {
command *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
parent context.Context
cancel context.CancelFunc
waitOnce sync.Once
waitErr error
}
// Stdin returns the child process input pipe. The caller owns its closure.
func (child *PipedProcess) Stdin() io.WriteCloser {
return child.stdin
}
// Stdout returns the child process output pipe. The caller must consume it
// before Wait on a normal exit and owns its closure on an abort.
func (child *PipedProcess) Stdout() io.ReadCloser {
return child.stdout
}
// Cancel interrupts the child without cancelling its parent context.
func (child *PipedProcess) Cancel() {
child.cancel()
}
// Wait waits for and reaps the child. It preserves the parent's cancellation
// cause and otherwise returns the command's wrapped exit error.
func (child *PipedProcess) Wait() error {
child.waitOnce.Do(func() {
err := child.command.Wait()
child.cancel()
if err == nil {
return
}
if contextErr := child.parent.Err(); contextErr != nil {
child.waitErr = fmt.Errorf("run %q: %w", child.command.Args[0], contextErr)
return
}
child.waitErr = fmt.Errorf("run %q: %w", child.command.Args[0], err)
})
return child.waitErr
}
// StartPiped starts a child with stdin and stdout connected to pipes. Command
// settings come from invocation; its Stdin and Stdout fields are ignored, while
// Stderr remains routed as requested.
func StartPiped(ctx context.Context, invocation Invocation) (*PipedProcess, error) {
childContext, cancel := context.WithCancel(ctx)
command, err := newCommand(childContext, invocation)
if err != nil {
cancel()
return nil, err
}
command.Stderr = invocation.Stderr
stdin, err := command.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("pipe stdin for %q: %w", invocation.Args[0], err)
}
stdout, err := command.StdoutPipe()
if err != nil {
_ = stdin.Close()
cancel()
return nil, fmt.Errorf("pipe stdout for %q: %w", invocation.Args[0], err)
}
if err := command.Start(); err != nil {
_ = stdin.Close()
_ = stdout.Close()
cancel()
return nil, fmt.Errorf("start %q: %w", invocation.Args[0], err)
}
return &PipedProcess{
command: command,
stdin: stdin,
stdout: stdout,
parent: ctx,
cancel: cancel,
}, nil
}
func newCommand(ctx context.Context, invocation Invocation) (*exec.Cmd, error) {
if len(invocation.Args) == 0 || invocation.Args[0] == "" {
return nil, errors.New("process invocation requires a command")
}
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
command.Dir = invocation.Dir
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
}
return command, nil
}
func environmentEntries(overlay map[string]string, unset []string) []string {
environment := make(map[string]string, len(os.Environ())+len(overlay))
for _, entry := range os.Environ() {