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 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
- 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 WithProcessReplaceEnv(env []string) ProcessOption
- func WithProcessStdin(r io.Reader) ProcessOption
- type Resettable
- 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 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 avoid this API and use raw exec.Cmd + pty.Start() instead (see session/tmux/pty.go), with a //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 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 WithProcessReplaceEnv ¶ added in v1.35.0
func WithProcessReplaceEnv(env []string) ProcessOption
WithProcessReplaceEnv replaces the entire subprocess environment with env.
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.
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 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).