detached 269f2d6

perf(history): make full-history eviction constant-time

Skip the retained row-ID scan for IDs above nextRowID, advance an
uncaptured head chunk wrapper in place instead of allocating one per
evicted row, and collect row styles in a small inline set. Adds
BenchmarkScreenScrollbackFlood (160x45, history 0/10k/50k).

history-10000: 4.85 -> 8.87 MiB/s, 1078 -> 86 allocs/op
history-50000: 3.69 -> 8.87 MiB/s
commit
269f2d6f687c1ed70f1985018c035a378e05fe8a
author
brice · · 2026-10-07 16:19 UTC
parent
b250c5e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +319 -55
M benchmark_test.go +36 -0
@@ -3,6 +3,7 @@ package vt
33 import (
44 "bytes"
55 "encoding/base64"
6+ "fmt"
67 "testing"
78
89 renderer "github.com/bnema/vev-vt/core"
@@ -260,6 +261,41 @@ func BenchmarkScreenKittyAPC(b *testing.B) {
260261 }
261262 }
262263
264+// scrollbackFloodChunk is 1000 colored 160-column lines ending in CRLF, the
265+// shape of a build log or `yes`-style flood scrolling into history.
266+func scrollbackFloodChunk() []byte {
267+ var chunk bytes.Buffer
268+ line := bytes.Repeat([]byte("x"), 140)
269+ for i := range 1000 {
270+ fmt.Fprintf(&chunk, "\x1b[3%dmflood %06d %s\x1b[0m\r\n", i%8, i, line)
271+ }
272+ return chunk.Bytes()
273+}
274+
275+// BenchmarkScreenScrollbackFlood measures steady-state output once history is
276+// full, so every new line evicts the oldest retained row.
277+func BenchmarkScreenScrollbackFlood(b *testing.B) {
278+ chunk := scrollbackFloodChunk()
279+ for _, rows := range []int{0, 10_000, 50_000} {
280+ b.Run(fmt.Sprintf("history-%d", rows), func(b *testing.B) {
281+ s := NewScreen(160, 45)
282+ if rows > 0 {
283+ s = NewScreenWithHistory(160, 45, HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30})
284+ }
285+ for range rows/1000 + 1 {
286+ s.Write(chunk)
287+ }
288+ s.ClearDamage()
289+ b.SetBytes(int64(len(chunk)))
290+ b.ReportAllocs()
291+ for b.Loop() {
292+ s.Write(chunk)
293+ s.ClearDamage()
294+ }
295+ })
296+ }
297+}
298+
263299 func BenchmarkScreenMixedUTF8(b *testing.B) {
264300 s := NewScreen(120, 40)
265301 chunk := append(bytes.Repeat([]byte("Aé界"), 20), '\r')
@@ -3,6 +3,7 @@ package vt
3 import (3 import (
4 "bytes"4 "bytes"
5 "encoding/base64"5 "encoding/base64"
6+ "fmt"
6 "testing"7 "testing"
7 8
8 renderer "github.com/bnema/vev-vt/core"9 renderer "github.com/bnema/vev-vt/core"
@@ -260,6 +261,41 @@ func BenchmarkScreenKittyAPC(b *testing.B) {
260 }261 }
261 }262 }
262 263
264+// scrollbackFloodChunk is 1000 colored 160-column lines ending in CRLF, the
265+// shape of a build log or `yes`-style flood scrolling into history.
266+func scrollbackFloodChunk() []byte {
267+ var chunk bytes.Buffer
268+ line := bytes.Repeat([]byte("x"), 140)
269+ for i := range 1000 {
270+ fmt.Fprintf(&chunk, "\x1b[3%dmflood %06d %s\x1b[0m\r\n", i%8, i, line)
271+ }
272+ return chunk.Bytes()
273+}
274+
275+// BenchmarkScreenScrollbackFlood measures steady-state output once history is
276+// full, so every new line evicts the oldest retained row.
277+func BenchmarkScreenScrollbackFlood(b *testing.B) {
278+ chunk := scrollbackFloodChunk()
279+ for _, rows := range []int{0, 10_000, 50_000} {
280+ b.Run(fmt.Sprintf("history-%d", rows), func(b *testing.B) {
281+ s := NewScreen(160, 45)
282+ if rows > 0 {
283+ s = NewScreenWithHistory(160, 45, HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30})
284+ }
285+ for range rows/1000 + 1 {
286+ s.Write(chunk)
287+ }
288+ s.ClearDamage()
289+ b.SetBytes(int64(len(chunk)))
290+ b.ReportAllocs()
291+ for b.Loop() {
292+ s.Write(chunk)
293+ s.ClearDamage()
294+ }
295+ })
296+ }
297+}
298+
263 func BenchmarkScreenMixedUTF8(b *testing.B) {299 func BenchmarkScreenMixedUTF8(b *testing.B) {
264 s := NewScreen(120, 40)300 s := NewScreen(120, 40)
265 chunk := append(bytes.Repeat([]byte("Aé界"), 20), '\r')301 chunk := append(bytes.Repeat([]byte("Aé界"), 20), '\r')
M history.go +57 -47
@@ -41,7 +41,7 @@ type History struct {
4141 tailPageWidth int
4242 tailPageRows int
4343 tailPageStyles map[renderer.Style]struct{}
44- styleScratch map[renderer.Style]struct{}
44+ styleScratch styleSet
4545 payloadScratch map[renderer.CellPayload]struct{}
4646 tailPagePayloads map[renderer.CellPayload]struct{}
4747 styleRowScratch map[renderer.Style]int
@@ -50,6 +50,10 @@ type History struct {
5050 logicalBytes uint64
5151 nextRowID RowID
5252 compressionCursor int
53+ // privateHead is the oldest sealed chunk's wrapper when it was created by
54+ // eviction and no view has captured it since. Eviction may then advance it
55+ // in place instead of allocating a new wrapper per evicted row.
56+ privateHead *HistoryChunk
5357 }
5458
5559 // HistoryChunk is an immutable compact slab of equal-width history rows. Its
@@ -270,62 +274,45 @@ func historyChunksLogicalBytes(chunks []*HistoryChunk) uint64 {
270274
271275 func (h *History) prepareRowStyles(row []renderer.Cell) uint64 {
272276 clear(h.payloadScratch)
273- if h.styleScratch == nil {
274- h.styleScratch = make(map[renderer.Style]struct{})
275- } else {
276- clear(h.styleScratch)
277- }
278- if len(row) > 0 {
279- firstRaw := row[0].Style
280- same := true
281- for _, cell := range row {
282- h.recordPayloadScratch(cell.Payload)
283- if cell.Style != firstRaw {
284- same = false
285- }
277+ h.styleScratch.reset()
278+ defaultStyle := renderer.DefaultStyle()
279+ // A run of identical raw styles canonicalizes identically, so only the
280+ // first cell of each run pays for Canonical and the set insert.
281+ var previous renderer.Style
282+ for i, cell := range row {
283+ h.recordPayloadScratch(cell.Payload)
284+ if i > 0 && cell.Style == previous {
285+ continue
286286 }
287- if same {
288- first := firstRaw.Canonical()
289- if first != renderer.DefaultStyle() {
290- h.styleScratch[first] = struct{}{}
291- }
292- } else {
293- for _, cell := range row {
294- style := cell.Style.Canonical()
295- if style != renderer.DefaultStyle() {
296- h.styleScratch[style] = struct{}{}
297- }
298- }
287+ previous = cell.Style
288+ if style := cell.Style.Canonical(); style != defaultStyle {
289+ h.styleScratch.add(style)
299290 }
300291 }
301- return historyRowBaseLogicalBytes(len(row)) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
292+ return historyRowBaseLogicalBytes(len(row)) + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
302293 }
303294
304295 func (h *History) prepareChunkRowStyles(chunk *HistoryChunk, row int) uint64 {
305296 clear(h.payloadScratch)
306- if h.styleScratch == nil {
307- h.styleScratch = make(map[renderer.Style]struct{})
308- } else {
309- clear(h.styleScratch)
310- }
297+ h.styleScratch.reset()
298+ defaultStyle := renderer.DefaultStyle()
311299 for column := range chunk.width {
312300 cell := chunk.cell(row, column)
313301 h.recordPayloadScratch(cell.Payload)
314- style := cell.Style.Canonical()
315- if style != renderer.DefaultStyle() {
316- h.styleScratch[style] = struct{}{}
302+ if style := cell.Style.Canonical(); style != defaultStyle {
303+ h.styleScratch.add(style)
317304 }
318305 }
319- return historyRowBaseLogicalBytes(chunk.width) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
306+ return historyRowBaseLogicalBytes(chunk.width) + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
320307 }
321308
322309 func (h *History) tailAppendDelta(width int) uint64 {
323310 newPage := h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width)
324311 delta := historyRowBaseLogicalBytes(width)
325312 if newPage {
326- return delta + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
313+ return delta + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
327314 }
328- for style := range h.styleScratch {
315+ for style := range h.styleScratch.all() {
329316 if _, ok := h.tailPageStyles[style]; !ok {
330317 delta += renderer.StyleRecordLogicalBytes
331318 }
@@ -351,7 +338,7 @@ func (h *History) recordTailRowStyles(width int) {
351338 }
352339 h.tailPagePayloads[p] = struct{}{}
353340 }
354- for style := range h.styleScratch {
341+ for style := range h.styleScratch.all() {
355342 h.tailPageStyles[style] = struct{}{}
356343 }
357344 h.tailPageRows++
@@ -468,7 +455,13 @@ func (h *History) allocateRowID() (RowID, error) {
468455 return id, nil
469456 }
470457
458+// hasRowID reports whether id is retained. Every retained ID is below
459+// nextRowID (appendRow advances it and restore validates it), so the newest-ID
460+// case used by screen eviction answers without scanning retained rows.
471461 func (h *History) hasRowID(id RowID) bool {
462+ if id >= h.nextRowID {
463+ return false
464+ }
472465 for _, chunk := range h.chunks {
473466 if slices.Contains(chunk.rowIDs, id) {
474467 return true
@@ -589,14 +582,19 @@ func (h *History) evictFor(row []renderer.Cell) {
589582 if h.rows <= h.maxRows-1 && h.logicalBytes <= h.maxBytes-delta {
590583 return
591584 }
592- h.evictOldest()
593- h.prepareRowStyles(row)
585+ // Only a tail eviction rebuilds tail accounting, which reuses the row
586+ // scratch; sealed-chunk eviction leaves this row's styles intact.
587+ if h.evictOldest() {
588+ h.prepareRowStyles(row)
589+ }
594590 }
595591 }
596592
597-func (h *History) evictOldest() {
593+// evictOldest drops the oldest retained row. It reports whether it rebuilt
594+// tail accounting, which overwrites the row style and payload scratch.
595+func (h *History) evictOldest() (scratchClobbered bool) {
598596 if h.rows == 0 {
599- return
597+ return false
600598 }
601599 if len(h.chunks) > 0 {
602600 chunk := h.chunks[0]
@@ -604,6 +602,9 @@ func (h *History) evictOldest() {
604602 h.rows--
605603 h.cells -= chunk.width
606604 if chunk.count == 1 {
605+ if chunk == h.privateHead {
606+ h.privateHead = nil
607+ }
607608 h.logicalBytes -= oldBytes
608609 copy(h.chunks, h.chunks[1:])
609610 h.chunks[len(h.chunks)-1] = nil
@@ -611,14 +612,20 @@ func (h *History) evictOldest() {
611612 } else {
612613 // Preserve cell storage while replacing only the chunk wrapper: a
613614 // retained view may still refer to the original immutable chunk. The
614- // wrapper tracks only styles still used by its retained suffix.
615- h.chunks[0] = chunk.withoutFirstRow()
615+ // wrapper tracks only styles still used by its retained suffix. A
616+ // wrapper no view has seen is advanced in place.
617+ if chunk == h.privateHead {
618+ chunk.dropFirstRow()
619+ } else {
620+ h.chunks[0] = chunk.withoutFirstRow()
621+ h.privateHead = h.chunks[0]
622+ }
616623 h.logicalBytes -= oldBytes - historyChunkLogicalBytes(h.chunks[0])
617624 }
618- return
625+ return false
619626 }
620627 if len(h.tail) == 0 {
621- return
628+ return false
622629 }
623630 h.cachedTail = nil
624631 row := h.tail[0]
@@ -633,6 +640,7 @@ func (h *History) evictOldest() {
633640 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
634641 h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]
635642 h.rebuildTailAccounting()
643+ return true
636644 }
637645
638646 // SealAndView rotates the mutable tail into an immutable chunk, then captures
@@ -660,6 +668,7 @@ func (h *History) View() HistoryView {
660668 if h.rows == 0 {
661669 return HistoryView{nextRowID: h.nextRowID}
662670 }
671+ h.privateHead = nil
663672 chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)
664673 copy(chunks, h.chunks)
665674 if len(h.tail) > 0 {
@@ -677,6 +686,7 @@ func (h *History) SnapshotView() HistorySnapshotView {
677686 if h.rows == 0 {
678687 return HistorySnapshotView{nextRowID: h.nextRowID}
679688 }
689+ h.privateHead = nil
680690 tailChunks := newHistoryChunks(h.tail, h.tailBounds, h.tailIDs)
681691 return HistorySnapshotView{
682692 chunks: append([]*HistoryChunk(nil), h.chunks...),
@@ -41,7 +41,7 @@ type History struct {
41 tailPageWidth int41 tailPageWidth int
42 tailPageRows int42 tailPageRows int
43 tailPageStyles map[renderer.Style]struct{}43 tailPageStyles map[renderer.Style]struct{}
44- styleScratch map[renderer.Style]struct{}44+ styleScratch styleSet
45 payloadScratch map[renderer.CellPayload]struct{}45 payloadScratch map[renderer.CellPayload]struct{}
46 tailPagePayloads map[renderer.CellPayload]struct{}46 tailPagePayloads map[renderer.CellPayload]struct{}
47 styleRowScratch map[renderer.Style]int47 styleRowScratch map[renderer.Style]int
@@ -50,6 +50,10 @@ type History struct {
50 logicalBytes uint6450 logicalBytes uint64
51 nextRowID RowID51 nextRowID RowID
52 compressionCursor int52 compressionCursor int
53+ // privateHead is the oldest sealed chunk's wrapper when it was created by
54+ // eviction and no view has captured it since. Eviction may then advance it
55+ // in place instead of allocating a new wrapper per evicted row.
56+ privateHead *HistoryChunk
53 }57 }
54 58
55 // HistoryChunk is an immutable compact slab of equal-width history rows. Its59 // HistoryChunk is an immutable compact slab of equal-width history rows. Its
@@ -270,62 +274,45 @@ func historyChunksLogicalBytes(chunks []*HistoryChunk) uint64 {
270 274
271 func (h *History) prepareRowStyles(row []renderer.Cell) uint64 {275 func (h *History) prepareRowStyles(row []renderer.Cell) uint64 {
272 clear(h.payloadScratch)276 clear(h.payloadScratch)
273- if h.styleScratch == nil {277+ h.styleScratch.reset()
274- h.styleScratch = make(map[renderer.Style]struct{})278+ defaultStyle := renderer.DefaultStyle()
275- } else {279+ // A run of identical raw styles canonicalizes identically, so only the
276- clear(h.styleScratch)280+ // first cell of each run pays for Canonical and the set insert.
277- }281+ var previous renderer.Style
278- if len(row) > 0 {282+ for i, cell := range row {
279- firstRaw := row[0].Style283+ h.recordPayloadScratch(cell.Payload)
280- same := true284+ if i > 0 && cell.Style == previous {
281- for _, cell := range row {285+ continue
282- h.recordPayloadScratch(cell.Payload)
283- if cell.Style != firstRaw {
284- same = false
285- }
286 }286 }
287- if same {287+ previous = cell.Style
288- first := firstRaw.Canonical()288+ if style := cell.Style.Canonical(); style != defaultStyle {
289- if first != renderer.DefaultStyle() {289+ h.styleScratch.add(style)
290- h.styleScratch[first] = struct{}{}
291- }
292- } else {
293- for _, cell := range row {
294- style := cell.Style.Canonical()
295- if style != renderer.DefaultStyle() {
296- h.styleScratch[style] = struct{}{}
297- }
298- }
299 }290 }
300 }291 }
301- return historyRowBaseLogicalBytes(len(row)) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()292+ return historyRowBaseLogicalBytes(len(row)) + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
302 }293 }
303 294
304 func (h *History) prepareChunkRowStyles(chunk *HistoryChunk, row int) uint64 {295 func (h *History) prepareChunkRowStyles(chunk *HistoryChunk, row int) uint64 {
305 clear(h.payloadScratch)296 clear(h.payloadScratch)
306- if h.styleScratch == nil {297+ h.styleScratch.reset()
307- h.styleScratch = make(map[renderer.Style]struct{})298+ defaultStyle := renderer.DefaultStyle()
308- } else {
309- clear(h.styleScratch)
310- }
311 for column := range chunk.width {299 for column := range chunk.width {
312 cell := chunk.cell(row, column)300 cell := chunk.cell(row, column)
313 h.recordPayloadScratch(cell.Payload)301 h.recordPayloadScratch(cell.Payload)
314- style := cell.Style.Canonical()302+ if style := cell.Style.Canonical(); style != defaultStyle {
315- if style != renderer.DefaultStyle() {303+ h.styleScratch.add(style)
316- h.styleScratch[style] = struct{}{}
317 }304 }
318 }305 }
319- return historyRowBaseLogicalBytes(chunk.width) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()306+ return historyRowBaseLogicalBytes(chunk.width) + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
320 }307 }
321 308
322 func (h *History) tailAppendDelta(width int) uint64 {309 func (h *History) tailAppendDelta(width int) uint64 {
323 newPage := h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width)310 newPage := h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width)
324 delta := historyRowBaseLogicalBytes(width)311 delta := historyRowBaseLogicalBytes(width)
325 if newPage {312 if newPage {
326- return delta + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()313+ return delta + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
327 }314 }
328- for style := range h.styleScratch {315+ for style := range h.styleScratch.all() {
329 if _, ok := h.tailPageStyles[style]; !ok {316 if _, ok := h.tailPageStyles[style]; !ok {
330 delta += renderer.StyleRecordLogicalBytes317 delta += renderer.StyleRecordLogicalBytes
331 }318 }
@@ -351,7 +338,7 @@ func (h *History) recordTailRowStyles(width int) {
351 }338 }
352 h.tailPagePayloads[p] = struct{}{}339 h.tailPagePayloads[p] = struct{}{}
353 }340 }
354- for style := range h.styleScratch {341+ for style := range h.styleScratch.all() {
355 h.tailPageStyles[style] = struct{}{}342 h.tailPageStyles[style] = struct{}{}
356 }343 }
357 h.tailPageRows++344 h.tailPageRows++
@@ -468,7 +455,13 @@ func (h *History) allocateRowID() (RowID, error) {
468 return id, nil455 return id, nil
469 }456 }
470 457
458+// hasRowID reports whether id is retained. Every retained ID is below
459+// nextRowID (appendRow advances it and restore validates it), so the newest-ID
460+// case used by screen eviction answers without scanning retained rows.
471 func (h *History) hasRowID(id RowID) bool {461 func (h *History) hasRowID(id RowID) bool {
462+ if id >= h.nextRowID {
463+ return false
464+ }
472 for _, chunk := range h.chunks {465 for _, chunk := range h.chunks {
473 if slices.Contains(chunk.rowIDs, id) {466 if slices.Contains(chunk.rowIDs, id) {
474 return true467 return true
@@ -589,14 +582,19 @@ func (h *History) evictFor(row []renderer.Cell) {
589 if h.rows <= h.maxRows-1 && h.logicalBytes <= h.maxBytes-delta {582 if h.rows <= h.maxRows-1 && h.logicalBytes <= h.maxBytes-delta {
590 return583 return
591 }584 }
592- h.evictOldest()585+ // Only a tail eviction rebuilds tail accounting, which reuses the row
593- h.prepareRowStyles(row)586+ // scratch; sealed-chunk eviction leaves this row's styles intact.
587+ if h.evictOldest() {
588+ h.prepareRowStyles(row)
589+ }
594 }590 }
595 }591 }
596 592
597-func (h *History) evictOldest() {593+// evictOldest drops the oldest retained row. It reports whether it rebuilt
594+// tail accounting, which overwrites the row style and payload scratch.
595+func (h *History) evictOldest() (scratchClobbered bool) {
598 if h.rows == 0 {596 if h.rows == 0 {
599- return597+ return false
600 }598 }
601 if len(h.chunks) > 0 {599 if len(h.chunks) > 0 {
602 chunk := h.chunks[0]600 chunk := h.chunks[0]
@@ -604,6 +602,9 @@ func (h *History) evictOldest() {
604 h.rows--602 h.rows--
605 h.cells -= chunk.width603 h.cells -= chunk.width
606 if chunk.count == 1 {604 if chunk.count == 1 {
605+ if chunk == h.privateHead {
606+ h.privateHead = nil
607+ }
607 h.logicalBytes -= oldBytes608 h.logicalBytes -= oldBytes
608 copy(h.chunks, h.chunks[1:])609 copy(h.chunks, h.chunks[1:])
609 h.chunks[len(h.chunks)-1] = nil610 h.chunks[len(h.chunks)-1] = nil
@@ -611,14 +612,20 @@ func (h *History) evictOldest() {
611 } else {612 } else {
612 // Preserve cell storage while replacing only the chunk wrapper: a613 // Preserve cell storage while replacing only the chunk wrapper: a
613 // retained view may still refer to the original immutable chunk. The614 // retained view may still refer to the original immutable chunk. The
614- // wrapper tracks only styles still used by its retained suffix.615+ // wrapper tracks only styles still used by its retained suffix. A
615- h.chunks[0] = chunk.withoutFirstRow()616+ // wrapper no view has seen is advanced in place.
617+ if chunk == h.privateHead {
618+ chunk.dropFirstRow()
619+ } else {
620+ h.chunks[0] = chunk.withoutFirstRow()
621+ h.privateHead = h.chunks[0]
622+ }
616 h.logicalBytes -= oldBytes - historyChunkLogicalBytes(h.chunks[0])623 h.logicalBytes -= oldBytes - historyChunkLogicalBytes(h.chunks[0])
617 }624 }
618- return625+ return false
619 }626 }
620 if len(h.tail) == 0 {627 if len(h.tail) == 0 {
621- return628+ return false
622 }629 }
623 h.cachedTail = nil630 h.cachedTail = nil
624 row := h.tail[0]631 row := h.tail[0]
@@ -633,6 +640,7 @@ func (h *History) evictOldest() {
633 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]640 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
634 h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]641 h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]
635 h.rebuildTailAccounting()642 h.rebuildTailAccounting()
643+ return true
636 }644 }
637 645
638 // SealAndView rotates the mutable tail into an immutable chunk, then captures646 // SealAndView rotates the mutable tail into an immutable chunk, then captures
@@ -660,6 +668,7 @@ func (h *History) View() HistoryView {
660 if h.rows == 0 {668 if h.rows == 0 {
661 return HistoryView{nextRowID: h.nextRowID}669 return HistoryView{nextRowID: h.nextRowID}
662 }670 }
671+ h.privateHead = nil
663 chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)672 chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)
664 copy(chunks, h.chunks)673 copy(chunks, h.chunks)
665 if len(h.tail) > 0 {674 if len(h.tail) > 0 {
@@ -677,6 +686,7 @@ func (h *History) SnapshotView() HistorySnapshotView {
677 if h.rows == 0 {686 if h.rows == 0 {
678 return HistorySnapshotView{nextRowID: h.nextRowID}687 return HistorySnapshotView{nextRowID: h.nextRowID}
679 }688 }
689+ h.privateHead = nil
680 tailChunks := newHistoryChunks(h.tail, h.tailBounds, h.tailIDs)690 tailChunks := newHistoryChunks(h.tail, h.tailBounds, h.tailIDs)
681 return HistorySnapshotView{691 return HistorySnapshotView{
682 chunks: append([]*HistoryChunk(nil), h.chunks...),692 chunks: append([]*HistoryChunk(nil), h.chunks...),
A history_evict_inplace_test.go +134 -0
@@ -0,0 +1,134 @@
1+package vt
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt/core"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// Eviction advances an uncaptured head wrapper in place. A view or snapshot
12+// taken between evictions must keep its rows, accounting, and IDs.
13+func TestHistoryInPlaceEvictionKeepsCapturedViewsStable(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ capture func(*History) func() ([]string, int, uint64, RowID)
17+ }{
18+ {name: "view", capture: func(h *History) func() ([]string, int, uint64, RowID) {
19+ v := h.View()
20+ return func() ([]string, int, uint64, RowID) {
21+ return rangeTextsNoT(v), v.Len(), v.LogicalBytes(), v.RowID(0)
22+ }
23+ }},
24+ {name: "snapshot", capture: func(h *History) func() ([]string, int, uint64, RowID) {
25+ s := h.SnapshotView()
26+ return func() ([]string, int, uint64, RowID) {
27+ var texts []string
28+ for i := range s.ChunkCount() {
29+ c := s.Chunk(i)
30+ for r := range c.len() {
31+ texts = append(texts, rowText(c.row(r)))
32+ }
33+ }
34+ texts = append(texts, rangeTextsNoT(s.Tail())...)
35+ var first RowID
36+ if s.ChunkCount() > 0 {
37+ first = s.Chunk(0).rowIDs[0]
38+ }
39+ return texts, s.Len(), s.LogicalBytes(), first
40+ }
41+ }},
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ h := NewHistory(HistoryConfig{MaxRows: 12, MaxBytes: 1 << 20, ChunkRows: 4})
46+ add := func(i int) {
47+ require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%3+1), LineBound{End: 4}))
48+ }
49+ for i := range 14 { // two evictions: the head wrapper is now private
50+ add(i)
51+ }
52+ read := tt.capture(h)
53+ texts, rows, bytes, first := read()
54+ for i := 14; i < 30; i++ { // evicts through the captured head and beyond
55+ add(i)
56+ }
57+ gotTexts, gotRows, gotBytes, gotFirst := read()
58+ require.Equal(t, texts, gotTexts)
59+ require.Equal(t, rows, gotRows)
60+ require.Equal(t, bytes, gotBytes)
61+ require.Equal(t, first, gotFirst)
62+ require.Equal(t, 12, h.Len())
63+ require.Equal(t, "r018", rangeTextsNoT(h.View())[0])
64+ })
65+ }
66+}
67+
68+// In-place eviction keeps logical accounting identical to wrapper replacement.
69+func TestHistoryInPlaceEvictionAccountingMatchesFreshBuild(t *testing.T) {
70+ h := NewHistory(HistoryConfig{MaxRows: 10, MaxBytes: 1 << 20, ChunkRows: 4})
71+ for i := range 37 {
72+ require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%5+1), LineBound{End: 4}))
73+ }
74+ view := h.View()
75+ for i := range view.ChunkCount() {
76+ require.NoError(t, view.Chunk(i).CheckInvariants())
77+ }
78+ var sum uint64
79+ for i := range view.ChunkCount() {
80+ sum += historyChunkLogicalBytes(view.Chunk(i))
81+ }
82+ require.Equal(t, sum, h.LogicalBytes())
83+}
84+
85+func TestStyleSet(t *testing.T) {
86+ style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} }
87+ tests := []struct {
88+ name string
89+ input []int
90+ want int
91+ }{
92+ {name: "empty", want: 0},
93+ {name: "duplicates", input: []int{1, 1, 2, 2, 1}, want: 2},
94+ {name: "inline capacity", input: seqInts(styleSetInline), want: styleSetInline},
95+ {name: "spills to map", input: append(seqInts(styleSetInline+5), 0, 1, 2), want: styleSetInline + 5},
96+ {name: "large", input: seqInts(400), want: 400},
97+ }
98+ var s styleSet
99+ for _, tt := range tests {
100+ t.Run(tt.name, func(t *testing.T) {
101+ s.reset()
102+ want := map[renderer.Style]struct{}{}
103+ for _, i := range tt.input {
104+ s.add(style(i))
105+ want[style(i)] = struct{}{}
106+ }
107+ require.Equal(t, tt.want, s.len())
108+ got := map[renderer.Style]struct{}{}
109+ for st := range s.all() {
110+ _, dup := got[st]
111+ require.False(t, dup, "style yielded twice")
112+ got[st] = struct{}{}
113+ }
114+ require.Equal(t, want, got)
115+ })
116+ }
117+}
118+
119+func seqInts(n int) []int {
120+ out := make([]int, n)
121+ for i := range out {
122+ out[i] = i
123+ }
124+ return out
125+}
126+
127+func rangeTextsNoT(v HistoryView) []string {
128+ var out []string
129+ _ = v.Range(func(row []renderer.Cell) bool {
130+ out = append(out, rowText(row))
131+ return true
132+ })
133+ return out
134+}
@@ -0,0 +1,134 @@
1+package vt
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt/core"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// Eviction advances an uncaptured head wrapper in place. A view or snapshot
12+// taken between evictions must keep its rows, accounting, and IDs.
13+func TestHistoryInPlaceEvictionKeepsCapturedViewsStable(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ capture func(*History) func() ([]string, int, uint64, RowID)
17+ }{
18+ {name: "view", capture: func(h *History) func() ([]string, int, uint64, RowID) {
19+ v := h.View()
20+ return func() ([]string, int, uint64, RowID) {
21+ return rangeTextsNoT(v), v.Len(), v.LogicalBytes(), v.RowID(0)
22+ }
23+ }},
24+ {name: "snapshot", capture: func(h *History) func() ([]string, int, uint64, RowID) {
25+ s := h.SnapshotView()
26+ return func() ([]string, int, uint64, RowID) {
27+ var texts []string
28+ for i := range s.ChunkCount() {
29+ c := s.Chunk(i)
30+ for r := range c.len() {
31+ texts = append(texts, rowText(c.row(r)))
32+ }
33+ }
34+ texts = append(texts, rangeTextsNoT(s.Tail())...)
35+ var first RowID
36+ if s.ChunkCount() > 0 {
37+ first = s.Chunk(0).rowIDs[0]
38+ }
39+ return texts, s.Len(), s.LogicalBytes(), first
40+ }
41+ }},
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ h := NewHistory(HistoryConfig{MaxRows: 12, MaxBytes: 1 << 20, ChunkRows: 4})
46+ add := func(i int) {
47+ require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%3+1), LineBound{End: 4}))
48+ }
49+ for i := range 14 { // two evictions: the head wrapper is now private
50+ add(i)
51+ }
52+ read := tt.capture(h)
53+ texts, rows, bytes, first := read()
54+ for i := 14; i < 30; i++ { // evicts through the captured head and beyond
55+ add(i)
56+ }
57+ gotTexts, gotRows, gotBytes, gotFirst := read()
58+ require.Equal(t, texts, gotTexts)
59+ require.Equal(t, rows, gotRows)
60+ require.Equal(t, bytes, gotBytes)
61+ require.Equal(t, first, gotFirst)
62+ require.Equal(t, 12, h.Len())
63+ require.Equal(t, "r018", rangeTextsNoT(h.View())[0])
64+ })
65+ }
66+}
67+
68+// In-place eviction keeps logical accounting identical to wrapper replacement.
69+func TestHistoryInPlaceEvictionAccountingMatchesFreshBuild(t *testing.T) {
70+ h := NewHistory(HistoryConfig{MaxRows: 10, MaxBytes: 1 << 20, ChunkRows: 4})
71+ for i := range 37 {
72+ require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%5+1), LineBound{End: 4}))
73+ }
74+ view := h.View()
75+ for i := range view.ChunkCount() {
76+ require.NoError(t, view.Chunk(i).CheckInvariants())
77+ }
78+ var sum uint64
79+ for i := range view.ChunkCount() {
80+ sum += historyChunkLogicalBytes(view.Chunk(i))
81+ }
82+ require.Equal(t, sum, h.LogicalBytes())
83+}
84+
85+func TestStyleSet(t *testing.T) {
86+ style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} }
87+ tests := []struct {
88+ name string
89+ input []int
90+ want int
91+ }{
92+ {name: "empty", want: 0},
93+ {name: "duplicates", input: []int{1, 1, 2, 2, 1}, want: 2},
94+ {name: "inline capacity", input: seqInts(styleSetInline), want: styleSetInline},
95+ {name: "spills to map", input: append(seqInts(styleSetInline+5), 0, 1, 2), want: styleSetInline + 5},
96+ {name: "large", input: seqInts(400), want: 400},
97+ }
98+ var s styleSet
99+ for _, tt := range tests {
100+ t.Run(tt.name, func(t *testing.T) {
101+ s.reset()
102+ want := map[renderer.Style]struct{}{}
103+ for _, i := range tt.input {
104+ s.add(style(i))
105+ want[style(i)] = struct{}{}
106+ }
107+ require.Equal(t, tt.want, s.len())
108+ got := map[renderer.Style]struct{}{}
109+ for st := range s.all() {
110+ _, dup := got[st]
111+ require.False(t, dup, "style yielded twice")
112+ got[st] = struct{}{}
113+ }
114+ require.Equal(t, want, got)
115+ })
116+ }
117+}
118+
119+func seqInts(n int) []int {
120+ out := make([]int, n)
121+ for i := range out {
122+ out[i] = i
123+ }
124+ return out
125+}
126+
127+func rangeTextsNoT(v HistoryView) []string {
128+ var out []string
129+ _ = v.Range(func(row []renderer.Cell) bool {
130+ out = append(out, rowText(row))
131+ return true
132+ })
133+ return out
134+}
M history_payload.go +14 -8
@@ -31,16 +31,22 @@ func (c *HistoryChunk) recordPayloads() {
3131 }
3232
3333 func (c *HistoryChunk) withoutFirstRow() *HistoryChunk {
34- payloadBytes := c.payloadBytes
34+ next := *c
35+ next.dropFirstRow()
36+ return &next
37+}
38+
39+// dropFirstRow advances the wrapper past its first row in place. Only a wrapper
40+// no view has captured may be mutated; see History.privateHead.
41+func (c *HistoryChunk) dropFirstRow() {
3542 if len(c.payloadDrops) != 0 {
36- payloadBytes -= c.payloadDrops[c.start]
37- }
38- return &HistoryChunk{
39- page: c.page, start: c.start + 1, count: c.count - 1, width: c.width,
40- bounds: c.bounds[1:], rowIDs: c.rowIDs[1:],
41- styleDrops: c.styleDrops, styleCount: c.styleCount - c.styleDrops[c.start],
42- payloadDrops: c.payloadDrops, payloadBytes: payloadBytes,
43+ c.payloadBytes -= c.payloadDrops[c.start]
4344 }
45+ c.styleCount -= c.styleDrops[c.start]
46+ c.start++
47+ c.count--
48+ c.bounds = c.bounds[1:]
49+ c.rowIDs = c.rowIDs[1:]
4450 }
4551
4652 func (h *History) recordPayloadScratch(p renderer.CellPayload) {
@@ -31,16 +31,22 @@ func (c *HistoryChunk) recordPayloads() {
31 }31 }
32 32
33 func (c *HistoryChunk) withoutFirstRow() *HistoryChunk {33 func (c *HistoryChunk) withoutFirstRow() *HistoryChunk {
34- payloadBytes := c.payloadBytes34+ next := *c
35+ next.dropFirstRow()
36+ return &next
37+}
38+
39+// dropFirstRow advances the wrapper past its first row in place. Only a wrapper
40+// no view has captured may be mutated; see History.privateHead.
41+func (c *HistoryChunk) dropFirstRow() {
35 if len(c.payloadDrops) != 0 {42 if len(c.payloadDrops) != 0 {
36- payloadBytes -= c.payloadDrops[c.start]43+ c.payloadBytes -= c.payloadDrops[c.start]
37- }
38- return &HistoryChunk{
39- page: c.page, start: c.start + 1, count: c.count - 1, width: c.width,
40- bounds: c.bounds[1:], rowIDs: c.rowIDs[1:],
41- styleDrops: c.styleDrops, styleCount: c.styleCount - c.styleDrops[c.start],
42- payloadDrops: c.payloadDrops, payloadBytes: payloadBytes,
43 }44 }
45+ c.styleCount -= c.styleDrops[c.start]
46+ c.start++
47+ c.count--
48+ c.bounds = c.bounds[1:]
49+ c.rowIDs = c.rowIDs[1:]
44 }50 }
45 51
46 func (h *History) recordPayloadScratch(p renderer.CellPayload) {52 func (h *History) recordPayloadScratch(p renderer.CellPayload) {
A history_styleset.go +78 -0
@@ -0,0 +1,78 @@
1+package vt
2+
3+import (
4+ "iter"
5+ "slices"
6+
7+ renderer "github.com/bnema/vev-vt/core"
8+)
9+
10+// styleSetInline is the number of distinct styles kept in a linear slice
11+// before the set switches to a map. Typical rows use a handful of styles, where
12+// a short compare loop beats hashing the 56-byte Style key.
13+const styleSetInline = 16
14+
15+// styleSet is a reusable set of canonical styles for one history row. It keeps
16+// small sets in a slice and only builds a map for high-cardinality rows. The
17+// zero value is empty and ready to use.
18+type styleSet struct {
19+ small []renderer.Style
20+ large map[renderer.Style]struct{}
21+}
22+
23+func (s *styleSet) reset() {
24+ s.small = s.small[:0]
25+ if len(s.large) > maxRetainedStyleScratch {
26+ s.large = nil
27+ } else {
28+ clear(s.large)
29+ }
30+}
31+
32+func (s *styleSet) add(style renderer.Style) {
33+ if len(s.large) > 0 {
34+ s.large[style] = struct{}{}
35+ return
36+ }
37+ if slices.Contains(s.small, style) {
38+ return
39+ }
40+ if len(s.small) < styleSetInline {
41+ s.small = append(s.small, style)
42+ return
43+ }
44+ if s.large == nil {
45+ s.large = make(map[renderer.Style]struct{}, 2*styleSetInline)
46+ }
47+ for _, existing := range s.small {
48+ s.large[existing] = struct{}{}
49+ }
50+ s.large[style] = struct{}{}
51+ s.small = s.small[:0]
52+}
53+
54+func (s *styleSet) len() int {
55+ if len(s.large) > 0 {
56+ return len(s.large)
57+ }
58+ return len(s.small)
59+}
60+
61+// all yields each style once, in unspecified order.
62+func (s *styleSet) all() iter.Seq[renderer.Style] {
63+ return func(yield func(renderer.Style) bool) {
64+ if len(s.large) > 0 {
65+ for style := range s.large {
66+ if !yield(style) {
67+ return
68+ }
69+ }
70+ return
71+ }
72+ for _, style := range s.small {
73+ if !yield(style) {
74+ return
75+ }
76+ }
77+ }
78+}
@@ -0,0 +1,78 @@
1+package vt
2+
3+import (
4+ "iter"
5+ "slices"
6+
7+ renderer "github.com/bnema/vev-vt/core"
8+)
9+
10+// styleSetInline is the number of distinct styles kept in a linear slice
11+// before the set switches to a map. Typical rows use a handful of styles, where
12+// a short compare loop beats hashing the 56-byte Style key.
13+const styleSetInline = 16
14+
15+// styleSet is a reusable set of canonical styles for one history row. It keeps
16+// small sets in a slice and only builds a map for high-cardinality rows. The
17+// zero value is empty and ready to use.
18+type styleSet struct {
19+ small []renderer.Style
20+ large map[renderer.Style]struct{}
21+}
22+
23+func (s *styleSet) reset() {
24+ s.small = s.small[:0]
25+ if len(s.large) > maxRetainedStyleScratch {
26+ s.large = nil
27+ } else {
28+ clear(s.large)
29+ }
30+}
31+
32+func (s *styleSet) add(style renderer.Style) {
33+ if len(s.large) > 0 {
34+ s.large[style] = struct{}{}
35+ return
36+ }
37+ if slices.Contains(s.small, style) {
38+ return
39+ }
40+ if len(s.small) < styleSetInline {
41+ s.small = append(s.small, style)
42+ return
43+ }
44+ if s.large == nil {
45+ s.large = make(map[renderer.Style]struct{}, 2*styleSetInline)
46+ }
47+ for _, existing := range s.small {
48+ s.large[existing] = struct{}{}
49+ }
50+ s.large[style] = struct{}{}
51+ s.small = s.small[:0]
52+}
53+
54+func (s *styleSet) len() int {
55+ if len(s.large) > 0 {
56+ return len(s.large)
57+ }
58+ return len(s.small)
59+}
60+
61+// all yields each style once, in unspecified order.
62+func (s *styleSet) all() iter.Seq[renderer.Style] {
63+ return func(yield func(renderer.Style) bool) {
64+ if len(s.large) > 0 {
65+ for style := range s.large {
66+ if !yield(style) {
67+ return
68+ }
69+ }
70+ return
71+ }
72+ for _, style := range s.small {
73+ if !yield(style) {
74+ return
75+ }
76+ }
77+ }
78+}
A vev-vt.test +0 -0 binary file: no text diff

binary file: no text diff

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close