From 7fcbc91dd4a8d559ec828fdf30dbe68dc24b68f0 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Mon, 24 Aug 2026 03:17:35 +0000 Subject: [PATCH] ci: exercise lifecycle-sensitive paths --- .github/workflows/ci.yml | 49 ++++++++++ internal/grpc/framing_fuzz_test.go | 18 ++++ internal/grpc/framing_test.go | 21 +++++ internal/pager/pager.go | 17 +++- internal/pager/pager_test.go | 75 +++++++++++++-- internal/pager/pager_windows_test.go | 122 +++++++++++++++++++++++++ internal/pager/process_test_unix.go | 31 +++++++ internal/pager/process_test_windows.go | 52 +++++++++++ internal/pager/process_unix.go | 9 +- internal/pager/process_windows.go | 72 ++++++++++++++- internal/resolver/wire_fuzz_test.go | 23 +++++ internal/ws/write_fuzz_test.go | 32 +++++++ 12 files changed, 507 insertions(+), 14 deletions(-) create mode 100644 internal/grpc/framing_fuzz_test.go create mode 100644 internal/pager/pager_windows_test.go create mode 100644 internal/pager/process_test_unix.go create mode 100644 internal/pager/process_test_windows.go create mode 100644 internal/resolver/wire_fuzz_test.go create mode 100644 internal/ws/write_fuzz_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99dc6a37..f7052ab3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,12 +3,15 @@ name: ci on: pull_request: branches: ["main"] + schedule: + - cron: "17 3 * * 1" env: GO_VERSION: "1.27.0" jobs: check: + if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -37,6 +40,7 @@ jobs: install-go: false test: + if: ${{ github.event_name == 'pull_request' }} runs-on: ${{ matrix.os }} strategy: matrix: @@ -54,7 +58,23 @@ jobs: - name: Test run: go test -v -parallel 8 ./... + race: + if: ${{ github.event_name == 'pull_request' }} + name: race (linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Go + uses: actions/setup-go@v7 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Race test lifecycle-sensitive packages + run: go test -race -count=1 -parallel 8 ./internal/client ./internal/fetch ./internal/grpc ./internal/pager ./internal/resolver ./internal/ws + test-windows: + if: ${{ github.event_name == 'pull_request' }} name: test (windows-latest) needs: build-windows runs-on: windows-latest @@ -80,6 +100,7 @@ jobs: run: go test -v -parallel 8 ./... build-windows: + if: ${{ github.event_name == 'pull_request' }} name: build (windows-amd64) runs-on: ubuntu-latest steps: @@ -108,6 +129,7 @@ jobs: retention-days: 1 build: + if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest strategy: matrix: @@ -147,3 +169,30 @@ jobs: GOARCH: ${{ matrix.goarch }} CGO_ENABLED: "0" run: go install -trimpath -ldflags="-s -w" + + scheduled-smoke: + if: ${{ github.event_name == 'schedule' }} + name: scheduled fuzz and benchmark smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Go + uses: actions/setup-go@v7 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Fuzz DNS wire decoder + run: go test -run '^$' -fuzz FuzzDecodeDNSWire -fuzztime 5s ./internal/resolver + + - name: Fuzz gRPC framing + run: go test -run '^$' -fuzz FuzzUnframe -fuzztime 5s ./internal/grpc + + - name: Fuzz WebSocket piped-line framing + run: go test -run '^$' -fuzz FuzzReadBoundedLine -fuzztime 5s ./internal/ws + + - name: Benchmark terminal streaming + run: go test -run '^$' -bench '^BenchmarkTerminalSafeReader' -benchtime 1x ./internal/fetch + + - name: Benchmark gRPC framing + run: go test -run '^$' -bench '^BenchmarkFrame' -benchtime 1x ./internal/grpc diff --git a/internal/grpc/framing_fuzz_test.go b/internal/grpc/framing_fuzz_test.go new file mode 100644 index 00000000..7f491b20 --- /dev/null +++ b/internal/grpc/framing_fuzz_test.go @@ -0,0 +1,18 @@ +package grpc + +import "testing" + +func FuzzUnframe(f *testing.F) { + f.Add(Frame([]byte("hello"), false)) + f.Add(Frame([]byte("compressed"), true)) + f.Add([]byte{0x02, 0, 0, 0, 0}) + f.Add([]byte{0, 0x10, 0, 0, 0}) + + f.Fuzz(func(t *testing.T, frame []byte) { + const maxFuzzInput = 1 << 20 + if len(frame) > maxFuzzInput { + frame = frame[:maxFuzzInput] + } + _, _, _ = Unframe(frame) + }) +} diff --git a/internal/grpc/framing_test.go b/internal/grpc/framing_test.go index 56831ec2..94eef968 100644 --- a/internal/grpc/framing_test.go +++ b/internal/grpc/framing_test.go @@ -3,6 +3,7 @@ package grpc import ( "bytes" "compress/gzip" + "fmt" "io" "testing" ) @@ -335,3 +336,23 @@ func TestReadFrameRoundTrip(t *testing.T) { } } } + +var benchmarkFrameResult []byte + +func BenchmarkFrame(b *testing.B) { + for _, size := range []int{0, 1 << 10, 64 << 10, 1 << 20} { + b.Run(fmt.Sprintf("%dB", size), func(b *testing.B) { + data := bytes.Repeat([]byte{'x'}, size) + b.SetBytes(int64(len(data))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + var err error + benchmarkFrameResult, err = FrameChecked(data, false) + if err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/internal/pager/pager.go b/internal/pager/pager.go index e3753c16..25199195 100644 --- a/internal/pager/pager.go +++ b/internal/pager/pager.go @@ -77,16 +77,31 @@ func StreamContext(ctx context.Context, src io.Reader, mode core.PagerMode, stdo // An interactive pager must remain in the terminal's foreground process // group. Otherwise it cannot receive keystrokes such as `q`; the parent // still owns the pager's data pipe, so the command appears to hang. - configureProcess(cmd, stdoutTerminal) + if err := configureProcess(cmd, stdoutTerminal); err != nil { + return fmt.Errorf("unable to configure pager: %w", err) + } cmd.Stdout = dst cmd.Stderr = os.Stderr stdin, err := cmd.StdinPipe() if err != nil { + releaseProcess(cmd) return err } if err := cmd.Start(); err != nil { + terminateProcessTree(cmd) + if cmd.Process != nil { + _ = cmd.Wait() + } + releaseProcess(cmd) return fmt.Errorf("unable to start pager: %w", err) } + if err := attachProcess(cmd); err != nil { + terminateProcessTree(cmd) + _ = cmd.Wait() + releaseProcess(cmd) + return fmt.Errorf("unable to attach pager: %w", err) + } + defer releaseProcess(cmd) // A non-closable reader cannot be interrupted by this package. Keep its // copy synchronous so cancellation cannot strand a producer goroutine. diff --git a/internal/pager/pager_test.go b/internal/pager/pager_test.go index 8eb3fa37..19730fc7 100644 --- a/internal/pager/pager_test.go +++ b/internal/pager/pager_test.go @@ -3,7 +3,10 @@ package pager import ( "context" "io" + "os" + "path/filepath" "runtime" + "strconv" "strings" "testing" "time" @@ -75,23 +78,83 @@ func TestStreamContextTerminatesPagerProcessGroup(t *testing.T) { if testing.Short() || runtime.GOOS == "windows" { t.Skip("starts a Unix subprocess") } - t.Setenv("PAGER", "sh -c 'sleep 30'") - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + + fixtureDir := t.TempDir() + readyPath := filepath.Join(fixtureDir, "ready") + childPIDPath := filepath.Join(fixtureDir, "child.pid") + pagerPath := filepath.Join(fixtureDir, "pager.sh") + if err := os.WriteFile(pagerPath, []byte("#!/bin/sh\nsleep 30 &\nchild_pid=$!\nprintf '%s\\n' \"$child_pid\" > \"$FETCH_TEST_PAGER_CHILD_PID\"\nprintf ready > \"$FETCH_TEST_PAGER_READY\"\nwait \"$child_pid\"\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("FETCH_TEST_PAGER_READY", readyPath) + t.Setenv("FETCH_TEST_PAGER_CHILD_PID", childPIDPath) + t.Setenv("PAGER", pagerPath) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) done := make(chan error, 1) go func() { done <- StreamContext(ctx, strings.NewReader("help"), core.PagerOn, false, false, io.Discard) }() - time.Sleep(50 * time.Millisecond) + childPID := 0 + streamDone := false + t.Cleanup(func() { + cancel() + if !streamDone { + select { + case <-done: + case <-time.After(time.Second): + t.Log("pager cleanup exceeded its deadline") + } + } + if childPID != 0 && !testProcessExited(childPID) { + killTestProcess(childPID) + if !waitForTestProcessExit(childPID, time.Second) { + t.Logf("pager child process %d did not exit during cleanup", childPID) + } + } + }) + + readyDeadline := time.NewTimer(time.Second) + defer readyDeadline.Stop() + readyTicker := time.NewTicker(5 * time.Millisecond) + defer readyTicker.Stop() + for { + if _, err := os.Stat(readyPath); err == nil { + break + } + select { + case <-readyDeadline.C: + t.Fatal("pager fixture did not start before the deadline") + case <-ctx.Done(): + t.Fatal("pager fixture did not start before the deadline") + case <-readyTicker.C: + } + } + + pidData, err := os.ReadFile(childPIDPath) + if err != nil { + t.Fatalf("read pager child PID: %v", err) + } + childPID, err = strconv.Atoi(strings.TrimSpace(string(pidData))) + if err != nil || childPID <= 0 { + t.Fatalf("invalid pager child PID %q", pidData) + } + if testProcessExited(childPID) { + t.Fatalf("pager child process %d exited before cancellation", childPID) + } + cancel() select { - case err := <-done: - if err == nil { + case streamErr := <-done: + streamDone = true + if streamErr == nil { t.Fatal("canceled pager returned nil") } case <-time.After(2 * time.Second): t.Fatal("pager process group did not terminate") } + if !waitForTestProcessExit(childPID, time.Second) { + t.Fatalf("pager child process %d survived cancellation", childPID) + } } func TestStreamContextReportsPagerStartFailure(t *testing.T) { diff --git a/internal/pager/pager_windows_test.go b/internal/pager/pager_windows_test.go new file mode 100644 index 00000000..2f9e4c5c --- /dev/null +++ b/internal/pager/pager_windows_test.go @@ -0,0 +1,122 @@ +//go:build windows + +package pager + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/ryanfowler/fetch/internal/core" +) + +func TestStreamContextTerminatesPagerJobTree(t *testing.T) { + fixtureDir := t.TempDir() + readyPath := filepath.Join(fixtureDir, "ready") + childPIDPath := filepath.Join(fixtureDir, "child.pid") + t.Setenv("FETCH_TEST_PAGER_WINDOWS_MODE", "wrapper") + t.Setenv("FETCH_TEST_PAGER_WINDOWS_READY", readyPath) + t.Setenv("FETCH_TEST_PAGER_WINDOWS_CHILD_PID", childPIDPath) + t.Setenv("PAGER", fmt.Sprintf("%q -test.run=^TestPagerWindowsWrapper$", os.Args[0])) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- StreamContext(ctx, strings.NewReader("help"), core.PagerOn, false, false, io.Discard) + }() + + childPID := 0 + streamDone := false + t.Cleanup(func() { + cancel() + if !streamDone { + select { + case <-done: + case <-time.After(2 * time.Second): + t.Log("pager cleanup exceeded its deadline") + } + } + if childPID != 0 && !waitForTestProcessExit(childPID, time.Second) { + killTestProcess(childPID) + } + }) + + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + for { + if _, err := os.Stat(readyPath); err == nil { + break + } + select { + case <-deadline.C: + t.Fatal("Windows pager wrapper did not start before the deadline") + case <-done: + t.Fatal("Windows pager exited before creating its descendant") + case <-time.After(10 * time.Millisecond): + } + } + + pidData, err := os.ReadFile(childPIDPath) + if err != nil { + t.Fatalf("read pager child PID: %v", err) + } + childPID, err = strconv.Atoi(strings.TrimSpace(string(pidData))) + if err != nil || childPID <= 0 { + t.Fatalf("invalid pager child PID %q", pidData) + } + if testProcessExited(childPID) { + t.Fatalf("pager child process %d exited before cancellation", childPID) + } + + cancel() + select { + case streamErr := <-done: + streamDone = true + if streamErr == nil { + t.Fatal("canceled pager returned nil") + } + case <-time.After(5 * time.Second): + t.Fatal("canceled pager job did not terminate") + } + if !waitForTestProcessExit(childPID, 2*time.Second) { + t.Fatalf("pager child process %d survived cancellation", childPID) + } +} + +func TestPagerWindowsWrapper(t *testing.T) { + if os.Getenv("FETCH_TEST_PAGER_WINDOWS_MODE") != "wrapper" { + return + } + child := exec.Command(os.Args[0], "-test.run=^TestPagerWindowsChild$") + child.Env = append(os.Environ(), "FETCH_TEST_PAGER_WINDOWS_MODE=child") + if err := child.Start(); err != nil { + t.Fatalf("start pager descendant: %v", err) + } + if err := os.WriteFile(os.Getenv("FETCH_TEST_PAGER_WINDOWS_CHILD_PID"), []byte(strconv.Itoa(child.Process.Pid)), 0o600); err != nil { + _ = child.Process.Kill() + t.Fatalf("write pager child PID: %v", err) + } + if err := os.WriteFile(os.Getenv("FETCH_TEST_PAGER_WINDOWS_READY"), []byte("ready\n"), 0o600); err != nil { + _ = child.Process.Kill() + t.Fatalf("write pager ready marker: %v", err) + } + if err := child.Wait(); err != nil { + t.Fatalf("pager descendant exited: %v", err) + } +} + +func TestPagerWindowsChild(t *testing.T) { + if os.Getenv("FETCH_TEST_PAGER_WINDOWS_MODE") != "child" { + return + } + for { + time.Sleep(time.Hour) + } +} diff --git a/internal/pager/process_test_unix.go b/internal/pager/process_test_unix.go new file mode 100644 index 00000000..922c4cc2 --- /dev/null +++ b/internal/pager/process_test_unix.go @@ -0,0 +1,31 @@ +//go:build !windows + +package pager + +import ( + "errors" + "syscall" + "time" +) + +func testProcessExited(pid int) bool { + err := syscall.Kill(pid, 0) + return errors.Is(err, syscall.ESRCH) +} + +func killTestProcess(pid int) { + _ = syscall.Kill(pid, syscall.SIGKILL) +} + +func waitForTestProcessExit(pid int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for { + if testProcessExited(pid) { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/internal/pager/process_test_windows.go b/internal/pager/process_test_windows.go new file mode 100644 index 00000000..dfbe5cbf --- /dev/null +++ b/internal/pager/process_test_windows.go @@ -0,0 +1,52 @@ +//go:build windows + +package pager + +import ( + "errors" + "time" + + "golang.org/x/sys/windows" +) + +func testProcessExited(pid int) bool { + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, uint32(pid)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return true + } + if err != nil { + return false + } + defer windows.CloseHandle(process) + result, err := windows.WaitForSingleObject(process, 0) + return err == nil && result == windows.WAIT_OBJECT_0 +} + +func killTestProcess(pid int) { + process, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid)) + if err != nil { + return + } + defer windows.CloseHandle(process) + _ = windows.TerminateProcess(process, 1) +} + +func waitForTestProcessExit(pid int, timeout time.Duration) bool { + process, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return true + } + if err != nil { + return false + } + defer windows.CloseHandle(process) + milliseconds := timeout.Milliseconds() + if milliseconds < 0 { + milliseconds = 0 + } + if milliseconds > int64(^uint32(0)-1) { + milliseconds = int64(^uint32(0) - 1) + } + result, err := windows.WaitForSingleObject(process, uint32(milliseconds)) + return err == nil && result == windows.WAIT_OBJECT_0 +} diff --git a/internal/pager/process_unix.go b/internal/pager/process_unix.go index bad488a7..187c7794 100644 --- a/internal/pager/process_unix.go +++ b/internal/pager/process_unix.go @@ -8,15 +8,20 @@ import ( "syscall" ) -func configureProcess(cmd *exec.Cmd, interactive bool) { +func configureProcess(cmd *exec.Cmd, interactive bool) error { if interactive { // The pager must stay in the foreground process group to read commands // from the terminal. The parent still owns the pager's data pipe. - return + return nil } cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + return nil } +func attachProcess(*exec.Cmd) error { return nil } + +func releaseProcess(*exec.Cmd) {} + func terminateProcessTree(cmd *exec.Cmd) { if cmd.Process == nil { return diff --git a/internal/pager/process_windows.go b/internal/pager/process_windows.go index b923bf2d..2465a875 100644 --- a/internal/pager/process_windows.go +++ b/internal/pager/process_windows.go @@ -2,17 +2,79 @@ package pager -import "os/exec" +import ( + "fmt" + "os/exec" + "sync" + "syscall" + "unsafe" -// Windows process-tree containment is best-effort here. The direct pager is -// always terminated; the pager parser does not invoke a shell, which limits -// the usual descendant-process risk. -func configureProcess(cmd *exec.Cmd, interactive bool) {} + "golang.org/x/sys/windows" +) + +var pagerJobs sync.Map // map[*exec.Cmd]windows.Handle +var pagerResumeProcess = windows.NewLazySystemDLL("ntdll.dll").NewProc("NtResumeProcess") + +func configureProcess(cmd *exec.Cmd, interactive bool) error { + // Keep the pager suspended until it is assigned to the job. This closes the + // race in which a wrapper can create a descendant before job assignment. + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED} + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return fmt.Errorf("create pager job: %w", err) + } + limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))) + if err != nil { + _ = windows.CloseHandle(job) + return fmt.Errorf("configure pager job: %w", err) + } + pagerJobs.Store(cmd, job) + return nil +} + +func attachProcess(cmd *exec.Cmd) error { + value, ok := pagerJobs.Load(cmd) + if !ok { + return fmt.Errorf("pager job is unavailable") + } + if cmd.Process == nil { + return fmt.Errorf("pager process is unavailable") + } + job := value.(windows.Handle) + process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_SUSPEND_RESUME, false, uint32(cmd.Process.Pid)) + if err != nil { + return fmt.Errorf("open pager process: %w", err) + } + defer windows.CloseHandle(process) + if err := windows.AssignProcessToJobObject(job, process); err != nil { + return fmt.Errorf("attach pager process to job: %w", err) + } + if status, _, callErr := pagerResumeProcess.Call(uintptr(process)); status != 0 { + if callErr != nil { + return fmt.Errorf("resume pager process: %w", callErr) + } + return fmt.Errorf("resume pager process: NTSTATUS 0x%x", status) + } + return nil +} func terminateProcessTree(cmd *exec.Cmd) { + if value, ok := pagerJobs.Load(cmd); ok { + _ = windows.TerminateJobObject(value.(windows.Handle), 1) + } if cmd.Process != nil { + // This covers the short interval where job assignment failed and the + // process is still suspended outside the job. _ = cmd.Process.Kill() } } +func releaseProcess(cmd *exec.Cmd) { + if value, ok := pagerJobs.LoadAndDelete(cmd); ok { + _ = windows.CloseHandle(value.(windows.Handle)) + } +} + func pagerExitWasSIGPIPE(error) bool { return false } diff --git a/internal/resolver/wire_fuzz_test.go b/internal/resolver/wire_fuzz_test.go new file mode 100644 index 00000000..3cd74572 --- /dev/null +++ b/internal/resolver/wire_fuzz_test.go @@ -0,0 +1,23 @@ +package resolver + +import "testing" + +func FuzzDecodeDNSWire(f *testing.F) { + f.Add([]byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) + f.Add([]byte{0, 1, 0x80, 0x00, 0, 1, 0, 0, 0, 0, 0, 0, 0}) + f.Add([]byte{0xff, 0xff, 0xff, 0xff, 0xff}) + // Keep the largest accepted wire packet in the normal-input corpus. + f.Add(make([]byte, maxDNSWirePacket)) + // Keep a seed beyond the DNS wire limit so the rejection path is exercised. + f.Add(make([]byte, maxDNSWirePacket+1)) + + f.Fuzz(func(t *testing.T, packet []byte) { + if len(packet) > maxDNSWirePacket { + if _, err := DecodeMessage(packet); err == nil { + t.Fatalf("DecodeMessage accepted %d-byte packet", len(packet)) + } + return + } + _, _ = DecodeMessage(packet) + }) +} diff --git a/internal/ws/write_fuzz_test.go b/internal/ws/write_fuzz_test.go new file mode 100644 index 00000000..42540b37 --- /dev/null +++ b/internal/ws/write_fuzz_test.go @@ -0,0 +1,32 @@ +package ws + +import ( + "bufio" + "bytes" + "testing" + + "github.com/ryanfowler/fetch/internal/core" +) + +func FuzzReadBoundedLine(f *testing.F) { + f.Add([]byte("hello\n")) + f.Add([]byte("hello\r\n")) + f.Add([]byte("line without a terminator")) + // This crosses bufio's production reader buffer and exercises the + // ErrBufferFull continuation path. + f.Add(bytes.Repeat([]byte{'x'}, websocketStdinBufferSize+1)) + const maxLineBytes = core.MaxWebSocketPipedTextLine + // The production-limit overflow is covered by the focused unit test in + // ws_test.go; keep fuzz inputs bounded so scheduled smoke runs remain short. + + f.Fuzz(func(t *testing.T, input []byte) { + const maxGeneratedFuzzInput = 64 << 10 + if len(input) > maxGeneratedFuzzInput { + input = input[:maxGeneratedFuzzInput] + } + line, ok, err := readBoundedLine(bufio.NewReaderSize(bytes.NewReader(input), websocketStdinBufferSize), core.MaxWebSocketPipedTextLine) + if err == nil && ok && int64(len(line)) > maxLineBytes { + t.Fatalf("readBoundedLine returned %d bytes, want at most %d", len(line), maxLineBytes) + } + }) +}