Files
2026-09-08 07:25:56 +00:00

176 lines
4.3 KiB
Go

package process
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agentctl/types"
)
// PipedStartRequest describes a directly executed, manager-owned process whose
// stdin and stdout are consumed by another agentctl subsystem.
type PipedStartRequest struct {
SessionID string
Kind types.ProcessKind
ScriptName string
Command string
Args []string
WorkingDir string
Env map[string]string
StripEnv []string
PipeStderr bool
}
// PipedProcess exposes protocol streams while ProcessRunner remains the sole
// owner of waiting and process-tree teardown.
type PipedProcess struct {
ID string
Stdin io.WriteCloser
Stdout io.ReadCloser
Stderr io.ReadCloser
Done <-chan struct{}
process *commandProcess
}
// Wait blocks until the command and its process tree are reaped.
func (p *PipedProcess) Wait() error {
<-p.Done
p.process.mu.Lock()
waitErr := p.process.waitErr
reapErr := p.process.reapErr
p.process.mu.Unlock()
return errors.Join(waitErr, reapErr)
}
// StartPiped starts a directly executed process with caller-owned stdin/stdout.
// It is intentionally independent of request contexts so the runner owns its
// full process tree until Stop or StopAllAndWait reaps it.
func (r *ProcessRunner) StartPiped(req PipedStartRequest) (*PipedProcess, error) {
r.admission.RLock()
defer r.admission.RUnlock()
if err := r.validatePipedStart(req); err != nil {
return nil, err
}
id := uuid.New().String()
cmd := exec.Command(req.Command, req.Args...)
cmd.Dir = req.WorkingDir
cmd.Env = mergeEnvWithStrip(req.Env, req.StripEnv)
setManagedProcGroup(cmd)
streams, err := newPipedCommandStreams(cmd)
if err != nil {
return nil, err
}
defer streams.closeChildEnds()
proc := r.newPipedCommandProcess(id, req, cmd, streams.stdinWriter)
r.mu.Lock()
r.processes[id] = proc
r.mu.Unlock()
r.publishStatus(proc)
if err := r.startAndActivate(
proc, cmd, id, streams.stdoutReader, streams.stderrReader, false, !req.PipeStderr,
); err != nil {
return nil, err
}
piped := &PipedProcess{
ID: id,
Stdin: streams.stdinWriter,
Stdout: streams.stdoutReader,
Done: proc.done,
process: proc,
}
if req.PipeStderr {
piped.Stderr = streams.stderrReader
}
return piped, nil
}
func (r *ProcessRunner) validatePipedStart(req PipedStartRequest) error {
if r.stopping {
return ErrManagerStopping
}
if req.SessionID == "" {
return fmt.Errorf("session_id is required")
}
if req.Command == "" {
return fmt.Errorf("command is required")
}
return nil
}
type pipedCommandStreams struct {
stdinWriter *os.File
stdoutReader *os.File
stderrReader *os.File
childEnds []*os.File
}
func newPipedCommandStreams(cmd *exec.Cmd) (*pipedCommandStreams, error) {
childStdin, stdin, err := os.Pipe()
if err != nil {
return nil, fmt.Errorf("failed to attach stdin: %w", err)
}
stdout, childStdout, err := os.Pipe()
if err != nil {
closePipedFiles(childStdin, stdin)
return nil, fmt.Errorf("failed to attach stdout: %w", err)
}
stderr, childStderr, err := os.Pipe()
if err != nil {
closePipedFiles(childStdin, stdin, stdout, childStdout)
return nil, fmt.Errorf("failed to attach stderr: %w", err)
}
cmd.Stdin = childStdin
cmd.Stdout = childStdout
cmd.Stderr = childStderr
return &pipedCommandStreams{
stdinWriter: stdin, stdoutReader: stdout, stderrReader: stderr,
childEnds: []*os.File{childStdin, childStdout, childStderr},
}, nil
}
func (s *pipedCommandStreams) closeChildEnds() {
closePipedFiles(s.childEnds...)
}
func closePipedFiles(files ...*os.File) {
for _, file := range files {
_ = file.Close()
}
}
func (r *ProcessRunner) newPipedCommandProcess(
id string,
req PipedStartRequest,
cmd *exec.Cmd,
stdin io.WriteCloser,
) *commandProcess {
now := time.Now().UTC()
return &commandProcess{
info: ProcessInfo{
ID: id,
SessionID: req.SessionID,
Kind: req.Kind,
ScriptName: req.ScriptName,
Command: strings.Join(append([]string{req.Command}, req.Args...), " "),
WorkingDir: req.WorkingDir,
Status: types.ProcessStatusStarting,
StartedAt: now,
UpdatedAt: now,
},
cmd: cmd,
stdin: stdin,
buffer: newRingBuffer(r.bufferMaxBytes),
stopSignal: make(chan struct{}),
done: make(chan struct{}),
}
}