@@ -3,20 +3,24 @@ package shell
33import (
44 "bytes"
55 "context"
6− "log/slog"
76 "os"
7+ "sync"
88)
99
1010// progressWriter wraps an io.Writer and calls onProgress with each write.
11+// It is safe for concurrent use from multiple goroutines (e.g. stdout and
12+// stderr writing simultaneously).
1113type progressWriter struct {
14+ mu sync.Mutex
1215 buf bytes.Buffer
1316 onProgress func(string)
1417}
1518
1619func (w *progressWriter) Write(p []byte) (int, error) {
20+ w.mu.Lock()
21+ defer w.mu.Unlock()
1722 n, err := w.buf.Write(p)
1823 if n > 0 && w.onProgress != nil {
19− slog.Debug("Shell stream progress", "bytes", n)
2024 w.onProgress(string(p[:n]))
2125 }
2226 return n, err
@@ -30,10 +34,9 @@ func RunAndCaptureStream(ctx context.Context, opts RunOptions, onProgress func(s
3034 }
3135 opts.Env = append(opts.Env, ptyColorEnvVars...)
3236
33− stdout := &progressWriter{onProgress: onProgress}
34− stderr := &progressWriter{onProgress: onProgress}
35− opts.Stdout = stdout
36− opts.Stderr = stderr
37+ buf := &progressWriter{onProgress: onProgress}
38+ opts.Stdout = buf
39+ opts.Stderr = buf
3740
3841 runErr := Run(ctx, opts)
3942
@@ -42,16 +45,8 @@ func RunAndCaptureStream(ctx context.Context, opts RunOptions, onProgress func(s
4245 exitCode = ExitCode(runErr)
4346 }
4447
45− output := stdout.buf.String()
46− if stderr.buf.Len() > 0 {
47− if output != "" {
48− output += "\n"
49− }
50− output += stderr.buf.String()
51− }
52−
5348 return CaptureResult{
54− Output: output,
49+ Output: buf.buf.String(),
5550 ExitCode: exitCode,
5651 }, nil
5752}