go-process/process.go
Claude 861c88b8e8
fix(ax): AX compliance sweep — banned imports, naming, test coverage
- pkg/api/provider.go: remove banned os/syscall imports; delegate to
  new process.KillPID and process.IsPIDAlive exported helpers
- service.go: rename `sr` → `startResult`; add KillPID/IsPIDAlive exports
- runner.go: rename `aggResult` → `aggregate` in all three RunXxx methods;
  add usage-example comments on all exported functions
- process.go: replace prose doc-comments with usage-example comments
- buffer.go, registry.go, health.go: replace prose comments with examples
- buffer_test.go: rename TestRingBuffer_Basics_Good → TestBuffer_{Write,String,Reset}_{Good,Bad,Ugly}
- All test files: add missing _Bad and _Ugly variants for all functions
  (daemon, health, pidfile, registry, runner, process, program, exec, pkg/api)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 08:15:47 +01:00

222 lines
4.5 KiB
Go

package process
import (
"context"
"strconv"
"sync"
"syscall"
"time"
"dappco.re/go/core"
)
type processStdin interface {
Write(p []byte) (n int, err error)
Close() error
}
// ManagedProcess represents a tracked external process started by the service.
type ManagedProcess struct {
ID string
PID int
Command string
Args []string
Dir string
Env []string
StartedAt time.Time
Status Status
ExitCode int
Duration time.Duration
cmd *execCmd
ctx context.Context
cancel context.CancelFunc
output *RingBuffer
stdin processStdin
done chan struct{}
mu sync.RWMutex
gracePeriod time.Duration
killGroup bool
lastSignal string
}
// Process is kept as a compatibility alias for ManagedProcess.
type Process = ManagedProcess
// info := proc.Info()
// fmt.Println(info.Status, info.ExitCode)
func (p *ManagedProcess) Info() ProcessInfo {
p.mu.RLock()
defer p.mu.RUnlock()
return ProcessInfo{
ID: p.ID,
Command: p.Command,
Args: append([]string(nil), p.Args...),
Dir: p.Dir,
StartedAt: p.StartedAt,
Running: p.Status == StatusRunning,
Status: p.Status,
ExitCode: p.ExitCode,
Duration: p.Duration,
PID: p.PID,
}
}
// output := proc.Output() // returns combined stdout+stderr
func (p *ManagedProcess) Output() string {
p.mu.RLock()
defer p.mu.RUnlock()
if p.output == nil {
return ""
}
return p.output.String()
}
// data := proc.OutputBytes() // nil if capture is disabled
func (p *ManagedProcess) OutputBytes() []byte {
p.mu.RLock()
defer p.mu.RUnlock()
if p.output == nil {
return nil
}
return p.output.Bytes()
}
// if proc.IsRunning() { log.Println("still running") }
func (p *ManagedProcess) IsRunning() bool {
select {
case <-p.done:
return false
default:
return true
}
}
// if err := proc.Wait(); err != nil { /* non-zero exit or killed */ }
func (p *ManagedProcess) Wait() error {
<-p.done
p.mu.RLock()
defer p.mu.RUnlock()
if p.Status == StatusFailed {
return core.E("process.wait", core.Concat("process failed to start: ", p.ID), nil)
}
if p.Status == StatusKilled {
return core.E("process.wait", core.Concat("process was killed: ", p.ID), nil)
}
if p.ExitCode != 0 {
return core.E("process.wait", core.Concat("process exited with code ", strconv.Itoa(p.ExitCode)), nil)
}
return nil
}
// <-proc.Done() // blocks until process exits
func (p *ManagedProcess) Done() <-chan struct{} {
return p.done
}
// Kill forcefully terminates the process.
// If KillGroup is set, kills the entire process group.
func (p *ManagedProcess) Kill() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.Status != StatusRunning {
return nil
}
if p.cmd == nil || p.cmd.Process == nil {
return nil
}
p.lastSignal = "SIGKILL"
if p.killGroup {
// Kill entire process group (negative PID)
return syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
}
return p.cmd.Process.Kill()
}
// Shutdown gracefully stops the process: SIGTERM, then SIGKILL after grace period.
// If GracePeriod was not set (zero), falls back to immediate Kill().
// If KillGroup is set, signals are sent to the entire process group.
func (p *ManagedProcess) Shutdown() error {
p.mu.RLock()
grace := p.gracePeriod
p.mu.RUnlock()
if grace <= 0 {
return p.Kill()
}
// Send SIGTERM
if err := p.terminate(); err != nil {
return p.Kill()
}
// Wait for exit or grace period
select {
case <-p.done:
return nil
case <-time.After(grace):
return p.Kill()
}
}
// terminate sends SIGTERM to the process (or process group if KillGroup is set).
func (p *ManagedProcess) terminate() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.Status != StatusRunning {
return nil
}
if p.cmd == nil || p.cmd.Process == nil {
return nil
}
pid := p.cmd.Process.Pid
if p.killGroup {
pid = -pid
}
p.lastSignal = "SIGTERM"
return syscall.Kill(pid, syscall.SIGTERM)
}
// _ = proc.SendInput("yes\n") // write to process stdin
func (p *ManagedProcess) SendInput(input string) error {
p.mu.RLock()
defer p.mu.RUnlock()
if p.Status != StatusRunning {
return ErrProcessNotRunning
}
if p.stdin == nil {
return ErrStdinNotAvailable
}
_, err := p.stdin.Write([]byte(input))
return err
}
// _ = proc.CloseStdin() // signals EOF to the subprocess
func (p *ManagedProcess) CloseStdin() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.stdin == nil {
return nil
}
err := p.stdin.Close()
p.stdin = nil
return err
}
func (p *ManagedProcess) requestedSignal() string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.lastSignal
}