176 lines
4.3 KiB
Go
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{}),
|
|
}
|
|
}
|