proctree

package module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 31, 2026 License: MIT Imports: 14 Imported by: 0

README

proctree

Cross-platform Go helpers for running shell and exec commands in an isolated process group or tree, streaming stdout/stderr, killing the full child tree on context cancellation or timeout, and inspecting process state.

Install

go get github.com/brandonkramer/proctree@v0.1.2

Documentation and versions: pkg.go.dev/github.com/brandonkramer/proctree

For the latest release, omit the version or use @latest:

go get github.com/brandonkramer/proctree@latest

Quick start

Shell command
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

var buf bytes.Buffer
spec := &proctree.Spec{Shell: "make test"}
opts := &proctree.Options{
    OnStdout: func(line string) { fmt.Println("out:", line) },
    Stdout:   &buf,
}
res, err := proctree.Run(ctx, spec, opts)
if err != nil {
    // context.Canceled, context.DeadlineExceeded, or non-zero exit
}
fmt.Println("exit", res.ExitCode, "pid", res.PID, "started", res.StartedAt)
Exec (argv) — preferred when possible
res, err := proctree.Run(ctx, &proctree.Spec{
    Path: "/usr/bin/git",
    Args: []string{"status", "--short"},
    Dir:  "/path/to/repo",
    Env:  []string{"GIT_TERMINAL_PROMPT=0"},
}, &proctree.Options{
    Stdin: strings.NewReader(""),
})
Recovery reconcile
res := proctree.ReconcilePID(pid, &proctree.Ownership{
    Spec:      *spec,
    StartedAt: startedAt,
})
switch res.Outcome {
case proctree.ReconcileKilled:
    // verified and terminated
case proctree.ReconcileUnverified:
    // alive but not ours — left running (fail closed)
case proctree.ReconcileNotRunning:
    // already gone
}
Tee stdout/stderr to logs + callbacks
opts := proctree.TeeOptions(stdoutFile, stderrFile,
    func(line string) { emit("stdout", line) },
    func(line string) { emit("stderr", line) },
)
proctree.Run(ctx, spec, opts)
Ownership verification (PID reuse safe)
own := &proctree.Ownership{
    Spec:      *spec,
    StartedAt: res.StartedAt,
}
if proctree.VerifyOwnership(pid, own) {
    _ = proctree.KillTreeByPID(pid)
}

Custom matcher for processes that do not match Spec shape:

own := &proctree.Ownership{
    StartedAt: res.StartedAt,
    Match: func(parts []string) bool {
        return len(parts) > 0 && strings.Contains(parts[0], "my-worker")
    },
}
proctree.VerifyOwnershipOpts(pid, own, &proctree.VerifyOptions{
    SkipProcessGroup: true,
})

VerifyOwnership checks cmdline match, Unix process-group leader (when applicable), and optional create-time window. Fails closed when uncertain.

Introspection and recovery
info, err := proctree.Inspect(pid)
// info.Cmdline, info.CreateTime, info.MemoryRSS, info.Status, info.OpenFiles (linux)

kids, _ := proctree.Children(pid)
tree, _ := proctree.InspectTree(pid) // pid + descendants
Low-level helpers
cmd := proctree.NewCommand(&proctree.Spec{Shell: "sleep 300"})
cmd.Start()
proctree.KillTree(cmd)

proctree.KillTreeByPID(pid)
proctree.Alive(pid)
proctree.VerifyOwned(pid, spec)

Platform behavior

Platform Process isolation Tree kill Alive Inspect sources Ownership verify
Linux Setpgid SIGKILL to -pid /proc stat (skip zombies) /proc cmdline + pgid + create time
macOS Setpgid SIGKILL to -pid and pid sysctl stat (skip zombies) sysctl (KinfoProc, procargs2) cmdline + pgid + create time
Windows process group + Job Object TerminateJobObject (fallback: descendant walk) OpenProcess Toolhelp + NT APIs cmdline + create time
Windows notes
  • Run assigns each child to a kill-on-close Job Object when the OS allows it.
  • Inspect uses NtQueryInformationProcess, GetProcessTimes, and GetProcessMemoryInfo (no wmic).
Unix zombie processes

Alive consults /proc on Linux and sysctl process state on macOS so zombies count as not running. Use VerifyOwnership or ReconcilePID before killing stale pids.

API surface

Execution

  • Run(ctx, spec, opts) — context-first execution with streaming, optional stdin/writer sinks, tree kill on cancel/timeout
  • NewCommand(spec) — build a configured *exec.Cmd without starting
  • KillTree(cmd) / KillTreeByPID(pid) — terminate process trees
  • Alive(pid) — liveness probe

Recovery

  • ReconcilePID(pid, own) / ReconcilePIDOpts(pid, own, opts) — verify-and-kill for daemon recovery
  • WaitNotAlive(pid, timeout) — poll until process exits

Streaming helpers

  • TeeLine(w, fn) / TeeOptions(stdoutW, stderrW, onStdout, onStderr) — mirror lines to writers and callbacks

