executor

package
v1.42.0 Latest Latest
Warning

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

Go to latest
Published: Aug 13, 2026 License: AGPL-3.0 Imports: 16 Imported by: 0

Documentation

Overview

Package executor provides safe subprocess management for stapler-squad. This file implements audit logging for all subprocess invocations.

Package executor provides safe subprocess management for stapler-squad. This file implements ManagedProcess, a lifecycle-managed long-running subprocess.

Package executor provides safe subprocess management for stapler-squad. This file defines the RlimitConfig struct shared across all platforms.

Package executor provides safe subprocess management for stapler-squad. This file implements ShortLivedCmd, a builder API for one-shot subprocesses.

Index

Constants

This section is empty.

Variables

View Source
var ErrCircuitOpen = fmt.Errorf("circuit breaker is open")

ErrCircuitOpen is returned when a call is rejected because the circuit is open.

Functions

func ToString

func ToString(cmd *exec.Cmd) string

func WithAuditHook added in v1.35.0

func WithAuditHook(ctx context.Context, hook AuditHook) context.Context

WithAuditHook returns a new context that carries hook. Pass this context to executor.New or executor.StartProcess to enable audit logging for that invocation. The hook is called once per subprocess after Wait() returns.

Types

type AuditEntry added in v1.35.0

type AuditEntry struct {
	// Command is the argv for the subprocess. Secret positions are replaced
	// with "<redacted>" when WithRedactArgs or WithProcessRedactArgs is used.
	Command []string

	// WorkDir is cmd.Dir at invocation time. Empty string means the process
	// inherited the Go binary's current working directory.
	WorkDir string

	// StartTime is the time the subprocess was started (before cmd.Start).
	StartTime time.Time

	// Duration is the elapsed time from Start to Wait returning.
	Duration time.Duration

	// ExitCode is the process exit code. -1 if the process was killed by signal.
	ExitCode int

	// PID is the process ID, valid after cmd.Start returns.
	PID int

	// KilledByCtx is true if the subprocess was killed because its context was
	// cancelled or its deadline expired.
	KilledByCtx bool

	// KilledByStop is true if the subprocess was killed by ManagedProcess.Stop().
	KilledByStop bool
}

AuditEntry holds structured metadata for one subprocess invocation. It is passed to AuditHook.OnExec after cmd.Wait() returns.

type AuditHook added in v1.35.0

type AuditHook interface {
	OnExec(entry AuditEntry)
}

AuditHook is implemented by consumers that want to observe subprocess invocations. OnExec is called synchronously after cmd.Wait() returns; it must not block. Heavy work (disk I/O, network calls) should be dispatched to a background goroutine inside the implementation.

func AuditHookFromCtx added in v1.35.0

func AuditHookFromCtx(ctx context.Context) AuditHook

AuditHookFromCtx extracts the AuditHook from ctx. Returns nil if no hook has been associated with this context via WithAuditHook.

type CircuitBreakerConfig

type CircuitBreakerConfig struct {
	FailureThreshold   int           // Number of consecutive failures to trip the breaker
	RecoveryTimeout    time.Duration // Base time to wait before probing in HALF-OPEN
	MaxRecoveryTimeout time.Duration // Cap on exponential backoff (0 = no cap)
	// IsFailure classifies whether a command result counts as a circuit breaker failure.
	// Receives the command class, combined output (nil for Run calls), and the error.
	// If nil, any non-nil error is treated as a failure (default behavior).
	IsFailure func(commandClass string, output []byte, err error) bool
}

CircuitBreakerConfig holds configuration for a circuit breaker.

func DefaultCircuitBreakerConfig

func DefaultCircuitBreakerConfig() CircuitBreakerConfig

DefaultCircuitBreakerConfig returns the default configuration.

type CircuitBreakerExecutor

type CircuitBreakerExecutor struct {
	// contains filtered or unexported fields
}

