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 ¶
- Variables
- func ToString(cmd *exec.Cmd) string
- func WithAuditHook(ctx context.Context, hook AuditHook) context.Context
- type AuditEntry
- type AuditHook
- type CircuitBreakerConfig
- type CircuitBreakerExecutor
- func (e *CircuitBreakerExecutor) AllBreakers() map[string]CircuitBreakerSnapshot
- func (e *CircuitBreakerExecutor) CombinedOutput(cmd *exec.Cmd) ([]byte, error)
- func (e *CircuitBreakerExecutor) Output(cmd *exec.Cmd) ([]byte, error)
- func (e *CircuitBreakerExecutor) Reset()
- func (e *CircuitBreakerExecutor) Run(cmd *exec.Cmd) error
- type CircuitBreakerRegistry
- type CircuitBreakerSnapshot
- type CircuitState
- type Clock
- type Exec
- type Executor
- type LoggingAuditHook
- type ManagedProcess
- func (p *ManagedProcess) IsAlive() bool
- func (p *ManagedProcess) PID() int
- func (p *ManagedProcess) ScanLines(ctx context.Context, fn func(line string)) error
- func (p *ManagedProcess) Stderr() io.Reader
- func (p *ManagedProcess) Stdout() io.Reader
- func (p *ManagedProcess) Stop() error
- func (p *ManagedProcess) Wait() error
- type Option
- func WithDir(dir string) Option
- func WithEnv(key, val string) Option
- func WithRedactArgs(indices ...int) Option
- func WithReplaceEnv(env []string) Option
- func WithRlimits(cfg RlimitConfig) Option
- func WithStdin(r io.Reader) Option
- func WithTimeout(d time.Duration) Option
- func WithoutProcessGroup() Option
- type ProcessOption
- func WithConsumeStderr(w io.Writer) ProcessOption
- func WithConsumeStdout(w io.Writer) ProcessOption
- func WithGracePeriod(d time.Duration) ProcessOption
- func WithNewSession() ProcessOption
- func WithNoControllingTerminal() ProcessOption
- func WithProcessDir(dir string) ProcessOption
- func WithProcessEnv(key, val string) ProcessOption
- func WithProcessRedactArgs(indices ...int) ProcessOption
- func WithProcessReplaceEnv(env []string) ProcessOption
- func WithProcessRlimits(cfg RlimitConfig) ProcessOption
- func WithProcessStdin(r io.Reader) ProcessOption
- func WithoutProcessGroupMP() ProcessOption
- type Resettable
- type RlimitConfig
- type ShortLivedCmd
- type TimeoutExecutor
Constants ¶
This section is empty.
Variables ¶
var ErrCircuitOpen = fmt.Errorf("circuit breaker is open")
ErrCircuitOpen is returned when a call is rejected because the circuit is open.
Functions ¶
func WithAuditHook ¶ added in v1.35.0
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
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 ¶
func (e *CircuitBreakerExecutor) AllBreakers() map[string]CircuitBreakerSnapshot
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).
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 ¶
func (r *CircuitBreakerRegistry) AllBreakers() map[string]CircuitBreakerSnapshot
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 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 ¶
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
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
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
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
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
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
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
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).