返回 DeepSeek-Reasonix
runner.go
根目录 / internal / shellrun / runner.go
1 // Package shellrun provides a shared foreground shell runner used by the model
2 // shell tool and the user !command path. It classifies exits, collects a bounded
3 // output tail, and keeps combined stdout/stderr model-visible output intact.
4 package shellrun
5
6 import (
7 "bytes"
8 "context"
9 "errors"
10 "fmt"
11 "io"
12 "os/exec"
13 "strings"
14 "sync"
15 "time"
16
17 fileenc "reasonix/internal/fileutil/encoding"
18 "reasonix/internal/proc"
19 "reasonix/internal/tool"
20 )
21
22 // DefaultWaitDelay mirrors the shell tool's child-process wait grace.
23 const DefaultWaitDelay = 5 * time.Second
24
25 const (
26 // combinedOutputMaxBytes bounds the foreground output retained in memory.
27 // Tool-result truncation happens only after the process exits, so it cannot
28 // protect the host from a command that prints forever (#6473, #6528).
29 combinedOutputMaxBytes = 10 << 20
30 // Keep the final diagnostics as well as the command's opening context after
31 // the cap is crossed. Build and test failures are commonly printed last.
32 combinedOutputTailBytes = 64 << 10
33 combinedOutputTruncated = "\n\n...[shell output truncated at 10 MiB; showing the final 64 KiB]...\n\n"
34 // Live progress crosses async UI queues and append-only reducers before the
35 // final bounded result replaces it. Keep that transient path small too, or a
36 // never-ending command can still exhaust memory while Combined stays bounded.
37 progressOutputMaxBytes = 64 << 10
38 progressOutputTruncated = "\n\n...[live shell output capped at 64 KiB; final diagnostics will appear when the command exits]...\n\n"
39 )
40
41 var errForegroundTimeout = errors.New("shell foreground timeout")
42
43 // Request describes one foreground shell launch. Argv must already include the
44 // interpreter and any sandbox wrapping; Command is only for diagnostics.
45 type Request struct {
46 Argv []string
47 Dir string
48 Env []string
49 Timeout time.Duration
50 WaitDelay time.Duration
51 CommandPreview string
52 ShellKind string
53 ShellPath string
54 Source string
55 Track bool
56 PreserveWaitDelay bool
57 // Progress receives live combined output chunks (optional).
58 Progress func(chunk string)
59 // Run is optional; tests inject a process runner. When nil, proc.RunCommand.
60 Run func(ctx context.Context, cmd *exec.Cmd, opts proc.RunOptions) (*proc.TrackedCommand, error)
61 }
62
63 // Result is the structured outcome of a foreground run.
64 type Result struct {
65 Combined string
66 // OutputTail is the bounded tail of combined output, populated only when the
67 // run did not complete successfully. Stdout and stderr share one pipe so the
68 // model-visible ordering is preserved, which makes a stderr-only tail
69 // impossible; in practice the last bytes before a failure are the diagnosis.
70 OutputTail string
71 ExitCode *int
72 Started bool
73 State string
74 FailurePhase string
75 Err error
76 Tracked *proc.TrackedCommand
77 Cmd *exec.Cmd
78 }
79
80 // RunForeground starts the process, captures combined stdout/stderr with a
81 // lock-safe collector, and classifies timeout / cancel / launch / execution
82 // failures. Combined output is always returned so callers can feed the model.
83 func RunForeground(ctx context.Context, req Request) Result {
84 if len(req.Argv) == 0 {
85 return Result{
86 State: tool.ShellStateFailed,
87 FailurePhase: tool.ShellPhaseLaunch,
88 Err: fmt.Errorf("empty argv"),
89 }
90 }
91 waitDelay := req.WaitDelay
92 if waitDelay <= 0 {
93 waitDelay = DefaultWaitDelay
94 }
95 runCtx := ctx
96 var cancel context.CancelFunc
97 if req.Timeout > 0 {
98 runCtx, cancel = context.WithTimeoutCause(ctx, req.Timeout, errForegroundTimeout)
99 defer cancel()
100 }
101
102 cmd := proc.CommandContext(runCtx, req.Argv[0], req.Argv[1:]...)
103 cmd.Dir = req.Dir
104 cmd.Env = req.Env
105 cmd.WaitDelay = waitDelay
106
107 collector := newOutputCollector(combinedOutputMaxBytes, tool.OutputTailMaxBytes)
108 var writers []io.Writer
109 writers = append(writers, collector.combined, collector.tail)
110 var progress *progressWriter
111 if req.Progress != nil {
112 progress = newProgressWriter(req.Progress, progressOutputMaxBytes, progressOutputTruncated)
113 writers = append(writers, progress)
114 }
115 // Stdout and Stderr must stay the *same* writer value: os/exec then hands the
116 // child a single pipe, so the two streams interleave in the order the child
117 // wrote them and only one copy goroutine calls Progress. Two MultiWriters
118 // would mean two pipes, and combined output would be reordered per stream.
119 // The bounded tail therefore covers combined output rather than stderr only;
120 // failing commands routinely report on stdout, so the tail stays useful.
121 w := io.MultiWriter(writers...)
122 cmd.Stdout = w
123 cmd.Stderr = w
124
125 run := req.Run
126 if run == nil {
127 run = proc.RunCommand
128 }
129 source := req.Source
130 if source == "" {
131 source = "shellrun"
132 }
133 tracked, err := run(runCtx, cmd, proc.RunOptions{
134 Track: req.Track,
135 CancelWaitGrace: waitDelay + time.Second,
136 Source: source,
137 ShellKind: req.ShellKind,
138 ShellPath: req.ShellPath,
139 CommandPreview: req.CommandPreview,
140 })
141
142 if progress != nil {
143 progress.Flush()
144 }
145 out := Result{
146 Combined: collector.combined.String(),
147 OutputTail: collector.tailString(),
148 Started: processStarted(cmd, err),
149 Tracked: tracked,
150 Cmd: cmd,
151 }
152
153 return classifyForegroundResult(runCtx, req, out, err)
154 }
155
156 func classifyForegroundResult(runCtx context.Context, req Request, out Result, err error) Result {
157 if req.PreserveWaitDelay && runCtx.Err() == nil && errors.Is(err, exec.ErrWaitDelay) {
158 err = nil
159 }
160
161 // Timeout takes precedence when the tool-local deadline fired.
162 if errors.Is(context.Cause(runCtx), errForegroundTimeout) {
163 out.State = tool.ShellStateTimedOut
164 out.FailurePhase = tool.ShellPhaseTimeout
165 out.ExitCode = exitCodeFromErr(err)
166 out.Err = fmt.Errorf("command timed out (> %s)", req.Timeout)
167 return out
168 }
169 // Parent cancellation (user stop / session cancel).
170 if err != nil && (errors.Is(err, context.Canceled) || errors.Is(runCtx.Err(), context.Canceled) || isCanceledWait(err)) {
171 out.State = tool.ShellStateCancelled
172 out.FailurePhase = tool.ShellPhaseCancellation
173 out.ExitCode = exitCodeFromErr(err)
174 if cause := context.Cause(runCtx); cause != nil {
175 out.Err = cause
176 } else {
177 out.Err = err
178 }
179 return out
180 }
181 if err == nil {
182 code := 0
183 out.ExitCode = &code
184 out.State = tool.ShellStateCompleted
185 // The tail exists to explain a failure. Dropping it on success keeps
186 // successful runs from persisting up to 16 KiB of ordinary stdout into
187 // every session record and tool card.
188 out.OutputTail = ""
189 return out
190 }
191 if code := exitCodeFromErr(err); code != nil {
192 out.ExitCode = code
193 out.Started = true
194 out.State = tool.ShellStateFailed
195 out.FailurePhase = tool.ShellPhaseExecution
196 out.Err = fmt.Errorf("command exited: %w", err)
197 if diagnostic := WindowsRuntimeDiagnostic(out.Combined); diagnostic != "" {
198 out.Err = fmt.Errorf("%s: %w", diagnostic, out.Err)
199 }
200 return out
201 }
202 // Process never produced an exit status — launch / dependency style failure.
203 out.State = tool.ShellStateFailed
204 if out.Started {
205 out.FailurePhase = tool.ShellPhaseExecution
206 } else {
207 out.FailurePhase = tool.ShellPhaseLaunch
208 }
209 out.Err = err
210 return out
211 }
212
213 func processStarted(cmd *exec.Cmd, err error) bool {
214 if cmd != nil && cmd.Process != nil {
215 return true
216 }
217 // ExitError means the process ran.
218 var ee *exec.ExitError
219 return errors.As(err, &ee)
220 }
221
222 func exitCodeFromErr(err error) *int {
223 if err == nil {
224 code := 0
225 return &code
226 }
227 var ee *exec.ExitError
228 if errors.As(err, &ee) {
229 code := ee.ExitCode()
230 return &code
231 }
232 return nil
233 }
234
235 func isCanceledWait(err error) bool {
236 var c proc.CanceledWaitError
237 return errors.As(err, &c)
238 }
239
240 // outputCollector owns the combined buffer and a bounded tail ring. Writes stay
241 // serialized behind one mutex so a caller that does wire two pipes cannot race
242 // on the Buffer.
243 type outputCollector struct {
244 mu sync.Mutex
245 combined *boundedBuffer
246 tail *tailWriter
247 }
248
249 func newOutputCollector(combinedLimit, tailLimit int) *outputCollector {
250 c := &outputCollector{}
251 c.combined = &boundedBuffer{
252 mu: &c.mu,
253 limit: combinedLimit,
254 tailLimit: combinedOutputTailBytes,
255 marker: combinedOutputTruncated,
256 }
257 c.tail = &tailWriter{mu: &c.mu, limit: tailLimit}
258 return c
259 }
260
261 func (c *outputCollector) tailString() string {
262 c.mu.Lock()
263 defer c.mu.Unlock()
264 return string(fileenc.DecodeOutput(c.tail.buf, fileenc.Cut{Head: c.tail.cut}))
265 }
266
267 // boundedBuffer keeps complete output up to limit. Once output crosses the
268 // limit it retains a head plus a rolling tail separated by marker. Write always
269 // reports the full input consumed so a safety cap never changes child-process
270 // behavior into an artificial short-write failure.
271 type boundedBuffer struct {
272 mu *sync.Mutex
273 buf bytes.Buffer
274 tail []byte
275 limit int
276 tailLimit int
277 marker string
278 truncated bool
279 }
280
281 func (b *boundedBuffer) Write(p []byte) (int, error) {
282 b.mu.Lock()
283 defer b.mu.Unlock()
284 if len(p) == 0 {
285 return 0, nil
286 }
287 if !b.truncated && (b.limit <= 0 || b.buf.Len()+len(p) <= b.limit) {
288 _, err := b.buf.Write(p)
289 return len(p), err
290 }
291 if !b.truncated {
292 b.truncated = true
293 headLimit := max(0, b.limit-b.tailLimit-len(b.marker))
294 previous := b.buf.Bytes()
295 b.tail = appendBoundedTail(b.tail, previous, b.tailLimit)
296 if b.buf.Len() > headLimit {
297 b.buf.Truncate(headLimit)
298 } else if remaining := headLimit - b.buf.Len(); remaining > 0 {
299 b.buf.Write(p[:min(remaining, len(p))])
300 }
301 }
302 b.tail = appendBoundedTail(b.tail, p, b.tailLimit)
303 return len(p), nil
304 }
305
306 // String decodes the output in the encoding the child wrote it in; a Windows
307 // console tool answers in the machine's code page, not UTF-8. Once truncated,
308 // the head lost its end and the tail its start, so each is decoded on its own.
309 func (b *boundedBuffer) String() string {
310 b.mu.Lock()
311 defer b.mu.Unlock()
312 if !b.truncated {
313 return string(fileenc.DecodeOutput(b.buf.Bytes(), fileenc.Cut{}))
314 }
315 var out strings.Builder
316 out.Grow(b.buf.Len() + len(b.marker) + len(b.tail))
317 out.Write(fileenc.DecodeOutput(b.buf.Bytes(), fileenc.Cut{Tail: true}))
318 out.WriteString(b.marker)
319 out.Write(fileenc.DecodeOutput(b.tail, fileenc.Cut{Head: true}))
320 return out.String()
321 }
322
323 func appendBoundedTail(dst, p []byte, limit int) []byte {
324 if limit <= 0 || len(p) >= limit {
325 if limit <= 0 {
326 return nil
327 }
328 return append(dst[:0], p[len(p)-limit:]...)
329 }
330 if overflow := len(dst) + len(p) - limit; overflow > 0 {
331 copy(dst, dst[overflow:])
332 dst = dst[:len(dst)-overflow]
333 }
334 return append(dst, p...)
335 }
336
337 type tailWriter struct {
338 mu *sync.Mutex
339 limit int
340 buf []byte
341 cut bool // bytes before buf were dropped to hold the limit
342 }
343
344 func (w *tailWriter) Write(p []byte) (int, error) {
345 w.mu.Lock()
346 defer w.mu.Unlock()
347 w.buf = append(w.buf, p...)
348 if w.limit > 0 && len(w.buf) > w.limit {
349 w.buf = append([]byte(nil), w.buf[len(w.buf)-w.limit:]...)
350 w.cut = true
351 }
352 return len(p), nil
353 }
354
355 type progressWriter struct {
356 mu sync.Mutex
357 emit func(string)
358 limit int
359 forwarded int
360 marker string
361 truncated bool
362 pending []byte
363 }
364
365 func newProgressWriter(emit func(string), limit int, marker string) *progressWriter {
366 return &progressWriter{emit: emit, limit: max(0, limit), marker: marker}
367 }
368
369 func (w *progressWriter) Write(p []byte) (int, error) {
370 if len(p) == 0 {
371 return 0, nil
372 }
373 w.mu.Lock()
374 defer w.mu.Unlock()
375 if w.emit == nil || w.truncated {
376 return len(p), nil
377 }
378 w.writeUTF8(p, false)
379 return len(p), nil
380 }
381
381 lines GO