CircuitBreakerExecutor wraps an Executor with per-command-class circuit breakers.

func NewCircuitBreakerExecutor

func NewCircuitBreakerExecutor(delegate Executor, config CircuitBreakerConfig) *CircuitBreakerExecutor

NewCircuitBreakerExecutor creates a new CircuitBreakerExecutor wrapping the delegate.

func NewCircuitBreakerExecutorWithClock

func NewCircuitBreakerExecutorWithClock(delegate Executor, config CircuitBreakerConfig, clock Clock) *CircuitBreakerExecutor

NewCircuitBreakerExecutorWithClock creates a CircuitBreakerExecutor with an injectable clock (for testing).

func (*CircuitBreakerExecutor) AllBreakers

AllBreakers returns a snapshot of all circuit breakers for observability.

func (*CircuitBreakerExecutor) CombinedOutput

func (e *CircuitBreakerExecutor) CombinedOutput(cmd *exec.Cmd) ([]byte, error)

CombinedOutput executes the command and returns its combined stdout+stderr through the circuit breaker. Returns ErrCircuitOpen if the breaker for this command class is open.

func (*CircuitBreakerExecutor) Output

func (e *CircuitBreakerExecutor) Output(cmd *exec.Cmd) ([]byte, error)

Output executes the command and returns its output through the circuit breaker. Returns ErrCircuitOpen if the breaker for this command class is open.

func (*CircuitBreakerExecutor) Reset added in v1.1.0

func (e *CircuitBreakerExecutor) Reset()

Reset resets all circuit breakers in this executor to closed state. Call after successfully recovering an external dependency (e.g. tmux server restart).

func (*CircuitBreakerExecutor) Run

func (e *CircuitBreakerExecutor) Run(cmd *exec.Cmd) error

Run executes the command through the circuit breaker, delegating to the wrapped executor. Returns ErrCircuitOpen if the breaker for this command class is open.

type CircuitBreakerRegistry

type CircuitBreakerRegistry struct {
	// contains filtered or unexported fields
}

CircuitBreakerRegistry maintains a global registry of all CircuitBreakerExecutor instances. This enables the debug endpoint to aggregate circuit breaker state from all sessions.

func GetGlobalRegistry

func GetGlobalRegistry() *CircuitBreakerRegistry

GetGlobalRegistry returns the global circuit breaker registry.

func (*CircuitBreakerRegistry) AllBreakers

AllBreakers returns a combined snapshot of all circuit breakers across all registered executors. The returned map keys are prefixed with the executor key for disambiguation (e.g., "git-session1/git-diff").

func (*CircuitBreakerRegistry) Register

func (r *CircuitBreakerRegistry) Register(key string, cbe *CircuitBreakerExecutor)

Register adds a CircuitBreakerExecutor to the registry with a unique key. The key should identify the owner (e.g., "git-<session-title>" or "tmux-<session-title>").

func (*CircuitBreakerRegistry) ResetAll added in v1.1.0

func (r *CircuitBreakerRegistry) ResetAll()

ResetAll resets all circuit breakers in all registered executors to closed state. Call after successfully recovering an external dependency (e.g. tmux server restart).

func (*CircuitBreakerRegistry) Unregister

func (r *CircuitBreakerRegistry) Unregister(key string)

Unregister removes a CircuitBreakerExecutor from the registry.

type CircuitBreakerSnapshot

type CircuitBreakerSnapshot struct {
	State                CircuitState
	ConsecutiveFailures  int
	ConsecutiveOpenTrips int           // number of failed HALF-OPEN probes (backoff depth)
	EffectiveRecovery    time.Duration // current recovery wait including backoff
	LastStateChange      time.Time
	Config               CircuitBreakerConfig
}

CircuitBreakerSnapshot holds a point-in-time view of a circuit breaker's state.

type CircuitState

type CircuitState int

CircuitState represents the current state of a circuit breaker.

