diff --git a/docs/allocations.md b/docs/allocations.md index 04782f9..9ae1658 100644 --- a/docs/allocations.md +++ b/docs/allocations.md @@ -149,8 +149,9 @@ opens the same log in the allocation it replaced. where it is. Press it again to go further back, as far as the cluster keeps the log; while a read is still out, `e` waits for it. The title then says how much of the log is read, for example `last 1.1 MiB of 5.3 MiB`, until the log -drops lines off its front. It stops at the 50,000 lines a screen keeps. It needs a -token that can read the files of the allocation, as `b` does. +drops lines off its front. It stops at what a screen keeps: 50,000 lines or +64 MiB of text, whichever comes first. It needs a token that can read the files +of the allocation, as `b` does. A line under the title shows the state of three toggles: diff --git a/internal/ui/logback_test.go b/internal/ui/logback_test.go index dd64e98..0ee1999 100644 --- a/internal/ui/logback_test.go +++ b/internal/ui/logback_test.go @@ -290,12 +290,43 @@ func TestLogEarlier_AnAnswerIsDroppedWhenItNoLongerFits(t *testing.T) { r.Equal(flashWarn, m.flash.level) } +func TestLogEarlier_AnAnswerIsDroppedWhenItsBytesDoNotFit(t *testing.T) { + r := require.New(t) + + eighth := maxTextBytes / 8 + read := logState{before: midLog, more: true} + read.content.add(sizedLines(0, 7, eighth), arrivedAt, nil, nil) + + part := nomad.LogPart{Lines: sizedLines(10, 2, eighth), From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + read, out, ok := read.earlier(logPartMsg{at: midLog, part: part}) + + r.True(ok) + r.Equal(7, len(read.content.lines)) + r.Equal(midLog, read.before) + r.Len(out.now, 1) + r.Equal(warnMsg("The lines read back no longer fit on the screen."), out.now[0]) +} + func TestLogEarlier_AFullTextOffersNoKey(t *testing.T) { m, _ := sentLog(t, &fakeClient{jobs: twoJobs(), allocs: twoAllocs()}, midLog, numberedLines("late", 0, maxTextLines)) require.False(t, offersLabel(m, "e", "Read Earlier")) } +func TestLogEarlier_ATextFullOfBytesOffersNoKey(t *testing.T) { + for name, lines := range map[string][]string{ + "as many bytes as it keeps": sizedLines(0, 8, maxTextBytes/8), + "lines dropped for their bytes": sizedLines(0, 3, maxTextBytes*2/5), + } { + t.Run(name, func(t *testing.T) { + p := logsPage{read: logState{before: midLog, more: true}} + p.read.content.add(lines, arrivedAt, nil, nil) + + require.False(t, hasEarlier(p, env{})) + }) + } +} + func TestLogEarlier_TheLinesReadBackAreCleaned(t *testing.T) { r := require.New(t) diff --git a/internal/ui/logs.go b/internal/ui/logs.go index 6715397..f6ad8f2 100644 --- a/internal/ui/logs.go +++ b/internal/ui/logs.go @@ -215,10 +215,9 @@ func openPrevious(p logsPage, _ env) (logsPage, outcome) { } // hasEarlier says the cluster keeps more of the log before what is shown, -// no read of it is out, and the screen has room for it: a text that dropped -// lines is full. +// no read of it is out, and the screen has room for it. func hasEarlier(p logsPage, _ env) bool { - return p.read.more && !p.read.asking && len(p.read.content.lines) < maxTextLines + return p.read.more && !p.read.asking && !p.read.content.full() } // readEarlier reads the step of the log before what is shown, to put above @@ -343,9 +342,8 @@ func (l logState) take(msg tea.Msg) (logState, outcome, bool) { // earlier puts a part read from before the text in front of it, when it was // read for the place the text starts at now. A failed read shows its error. // The answer is dropped if the stream took lines off the front meanwhile: the -// text no longer starts there. It is also dropped if the stream added so many -// lines meanwhile that it no longer fits under the limit, with a warning: the -// press shows nothing else. +// text no longer starts there. It is also dropped if it does not fit under +// the limits of the text, with a warning: the press shows nothing else. func (l logState) earlier(msg logPartMsg) (logState, outcome, bool) { l.asking = false @@ -357,11 +355,10 @@ func (l logState) earlier(msg logPartMsg) (logState, outcome, bool) { return l, outcome{reading: true}, true } - if len(l.content.lines)+len(msg.part.Lines) > maxTextLines { + if !l.content.addBefore(msg.part.Lines) { return l, outcome{now: []tea.Msg{warnMsg("The lines read back no longer fit on the screen.")}, reading: true}, true } - l.content.addBefore(msg.part.Lines) l.before = msg.part.From l.size, l.from = msg.part.Size, msg.part.Before l.more = msg.part.Before > 0 diff --git a/internal/ui/text.go b/internal/ui/text.go index a60a75c..37e7a2f 100644 --- a/internal/ui/text.go +++ b/internal/ui/text.go @@ -84,6 +84,11 @@ type rowCache struct { // or more. const maxTextLines = 50_000 +// maxTextBytes is how many bytes the lines of a text hold at most. A task can +// write a line of 64 KiB, and 50,000 of them would take gigabytes. 50,000 lines +// of up to 1.3 KiB still fit. +const maxTextBytes = 64 << 20 + // textContent is what a text holds, apart from how it is shown: a text page // keeps it, and the root model keeps the window over it. type textContent struct { @@ -93,6 +98,9 @@ type textContent struct { lines []string + // size is the bytes of lines. + size int + // dropped are the lines taken off the front so far. dropped int @@ -167,7 +175,9 @@ func (t *textContent) add(lines []string, arrived time.Time, label *tag, style * } for _, line := range lines { - t.lines = append(t.lines, cleanLine(line)) + clean := cleanLine(line) + t.lines = append(t.lines, clean) + t.size += len(clean) t.stamps = append(t.stamps, arrived) if tagged { @@ -191,23 +201,39 @@ func (t *textContent) addChunk(lines []string, old int, arrived time.Time, label t.add(lines[old:], arrived, label, style) } -// addBefore puts lines read from before the text in front of it. They have -// no time, tag or style, and the text gets a new id so that its rows are drawn -// afresh: the kept ones would take the new lines as added at the end. The -// caller keeps the text under the limit, so none are dropped. -func (t *textContent) addBefore(lines []string) { - n := len(lines) +// addBefore puts lines read from before the text in front of it, and says +// whether they fit under the limits of the text: lines that do not fit are +// not added, since they would be the first ones dropped. They have no time, +// tag or style, and the text gets a new id so that its rows are drawn afresh: +// the kept ones would take the new lines as added at the end. +func (t *textContent) addBefore(lines []string) bool { + n, size := len(lines), t.size front := make([]string, 0, n+len(t.lines)) for _, line := range lines { - front = append(front, cleanLine(line)) + clean := cleanLine(line) + front = append(front, clean) + size += len(clean) + } + + if n+len(t.lines) > maxTextLines || size > maxTextBytes { + return false } - t.lines = append(front, t.lines...) + t.lines, t.size = append(front, t.lines...), size t.stamps = fronted(t.stamps, n, len(t.lines)-n) t.tags = fronted(t.tags, n, len(t.lines)-n) t.paint = fronted(t.paint, n, len(t.lines)-n) t.id = &textID{} + + return true +} + +// full says no lines can be put in front of the text: it holds as many lines +// or bytes as it keeps, or it dropped lines and no longer starts where the +// lines before it end. +func (t textContent) full() bool { + return t.dropped > 0 || len(t.lines) >= maxTextLines || t.size >= maxTextBytes } // fronted puts n unset items in front of s, after growing it to total items. @@ -220,11 +246,21 @@ func fronted[T any](s []T, n, total int) []T { return append(make([]T, n), grown(s, total)...) } -// dropOld takes the lines past maxTextLines off the front. The slices are cut, +// dropOld takes lines off the front while there are more than maxTextLines +// or more than maxTextBytes, and keeps the newest line. The slices are cut, // not copied: the next append grows them anew and frees the old front. func (t *textContent) dropOld() { - n := len(t.lines) - maxTextLines - if n <= 0 { + n := max(0, len(t.lines)-maxTextLines) + for _, line := range t.lines[:n] { + t.size -= len(line) + } + + for n < len(t.lines)-1 && t.size > maxTextBytes { + t.size -= len(t.lines[n]) + n++ + } + + if n == 0 { return } diff --git a/internal/ui/textrows_test.go b/internal/ui/textrows_test.go index 31bf700..95b0369 100644 --- a/internal/ui/textrows_test.go +++ b/internal/ui/textrows_test.go @@ -209,6 +209,97 @@ func TestText_AStreamKeepsItsLastLines(t *testing.T) { r.Equal(maxTextLines, len(text.stamps)) } +// sizedLines are n lines of size bytes, each starting with its place, from +// first on. +func sizedLines(first, n, size int) []string { + lines := make([]string, 0, n) + for i := first; i < first+n; i++ { + place := fmt.Sprintf("%02d ", i) + lines = append(lines, place+strings.Repeat("x", size-len(place))) + } + + return lines +} + +// bytesOf is the bytes of the lines. +func bytesOf(lines []string) int { + n := 0 + for _, line := range lines { + n += len(line) + } + + return n +} + +func TestText_CountsTheBytesOfItsLines(t *testing.T) { + r := require.New(t) + + // A tab is spaces up to the next stop: the text holds more bytes than + // it was given. + text := textModel{} + text.add([]string{"a\tb", "cd"}, arrivedAt, nil, nil) + r.Equal(7, text.size) + + r.True(text.addBefore([]string{"\tx"})) + r.Equal(bytesOf(text.lines), text.size) + + // Lines past maxTextLines leave with their bytes. + text.add(linesFrom(0, maxTextLines), arrivedAt, nil, nil) + r.Equal(bytesOf(text.lines), text.size) +} + +func TestText_AStreamKeepsItsLastBytes(t *testing.T) { + r := require.New(t) + + old := &tag{text: "old │ "} + own := &tag{text: "own │ "} + bold := lipgloss.NewStyle().Bold(true) + quarter := maxTextBytes / 4 + + text := textModel{} + text.add(sizedLines(0, 1, quarter), arrivedAt.Add(-time.Hour), old, nil) + + // The second line is the first one kept, with what it came with. + text.add(sizedLines(1, 1, quarter), arrivedAt, own, &bold) + text.add(sizedLines(2, 3, quarter), arrivedAt.Add(time.Hour), old, nil) + + r.Equal(4, len(text.lines)) + r.True(strings.HasPrefix(text.lines[0], "01 ")) + r.True(strings.HasPrefix(text.lines[3], "04 ")) + r.Equal(1, text.dropped) + r.Equal(5, text.total()) + r.Equal(bytesOf(text.lines), text.size) + + r.Equal(arrivedAt, text.stampAt(0)) + r.Same(own, text.tagAt(0)) + r.Same(&bold, text.paintAt(0)) + r.Same(old, text.tagAt(1)) + r.Nil(text.paintAt(1)) + r.Len(text.stamps, 4) + r.Len(text.tags, 4) + r.Len(text.paint, 4) +} + +func TestText_KeepsItsNewestLineEvenOverTheByteLimit(t *testing.T) { + r := require.New(t) + + text := textModel{} + text.add([]string{"first", strings.Repeat("x", maxTextBytes+1)}, arrivedAt, nil, nil) + + r.Equal(1, len(text.lines)) + r.Equal(maxTextBytes+1, len(text.lines[0])) + r.Equal(1, text.dropped) + r.Len(text.stamps, 1) + + // The next line takes its place. + text.add([]string{"next"}, arrivedAt, nil, nil) + + r.Equal(1, len(text.lines)) + r.Equal("next", text.lines[0]) + r.Equal(2, text.dropped) + r.Equal(4, text.size) +} + func TestText_ALongTextHasItsFrontDroppedWithoutMissingRows(t *testing.T) { text := textModel{wrap: true, filter: "5"} text.setSize(8, 6)