From 92b33cf10d8f3501daa496abe07971581ed34900 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:35:51 +0000 Subject: [PATCH] Stream instance log lines longer than 64 KiB bufio.Scanner fails with ErrTooLong on a line over 64 KiB. The log stream then stops reading tail's output, so the rest of the log is lost and the stream stays open until the client disconnects. Return long lines in pieces instead, without splitting a UTF-8 sequence. --- lib/instances/logs.go | 27 ++++++++++++++++ lib/instances/logs_long_line_test.go | 47 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 lib/instances/logs_long_line_test.go diff --git a/lib/instances/logs.go b/lib/instances/logs.go index c1ee84240..e1907e20a 100644 --- a/lib/instances/logs.go +++ b/lib/instances/logs.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/kernel/hypeman/lib/logger" ) @@ -118,6 +119,7 @@ func (m *manager) streamInstanceLogs(ctx context.Context, id string, tail int, f defer cmd.Process.Kill() scanner := bufio.NewScanner(stdout) + scanner.Split(scanLogLines) for scanner.Scan() { line := scanner.Text() if source == LogSourceApp && shouldSkipAppLogLine(line) { @@ -142,6 +144,31 @@ func (m *manager) streamInstanceLogs(ctx context.Context, id string, tail int, f return out, nil } +// maxLogLineBytes is the longest line streamed as a single entry. It matches +// bufio.Scanner's default token limit. +const maxLogLineBytes = bufio.MaxScanTokenSize + +// scanLogLines is bufio.ScanLines, except that a line longer than +// maxLogLineBytes is returned in pieces instead of failing the scan with +// bufio.ErrTooLong, which would end the stream. Pieces do not split a UTF-8 +// sequence. +func scanLogLines(data []byte, atEOF bool) (advance int, token []byte, err error) { + advance, token, err = bufio.ScanLines(data, atEOF) + if advance > 0 || token != nil || err != nil || len(data) < maxLogLineBytes { + return advance, token, err + } + n := maxLogLineBytes + for i := n - 1; i >= 0 && i > n-utf8.UTFMax; i-- { + if utf8.RuneStart(data[i]) { + if !utf8.FullRune(data[i:n]) { + n = i + } + break + } + } + return n, data[:n], nil +} + // rotateLogIfNeeded performs copytruncate rotation if file exceeds maxBytes // Keeps up to maxFiles old backups (.1, .2, etc.) func rotateLogIfNeeded(path string, maxBytes int64, maxFiles int) error { diff --git a/lib/instances/logs_long_line_test.go b/lib/instances/logs_long_line_test.go new file mode 100644 index 000000000..4a2d39f40 --- /dev/null +++ b/lib/instances/logs_long_line_test.go @@ -0,0 +1,47 @@ +package instances + +import ( + "context" + "os" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func TestStreamInstanceLogsLongLine(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + require.NoError(t, m.ensureDirectories("long-line")) + require.NoError(t, m.saveMetadata(&metadata{StoredMetadata: StoredMetadata{Id: "long-line", DataDir: p.InstanceDir("long-line")}})) + + // The odd-length prefix puts piece boundaries inside two-byte runes. + longLine := "x" + strings.Repeat("é", 200*1024) + content := "before\n" + longLine + "\nafter\n" + require.NoError(t, os.WriteFile(p.InstanceAppLog("long-line"), []byte(content), 0644)) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + logs, err := m.streamInstanceLogs(ctx, "long-line", 10, false, LogSourceApp) + require.NoError(t, err) + + var lines []string + for { + select { + case line, ok := <-logs: + if !ok { + require.Equal(t, "before", lines[0]) + require.Equal(t, "after", lines[len(lines)-1]) + require.Equal(t, longLine, strings.Join(lines[1:len(lines)-1], "")) + return + } + require.True(t, utf8.ValidString(line), "piece %d splits a UTF-8 sequence", len(lines)) + lines = append(lines, line) + case <-ctx.Done(): + t.Fatalf("log stream did not finish; received %d lines", len(lines)) + } + } +}