const (
	CircuitClosed   CircuitState = iota // Normal operation
	CircuitOpen                         // Fail-fast mode
	CircuitHalfOpen                     // Probing for recovery
)

func (CircuitState) String

func (s CircuitState) String() string

type Clock

type Clock interface {
	Now() time.Time
}

Clock is an interface for time operations (injectable for testing).

type Exec

type Exec struct{}

func (Exec) CombinedOutput

func (e Exec) CombinedOutput(cmd *exec.Cmd) ([]byte, error)

func (Exec) Output

func (e Exec) Output(cmd *exec.Cmd) ([]byte, error)

func (Exec) Run

func (e Exec) Run(cmd *exec.Cmd) error

type Executor

type Executor interface {
	Run(cmd *exec.Cmd) error
	Output(cmd *exec.Cmd) ([]byte, error)
	CombinedOutput(cmd *exec.Cmd) ([]byte, error)
}

func MakeExecutor

func MakeExecutor() Executor

func MakeTimeoutExecutor

func MakeTimeoutExecutor(timeout time.Duration) Executor

MakeTimeoutExecutor creates an executor with timeout protection. This prevents commands from hanging indefinitely, which is critical for preventing test hangs and production issues with external commands.

type LoggingAuditHook added in v1.35.0

type LoggingAuditHook struct {
	// Logger is the slog.Logger to use. If nil, slog.Default() is used.
	Logger *slog.Logger
}

LoggingAuditHook is the default AuditHook implementation. It emits structured log records via log/slog:

  • slog.LevelDebug for successful exits (ExitCode == 0, no kill)
  • slog.LevelInfo for non-zero exit codes or killed processes

func (*LoggingAuditHook) OnExec added in v1.35.0

func (h *LoggingAuditHook) OnExec(entry AuditEntry)

OnExec implements AuditHook. It logs the AuditEntry at Debug or Info level.

type ManagedProcess added in v1.35.0

type ManagedProcess struct {
	// contains filtered or unexported fields
}

ManagedProcess is a lifecycle handle for a long-running subprocess started with cmd.Start(). Construct via StartProcess; do not create directly.

ManagedProcess ensures:

  • The process runs in a new process group (Setpgid: true) by default
  • Stop() sends SIGTERM to the process group, then SIGKILL after gracePeriod
  • stdout/stderr are exposed as io.Reader (backed by os.Pipe(), not io.Pipe())
  • A single reaper goroutine owns cmd.Wait(), preventing multiple Wait() races
  • A finalizer provides last-resort cleanup if Stop() is never called

func StartProcess added in v1.35.0

func StartProcess(ctx context.Context, name string, args []string, opts ...ProcessOption) (*ManagedProcess, error)

StartProcess starts name with args, applies opts, sets up a process group, pipes stdout/stderr via os.Pipe(), and launches a reaper goroutine. Returns a handle immediately after cmd.Start() succeeds.

