go-process/process.go
Virgil 94b99bfd18 fix(process): align service contract with RFC
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-29 14:55:24 +00:00

221 lines
4.4 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 returns a snapshot of process state.
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 returns the captured output as a string.
func (p *ManagedProcess) Output() string {
p.mu.RLock()
defer p.mu.RUnlock()
if p.output == nil {
return ""
}
return p.output.String()
}
// OutputBytes returns the captured output as bytes.
func (p *ManagedProcess) OutputBytes() []byte {
p.mu.RLock()
defer p.mu.RUnlock()
if p.output == nil {
return nil
}
return p.output.Bytes()
}
// IsRunning returns true if the process is still executing.
func (p *ManagedProcess) IsRunning() bool {
select {
case <-p.done:
return false
default:
return true
}
}
// Wait blocks until the process exits.
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
}
// Done returns a channel that closes when the 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)
}
// SendInput writes to the 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
}
// CloseStdin closes the process stdin pipe.
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
}