Skip to content

Commit 43227f4

Browse files
committed
cli_executor: handle pipe read errors
Previously, when reading and logging the stdout/stderr streams, we ignored potential read errors. After scanner.Scan() returns false, scanner.Err() may contain any errors encountered while reading. Handle this error. Stop logging the stdout/stderr (and log a warning about it) but still consume the rest of the pipe if possible to collect the entire stdout/stderr. If direct consumption of the pipe also fails, return the error. Assisted-by: Claude Signed-off-by: Adam Cmiel <acmiel@redhat.com>
1 parent 45c882d commit 43227f4

File tree

2 files changed

+64
-14
lines changed

2 files changed

+64
-14
lines changed

pkg/cliwrappers/cli_executor.go

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,34 +70,50 @@ func (e *CliExecutor) Execute(c Cmd) (string, string, int, error) {
7070

7171
var stdoutBuf, stderrBuf bytes.Buffer
7272

73-
readStream := func(linePrefix string, r io.Reader, buf *bytes.Buffer) {
74-
scanner := bufio.NewScanner(r)
73+
readStream := func(linePrefix string, r io.Reader, buf *bytes.Buffer) error {
74+
tee := io.TeeReader(r, buf)
75+
scanner := bufio.NewScanner(tee)
7576
for scanner.Scan() {
76-
line := scanner.Text()
77-
executorLog.Info(linePrefix + line)
78-
buf.WriteString(line + "\n")
77+
executorLog.Info(linePrefix + scanner.Text())
7978
}
79+
if scanner.Err() != nil {
80+
executorLog.Warnf("%sstopped logging output: %s", linePrefix, scanner.Err())
81+
// Read the rest of the pipe directly into buf.
82+
//
83+
// At this point, buf contains everything that was read from r via the scanner
84+
// (by property of TeeReader - "the write must complete before the read completes").
85+
// A second property of TeeReader that could break this:
86+
// "Any error encountered while writing is reported as a read error".
87+
// Not a problem here - [bytes.Buffer.Write] never errors, only panics.
88+
//
89+
// Naturally, buf also doesn't contain anything that *wasn't* read via the scanner,
90+
// because the tee being read by the scanner is the only thing doing the writing.
91+
// So this Copy() picks up exactly where the scanner left off.
92+
if _, err := io.Copy(buf, r); err != nil {
93+
return fmt.Errorf("failed to read remaining output: %w", err)
94+
}
95+
}
96+
return nil
8097
}
8198

8299
nameInLogs := c.NameInLogs
83100
if nameInLogs == "" {
84101
nameInLogs = c.Name
85102
}
86103

87-
done := make(chan struct{}, 2)
104+
done := make(chan error, 2)
88105
go func() {
89-
readStream(nameInLogs+" [stdout] ", stdoutPipe, &stdoutBuf)
90-
done <- struct{}{}
106+
done <- readStream(nameInLogs+" [stdout] ", stdoutPipe, &stdoutBuf)
91107
}()
92108
go func() {
93-
readStream(nameInLogs+" [stderr] ", stderrPipe, &stderrBuf)
94-
done <- struct{}{}
109+
done <- readStream(nameInLogs+" [stderr] ", stderrPipe, &stderrBuf)
95110
}()
96111

97-
err = cmd.Wait()
98-
// Wait for both output streams to finish
99-
<-done
100-
<-done
112+
// Wait for both output streams to finish before calling cmd.Wait().
113+
// Per [exec.Cmd.StdoutPipe] docs, Wait closes the pipes, so all reads must complete first.
114+
readErr := errors.Join(<-done, <-done)
115+
cmdErr := cmd.Wait()
116+
err = errors.Join(readErr, cmdErr)
101117

102118
return stdoutBuf.String(), stderrBuf.String(), getExitCodeFromError(err), err
103119
}

pkg/cliwrappers/cli_executor_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cliwrappers_test
22

33
import (
44
"bytes"
5+
"fmt"
56
"os"
67
"path/filepath"
78
"runtime"
@@ -329,6 +330,39 @@ func TestCliExecutor_ExecuteWithLogOutput(t *testing.T) {
329330
g.Expect(stdout).To(BeEmpty())
330331
g.Expect(stderr).To(BeEmpty())
331332
})
333+
334+
t.Run("should capture full output even when a line exceeds the scanner buffer size", func(t *testing.T) {
335+
g := NewWithT(t)
336+
337+
longLineFile := filepath.Join(t.TempDir(), "long_line.txt")
338+
// bufio.Scanner's default max token size is 64KB
339+
longLine := strings.Repeat("x", 128*1024)
340+
fileContent := fmt.Sprintf("before\n%s\nafter\n", longLine)
341+
342+
err := os.WriteFile(longLineFile, []byte(fileContent), 0644)
343+
g.Expect(err).ToNot(HaveOccurred())
344+
345+
executor := cliwrappers.NewCliExecutor()
346+
347+
var stdout, stderr string
348+
var exitCode int
349+
logOutput := captureLogOutput(func() {
350+
cmd := cliwrappers.Command("cat", longLineFile)
351+
cmd.LogOutput = true
352+
stdout, stderr, exitCode, err = executor.Execute(cmd)
353+
})
354+
355+
g.Expect(err).ToNot(HaveOccurred())
356+
g.Expect(exitCode).To(Equal(0))
357+
g.Expect(stderr).To(BeEmpty())
358+
g.Expect(stdout).To(ContainSubstring("before"))
359+
g.Expect(stdout).To(ContainSubstring("\n" + longLine + "\n"))
360+
g.Expect(stdout).To(ContainSubstring("after"))
361+
362+
g.Expect(logOutput).To(ContainSubstring("before"))
363+
g.Expect(logOutput).To(ContainSubstring("stopped logging output: bufio.Scanner: token too long"))
364+
g.Expect(logOutput).ToNot(ContainSubstring(longLine))
365+
})
332366
}
333367

334368
func TestCheckCliToolAvailable(t *testing.T) {

0 commit comments

Comments
 (0)