Pure-Go (no cgo) PID-1 process supervision — a subreaper that collects every orphaned child the kernel reparents to a process-1 init, and a supervisor that starts a declared set of processes through a pluggable runtime, correlates their exits to the reaper, and restarts them per policy.
Extracted from a microVM init and generalized: nothing here is tied to any
particular workload model. The supervisor drives an abstract Runtime
(an OCI runtime like crun/runc, a raw fork+exec launcher, or a test fake)
over a small Spec of Procs.
Two responsibilities, split on purpose. The reaper must run unconditionally — PID 1 is the kernel's reaper of last resort for the whole orphan tree, including helpers a runtime forks that we never tracked. The supervisor only cares about the PIDs it launched, correlating reaper events back to the owning process to apply its restart policy.
- Subreaper —
signal.Notify(SIGCHLD)+ non-blockingWait4(-1, WNOHANG)drains every zombie, publishing a portableReaped{PID, ExitStatus, Signaled, Signal}event per collection (nosyscalltypes leak into the API). - Supervisor — start each
Procvia theRuntime, index its pid, and on exit applyRestartNever/RestartOnFailure/RestartAlways. - Clean shutdown —
StopsendsSIGTERM, waits a grace period, thenSIGKILLs survivors, disabling restart for the rest of its life. - Pluggable runtime — implement the six-method
Runtimeinterface and the supervisor drives anything: OCI runtimes, plain processes, fakes. - Portable — the Linux-only subreaper sits behind a build tag; on
macOS/Windows
Reaper.Runis a no-op stub sogo build/go vet/go teststay green everywhere. The supervisor logic itself is platform-independent.
CGO-free, dependency-free, 100% test coverage (measured on the Linux lane
where the reaper is built), gofmt + go vet clean, race-tested, and green
across the six 64-bit Go targets (amd64, arm64, riscv64, loong64, ppc64le, s390x).
go get github.com/go-proc/supervisorpackage main
import (
"context"
"log/slog"
"os"
"time"
"github.com/go-proc/supervisor"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
// 1. The subreaper — run it for the life of the process (PID 1).
reaper := supervisor.NewReaper(log)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go reaper.Run(ctx)
// 2. The supervisor — drive a Runtime over a Spec of Procs.
spec := &supervisor.Spec{Procs: []supervisor.Proc{
{ID: "web", Target: "/bundles/web", Restart: supervisor.RestartAlways},
{ID: "job", Target: "/bundles/job", Restart: supervisor.RestartOnFailure},
}}
sup := supervisor.New(log, myRuntime, reaper, spec)
go func() {
time.Sleep(30 * time.Second)
sup.Stop(ctx, 5*time.Second) // SIGTERM, grace, then SIGKILL
}()
if err := sup.Run(ctx); err != nil { // blocks until all done or ctx cancel
log.Error("supervisor exited", "err", err)
}
}myRuntime is any value implementing the Runtime interface below.
type RestartPolicy string
const (
RestartNever RestartPolicy = "" // never restart (zero value)
RestartOnFailure RestartPolicy = "on-failure" // restart on non-zero exit or signal
RestartAlways RestartPolicy = "always" // restart on any exit
)
type Proc struct { ID, Target string; Restart RestartPolicy }
type Spec struct { Procs []Proc }
type Stdio struct { Stdin, Stdout, Stderr *os.File }
// Runtime is the process backend the supervisor drives.
type Runtime interface {
Name() string
Create(ctx context.Context, id, target string, stdio Stdio) error
Start(ctx context.Context, id string) error
State(ctx context.Context, id string) (status string, pid int, err error)
Kill(ctx context.Context, id, signal string) error
Delete(ctx context.Context, id string) error
}
// Reaper: PID-1 subreaper.
type Reaped struct { PID, ExitStatus int; Signaled bool; Signal int }
func NewReaper(log *slog.Logger) *Reaper
func (r *Reaper) Run(ctx context.Context) // Linux: SIGCHLD+Wait4 ; else no-op stub
func (r *Reaper) Events() <-chan Reaped
// Supervisor.
func New(log *slog.Logger, rt Runtime, reaper *Reaper, spec *Spec) *Supervisor
func (s *Supervisor) Run(ctx context.Context) error
func (s *Supervisor) Stop(ctx context.Context, grace time.Duration)The supervisor is driven end-to-end against a programmable fake Runtime plus
synthetic reaper events, covering every restart policy and shutdown branch on
all platforms. The Linux subreaper's drain loop is exercised through a
wait4 seam (deterministic exit/signal/error/buffer-full cases) plus a real
SIGCHLD delivery test — the latter skips itself under qemu-user.
COVERPKG=$(go list ./... | paste -sd, -)
go test -race -coverpkg="$COVERPKG" -coverprofile=cover.out ./...
go tool cover -func=cover.out | tail -1 # 100.0%Coverage is gated at 100% on the native Linux lane, where the reaper is both compiled and run.
BSD-3-Clause — see LICENSE. Copyright the go-proc/supervisor authors.