Verification

  • VerifyOwned(pid, spec) — cmdline + platform checks
  • VerifyOwnership(pid, own) — adds create-time window and optional CmdlineMatcher
  • VerifyOwnershipOpts(pid, own, opts) — tune timing, matcher, and SkipProcessGroup

Introspection

  • Inspect(pid) — point-in-time ProcessInfo snapshot
  • InspectTree(root) — snapshots for root + descendants
  • Children(pid) / Descendants(root) — process tree discovery
  • Cmdline(pid) / CreateTime(pid) — convenience accessors

Non-goals

proctree targets supervised process trees, not general command ergonomics. Out of scope for now:

  • PTY / TTY allocation — use a dedicated PTY library or run interactively outside proctree
  • Shell pipelines (cmd | cmd) — compose with os/exec pipes or a pipeline helper; proctree runs one root command tree
  • Built-in retry/backoff — callers own policy on top of Run
  • Log rotation / persistence — use Options.Stdout/Stderr writers or callbacks
  • Container/cgroup isolation — run inside your orchestrator; proctree supervises the root pid you give it

Development

Run the same checks as CI before pushing:

./scripts/check.sh

Git hooks via lefthook (once per clone):

./scripts/install-hooks.sh

git push then runs ./scripts/check.sh automatically. Skip with LEFTHOOK=0 git push.

scripts/check.sh runs go test -race, a Linux cross-compile check, and golangci-lint installed with your local Go toolchain (must be >= go.mod). CI pins GOLANGCI_LINT_VERSION in .github/workflows/test.yml.

Releases

Tagged semver releases are published to pkg.go.dev. See GitHub releases for notes.

License

MIT

Documentation

Overview

Package proctree runs shell and exec commands in an isolated process group or tree, streams stdout/stderr, and kills the full child tree on context cancellation or timeout.

Introspection helpers (Inspect, Children, Descendants) read OS process tables directly. See README "Non-goals" for features intentionally out of scope (PTY, pipelines).

Platform notes:

  • Linux: Setpgid isolation; KillTreeByPID sends SIGKILL to -pid; inspect via /proc.
  • macOS: Setpgid isolation; KillTreeByPID signals the group and leader pid; inspect via sysctl (KinfoProc, procargs2); Alive skips zombies.
  • Windows: new process group plus kill-on-close Job Object on Run; tree kill via TerminateJobObject with descendant-walk fallback; inspect via Toolhelp and NT APIs.
  • VerifyOwnership and ReconcilePID fail closed when cmdline or create-time cannot be confirmed.

Index

Constants

This section is empty.

Variables

View Source
var ErrProcessNotFound = errors.New("proctree: process not found")

ErrProcessNotFound indicates pid does not refer to a live process we can read.

Functions

func Alive

func Alive(pid int) bool

Alive reports whether pid refers to a running (non-zombie) process.

func Children

func Children(pid int) ([]int, error)

Children returns direct child pids of pid.

func Cmdline

func Cmdline(pid int) ([]string, error)

Cmdline returns argv for pid when available.

func CreateTime

func CreateTime(pid int) (time.Time, error)

CreateTime returns the process start time when available.

func Descendants

func Descendants(root int) ([]int, error)

Descendants returns pid and all descendant pids breadth-first.

func KillTree

func KillTree(cmd *exec.Cmd)

KillTree terminates the command process tree.

func KillTreeByPID

func KillTreeByPID(pid int) error

KillTreeByPID sends SIGKILL to the process group rooted at pid.

func NewCommand

func NewCommand(spec *Spec) *exec.Cmd

NewCommand builds a not-yet-started exec.Cmd with an isolated process group.

func NewCommandContext

func NewCommandContext(ctx context.Context, spec *Spec) *exec.Cmd

NewCommandContext is like NewCommand but binds the command to ctx.

func Start

func Start(cmd *exec.Cmd) error

Start starts cmd. On Windows, assigns a kill-on-close job object when possible so later tree kills can use TerminateJobObject.

func TeeLine

func TeeLine(w io.Writer, fn func(line string)) func(string)

TeeLine returns a line handler that invokes fn and writes line plus newline to w.

func VerifyOwned

func VerifyOwned(pid int, spec *Spec) bool

VerifyOwned reports whether pid still refers to the process started for spec. Returns false when ownership cannot be confirmed (fail closed).

func VerifyOwnership

func VerifyOwnership(pid int, own *Ownership) bool

VerifyOwnership reports whether pid still matches own using cmdline, optional create-time window, and platform group checks. Fails closed when uncertain.

func VerifyOwnershipOpts

func VerifyOwnershipOpts(pid int, own *Ownership, opts *VerifyOptions) bool

VerifyOwnershipOpts is VerifyOwnership with explicit options.

func WaitNotAlive

func WaitNotAlive(pid int, timeout time.Duration) bool

