From 3e3a21382ff827c379fcd0766f8344fa5b294822 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Wed, 25 Mar 2026 14:29:20 -0400 Subject: [PATCH 1/3] fix: address memory edge cases in grep context, sort input, and script parsing - grep: add 512 KiB per-match-group aggregate byte cap on before-context sliding window (-B/-C) and after-context output stream (-A/-C) - sort: lower MaxTotalBytes cap from 256 MiB to 5 MiB to bound memory in sort chains where N concurrent instances hold their full input simultaneously; improve error message with limit value and guidance - interp: add ParseScript() helper and MaxScriptBytes (5 MiB) constant so library callers have a size-checked parse entry point; update the CLI execute() to use it Gap 4 (pipeline goroutine depth) is documented but not fixed: pipelines are parsed left-recursively so N stages produce N-1 simultaneous goroutines. A semaphore deadlocks due to pipe backpressure holding goroutine slots. The principled fix (flatten pipeline + sliding-window goroutine pool) requires significantly reworking the execution model and is not worth the complexity. Co-Authored-By: Claude Sonnet 4.6 --- allowedsymbols/symbols_interp.go | 2 ++ builtins/grep/grep.go | 26 +++++++++++++++++++++++--- builtins/sort/sort.go | 7 ++++--- cmd/rshell/main.go | 5 ++--- interp/api.go | 25 +++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/allowedsymbols/symbols_interp.go b/allowedsymbols/symbols_interp.go index b688a9a0..de519009 100644 --- a/allowedsymbols/symbols_interp.go +++ b/allowedsymbols/symbols_interp.go @@ -51,6 +51,7 @@ var interpAllowedSymbols = []string{ "strconv.Itoa", // 🟢 int-to-string conversion; pure function, no I/O. "strings.Builder", // 🟢 efficient string concatenation; pure in-memory buffer, no I/O. "strings.ContainsRune", // 🟢 checks if a rune is in a string; pure function, no I/O. + "strings.NewReader", // 🟢 wraps a string as an io.Reader; pure function, no I/O; used by ParseScript. "strings.Index", // 🟢 finds substring index; pure function, no I/O. "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. "strings.HasSuffix", // 🟢 pure function for suffix matching; no I/O. @@ -127,6 +128,7 @@ var interpAllowedSymbols = []string{ "mvdan.cc/sh/v3/syntax.TestClause", // 🟢 AST node for [[ ]] test command; pure type. "mvdan.cc/sh/v3/syntax.TestDecl", // 🟢 AST node for test declaration; pure type. "mvdan.cc/sh/v3/syntax.TimeClause", // 🟢 AST node for time command; pure type. + "mvdan.cc/sh/v3/syntax.NewParser", // 🟢 creates a new shell parser; used by ParseScript to parse scripts into AST nodes. "mvdan.cc/sh/v3/syntax.Walk", // 🟢 traverses the AST; pure function, no I/O. "mvdan.cc/sh/v3/syntax.WhileClause", // 🟢 AST node for while/until loop; pure type. "mvdan.cc/sh/v3/syntax.Word", // 🟢 AST node for a shell word; pure type. diff --git a/builtins/grep/grep.go b/builtins/grep/grep.go index d03f73d7..ae51dea2 100644 --- a/builtins/grep/grep.go +++ b/builtins/grep/grep.go @@ -138,6 +138,13 @@ const MaxLineBytes = 1 << 20 // 1 MiB // MaxContextLines caps -A/-B/-C to prevent excessive memory use. const MaxContextLines = 1_000 // 1k lines +// MaxContextBytes is the aggregate byte cap applied per match group to both +// the before-context sliding window and the after-context output stream. +// A single match group may emit at most this many bytes of context lines +// (before and after counted separately). The global executor output limit +// acts as the ceiling across all groups combined. +const MaxContextBytes = 512 * 1024 // 512 KiB + const scanBufInit = 4096 // initial scanner buffer // containsNUL reports whether p contains a NUL byte, which is the @@ -593,7 +600,9 @@ func grepFile(ctx context.Context, callCtx *builtins.CallContext, file string, o // used, even with value 0. This controls the "--" group separator. contextRequested := opts.afterContext > 0 || opts.beforeContext > 0 || opts.contextRequested var beforeBuf []contextLine // ring buffer for before-context + beforeBufBytes := 0 // aggregate byte count of lines in beforeBuf afterRemaining := 0 // lines of after-context still to print + afterGroupBytes := 0 // bytes of after-context emitted in current match group lastPrintedLine := 0 // last line number we printed (for separator) printedSeparator := false // have we ever printed a match group? @@ -652,6 +661,9 @@ func grepFile(ctx context.Context, callCtx *builtins.CallContext, file string, o } } + // Reset per-group counters. + afterGroupBytes = 0 + // Print the match. if opts.onlyMatching && opts.invertMatch { // -o -v: line was selected by inversion (doesn't contain @@ -673,22 +685,30 @@ func grepFile(ctx context.Context, callCtx *builtins.CallContext, file string, o // Clear before buffer since we've consumed it. beforeBuf = beforeBuf[:0] + beforeBufBytes = 0 } else { // Non-matching line: might be after-context or before-context. if !isBinary && afterRemaining > 0 && !opts.quiet && !opts.count && !opts.filesWithMatches && !opts.filesWithoutMatch { - printContextLine(callCtx, displayName, lineNum, lineBytes, opts, '-') - lastPrintedLine = lineNum + if afterGroupBytes+len(lineBytes) <= MaxContextBytes { + printContextLine(callCtx, displayName, lineNum, lineBytes, opts, '-') + lastPrintedLine = lineNum + afterGroupBytes += len(lineBytes) + } afterRemaining-- } // Add to before-context ring buffer. if !isBinary && opts.beforeContext > 0 { - if len(beforeBuf) >= opts.beforeContext { + // Evict oldest lines until both the line-count and aggregate + // byte limits are satisfied. + for len(beforeBuf) > 0 && (len(beforeBuf) >= opts.beforeContext || beforeBufBytes+len(lineBytes) > MaxContextBytes) { + beforeBufBytes -= len(beforeBuf[0].text) beforeBuf = beforeBuf[1:] } cp := make([]byte, len(lineBytes)) copy(cp, lineBytes) beforeBuf = append(beforeBuf, contextLine{num: lineNum, text: cp}) + beforeBufBytes += len(lineBytes) } } } diff --git a/builtins/sort/sort.go b/builtins/sort/sort.go index fe8e6da3..d7711f66 100644 --- a/builtins/sort/sort.go +++ b/builtins/sort/sort.go @@ -132,8 +132,9 @@ const MaxLineBytes = 1 << 20 // 1 MiB // MaxTotalBytes is the cumulative byte cap across all input lines. This // prevents OOM when many lines are each below MaxLineBytes but collectively -// consume excessive memory. 256 MiB is generous for agent workloads. -const MaxTotalBytes = 256 * 1024 * 1024 // 256 MiB +// consume excessive memory — especially in sort chains where N concurrent +// sort instances each hold their full input buffer simultaneously. +const MaxTotalBytes = 5 * 1024 * 1024 // 5 MiB // registerFlags registers all sort flags and returns the bound handler. func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { @@ -391,7 +392,7 @@ func readFile(ctx context.Context, callCtx *builtins.CallContext, file string, t line := sc.Text() *totalBytes += int64(len(line)) if *totalBytes > MaxTotalBytes { - return nil, errors.New("input exceeds maximum total size") + return nil, fmt.Errorf("input exceeds maximum of %d MiB; pre-filter or split the input before sorting", MaxTotalBytes/(1024*1024)) } lines = append(lines, line) if len(lines) > MaxLines { diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index c939b302..d5f6dd7c 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -19,7 +19,6 @@ import ( "github.com/DataDog/rshell/internal/interpoption" "github.com/DataDog/rshell/interp" "github.com/spf13/cobra" - "mvdan.cc/sh/v3/syntax" ) const exitCodeTimeout = 124 @@ -204,8 +203,8 @@ type executeOpts struct { } func execute(ctx context.Context, script, name string, opts executeOpts, stdin io.Reader, stdout, stderr io.Writer) error { - // Parse. - prog, err := syntax.NewParser().Parse(strings.NewReader(script), name) + // Parse (also enforces the MaxScriptBytes limit). + prog, err := interp.ParseScript(script, name) if err != nil { // Bash returns exit code 2 for syntax/parse errors. fmt.Fprintf(stderr, "%v\n", err) diff --git a/interp/api.go b/interp/api.go index 3a30d3db..0b708bab 100644 --- a/interp/api.go +++ b/interp/api.go @@ -437,6 +437,31 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { return nil } +// MaxScriptBytes is the maximum allowed byte length of a shell script passed +// to [ParseScript]. Scripts larger than this are rejected before parsing to +// prevent the parser from allocating unbounded memory. Unlike other per-input +// limits (variables, command substitution, per-line builtins) this cap is +// enforced at the API boundary rather than inside the interpreter, because the +// interpreter only receives the pre-parsed AST. +const MaxScriptBytes = 5 * 1024 * 1024 // 5 MiB + +// ParseScript parses script as a shell program and returns the resulting AST. +// It enforces [MaxScriptBytes]: if len(script) exceeds that limit the call +// returns an error immediately, before the parser allocates any memory. +// +// name is an optional filename used in parse-error messages (pass "" if +// there is no associated file). +// +// Library callers should use ParseScript rather than calling the underlying +// syntax parser directly so that the size limit is consistently enforced. +func ParseScript(script, name string) (*syntax.File, error) { + if len(script) > MaxScriptBytes { + return nil, fmt.Errorf("script size %d bytes exceeds maximum of %d bytes (%d MiB); split the script into smaller pieces", + len(script), MaxScriptBytes, MaxScriptBytes/(1024*1024)) + } + return syntax.NewParser().Parse(strings.NewReader(script), name) +} + // Close releases resources held by the Runner, such as os.Root file descriptors // opened by AllowedPaths. It is safe to call Close multiple times. func (r *Runner) Close() error { From 202cabc6ab4faa4f306a7b97ece5aea42d1ce649 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Wed, 25 Mar 2026 14:45:37 -0400 Subject: [PATCH 2/3] test: add coverage for grep context caps, sort input limit, and ParseScript - grep: TestGrepBeforeContextByteCapEvictsOldLines and TestGrepAfterContextByteCapTruncatesOutput verify that output stays within MaxContextBytes (512 KiB) per match group when large lines are used with -B/-A; TestGrepBeforeContextMemoryBounded (bench-as-test) checks per-operation allocation stays within budget for -B 1000 with 8 KiB lines - sort: TestSortInputExceedsMaxTotalBytes verifies exit 1 with descriptive error when input content exceeds MaxTotalBytes (5 MiB); TestSortInputBelowMaxTotalBytes confirms the boundary is accepted - ParseScript: TestParseScriptRejectsOversizedInput / AcceptsValidInput / RejectsInvalidSyntax cover the interp.ParseScript API directly; TestScriptExceedsMaxScriptBytes and TestScriptAtMaxScriptBytes cover the CLI path through execute() Co-Authored-By: Claude Sonnet 4.6 --- builtins/grep/builtin_grep_pentest_test.go | 53 ++++++++++++++++++++++ builtins/grep/grep_bench_test.go | 34 ++++++++++++++ builtins/sort/builtin_sort_pentest_test.go | 47 +++++++++++++++++++ cmd/rshell/main_test.go | 35 ++++++++++++++ interp/allowed_paths_test.go | 34 ++++++++++++++ 5 files changed, 203 insertions(+) diff --git a/builtins/grep/builtin_grep_pentest_test.go b/builtins/grep/builtin_grep_pentest_test.go index 6ed6cabb..65a9367e 100644 --- a/builtins/grep/builtin_grep_pentest_test.go +++ b/builtins/grep/builtin_grep_pentest_test.go @@ -297,6 +297,59 @@ func TestGrepPentestQuietWithMatch(t *testing.T) { assert.Equal(t, "", stderr) } +// --- Context byte cap --- + +// TestGrepBeforeContextByteCapEvictsOldLines verifies that the aggregate byte +// cap on the before-context sliding window (MaxContextBytes) is enforced. +// The file contains 1000 non-matching lines of ~1 KiB each (total ~1 MiB of +// context) followed by a single matching line. With -B 1000 the uncapped +// window would hold all 1000 lines (~1 MiB); the cap limits the window to +// MaxContextBytes (512 KiB), so the oldest lines are evicted and the total +// before-context output stays within the cap. +func TestGrepBeforeContextByteCapEvictsOldLines(t *testing.T) { + dir := t.TempDir() + linePayload := strings.Repeat("x", 1023) // 1023 bytes + newline = 1024 bytes/line + var sb strings.Builder + for i := 0; i < 1000; i++ { + sb.WriteString(linePayload) + sb.WriteByte('\n') + } + sb.WriteString("MATCH\n") + pentestWriteFile(t, dir, "ctx.txt", sb.String()) + + stdout, _, code := grepRun(t, "grep -B 1000 MATCH ctx.txt", dir) + require.Equal(t, 0, code) + // Output = (evicted-before-context lines) + "MATCH\n". + // Before-context bytes must not exceed the cap. + assert.LessOrEqual(t, len(stdout)-len("MATCH\n"), grep.MaxContextBytes, + "before-context output exceeded MaxContextBytes cap") +} + +// TestGrepAfterContextByteCapTruncatesOutput verifies that the aggregate byte +// cap on after-context output (MaxContextBytes) is enforced per match group. +// The file has one matching line followed by 1000 non-matching lines of ~1 KiB +// each (total ~1 MiB of potential after-context). With -A 1000 the uncapped +// stream would emit all 1000 lines; the cap stops emission once MaxContextBytes +// have been written for the group, so total after-context output stays within +// the cap. +func TestGrepAfterContextByteCapTruncatesOutput(t *testing.T) { + dir := t.TempDir() + linePayload := strings.Repeat("x", 1023) // 1023 bytes + newline = 1024 bytes/line + var sb strings.Builder + sb.WriteString("MATCH\n") + for i := 0; i < 1000; i++ { + sb.WriteString(linePayload) + sb.WriteByte('\n') + } + pentestWriteFile(t, dir, "ctx.txt", sb.String()) + + stdout, _, code := grepRun(t, "grep -A 1000 MATCH ctx.txt", dir) + require.Equal(t, 0, code) + // Output = "MATCH\n" + (after-context lines, capped at MaxContextBytes). + assert.LessOrEqual(t, len(stdout)-len("MATCH\n"), grep.MaxContextBytes, + "after-context output exceeded MaxContextBytes cap") +} + // --- GTFOBins validation --- // TestGrepGTFOBinsFileReadSandboxEscape verifies that the GTFOBins file-read diff --git a/builtins/grep/grep_bench_test.go b/builtins/grep/grep_bench_test.go index 19b2c8f5..1c7165f0 100644 --- a/builtins/grep/grep_bench_test.go +++ b/builtins/grep/grep_bench_test.go @@ -11,6 +11,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" "github.com/DataDog/rshell/builtins/testutil" @@ -101,6 +102,39 @@ func TestGrepMemoryBounded(t *testing.T) { } } +// TestGrepBeforeContextMemoryBounded asserts that grep -B N with large lines +// stays within the MaxContextBytes sliding-window cap. Lines are 8 KiB each; +// requesting -B 1000 would hold 1000 × 8 KiB ≈ 8 MiB live without the cap. +// With the cap the live window is bounded to MaxContextBytes (512 KiB). +// +// AllocedBytesPerOp captures total (not peak live) allocations: the before- +// context path allocates a copy of each line before deciding to evict, so +// total allocation tracks file size. The threshold here validates that +// allocations do not grow beyond the expected O(file_size) budget and that no +// additional unbounded accumulation occurs. +func TestGrepBeforeContextMemoryBounded(t *testing.T) { + dir := t.TempDir() + // 8 KiB lines; 10 MiB file ≈ 1280 lines. Requesting -B 1000 means the + // uncapped window would hold the entire file (1280 × 8 KiB ≈ 10 MiB live). + // With the cap the window is bounded to MaxContextBytes (512 KiB). + const lineSize = 8 * 1024 + createLargeFileGrep(t, dir, "input.txt", strings.Repeat("x", lineSize-1)+"\n", 10<<20) + + result := testing.Benchmark(func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + testutil.RunScriptDiscard(b, "grep -B 1000 NOMATCH input.txt", dir, interp.AllowedPaths([]string{dir})) + } + }) + + // Total allocation budget: ~10 MiB of per-line copies + shell/runner overhead. + // Capped at 24 MiB to catch any unexpected accumulation. + const maxBytesPerOp = 24 << 20 + if bpo := result.AllocedBytesPerOp(); bpo > maxBytesPerOp { + t.Errorf("grep -B 1000 allocated %d bytes/op on 10MB input with 8KiB lines; want < %d", bpo, maxBytesPerOp) + } +} + func BenchmarkGrepMatchDiscard(b *testing.B) { dir := b.TempDir() createLargeFileGrep(b, dir, "input.txt", "the quick brown fox jumps over the lazy dog\n", 10<<20) diff --git a/builtins/sort/builtin_sort_pentest_test.go b/builtins/sort/builtin_sort_pentest_test.go index 4d443df9..3c035275 100644 --- a/builtins/sort/builtin_sort_pentest_test.go +++ b/builtins/sort/builtin_sort_pentest_test.go @@ -23,6 +23,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/DataDog/rshell/builtins/sort" "github.com/DataDog/rshell/interp" ) @@ -204,6 +205,52 @@ func TestCmdPentestUnknownShortFlag(t *testing.T) { assert.Contains(t, stderr, "sort:") } +// --- Input size cap --- + +// TestSortInputExceedsMaxTotalBytes verifies that sort rejects input larger +// than MaxTotalBytes (5 MiB) with a descriptive error so callers know what +// limit was hit and can pre-filter their data. +// +// sort counts content bytes via sc.Text() which strips the line terminator, +// so we use lines of 1000 'a' chars (1001 bytes on disk, 1000 counted) to +// keep the content-to-file-size ratio high and ensure we reliably cross the +// limit. +func TestSortInputExceedsMaxTotalBytes(t *testing.T) { + dir := t.TempDir() + // Each line: 1000 content bytes + newline. Lines needed to exceed cap: + // ceil(MaxTotalBytes/1000) + 1. + line := []byte(strings.Repeat("a", 1000) + "\n") + nLines := sort.MaxTotalBytes/1000 + 2 + content := bytes.Repeat(line, nLines) + require.NoError(t, os.WriteFile(filepath.Join(dir, "big.txt"), content, 0644)) + + mustNotHang(t, func() { + _, stderr, code := sortRun(t, "sort big.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") + assert.Contains(t, stderr, "exceeds maximum") + assert.Contains(t, stderr, "5 MiB") + }) +} + +// TestSortInputBelowMaxTotalBytes verifies that sort succeeds when input is +// just below the MaxTotalBytes cap. +func TestSortInputBelowMaxTotalBytes(t *testing.T) { + dir := t.TempDir() + // Each line: 1000 content bytes + newline; nLines × 1000 < MaxTotalBytes + // and nLines << MaxLines (1,000,000) so neither cap is hit. + line := []byte(strings.Repeat("a", 1000) + "\n") + nLines := sort.MaxTotalBytes/1000 - 2 + content := bytes.Repeat(line, nLines) + require.NoError(t, os.WriteFile(filepath.Join(dir, "ok.txt"), content, 0644)) + + mustNotHang(t, func() { + _, stderr, code := sortRun(t, "sort ok.txt", dir) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + }) +} + // --- Double dash --- func TestCmdPentestFlagLikeName(t *testing.T) { diff --git a/cmd/rshell/main_test.go b/cmd/rshell/main_test.go index fe24bf29..0405c96b 100644 --- a/cmd/rshell/main_test.go +++ b/cmd/rshell/main_test.go @@ -17,6 +17,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" ) func runCLI(t *testing.T, args ...string) (exitCode int, stdout, stderr string) { @@ -281,3 +283,36 @@ func TestProcPathFlagInHelp(t *testing.T) { assert.Equal(t, 0, code) assert.Contains(t, stdout, "--proc-path") } + +// TestScriptExceedsMaxScriptBytes verifies that the CLI rejects a script +// larger than interp.MaxScriptBytes (5 MiB) with exit code 2 and a +// descriptive error message that tells the caller what limit was hit. +func TestScriptExceedsMaxScriptBytes(t *testing.T) { + // Build a syntactically valid script just over the limit so the error is + // definitely from the size check, not the parser. + line := "echo hello\n" + script := strings.Repeat(line, interp.MaxScriptBytes/len(line)+1) + require.Greater(t, len(script), interp.MaxScriptBytes) + + code, _, stderr := runCLI(t, "-c", script) + assert.Equal(t, 2, code, "oversized script should return exit code 2") + assert.Contains(t, stderr, "exceeds maximum") + assert.Contains(t, stderr, "5 MiB") +} + +// TestScriptAtMaxScriptBytes verifies that a script exactly at the limit is +// accepted (boundary condition). +func TestScriptAtMaxScriptBytes(t *testing.T) { + // Build a script of exactly MaxScriptBytes: a comment line followed by the + // command. The comment must end with \n so the parser sees two separate + // lines, not a single comment that swallows the command. + cmd := "echo ok\n" + // comment = (MaxScriptBytes - len(cmd) - 1) '#' chars + '\n' + comment := strings.Repeat("#", interp.MaxScriptBytes-len(cmd)-1) + "\n" + script := comment + cmd + require.Equal(t, interp.MaxScriptBytes, len(script)) + + code, stdout, _ := runCLI(t, "--allow-all-commands", "-c", script) + assert.Equal(t, 0, code) + assert.Equal(t, "ok\n", stdout) +} diff --git a/interp/allowed_paths_test.go b/interp/allowed_paths_test.go index d435fcfc..7a34d606 100644 --- a/interp/allowed_paths_test.go +++ b/interp/allowed_paths_test.go @@ -238,6 +238,40 @@ func TestAllowedPathsDefaultBlocksAll(t *testing.T) { assert.Contains(t, stderr, "permission denied") } +// TestParseScriptRejectsOversizedInput verifies that interp.ParseScript returns +// an error (without calling the parser) when the script byte length exceeds +// interp.MaxScriptBytes (5 MiB). This is the library-level enforcement that +// prevents callers from accidentally bypassing the limit by using the syntax +// parser directly. +func TestParseScriptRejectsOversizedInput(t *testing.T) { + // A script just over the limit; syntactically valid so any error is from + // the size check, not the parser. + line := "echo hello\n" + script := strings.Repeat(line, interp.MaxScriptBytes/len(line)+1) + require.Greater(t, len(script), interp.MaxScriptBytes) + + _, err := interp.ParseScript(script, "test.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") + assert.Contains(t, err.Error(), "5 MiB") +} + +// TestParseScriptAcceptsValidInput verifies that interp.ParseScript succeeds +// for a normal-sized, syntactically valid script. +func TestParseScriptAcceptsValidInput(t *testing.T) { + prog, err := interp.ParseScript("echo hello\n", "test.sh") + require.NoError(t, err) + require.NotNil(t, prog) +} + +// TestParseScriptRejectsInvalidSyntax verifies that interp.ParseScript +// propagates parse errors from the underlying syntax parser. +func TestParseScriptRejectsInvalidSyntax(t *testing.T) { + _, err := interp.ParseScript(`echo "unterminated`, "test.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "without closing quote") +} + func TestAllowedPathsClose(t *testing.T) { dir := t.TempDir() runner, err := interp.New( From ece2ab1c60f9bc1e2bafacd8448ffeffe5ec5106 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Wed, 25 Mar 2026 14:58:05 -0400 Subject: [PATCH 3/3] fix(fuzz): propagate fuzz budget cancellation to tail worker goroutines Change context.Background() to t.Context() as the parent for per-invocation timeouts in FuzzTailLines, FuzzTailBytes, FuzzTailStdin, and FuzzTailBytesOffset. Add early-exit checks (t.Context().Err() != nil) at the top of each fuzz function and after each cmdRunCtx call. When the 30-second fuzz budget expires the framework cancels t.Context(), which now propagates through the WithTimeout parent chain and unblocks stuck workers instead of leaving them to drain the budget. Co-Authored-By: Claude Sonnet 4.6 --- builtins/tests/tail/tail_fuzz_test.go | 32 +++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/builtins/tests/tail/tail_fuzz_test.go b/builtins/tests/tail/tail_fuzz_test.go index 2e90e5ef..dea12fa4 100644 --- a/builtins/tests/tail/tail_fuzz_test.go +++ b/builtins/tests/tail/tail_fuzz_test.go @@ -50,6 +50,9 @@ func FuzzTailLines(f *testing.F) { f.Add(bytes.Repeat([]byte("\n"), 1000), int64(5)) f.Fuzz(func(t *testing.T, input []byte, n int64) { + if t.Context().Err() != nil { + return + } if len(input) > 1<<20 { return } @@ -66,10 +69,13 @@ func FuzzTailLines(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() // safety net if t.Fatal fires before explicit cancel stdout, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -n %d input.txt", n), dir) cancel() + if t.Context().Err() != nil { + return + } if code != 0 && code != 1 { t.Errorf("tail -n %d unexpected exit code %d", n, code) } @@ -107,6 +113,9 @@ func FuzzTailBytes(f *testing.F) { f.Add(bytes.Repeat([]byte("z"), 32*1024+1), int64(1)) f.Fuzz(func(t *testing.T, input []byte, n int64) { + if t.Context().Err() != nil { + return + } if len(input) > 1<<20 { return } @@ -123,10 +132,13 @@ func FuzzTailBytes(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() // safety net if t.Fatal fires before explicit cancel stdout, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -c %d input.txt", n), dir) cancel() + if t.Context().Err() != nil { + return + } if code != 0 && code != 1 { t.Errorf("tail -c %d unexpected exit code %d", n, code) } @@ -154,6 +166,9 @@ func FuzzTailStdin(f *testing.F) { f.Add([]byte("line1\r\nline2\r\n"), int64(1)) f.Fuzz(func(t *testing.T, input []byte, n int64) { + if t.Context().Err() != nil { + return + } if len(input) > 1<<20 { return } @@ -170,10 +185,13 @@ func FuzzTailStdin(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -n %d < stdin.txt", n), dir) cancel() + if t.Context().Err() != nil { + return + } if code != 0 && code != 1 { t.Errorf("tail stdin unexpected exit code %d", code) } @@ -251,6 +269,9 @@ func FuzzTailBytesOffset(f *testing.F) { f.Add([]byte{0x00, 0x01, 0x02, 0xff, 0xfe}, int64(2)) f.Fuzz(func(t *testing.T, input []byte, n int64) { + if t.Context().Err() != nil { + return + } if len(input) > 1<<20 { return } @@ -267,10 +288,13 @@ func FuzzTailBytesOffset(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -c +%d input.txt", n), dir) cancel() + if t.Context().Err() != nil { + return + } if code != 0 && code != 1 { t.Errorf("tail -c +%d unexpected exit code %d", n, code) }