diff --git a/internal/submit/watch.go b/internal/submit/watch.go index 6f8dfa66..7083b0ae 100644 --- a/internal/submit/watch.go +++ b/internal/submit/watch.go @@ -504,7 +504,16 @@ const displayLineMax = 16 * 1024 * 1024 // non-nil error only on a genuine read failure (network drop, ctx cancel). func streamDisplayAndParse(r io.Reader, out io.Writer, maxLine int) error { scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 0, 64*1024), maxLine) + // bufio.Scanner's token cap is max(maxLine, cap(initialBuf)), so the initial + // buffer must never exceed maxLine or it would silently raise the effective + // cap above maxLine. Start at 64 KB (grows on demand) for the common case, + // but clamp it so maxLine stays authoritative — the display path relies on it + // (production passes 16 MB, so the clamp is a no-op there). + initCap := 64 * 1024 + if maxLine < initCap { + initCap = maxLine + } + scanner.Buffer(make([]byte, 0, initCap), maxLine) for scanner.Scan() { // scanner strips the trailing '\n'; re-add it. errcheck-friendly: the // write error is discarded because the exit code is the contract. diff --git a/internal/submit/watch_display_test.go b/internal/submit/watch_display_test.go index 6bf86c96..b08554e1 100644 --- a/internal/submit/watch_display_test.go +++ b/internal/submit/watch_display_test.go @@ -33,14 +33,23 @@ func (r *errAfterReader) Read(p []byte) (int, error) { // watch returns a false exit 9 on a healthy run. The drain-past-ErrTooLong must // keep pulling so the parser still resolves the summary. func TestStreamDisplayAndParse_DrainsPastOversizedLineSoParserSeesBanner(t *testing.T) { - oversized := strings.Repeat("\rprocessing... ", 500) // ~7 KB, no '\n' + // A tqdm-style progress "line": 500 '\r'-redraws, no '\n', ~7.5 KB. + oversized := strings.Repeat("\rprocessing... ", 500) + // maxLine is authoritative in streamDisplayAndParse (the initial buffer is + // clamped to it), so a small cap genuinely trips bufio.ErrTooLong and fires + // the drain. Guard it: a cap >= the line would make this test vacuously pass + // WITHOUT exercising the drain. Production uses 16 MB. + const maxLine = 4096 + if len(oversized) <= maxLine { + t.Fatalf("test setup: oversized line (%d B) must exceed maxLine (%d B) to trip ErrTooLong", + len(oversized), maxLine) + } stream := strings.NewReader(oversized + realIngestorBanner) parser := NewSummaryParser() tee := io.TeeReader(stream, parserWriter{parser: parser}) var out bytes.Buffer - // Tiny cap so the oversized line trips ErrTooLong (production uses 16 MB). - if err := streamDisplayAndParse(tee, &out, 1024); err != nil { + if err := streamDisplayAndParse(tee, &out, maxLine); err != nil { t.Fatalf("an over-long DISPLAY line must not be fatal; got: %v", err) } parser.FlushLine()