The process is started with Setpgid: true and Noctty: true by default, making it safe for background use. Callers that need a controlling terminal should use WithoutProcessGroupMP() and avoid this API (use raw exec.Cmd + pty.Start() instead, with //nolint:norawexec justification).

ctx governs the audit hook extraction. The process is not killed when ctx is done — use Stop() or WithNewSession() + a context that owns the lifetime.

func (*ManagedProcess) IsAlive added in v1.35.0

func (p *ManagedProcess) IsAlive() bool

IsAlive returns true if the process has not yet exited. Non-blocking. Returns false if ManagedProcess was not properly initialized via StartProcess.

func (*ManagedProcess) PID added in v1.35.0

func (p *ManagedProcess) PID() int

PID returns the process PID. Valid after StartProcess returns. Returns 0 if the ManagedProcess was not properly initialized via StartProcess.

func (*ManagedProcess) ScanLines added in v1.35.0

func (p *ManagedProcess) ScanLines(ctx context.Context, fn func(line string)) error

ScanLines reads lines from Stdout() via bufio.Scanner until EOF or ctx is done. fn is called for each complete line (without the trailing newline). Blocks until the process exits, EOF is reached, or ctx is cancelled. Returns nil on natural EOF, ctx.Err() if cancelled.

ScanLines returns nil if Stdout() is nil (WithConsumeStdout was used).

func (*ManagedProcess) Stderr added in v1.35.0

func (p *ManagedProcess) Stderr() io.Reader

Stderr returns an io.Reader for the process's stderr. Returns nil if WithConsumeStderr was used.

func (*ManagedProcess) Stdout added in v1.35.0

func (p *ManagedProcess) Stdout() io.Reader

Stdout returns an io.Reader for the process's stdout. Returns nil if WithConsumeStdout was used. Reading delivers io.EOF when the process exits and all buffered output has been consumed.

func (*ManagedProcess) Stop added in v1.35.0

func (p *ManagedProcess) Stop() error

Stop initiates graceful shutdown: sends SIGTERM to the process group (via p.cancel which triggers cmd.Cancel), then sends SIGKILL to the process group after gracePeriod if the process has not exited. Blocks until the process has exited. Idempotent: concurrent or repeated calls are safe. Only the first caller observes the process exit error; subsequent callers always return nil.

func (*ManagedProcess) Wait added in v1.35.0

func (p *ManagedProcess) Wait() error

Wait blocks until the process exits and returns the wait error. If the process was stopped via Stop() and exec.ErrWaitDelay fired, Wait returns nil.

type Option added in v1.35.0

type Option func(*config)

Option is a functional option for ShortLivedCmd.

func WithDir added in v1.35.0

func WithDir(dir string) Option

WithDir sets the working directory for the subprocess. If dir is empty, the subprocess inherits the calling process's current directory.

func WithEnv added in v1.35.0

func WithEnv(key, val string) Option

WithEnv appends a single KEY=VALUE pair to the subprocess environment. Multiple calls to WithEnv accumulate; all pairs are appended to os.Environ(). To replace the entire environment, use WithReplaceEnv instead.

func WithRedactArgs added in v1.35.0

func WithRedactArgs(indices ...int) Option

WithRedactArgs specifies argv positions that contain secrets. In the AuditEntry emitted after the command runs, these positions are replaced with "<redacted>". Positions are 0-indexed (0 = the command name, 1 = first arg, etc.).

func WithReplaceEnv added in v1.35.0

func WithReplaceEnv(env []string) Option

WithReplaceEnv replaces the entire subprocess environment with env. env must contain KEY=VALUE pairs. Takes precedence over WithEnv. Use when the subprocess must run with a minimal or controlled environment.

func WithRlimits added in v1.35.0

func WithRlimits(cfg RlimitConfig) Option

WithRlimits sets per-subprocess resource limits. On Linux, limits are applied to the child process via setrlimit. On other platforms this is a no-op.

func WithStdin added in v1.35.0

func WithStdin(r io.Reader) Option

WithStdin sets the subprocess's stdin reader. If not set, stdin is connected to /dev/null (the subprocess receives no input).

func WithTimeout added in v1.35.0

func WithTimeout(d time.Duration) Option

WithTimeout sets a per-command timeout. If d is shorter than the context's remaining deadline, the shorter of the two is used. If d is 0 or the context already has a shorter deadline, this option has no effect.

func WithoutProcessGroup added in v1.35.0

func WithoutProcessGroup() Option

WithoutProcessGroup disables Setpgid for this command. Use when the process needs to remain in the parent's process group (e.g. when a terminal or controlling PTY is involved). By default, all ShortLivedCmd instances run in a new process group for clean signal propagation.

type ProcessOption added in v1.35.0

type ProcessOption func(*processConfig)

ProcessOption is a functional option for StartProcess.

func WithConsumeStderr added in v1.35.0

func WithConsumeStderr(w io.Writer) ProcessOption

WithConsumeStderr directs stderr to w instead of exposing it via Stderr(). When this option is used, ManagedProcess.Stderr() returns nil.

func WithConsumeStdout added in v1.35.0

func WithConsumeStdout(w io.Writer) ProcessOption

WithConsumeStdout directs stdout to w instead of exposing it via Stdout(). When this option is used, ManagedProcess.Stdout() returns nil.

func WithGracePeriod added in v1.35.0

func WithGracePeriod(d time.Duration) ProcessOption

WithGracePeriod sets the time between SIGTERM and SIGKILL during Stop(). Defaults to 5 seconds. Set shorter for test processes.

func WithNewSession added in v1.35.0

func WithNewSession() ProcessOption

WithNewSession sets Setsid: true on SysProcAttr, creating a new session. This provides the strongest terminal isolation: the child cannot receive signals from the parent's session. Implies no controlling terminal. Use with caution — some processes (like tmux) require session membership.

func WithNoControllingTerminal added in v1.35.0

func WithNoControllingTerminal() ProcessOption

WithNoControllingTerminal sets Noctty: true on SysProcAttr. Use for background processes that must not receive SIGHUP when a terminal closes (e.g. tmux control-mode processes, daemons). This is the safe default and is set automatically; this option is provided for documentation clarity.

func WithProcessDir added in v1.35.0

func WithProcessDir(dir string) ProcessOption

WithProcessDir sets the working directory for the subprocess.

func WithProcessEnv added in v1.35.0

func WithProcessEnv(key, val string) ProcessOption

WithProcessEnv appends a single KEY=VALUE pair to the subprocess environment.

func WithProcessRedactArgs added in v1.35.0

func WithProcessRedactArgs(indices ...int) ProcessOption

WithProcessRedactArgs specifies argv positions containing secrets. These positions are replaced with "<redacted>" in audit log entries.

func WithProcessReplaceEnv added in v1.35.0

func WithProcessReplaceEnv(env []string) ProcessOption

WithProcessReplaceEnv replaces the entire subprocess environment with env.

func WithProcessRlimits added in v1.35.0

func WithProcessRlimits(cfg RlimitConfig) ProcessOption

WithProcessRlimits sets per-subprocess resource limits (Linux only).

func WithProcessStdin added in v1.35.0

func WithProcessStdin(r io.Reader) ProcessOption

WithProcessStdin sets the subprocess's stdin reader. The reader must remain open for the lifetime of the process. For tmux control-mode, use an os.Pipe() and keep the write end open until you want the process to exit.

func WithoutProcessGroupMP added in v1.35.0

func WithoutProcessGroupMP() ProcessOption

WithoutProcessGroupMP disables Setpgid for this process. Use for processes that need to remain in the parent's process group (rare; most background processes should use the default Setpgid: true).

type Resettable added in v1.1.0

type Resettable interface {
	Reset()
}

Resettable is implemented by executors that support resetting their internal failure state (e.g., after a successful recovery from a dependency outage). Use a type assertion to this interface rather than asserting to *CircuitBreakerExecutor directly, so that mock or wrapper executors can also participate in resets.

type RlimitConfig added in v1.35.0

type RlimitConfig struct {
	// MaxCPUSecs is the RLIMIT_CPU limit in seconds. The process receives
	// SIGXCPU when it reaches the soft limit. Zero means no limit.
	MaxCPUSecs uint64

	// MaxVirtBytes is the RLIMIT_AS (virtual address space) limit in bytes.
	// On Linux, attempts to grow the virtual address space beyond this limit
	// fail with ENOMEM. Zero means no limit.
	MaxVirtBytes uint64

	// MaxOpenFiles is the RLIMIT_NOFILE limit (max open file descriptors).
	// Attempts to open more than this many files fail with EMFILE.
	// Zero means no limit.
	MaxOpenFiles uint64
}

RlimitConfig specifies per-subprocess resource limits. Zero values mean "no limit" — the subprocess inherits the parent process's limits unchanged.

Resource limits are enforced on Linux via golang.org/x/sys/unix SysProcAttr.Rlimits, which applies the limits only to the child process (not the parent Go runtime). On non-Linux platforms (macOS, Windows), RlimitConfig is accepted by the API but has no effect — the struct compiles on all platforms, but applyRlimits is a no-op stub.

type ShortLivedCmd added in v1.35.0

type ShortLivedCmd struct {
	// contains filtered or unexported fields
}

ShortLivedCmd is a configured, not-yet-started one-shot subprocess. Construct with New(). Do not reuse after calling Run, Output, or CombinedOutput.

func New added in v1.35.0

func New(ctx context.Context, name string, args []string, opts ...Option) *ShortLivedCmd

New constructs a ShortLivedCmd. ctx governs cancellation, deadline, and audit hook extraction (via WithAuditHook). opts are applied in order; later options take precedence over earlier ones for scalar fields.

func (*ShortLivedCmd) CombinedOutput added in v1.35.0

func (c *ShortLivedCmd) CombinedOutput() ([]byte, error)

CombinedOutput runs the command and returns stdout and stderr merged into a single byte slice. Returns an error if the command exits with a non-zero status.

func (*ShortLivedCmd) Output added in v1.35.0

func (c *ShortLivedCmd) Output() ([]byte, error)

Output runs the command and returns its stdout as a byte slice. Stderr is discarded. Returns an error if the command exits with a non-zero status.

func (*ShortLivedCmd) Run added in v1.35.0

func (c *ShortLivedCmd) Run() error

Run runs the command and discards all output. Returns an error if the command exits with a non-zero status, is killed by a signal, or the context is done. Equivalent to exec.Cmd.Run() but with WaitDelay, process group, and audit logging applied.

type TimeoutExecutor

type TimeoutExecutor struct {
	// contains filtered or unexported fields
}

TimeoutExecutor wraps command execution with context-based timeouts to prevent indefinite blocking. This is critical for preventing hangs on external commands like 'which claude' or tmux operations.

func NewTimeoutExecutor

func NewTimeoutExecutor(timeout time.Duration) *TimeoutExecutor

NewTimeoutExecutor creates a new timeout-aware executor with the specified timeout duration. The timeout applies to each individual command execution.

func (*TimeoutExecutor) CombinedOutput

func (e *TimeoutExecutor) CombinedOutput(cmd *exec.Cmd) ([]byte, error)

CombinedOutput executes the command and returns combined stdout+stderr with timeout protection. Uses exec.CommandContext + WaitDelay so that both the process AND any orphaned grandchildren are cleaned up promptly after timeout, preventing zombie accumulation.

func (*TimeoutExecutor) Output

func (e *TimeoutExecutor) Output(cmd *exec.Cmd) ([]byte, error)

Output executes the command and returns its stdout with timeout protection. If the command does not complete within the timeout duration, it is killed and an error is returned.

func (*TimeoutExecutor) OutputWithPipes

func (e *TimeoutExecutor) OutputWithPipes(cmd *exec.Cmd) ([]byte, error)

OutputWithPipes captures combined stdout+stderr with timeout and WaitDelay to prevent zombie accumulation from orphaned grandchildren (e.g. git credential helpers).

func (*TimeoutExecutor) Run

func (e *TimeoutExecutor) Run(cmd *exec.Cmd) error

Run executes the command with timeout protection. If the command does not complete within the timeout duration, it is killed and an error is returned.

Directories

Path Synopsis
Package safeexec provides a thin wrapper around os/exec that pre-sets WaitDelay on every command.
Package safeexec provides a thin wrapper around os/exec that pre-sets WaitDelay on every command.

Jump to

Keyboard shortcuts

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