package runtime import ( "context" "errors" "fmt" "os" "os/exec" "strconv" "strings" "sync" "syscall" "time" ) // StopGrace is the default grace period between SIGTERM and SIGKILL // for ProcessRuntime.Stop (10s, matching the systemd default TimeoutStopSec). const StopGrace = 10 * time.Second // ProcessRuntime implements Runtime for the "process" one_of using // os/exec. It is the in-process equivalent of the systemd unit the CLI // emits in production: the CLI emits a .service file and the peer's // systemd runs the process; ProcessRuntime starts the process directly // in the current Go process. It is intended for LOCAL testing and // hermetic CI — NOT for production (production uses the systemd emitter // + the peer's systemd, not this in-process path). // // It tracks started PIDs in an in-memory map; restarts of the CLI lose // that state (acceptable for the local-test use case). type ProcessRuntime struct { mu sync.Mutex pids map[string]int // alloc.ID -> PID procs map[int]*os.Process // PID -> process handle stopped map[string]bool // alloc.ID -> reported stopped after Stop } // NewProcessRuntime returns a ProcessRuntime. func NewProcessRuntime() *ProcessRuntime { return &ProcessRuntime{ pids: make(map[string]int), procs: make(map[int]*os.Process), stopped: make(map[string]bool), } } // Prepare is a no-op for the process runtime: the systemd unit is // emitted by the systemd emitter (internal/emitter), not by the runtime. func (p *ProcessRuntime) Prepare(ctx context.Context, alloc *Alloc) error { _ = ctx _ = alloc return nil } // Start execs the alloc's command and returns the PID. The process is // left running in the background; Stop terminates it. func (p *ProcessRuntime) Start(ctx context.Context, alloc *Alloc) (int, error) { cmdStr, err := commandFor(alloc) if err != nil { return 0, err } // Parse the command string into argv. A leading "exec" form // (shell-style) is NOT supported — the command must be a direct // argv[0] + args. Split on whitespace (simple, matches the existing // executor.go behaviour which takes Command + Args separately). parts := strings.Fields(cmdStr) if len(parts) == 0 { return 0, fmt.Errorf("process: empty command for alloc %s", alloc.ID) } // Use a detached context so the process survives the request // context cancellation (the request ends; the workload keeps // running until Stop). We apply our own timeout for Start only. startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(startCtx, parts[0], parts[1:]...) // Detach the child from the parent's process group so it survives. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Discard output for the runtime; the systemd unit captures logs // in production. For tests, callers that need output run their // own exec.Command. cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return 0, fmt.Errorf("process: start: %w", err) } pid := cmd.Process.Pid // Background-reap the process so it doesn't become a zombie; we // only need the PID for Stop/Status. When the process exits // naturally, mark the alloc as stopped. go func() { _ = cmd.Wait() p.mu.Lock() delete(p.procs, pid) // Only mark stopped if the alloc is still associated with // this PID (Stop may have already removed the mapping). if cur, ok := p.pids[alloc.ID]; ok && cur == pid { delete(p.pids, alloc.ID) p.stopped[alloc.ID] = true } p.mu.Unlock() }() p.mu.Lock() p.pids[alloc.ID] = pid p.procs[pid] = cmd.Process delete(p.stopped, alloc.ID) p.mu.Unlock() return pid, nil } // Stop sends SIGTERM, waits the grace period, then SIGKILL. func (p *ProcessRuntime) Stop(ctx context.Context, alloc *Alloc) error { p.mu.Lock() pid, ok := p.pids[alloc.ID] proc := p.procs[pid] p.mu.Unlock() if !ok || proc == nil { return nil // not running; idempotent } // SIGTERM the process group (negative PID). _ = syscall.Kill(-pid, syscall.SIGTERM) grace := StopGrace if dl, ok := ctx.Deadline(); ok { if remaining := time.Until(dl); remaining > 0 && remaining < grace { grace = remaining } } deadline := time.Now().Add(grace) for time.Now().Before(deadline) { if !p.alive(pid) { p.forget(alloc.ID, pid) return nil } select { case <-ctx.Done(): p.forget(alloc.ID, pid) return ctx.Err() case <-time.After(100 * time.Millisecond): } } // SIGKILL the group. _ = syscall.Kill(-pid, syscall.SIGKILL) p.forget(alloc.ID, pid) return nil } // Status reports the alloc's state by checking if the process is alive. func (p *ProcessRuntime) Status(ctx context.Context, alloc *Alloc) (State, error) { _ = ctx p.mu.Lock() pid, ok := p.pids[alloc.ID] wasStopped := p.stopped[alloc.ID] p.mu.Unlock() if !ok { if wasStopped { return StateStopped, nil } return StatePending, nil } if !p.alive(pid) { // Process exited but the reaper hasn't run yet; mark it // stopped and clean up. p.forget(alloc.ID, pid) return StateStopped, nil } return StateRunning, nil } // alive reports whether the process with the given PID is still running. func (p *ProcessRuntime) alive(pid int) bool { proc, err := os.FindProcess(pid) if err != nil { return false } if err := proc.Signal(syscall.Signal(0)); err != nil { // ESRCH means the process is gone. return false } return true } // forget removes the alloc/PID mapping and marks the alloc stopped. func (p *ProcessRuntime) forget(allocID string, pid int) { p.mu.Lock() delete(p.pids, allocID) delete(p.procs, pid) p.stopped[allocID] = true p.mu.Unlock() } // PID returns the recorded PID for alloc (for tests/inspection). func (p *ProcessRuntime) PID(allocID string) (int, error) { p.mu.Lock() defer p.mu.Unlock() pid, ok := p.pids[allocID] if !ok { return 0, errors.New("process: no pid for alloc " + strconv.Quote(allocID)) } return pid, nil }