Skip to content

Commit bd2fd80

Browse files
authored
executor: filter command output lines instead of buffering all of them (#365)
Replace the full-output bytes.Buffer capture in Local.Run and Remote.sshRun with a streaming line writer, add RunOpts.KeepLine so each caller retains only the lines it reads, drop the dead RunOpts.Verbose field, and compile the secret masking patterns once in MakeLogs. Resolves #363.
1 parent f0b7144 commit bd2fd80

12 files changed

Lines changed: 319 additions & 130 deletions

File tree

pkg/executor/dry.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package executor
22

33
import (
4-
"bytes"
54
"context"
65
"io"
76
"log"
@@ -21,12 +20,12 @@ func NewDry(logs Logs) *Dry {
2120
}
2221

2322
// Run shows the command content, doesn't execute it
24-
func (ex *Dry) Run(_ context.Context, cmd string, _ *RunOpts) (out []string, err error) {
23+
func (ex *Dry) Run(_ context.Context, cmd string, opts *RunOpts) (out []string, err error) {
2524
log.Printf("[DEBUG] run %s", cmd)
26-
var stdoutBuf bytes.Buffer
27-
mwr := io.MultiWriter(ex.logs.Out, &stdoutBuf)
25+
capture := newLineCapture(opts)
26+
mwr := io.MultiWriter(ex.logs.Out, capture)
2827
mwr.Write([]byte(cmd)) // nolint
29-
return splitOutputLines(stdoutBuf.String()), nil
28+
return capture.result(), nil
3029
}
3130

3231
// Upload doesn't actually upload, just prints the command

pkg/executor/dry_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ func TestDry_Run(t *testing.T) {
1818
dry := NewDry(MakeLogs(true, false, nil))
1919

2020
t.Run("single line", func(t *testing.T) {
21-
res, err := dry.Run(ctx, "ls -la /srv", &RunOpts{Verbose: true})
21+
res, err := dry.Run(ctx, "ls -la /srv", nil)
2222
require.NoError(t, err)
2323
require.Len(t, res, 1)
2424
require.Equal(t, "ls -la /srv", res[0])
2525
})
2626

2727
t.Run("multi line with blank line", func(t *testing.T) {
28-
res, err := dry.Run(ctx, "ls -la /srv\n\ndf -h\n", &RunOpts{Verbose: true})
28+
res, err := dry.Run(ctx, "ls -la /srv\n\ndf -h\n", nil)
2929
require.NoError(t, err)
3030
require.Equal(t, []string{"ls -la /srv", "", "df -h"}, res)
3131
})
@@ -143,7 +143,7 @@ func TestDry_RunLineOverScannerLimit(t *testing.T) {
143143
logs.Out = logs.Out.WithWriter(&buf)
144144

145145
cmd := strings.Repeat("x", 100000)
146-
res, err := NewDry(logs).Run(context.Background(), cmd, &RunOpts{Verbose: true})
146+
res, err := NewDry(logs).Run(context.Background(), cmd, nil)
147147
require.NoError(t, err)
148148
require.Len(t, res, 1)
149149
assert.Equal(t, cmd, res[0])

pkg/executor/executor.go

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package executor
44

55
import (
6+
"bytes"
67
"context"
78
"path"
89
"strings"
@@ -21,8 +22,12 @@ type Interface interface {
2122
}
2223

2324
// RunOpts is a struct for run options.
25+
// Note it is not comparable, since KeepLine is a func.
2426
type RunOpts struct {
25-
Verbose bool // print more info to primary stdout
27+
// KeepLine decides which stdout lines Run returns; a nil predicate keeps every line.
28+
// Every line still reaches the log in full, so this bounds only what the caller holds:
29+
// a command printing a large log costs nothing extra when its output is not read.
30+
KeepLine func(line string) bool
2631
}
2732

2833
// UpDownOpts is a struct for upload and download options.
@@ -64,6 +69,73 @@ func splitOutputLines(s string) []string {
6469
return res
6570
}
6671

72+
// lineCapture splits everything written to it into lines exactly as splitOutputLines does, keeping
73+
// only the ones keep accepts. Executors pass it alongside the log writer, so retention scales with
74+
// what the caller reads rather than with what the command printed. A line of any length is handled,
75+
// there is no scanner token limit, and an unterminated final line is returned like a terminated one.
76+
type lineCapture struct {
77+
keep func(line string) bool
78+
partial []byte
79+
lines []string
80+
}
81+
82+
func (lc *lineCapture) Write(p []byte) (n int, err error) {
83+
n = len(p)
84+
for {
85+
i := bytes.IndexByte(p, '\n')
86+
if i < 0 {
87+
lc.partial = append(lc.partial, p...)
88+
return n, nil
89+
}
90+
if len(lc.partial) == 0 {
91+
lc.take(p[:i]) // whole line arrived in this write, no need to stage it
92+
} else {
93+
lc.partial = append(lc.partial, p[:i]...)
94+
lc.take(lc.partial)
95+
lc.reset()
96+
}
97+
p = p[i+1:]
98+
}
99+
}
100+
101+
// reset readies the staging buffer for the next line. A buffer that stayed small is kept, sparing an
102+
// allocation per line, but a large one is released: holding it would pin the longest line for the rest
103+
// of the command, which is the retention this type exists to avoid.
104+
func (lc *lineCapture) reset() {
105+
const maxStageReuse = 64 << 10
106+
if cap(lc.partial) > maxStageReuse {
107+
lc.partial = nil
108+
return
109+
}
110+
lc.partial = lc.partial[:0]
111+
}
112+
113+
// take converts one line and keeps it if the predicate accepts. The string conversion copies, so
114+
// the retained line never pins the writer's buffer, and a rejected line is garbage at once.
115+
func (lc *lineCapture) take(b []byte) {
116+
line := string(bytes.TrimSuffix(b, []byte("\r")))
117+
if lc.keep == nil || lc.keep(line) {
118+
lc.lines = append(lc.lines, line)
119+
}
120+
}
121+
122+
// result flushes an unterminated final line and returns the kept lines, nil when nothing was kept.
123+
func (lc *lineCapture) result() []string {
124+
if len(lc.partial) > 0 {
125+
lc.take(lc.partial)
126+
lc.partial = nil
127+
}
128+
return lc.lines
129+
}
130+
131+
// newLineCapture builds a capture honoring opts, which may be nil.
132+
func newLineCapture(opts *RunOpts) *lineCapture {
133+
if opts == nil {
134+
return &lineCapture{}
135+
}
136+
return &lineCapture{keep: opts.KeepLine}
137+
}
138+
67139
// isExcluded reports whether fpath matches any of the exclude patterns. A pattern ending in "/*"
68140
// also matches the directory it names, so the whole subtree is protected, but this directory match
69141
// only applies when fpath is itself a directory. This prevents a pattern like "dir*/*" from

pkg/executor/executor_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package executor
22

33
import (
44
"bytes"
5+
"fmt"
56
"io"
67
"os"
8+
"strings"
79
"testing"
810
"time"
911

@@ -145,3 +147,92 @@ func TestSplitOutputLines(t *testing.T) {
145147
})
146148
}
147149
}
150+
151+
func TestLineCapture(t *testing.T) {
152+
tbl := []struct {
153+
name string
154+
in string
155+
res []string
156+
}{
157+
{"empty", "", nil},
158+
{"single line, no trailing newline", "hello", []string{"hello"}},
159+
{"single line with trailing newline", "hello\n", []string{"hello"}},
160+
{"multiple lines", "line1\nline2\nline3\n", []string{"line1", "line2", "line3"}},
161+
{"blank line in the middle", "line1\n\nline2\n", []string{"line1", "", "line2"}},
162+
{"single newline", "\n", []string{""}},
163+
{"trailing blank line", "line1\n\n", []string{"line1", ""}},
164+
{"crlf line endings", "line1\r\nline2\r\n", []string{"line1", "line2"}},
165+
}
166+
167+
// a nil predicate reproduces splitOutputLines whatever the write boundaries are, including a
168+
// chunk size that splits a line, a crlf pair or a run of newlines
169+
for _, tt := range tbl {
170+
for _, chunk := range []int{0, 1, 2, 3, 7} {
171+
t.Run(fmt.Sprintf("%s/chunk %d", tt.name, chunk), func(t *testing.T) {
172+
lc := &lineCapture{}
173+
writeInChunks(t, lc, tt.in, chunk)
174+
assert.Equal(t, tt.res, lc.result())
175+
assert.Equal(t, splitOutputLines(tt.in), lc.result(), "result is idempotent and matches the batch split")
176+
})
177+
}
178+
}
179+
}
180+
181+
func TestLineCaptureKeepLine(t *testing.T) {
182+
t.Run("keeps only accepted lines", func(t *testing.T) {
183+
lc := &lineCapture{keep: func(line string) bool { return strings.HasPrefix(line, "setvar ") }}
184+
writeInChunks(t, lc, "noise\nsetvar a=1\nmore noise\nsetvar b=2\n", 3)
185+
assert.Equal(t, []string{"setvar a=1", "setvar b=2"}, lc.result())
186+
})
187+
188+
t.Run("rejecting everything retains nothing", func(t *testing.T) {
189+
lc := &lineCapture{keep: func(string) bool { return false }}
190+
writeInChunks(t, lc, "one\ntwo\nthree", 0)
191+
assert.Nil(t, lc.result())
192+
})
193+
194+
t.Run("predicate sees an unterminated final line", func(t *testing.T) {
195+
var seen []string
196+
lc := &lineCapture{keep: func(line string) bool { seen = append(seen, line); return true }}
197+
writeInChunks(t, lc, "first\nlast-no-newline", 4)
198+
assert.Equal(t, []string{"first"}, seen, "the final line is only offered once result is called")
199+
assert.Equal(t, []string{"first", "last-no-newline"}, lc.result())
200+
})
201+
202+
t.Run("line longer than a scanner token limit survives", func(t *testing.T) {
203+
long := strings.Repeat("x", 1<<20)
204+
lc := &lineCapture{}
205+
writeInChunks(t, lc, long+"\nshort\n", 4096)
206+
require.Len(t, lc.result(), 2)
207+
assert.Equal(t, long, lc.result()[0])
208+
assert.Equal(t, "short", lc.result()[1])
209+
})
210+
}
211+
212+
// writeInChunks feeds s to w in fixed-size pieces, or in one write when size is 0, so a test can
213+
// pin behavior across the write boundaries a real command produces.
214+
func writeInChunks(t *testing.T, w io.Writer, s string, size int) {
215+
t.Helper()
216+
if size <= 0 {
217+
_, err := w.Write([]byte(s))
218+
require.NoError(t, err)
219+
return
220+
}
221+
for i := 0; i < len(s); i += size {
222+
end := min(i+size, len(s))
223+
_, err := w.Write([]byte(s[i:end]))
224+
require.NoError(t, err)
225+
}
226+
}
227+
228+
func TestLineCaptureReleasesLargeStagingBuffer(t *testing.T) {
229+
// a long line arriving in pieces has to be staged, but once it is consumed the buffer must go:
230+
// keeping it would pin the longest line for as long as the command runs, which is the retention
231+
// this type exists to remove
232+
lc := &lineCapture{keep: func(string) bool { return false }}
233+
writeInChunks(t, lc, strings.Repeat("x", 4<<20)+"\n", 4096)
234+
assert.LessOrEqual(t, cap(lc.partial), 64<<10, "the staging buffer is released once the line is consumed")
235+
236+
writeInChunks(t, lc, "short\n", 2) // still usable for the output that follows
237+
assert.Nil(t, lc.result())
238+
}

pkg/executor/local.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package executor
22

33
import (
4-
"bytes"
54
"context"
65
"errors"
76
"fmt"
@@ -27,7 +26,7 @@ func NewLocal(logs Logs) *Local {
2726
}
2827

2928
// Run executes command on local hostAddr, inside the shell
30-
func (l *Local) Run(ctx context.Context, cmd string, _ *RunOpts) (out []string, err error) {
29+
func (l *Local) Run(ctx context.Context, cmd string, opts *RunOpts) (out []string, err error) {
3130
shell := func() string {
3231
if strings.HasPrefix(cmd, "sh -c") {
3332
return "sh" // command has sh -c prefix, so use sh
@@ -51,15 +50,15 @@ func (l *Local) Run(ctx context.Context, cmd string, _ *RunOpts) (out []string,
5150
errLog := l.logs.Err.WithHost("localhost", "")
5251
outLog.Write([]byte(cmd)) // nolint
5352

54-
var stdoutBuf bytes.Buffer
55-
mwr := io.MultiWriter(outLog, &stdoutBuf)
53+
capture := newLineCapture(opts)
54+
mwr := io.MultiWriter(outLog, capture)
5655
command.Stdout, command.Stderr = mwr, errLog
5756
err = command.Run()
5857
if err != nil {
5958
return nil, err
6059
}
6160

62-
return splitOutputLines(stdoutBuf.String()), nil
61+
return capture.result(), nil
6362
}
6463

6564
// Upload just copy file from one place to another

pkg/executor/local_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,19 @@ func TestRun(t *testing.T) {
2121
l := NewLocal(MakeLogs(true, false, nil))
2222

2323
t.Run("single line out, success", func(t *testing.T) {
24-
out, e := l.Run(ctx, "echo 'hello world'", &RunOpts{Verbose: true})
24+
out, e := l.Run(ctx, "echo 'hello world'", nil)
2525
require.NoError(t, e)
2626
assert.Equal(t, []string{"hello world"}, out)
2727
})
2828

2929
t.Run("single line with sh -c, success", func(t *testing.T) {
30-
out, e := l.Run(ctx, "sh -c echo hello world", &RunOpts{Verbose: true})
30+
out, e := l.Run(ctx, "sh -c echo hello world", nil)
3131
require.NoError(t, e)
3232
assert.Equal(t, []string{"hello world"}, out)
3333
})
3434

3535
t.Run("single line with sh -c with single quotes, success", func(t *testing.T) {
36-
out, e := l.Run(ctx, "sh -c 'echo hello world'", &RunOpts{Verbose: true})
36+
out, e := l.Run(ctx, "sh -c 'echo hello world'", nil)
3737
require.NoError(t, e)
3838
assert.Equal(t, []string{"hello world"}, out)
3939
})
@@ -45,11 +45,11 @@ func TestRun(t *testing.T) {
4545

4646
t.Run("multi line out success", func(t *testing.T) {
4747
// prepare the test environment
48-
_, err := l.Run(ctx, "mkdir -p /tmp/st", &RunOpts{Verbose: true})
48+
_, err := l.Run(ctx, "mkdir -p /tmp/st", nil)
4949
require.NoError(t, err)
50-
_, err = l.Run(ctx, "cp testdata/data1.txt /tmp/st/data1.txt", &RunOpts{Verbose: true})
50+
_, err = l.Run(ctx, "cp testdata/data1.txt /tmp/st/data1.txt", nil)
5151
require.NoError(t, err)
52-
_, err = l.Run(ctx, "cp testdata/data2.txt /tmp/st/data2.txt", &RunOpts{Verbose: true})
52+
_, err = l.Run(ctx, "cp testdata/data2.txt /tmp/st/data2.txt", nil)
5353
require.NoError(t, err)
5454

5555
out, err := l.Run(ctx, "ls -1 /tmp/st", nil)
@@ -65,7 +65,7 @@ func TestRun(t *testing.T) {
6565
})
6666

6767
t.Run("find out", func(t *testing.T) {
68-
out, e := l.Run(ctx, "find /tmp/st -type f", &RunOpts{Verbose: true})
68+
out, e := l.Run(ctx, "find /tmp/st -type f", nil)
6969
require.NoError(t, e)
7070
slices.Sort(out)
7171
assert.Contains(t, out, "/tmp/st/data1.txt")
@@ -86,7 +86,7 @@ func TestRun(t *testing.T) {
8686
t.Run("with secrets", func(t *testing.T) {
8787
stdout := captureStdOut(t, func() {
8888
l := NewLocal(MakeLogs(true, false, []string{"data2"}))
89-
out, e := l.Run(ctx, "find /tmp/st -type f", &RunOpts{Verbose: true})
89+
out, e := l.Run(ctx, "find /tmp/st -type f", nil)
9090
require.NoError(t, e)
9191
slices.Sort(out)
9292
assert.Equal(t, []string{"/tmp/st/data1.txt", "/tmp/st/data2.txt"}, out)

0 commit comments

Comments
 (0)