-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcommand.go
More file actions
311 lines (284 loc) · 12 KB
/
Copy pathcommand.go
File metadata and controls
311 lines (284 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
package processkit
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"os"
"time"
)
// Cmd describes a command to run: a program, its arguments, and run options.
// Build it with [Command] and the chainable WithX methods, then finish with a
// verb ([Cmd.Output], [Cmd.Run], [Cmd.ExitCode], [Cmd.Probe]).
//
// Each WithX method returns a new, independent *Cmd (copy-on-write), so a partly
// configured command is safe to reuse and branch:
//
// base := processkit.Command("git").WithDir(repo)
// status := base.WithArgs("status") // base is unchanged
// log := base.WithArgs("log") // independent of status
type Cmd struct {
program string
args []string
dir string
env []string
okCodes []int
timeout time.Duration
runner ProcessRunner
retry *retryPolicy // nil unless WithRetry was set
log runLog // optional *slog.Logger; zero value is a no-op
// Standard input for the capture verbs. At most one is set: stdin is a one-shot
// io.Reader (WithStdin); stdinBytes is re-readable (WithStdinBytes/String, safe
// across retries/restarts), tracked by hasStdinBytes (to keep the nil/empty
// distinction).
stdin io.Reader
stdinBytes []byte
hasStdinBytes bool
// uncheckedInPipe exempts this command from a Pipeline's pipefail attribution.
// Deliberately NOT carried in invocation(), so it is inert outside a Pipeline
// (Cmd.Output and Group.Start never see it).
uncheckedInPipe bool
}
// retryPolicy is the immutable configuration set by [Cmd.WithRetry]. It is shared
// by clones (copy-on-write never mutates it).
type retryPolicy struct {
maxAttempts int
backoff time.Duration
retryIf func(error) bool
}
// Command starts building a command that runs program with args. Finish with a
// verb (Output / Run / ExitCode / Probe).
func Command(program string, args ...string) *Cmd {
return &Cmd{program: program, args: append([]string(nil), args...)}
}
// clone returns a deep copy of c (slices copied), so WithX never mutates the
// receiver shared with another caller.
func (c *Cmd) clone() *Cmd {
cp := *c
cp.args = append([]string(nil), c.args...)
cp.env = cloneEnv(c.env)
cp.okCodes = append([]int(nil), c.okCodes...)
cp.stdinBytes = append([]byte(nil), c.stdinBytes...)
return &cp
}
// cloneEnv copies env, preserving the nil-vs-empty distinction (nil inherits the
// parent's environment; a non-nil empty slice clears it).
func cloneEnv(env []string) []string {
if env == nil {
return nil
}
return append([]string{}, env...)
}
// WithArgs returns a copy of the command with additional arguments appended.
func (c *Cmd) WithArgs(args ...string) *Cmd {
cp := c.clone()
cp.args = append(cp.args, args...)
return cp
}
// WithDir returns a copy of the command with the given working directory.
func (c *Cmd) WithDir(dir string) *Cmd {
cp := c.clone()
cp.dir = dir
return cp
}
// WithEnv returns a copy of the command with the full environment set, replacing
// the inherited one. Each entry is "KEY=VALUE"; calling it with no entries runs
// with an *empty* environment (no PATH) — usually you want to pass through the
// vars the program needs, or use [Cmd.AppendEnv] to add to the inherited set.
func (c *Cmd) WithEnv(env ...string) *Cmd {
cp := c.clone()
cp.env = append([]string{}, env...) // non-nil even when empty: clears the env
return cp
}
// AppendEnv returns a copy of the command with entries added to its environment.
// Unlike [Cmd.WithEnv] (which replaces the whole environment), AppendEnv builds on
// the existing one — the inherited process environment if WithEnv was never called
// — so it is the tool for the common "inherit, plus set a few" case (e.g.
// AppendEnv("GIT_TERMINAL_PROMPT=0")). A later entry overrides an earlier one for
// the same key, per exec's last-wins rule.
func (c *Cmd) AppendEnv(env ...string) *Cmd {
cp := c.clone()
if cp.env == nil {
cp.env = append(os.Environ(), env...) // materialise the inherited env, then add
} else {
cp.env = append(cp.env, env...)
}
return cp
}
// WithTimeout returns a copy of the command bounded by d. At the deadline the
// process tree is killed and the [Result] reports [Outcome.TimedOut] — a timeout
// is captured in the result, not raised, until a success-requiring verb turns it
// into an error. (Cancelling the context you pass is different: that is an error.)
func (c *Cmd) WithTimeout(d time.Duration) *Cmd {
cp := c.clone()
cp.timeout = d
return cp
}
// WithOkCodes returns a copy of the command whose listed exit codes count as
// success in addition to 0. Affects [Result.Success] and the success-requiring
// verbs, but not [Cmd.Probe].
func (c *Cmd) WithOkCodes(codes ...int) *Cmd {
cp := c.clone()
cp.okCodes = append([]int(nil), codes...)
return cp
}
// WithRunner returns a copy of the command that executes through r — the
// dependency-injection seam for tests. The default is a [JobRunner].
func (c *Cmd) WithRunner(r ProcessRunner) *Cmd {
cp := c.clone()
cp.runner = r
return cp
}
// WithStdin returns a copy of the command that feeds r as the process's standard
// input for the capture verbs ([Cmd.Output], [Cmd.Run], [Cmd.ExitCode], [Cmd.Probe])
// — e.g. streaming a source into a tool. r is read ONCE as the run proceeds, so it
// is not safe to reuse across attempts: with [Cmd.WithRetry] or under a [Supervisor]
// (which re-run the command) a second attempt sees EOF — use [Cmd.WithStdinBytes] /
// [Cmd.WithStdinString] for a re-readable buffer instead. WithStdin does NOT apply
// to a command used as a [Pipe] stage (the chain wires stdin) or started in a
// [Group] (use the [WithStdin] start option there); record/replay cassettes reject a
// command with stdin, whose result isn't reproducible from the recorded key.
func (c *Cmd) WithStdin(r io.Reader) *Cmd {
cp := c.clone()
cp.stdin, cp.stdinBytes, cp.hasStdinBytes = r, nil, false
return cp
}
// WithStdinBytes returns a copy of the command that feeds b as the process's
// standard input for the capture verbs. Unlike [Cmd.WithStdin], the buffer is
// re-readable — each run reads it afresh — so it is safe to combine with
// [Cmd.WithRetry] and [Supervisor] (each attempt/restart gets the full input). The
// other limitations of [Cmd.WithStdin] (Pipe/Group/cassette) apply.
func (c *Cmd) WithStdinBytes(b []byte) *Cmd {
cp := c.clone()
cp.stdinBytes, cp.hasStdinBytes, cp.stdin = append([]byte(nil), b...), true, nil
return cp
}
// WithStdinString returns a copy of the command that feeds s as the process's
// standard input — the string form of [Cmd.WithStdinBytes] (re-readable).
func (c *Cmd) WithStdinString(s string) *Cmd { return c.WithStdinBytes([]byte(s)) }
// WithLogger returns a copy of the command that emits structured [log/slog] events
// over its lifetime — spawn, exit, timeout, cancellation, and retries. The default
// is no logging; pass nil to disable. The events carry the program name, pid,
// mechanism, outcome, and durations, but NEVER the command's arguments,
// environment, working directory, or output — those routinely carry secrets.
// Lifecycle events come from the built-in [JobRunner]; with a custom [WithRunner]
// only the retry events are emitted (the runner logs its own runs).
func (c *Cmd) WithLogger(logger *slog.Logger) *Cmd {
cp := c.clone()
cp.log = runLog{logger}
return cp
}
// WithUncheckedInPipe returns a copy of the command exempt from a [Pipeline]'s
// pipefail attribution: as a pipeline stage, its failure never blames the chain —
// a non-zero exit always, and for a non-final stage a signal (including the
// SIGPIPE it gets when a downstream stage stops reading) or its own per-stage
// timeout too. This is the tool for the `producer | head` pattern. A final stage
// is only forgiven its non-zero exit; a timeout or signal kill still surfaces.
// Outside a pipeline it has no effect.
func (c *Cmd) WithUncheckedInPipe() *Cmd {
cp := c.clone()
cp.uncheckedInPipe = true
return cp
}
// WithRetry returns a copy of the command that replays a failed run up to
// maxAttempts times total (so maxAttempts <= 1 runs exactly once), sleeping
// backoff between tries, but only while retryIf classifies the failure as
// retryable. It stops on the first success, the first non-retryable failure, or
// the attempt budget — returning the last error unchanged (there is no
// retries-exhausted error). A cancelled context is terminal: it is never retried,
// whatever retryIf says, and it aborts a backoff sleep promptly.
//
// Retry applies to the success-requiring verbs ([Cmd.Run], [Cmd.ExitCode],
// [Cmd.Probe]) — the ones that turn a bad run into an error for retryIf to judge.
// It does NOT apply to [Cmd.Output] (a non-zero exit there is data, not an error),
// nor to a command used as a [Pipe] stage or under a [Supervisor] (those have
// their own control flow). There is no default classifier; pass one — for example
// errors.Is(err, [ErrTimeout]) to retry timeouts, or [IsTransient] for transient
// low-level spawn failures. A nil retryIf retries nothing (the command runs once).
func (c *Cmd) WithRetry(maxAttempts int, backoff time.Duration, retryIf func(error) bool) *Cmd {
cp := c.clone()
cp.retry = &retryPolicy{maxAttempts: maxAttempts, backoff: backoff, retryIf: retryIf}
return cp
}
func (c *Cmd) invocation() Invocation {
var stdin io.Reader
switch {
case c.hasStdinBytes:
stdin = bytes.NewReader(c.stdinBytes) // fresh each call: re-readable across retries/restarts
case c.stdin != nil:
stdin = c.stdin
}
return Invocation{
Program: c.program,
Args: append([]string(nil), c.args...),
Dir: c.dir,
Env: cloneEnv(c.env),
OkCodes: append([]int(nil), c.okCodes...),
Timeout: c.timeout,
Stdin: stdin,
}
}
func (c *Cmd) run(ctx context.Context) (*Result, error) {
r := c.runner
if r == nil {
r = JobRunner{log: c.log} // the built-in runner logs spawn/exit/timeout/cancel
}
return r.Output(ctx, c.invocation())
}
// Output runs the command and returns the full [Result]. A non-zero exit is data
// here, not an error; only a spawn failure, a cancelled context, or a context
// deadline errors.
func (c *Cmd) Output(ctx context.Context) (*Result, error) {
return c.run(ctx)
}
// Run requires a successful exit and returns stdout as text with trailing
// whitespace trimmed. A non-zero exit, timeout, signal kill, or cancellation is
// an error. Honours [Cmd.WithRetry].
func (c *Cmd) Run(ctx context.Context) (string, error) {
return retryRun(ctx, c, resultRun)
}
// ExitCode runs the command and returns its exit code. A run with no exit code
// (a timeout or signal kill) is an error rather than a fabricated -1. Honours
// [Cmd.WithRetry].
func (c *Cmd) ExitCode(ctx context.Context) (int, error) {
return retryRun(ctx, c, resultExitCode)
}
// Probe runs the command as a yes/no predicate: exit 0 → true, exit 1 → false,
// anything else (another code, a timeout, a signal kill) → error. OkCodes does
// not apply to a probe. Honours [Cmd.WithRetry].
func (c *Cmd) Probe(ctx context.Context) (bool, error) {
return retryRun(ctx, c, resultProbe)
}
// retryRun runs c and applies extract (a verb's success check) to each attempt,
// retrying per [Cmd.WithRetry] while the failure is classified retryable. On
// success it returns extract's value; otherwise it returns the last error.
func retryRun[T any](ctx context.Context, c *Cmd, extract func(*Result) (T, error)) (T, error) {
var zero T
policy := c.retry
maxAttempts := 1
if policy != nil && policy.maxAttempts > 1 {
maxAttempts = policy.maxAttempts
}
for tries := 1; ; tries++ {
res, err := c.run(ctx)
var val T
if err == nil {
val, err = extract(res)
}
if err == nil {
return val, nil // success
}
// A cancelled context is terminal — never retried, whatever retryIf says.
if errors.Is(err, ErrCancelled) {
return zero, err
}
if policy == nil || tries >= maxAttempts || policy.retryIf == nil || !policy.retryIf(err) {
return zero, err // no policy, budget spent, missing/false classifier
}
c.log.retrying(c.program, tries+1, maxAttempts, policy.backoff, err)
if !sleepCtx(ctx, policy.backoff) {
return zero, &CancelError{Program: c.program, Cause: ctx.Err()}
}
}
}