WaitNotAlive blocks until pid is no longer running or timeout elapses. Returns true when the process is no longer alive.

Types

type CmdlineMatcher

type CmdlineMatcher func(parts []string) bool

CmdlineMatcher returns whether argv parts belong to the supervised process.

type Options

type Options struct {
	OnStdout func(line string)
	OnStderr func(line string)
	// OnLine receives every stdout/stderr line when set.
	OnLine func(stream Stream, line string)
	// OnStart is invoked with the child pid immediately after Start succeeds.
	OnStart func(pid int)
	// Stdin is attached to the child process stdin when non-nil.
	Stdin io.Reader
	// Stdout receives each stdout line (with newline) when non-nil.
	Stdout io.Writer
	// Stderr receives each stderr line (with newline) when non-nil.
	Stderr io.Writer
}

Options configure streaming and lifecycle hooks for Run.

func TeeOptions

func TeeOptions(stdoutW, stderrW io.Writer, onStdout, onStderr func(line string)) *Options

TeeOptions returns Options that tee stdout/stderr to writers and callbacks. Either writer or callback may be nil.

type Ownership

type Ownership struct {
	Spec      Spec
	StartedAt time.Time
	// Match, when set, overrides Spec-based cmdline matching.
	Match CmdlineMatcher
}

Ownership describes a supervised process for conservative verification.

type ProcessInfo

type ProcessInfo struct {
	PID        int
	PPID       int
	PGID       int
	Name       string
	Status     string
	Cmdline    []string
	CreateTime time.Time
	MemoryRSS  uint64
	OpenFiles  int
}

ProcessInfo is a point-in-time snapshot of one process.

func Inspect

func Inspect(pid int) (ProcessInfo, error)

Inspect returns a snapshot of pid. Fails when the process is gone or unreadable.

func InspectTree

func InspectTree(root int) ([]ProcessInfo, error)

InspectTree returns snapshots for pid and all descendants.

type ReconcileOutcome

type ReconcileOutcome int

ReconcileOutcome describes the result of ReconcilePID.

const (
	// ReconcileNotRunning indicates pid is absent or not a live supervised process.
	ReconcileNotRunning ReconcileOutcome = iota
	// ReconcileKilled indicates pid matched own and was terminated.
	ReconcileKilled
	// ReconcileUnverified indicates pid is alive but ownership could not be confirmed.
	// The process is left running (fail closed).
	ReconcileUnverified
)

type ReconcileResult

type ReconcileResult struct {
	Outcome ReconcileOutcome
}

ReconcileResult is the outcome of a recovery reconcile attempt.

func ReconcilePID

func ReconcilePID(pid int, own *Ownership) ReconcileResult

ReconcilePID verifies ownership of pid and kills the process tree when confirmed. When pid is not alive, returns ReconcileNotRunning. When pid is alive but ownership cannot be verified, returns ReconcileUnverified without sending signals (fail closed).

func ReconcilePIDOpts

func ReconcilePIDOpts(pid int, own *Ownership, opts *VerifyOptions) ReconcileResult

ReconcilePIDOpts is ReconcilePID with explicit verification options.

type Result

type Result struct {
	PID       int
	StartedAt time.Time
	ExitCode  int
	// Canceled is true when ctx ended before a natural exit (cancel or deadline).
	Canceled bool
	// TimedOut is true when ctx ended due to deadline exceeded.
	TimedOut bool
}

Result is the outcome of Run after the process exits or is killed.

func Run

func Run(ctx context.Context, spec *Spec, opts *Options) (Result, error)

Run starts spec, streams stdout/stderr, waits for completion, and kills the process tree when ctx is canceled or its deadline passes.

type Spec

type Spec struct {
	// Shell runs command through the platform shell (sh -c / cmd /C).
	Shell string
	// Path is the executable for argv mode. When set, Shell is ignored.
	Path string
	Args []string
	Dir  string
	// Env entries are appended to os.Environ() when non-empty.
	Env []string
}

Spec describes a command to run. Prefer Path/Args over Shell when possible.

type Stream

type Stream int

Stream identifies stdout or stderr output.

const (
	Stdout Stream = iota + 1
	Stderr
)

type VerifyOptions

type VerifyOptions struct {
	// MaxClockSkew allows the OS create time to be slightly after StartedAt.
	MaxClockSkew time.Duration
	// MaxStartLead allows the OS create time to be before StartedAt (spawn latency).
	MaxStartLead time.Duration
	// Match, when set, overrides Ownership.Match and Spec-based cmdline matching.
	Match CmdlineMatcher
	// SkipProcessGroup skips the Unix pgid-leader check when true.
	SkipProcessGroup bool
}

VerifyOptions tune create-time and cmdline checks for VerifyOwnership.

func DefaultVerifyOptions

func DefaultVerifyOptions() VerifyOptions

DefaultVerifyOptions is used by VerifyOwnership.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL