detached 05e3a12

Merge pull request #77 from bnema/perf/history-chunks-v3

perf(snapshot): adopt immutable history chunks and v3 persistence
commit
05e3a12ceb337ddabd7b0fa76dcd10057b690b13
author
bnema · · 2026-07-13 05:02 UTC
committer
GitHub · · 2026-07-13 05:02 UTC
merge of
778bd38 + b23180e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
39 files · +4411 -1251
M internal/usecase/copy/mode.go +44 -18
@@ -7,6 +7,7 @@ import (
77 "unicode"
88
99 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
1011 )
1112
1213 // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits
@@ -17,45 +18,49 @@ import (
1718 const OSC52MaxPayloadBytes = 75_000
1819
1920 // Snapshot is the immutable scrollback-mode document: history followed by the
20-// visible screen. History rows are immutable by Scrollback's append contract;
21-// the visible frame is cloned once when the snapshot is constructed.
21+// visible screen. VT HistoryView retains immutable chunks; the visible frame is
22+// cloned once when the snapshot is constructed.
2223 type Snapshot struct {
23- history HistoryView
24+ history vt.HistoryView
2425 screen renderer.Frame
2526 Width int
2627 Height int
2728 }
2829
29-// NewSnapshot freezes the current scrollback view and clones the visible screen.
30-func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
31- var history HistoryView
32- if sb != nil {
33- history = sb.View()
30+// NewSnapshot seals the current VT history tail, then freezes that immutable
31+// view and clones the visible screen. Sealing prevents repeated copy entry from
32+// cloning every row in a partial tail.
33+func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
34+ var history vt.HistoryView
35+ if historySource != nil {
36+ history = historySource.SealAndView()
3437 }
3538 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
3639 }
3740
3841 // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.
3942 // It is intended for callers that already have a complete document rather than
40-// a scrollback and visible frame.
43+// a scrollback and visible frame. Height is viewport geometry, not a request to
44+// append blank document rows.
4145 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
42- owned := make([][]renderer.Cell, len(rows))
43- for i, row := range rows {
44- owned[i] = append([]renderer.Cell(nil), row...)
46+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256})
47+ for _, row := range rows {
48+ history.Append(row)
4549 }
46- return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height}
50+ return Snapshot{history: history.SealAndView(), Width: width, Height: height}
4751 }
4852
4953 // Len returns the number of document rows.
5054 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
5155
52-// Row returns document row i, or nil when i is out of range.
56+// Row returns document row i, or nil when i is out of range. History rows
57+// borrow immutable history storage and must be treated as read-only.
5358 func (s Snapshot) Row(i int) []renderer.Cell {
5459 if i < 0 {
5560 return nil
5661 }
5762 if i < s.history.Len() {
58- return s.history.Row(i)
63+ return s.history.BorrowedRow(i)
5964 }
6065 i -= s.history.Len()
6166 if i >= s.screen.Height {
@@ -64,6 +69,27 @@ func (s Snapshot) Row(i int) []renderer.Cell {
6469 return s.screen.Row(i)
6570 }
6671
72+func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
73+ rowIndex := 0
74+ stopped := false
75+ s.history.Range(func(row []renderer.Cell) bool {
76+ if !yield(rowIndex, row) {
77+ stopped = true
78+ return false
79+ }
80+ rowIndex++
81+ return true
82+ })
83+ if stopped {
84+ return
85+ }
86+ for y := range s.screen.Height {
87+ if !yield(rowIndex+y, s.screen.Row(y)) {
88+ return
89+ }
90+ }
91+}
92+
6793 // Mode stores per-client scrollback viewport and line-selection state.
6894 type SearchMatch struct {
6995 Row int
@@ -102,8 +128,7 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
102128 }
103129 needle := lowerRunes(query)
104130 matches := make([]SearchMatch, 0)
105- for row := range s.Len() {
106- cells := s.Row(row)
131+ s.rangeRows(func(row int, cells []renderer.Cell) bool {
107132 haystack, cellIndexes := searchableCells(cells)
108133 var text string
109134 for start := 0; start+len(needle) <= len(haystack); {
@@ -122,7 +147,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
122147 matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text})
123148 start += len(needle)
124149 }
125- }
150+ return true
151+ })
126152 return matches
127153 }
128154
M internal/usecase/copy/mode_test.go +128 -3
@@ -7,11 +7,30 @@ import (
77 "testing"
88
99 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
1011 "github.com/stretchr/testify/require"
1112 )
1213
1314 var benchmarkSnapshotSink Snapshot
1415
16+func row(text string) []renderer.Cell {
17+ result := make([]renderer.Cell, 0, len([]rune(text)))
18+ for _, r := range text {
19+ result = append(result, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
20+ }
21+ return result
22+}
23+
24+func newHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
25+
26+func rowText(cells []renderer.Cell) string {
27+ runes := make([]rune, len(cells))
28+ for i, cell := range cells {
29+ runes[i] = cell.Rune
30+ }
31+ return string(runes)
32+}
33+
1534 func snapshot(lines []string, height int) Snapshot {
1635 rows := make([][]renderer.Cell, len(lines))
1736 for i, line := range lines {
@@ -355,6 +374,24 @@ func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T)
355374 require.Equal(t, 2, matches[0].End)
356375 }
357376
377+func TestFindMatchesDoesNotCopyEveryHistoryRow(t *testing.T) {
378+ const rows = 100
379+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows, ChunkRows: 10})
380+ for range rows {
381+ history.Append(row(strings.Repeat("x", 120)))
382+ }
383+ s := NewSnapshot(history, renderer.NewFrame(120, 1))
384+
385+ allocs := testing.AllocsPerRun(10, func() {
386+ if matches := FindMatches(s, "missing"); len(matches) != 0 {
387+ t.Fatalf("unexpected matches: %d", len(matches))
388+ }
389+ })
390+ if allocs >= 250 {
391+ t.Fatalf("FindMatches allocations = %.0f, want < 250 (no row copy per history row)", allocs)
392+ }
393+}
394+
358395 func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
359396 row := []renderer.Cell{
360397 {Rune: '界'},
@@ -478,6 +515,94 @@ func TestOSC52FromBase64(t *testing.T) {
478515 }
479516 }
480517
518+func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {
519+ history := newHistory(1)
520+ history.Append(row("old"))
521+ screen := renderer.NewFrame(3, 1)
522+ copy(screen.Row(0), row("one"))
523+
524+ document := NewSnapshot(history, screen)
525+ history.Append(row("new"))
526+ screen.Set(0, 0, renderer.Cell{Rune: 'x'})
527+
528+ require.Equal(t, 2, document.Len())
529+ require.Equal(t, "old", rowText(document.Row(0)))
530+ require.Equal(t, "one", rowText(document.Row(1)))
531+ require.Nil(t, document.Row(-1))
532+ require.Nil(t, document.Row(2))
533+}
534+
535+func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
536+ rows := [][]renderer.Cell{row("one")}
537+ document := NewSnapshotFromRows(rows, 3, 1)
538+ rows[0][0].Rune = 'x'
539+ require.Equal(t, "one", rowText(document.Row(0)))
540+}
541+
542+func TestNewSnapshotFromRowsKeepsDocumentAndViewportDimensionsIndependent(t *testing.T) {
543+ document := NewSnapshotFromRows([][]renderer.Cell{row("one"), row("two")}, 7, 5)
544+
545+ require.Equal(t, 2, document.Len())
546+ require.Equal(t, 7, document.Width)
547+ require.Equal(t, 5, document.Height)
548+ require.Equal(t, "one", rowText(document.Row(0)))
549+ require.Equal(t, "two", rowText(document.Row(1)))
550+ require.Nil(t, document.Row(2))
551+}
552+
553+// This scaling gate deliberately compares equal row counts rather than an
554+// absolute budget, which varies between Go releases. A HistoryView shares
555+// sealed chunks, so its allocation must not grow with history-row width.
556+func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
557+ if testing.Short() {
558+ t.Skip("skipping benchmark-driven allocation gate in short mode")
559+ }
560+ narrow := benchmarkNewSnapshotBytes(16)
561+ wide := benchmarkNewSnapshotBytes(512)
562+ const conservativeWidthTolerance = 2
563+ require.LessOrEqual(t, wide, narrow*conservativeWidthTolerance,
564+ "NewSnapshot B/op scaled with row width: narrow=%d wide=%d", narrow, wide)
565+}
566+
567+func benchmarkNewSnapshotBytes(width int) int64 {
568+ const historyRows = 256
569+ history := newHistory(historyRows)
570+ for range historyRows {
571+ history.Append(make([]renderer.Cell, width))
572+ }
573+ // Keep visible-frame work constant so the comparison isolates history rows.
574+ screen := renderer.NewFrame(8, 4)
575+ result := testing.Benchmark(func(b *testing.B) {
576+ b.ReportAllocs()
577+ b.ResetTimer()
578+ for b.Loop() {
579+ benchmarkSnapshotSink = NewSnapshot(history, screen)
580+ }
581+ })
582+ return result.AllocedBytesPerOp()
583+}
584+
585+// This regression replaces the removed Scrollback ring tests: the VT history
586+// remains bounded oldest-first and a captured copy document survives eviction.
587+func TestNewSnapshotRetainsBoundedHistoryAcrossLaterEviction(t *testing.T) {
588+ history := newHistory(2)
589+ history.Append(row("one"))
590+ history.Append(row("two"))
591+ document := NewSnapshot(history, renderer.NewFrame(3, 1))
592+ history.Append(row("three"))
593+ history.Append(row("four"))
594+
595+ // A copy document is history followed by the visible frame. Its total is
596+ // therefore the bounded retained history plus the one visible row.
597+ require.Equal(t, 3, document.Len())
598+ require.Equal(t, "one", rowText(document.Row(0)))
599+ require.Equal(t, "two", rowText(document.Row(1)))
600+ require.Equal(t, " ", rowText(document.Row(2)), "the visible frame remains part of the document")
601+ view := history.View()
602+ require.Equal(t, "three", rowText(view.Row(0)))
603+ require.Equal(t, "four", rowText(view.Row(1)))
604+}
605+
481606 func BenchmarkNewSnapshot10KRows(b *testing.B) {
482607 const (
483608 width = 120
@@ -485,9 +610,9 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) {
485610 historyRows = 10_000
486611 )
487612
488- scrollback := NewScrollback(historyRows)
613+ history := newHistory(historyRows)
489614 for range historyRows {
490- scrollback.Append(row(strings.Repeat("x", width)))
615+ history.Append(row(strings.Repeat("x", width)))
491616 }
492617 screen := renderer.NewFrame(width, height)
493618 for y := range height {
@@ -497,7 +622,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) {
497622 b.ReportAllocs()
498623 b.ResetTimer()
499624 for b.Loop() {
500- benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
625+ benchmarkSnapshotSink = NewSnapshot(history, screen)
501626 }
502627 }
503628
D internal/usecase/copy/scrollback.go +0 -83
@@ -1,83 +0,0 @@
1-package copy
2-
3-import "github.com/bnema/vev/pkg/renderer"
4-
5-// Scrollback stores evicted terminal rows in a fixed-size ring buffer.
6-type Scrollback struct {
7- rows [][]renderer.Cell
8- head int
9- len int
10-}
11-
12-// NewScrollback constructs a scrollback ring capped at capacity rows.
13-func NewScrollback(capacity int) *Scrollback {
14- if capacity < 0 {
15- capacity = 0
16- }
17- return &Scrollback{rows: make([][]renderer.Cell, capacity)}
18-}
19-
20-// Cap returns the maximum number of rows retained.
21-func (s *Scrollback) Cap() int { return len(s.rows) }
22-
23-// Len returns the number of retained rows.
24-func (s *Scrollback) Len() int { return s.len }
25-
26-// Head returns the physical ring slot holding the oldest retained row.
27-func (s *Scrollback) Head() int { return s.head }
28-
29-// Append stores a copy of row, evicting the oldest row when the ring is full.
30-func (s *Scrollback) Append(row []renderer.Cell) {
31- if len(s.rows) == 0 {
32- return
33- }
34- cp := append([]renderer.Cell(nil), row...)
35- if s.len < len(s.rows) {
36- s.rows[(s.head+s.len)%len(s.rows)] = cp
37- s.len++
38- return
39- }
40- s.rows[s.head] = cp
41- s.head = (s.head + 1) % len(s.rows)
42-}
43-
44-// Snapshot returns retained rows oldest-first.
45-//
46-// The returned outer slice is a copy, but row slices are shared with the
47-// scrollback storage. Callers must treat the returned rows as read-only.
48-func (s *Scrollback) Snapshot() [][]renderer.Cell {
49- if s.len == 0 || len(s.rows) == 0 {
50- return nil
51- }
52- rows := make([][]renderer.Cell, s.len)
53- for i := range s.len {
54- rows[i] = s.rows[(s.head+i)%len(s.rows)]
55- }
56- return rows
57-}
58-
59-// HistoryView is a frozen oldest-first view of scrollback rows.
60-//
61-// Its rows share cell slices with the scrollback at the time View is called.
62-// Callers must treat rows returned by Row as read-only.
63-type HistoryView struct {
64- rows [][]renderer.Cell
65-}
66-
67-// View returns a frozen scrollback view. Appending to the scrollback cannot
68-// alter the view because Append replaces ring slots rather than mutating rows.
69-func (s *Scrollback) View() HistoryView {
70- return HistoryView{rows: s.Snapshot()}
71-}
72-
73-// Len returns the number of rows in the view.
74-func (v HistoryView) Len() int { return len(v.rows) }
75-
76-// Row returns the row at logical index i, oldest first, or nil when i is out
77-// of range. Returned rows are shared with the view and must be read-only.
78-func (v HistoryView) Row(i int) []renderer.Cell {
79- if i < 0 || i >= len(v.rows) {
80- return nil
81- }
82- return v.rows[i]
83-}
D internal/usecase/copy/scrollback_test.go +0 -209
@@ -1,209 +0,0 @@
1-package copy
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-func row(text string) []renderer.Cell {
10- runes := []rune(text)
11- cells := make([]renderer.Cell, len(runes))
12- for i, r := range runes {
13- cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
14- }
15- return cells
16-}
17-
18-func rowText(cells []renderer.Cell) string {
19- runes := make([]rune, len(cells))
20- for i, c := range cells {
21- runes[i] = c.Rune
22- }
23- return string(runes)
24-}
25-
26-func TestScrollbackRing(t *testing.T) {
27- tests := []struct {
28- name string
29- cap int
30- append []string
31- wantHead int
32- wantLen int
33- wantRows []string
34- }{
35- {
36- name: "empty ring drops rows",
37- cap: 0,
38- append: []string{"aa", "bb"},
39- wantHead: 0,
40- wantLen: 0,
41- },
42- {
43- name: "below capacity keeps head at zero",
44- cap: 3,
45- append: []string{"aa", "bb"},
46- wantHead: 0,
47- wantLen: 2,
48- wantRows: []string{"aa", "bb"},
49- },
50- {
51- name: "at capacity fills without rotating head",
52- cap: 3,
53- append: []string{"aa", "bb", "cc"},
54- wantHead: 0,
55- wantLen: 3,
56- wantRows: []string{"aa", "bb", "cc"},
57- },
58- {
59- name: "over capacity evicts oldest and rotates head",
60- cap: 3,
61- append: []string{"aa", "bb", "cc", "dd", "ee"},
62- wantHead: 2,
63- wantLen: 3,
64- wantRows: []string{"cc", "dd", "ee"},
65- },
66- }
67-
68- for _, tt := range tests {
69- t.Run(tt.name, func(t *testing.T) {
70- sb := NewScrollback(tt.cap)
71- for _, r := range tt.append {
72- sb.Append(row(r))
73- }
74-
75- if got := sb.Cap(); got != tt.cap {
76- t.Fatalf("Cap() = %d, want %d", got, tt.cap)
77- }
78- if got := sb.Head(); got != tt.wantHead {
79- t.Fatalf("Head() = %d, want %d", got, tt.wantHead)
80- }
81- if got := sb.Len(); got != tt.wantLen {
82- t.Fatalf("Len() = %d, want %d", got, tt.wantLen)
83- }
84- for i, want := range tt.wantRows {
85- if got := rowText(sb.View().Row(i)); got != want {
86- t.Fatalf("Row(%d) = %q, want %q", i, got, want)
87- }
88- }
89- })
90- }
91-}
92-
93-func TestScrollbackCopiesRows(t *testing.T) {
94- tests := []struct {
95- name string
96- }{
97- {name: "append copies caller row"},
98- }
99- for _, tt := range tests {
100- t.Run(tt.name, func(t *testing.T) {
101- sb := NewScrollback(1)
102- r := row("ab")
103- sb.Append(r)
104- r[0].Rune = 'z'
105-
106- if text := rowText(sb.View().Row(0)); text != "ab" {
107- t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text)
108- }
109- })
110- }
111-}
112-
113-func TestScrollbackSnapshot(t *testing.T) {
114- tests := []struct {
115- name string
116- cap int
117- append []string
118- wantRows []string
119- }{
120- {
121- name: "empty capacity snapshots no rows",
122- cap: 0,
123- append: []string{"aa", "bb"},
124- wantRows: nil,
125- },
126- {
127- name: "oldest first after ring wrap",
128- cap: 3,
129- append: []string{"aa", "bb", "cc", "dd"},
130- wantRows: []string{"bb", "cc", "dd"},
131- },
132- }
133-
134- for _, tt := range tests {
135- t.Run(tt.name, func(t *testing.T) {
136- sb := NewScrollback(tt.cap)
137- for _, r := range tt.append {
138- sb.Append(row(r))
139- }
140-
141- got := sb.Snapshot()
142- if len(got) != len(tt.wantRows) {
143- t.Fatalf("Snapshot() len = %d, want %d", len(got), len(tt.wantRows))
144- }
145- for i, want := range tt.wantRows {
146- if text := rowText(got[i]); text != want {
147- t.Fatalf("Snapshot()[%d] = %q, want %q", i, text, want)
148- }
149- }
150- if len(got) > 0 {
151- got[0] = row("zz")
152- if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] {
153- t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
154- }
155- }
156- })
157- }
158-}
159-
160-func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) {
161- sb := NewScrollback(2)
162- sb.Append(row("aa"))
163- sb.Append(row("bb"))
164- snapshot := sb.Snapshot()
165-
166- sb.Append(row("cc"))
167- sb.Append(row("dd"))
168-
169- if text := rowText(snapshot[0]); text != "aa" {
170- t.Fatalf("snapshot[0] after append = %q, want original row", text)
171- }
172- if text := rowText(snapshot[1]); text != "bb" {
173- t.Fatalf("snapshot[1] after append = %q, want original row", text)
174- }
175-}
176-
177-func TestScrollbackViewSurvivesCompleteRingOverwrite(t *testing.T) {
178- sb := NewScrollback(2)
179- sb.Append(row("aa"))
180- sb.Append(row("bb"))
181- view := sb.View()
182- if &view.Row(0)[0] != &sb.rows[0][0] {
183- t.Fatal("View() deep-copied cells, want shared row storage")
184- }
185-
186- sb.Append(row("cc"))
187- sb.Append(row("dd"))
188-
189- if got := view.Len(); got != 2 {
190- t.Fatalf("View().Len() = %d, want 2", got)
191- }
192- for i, want := range []string{"aa", "bb"} {
193- if got := rowText(view.Row(i)); got != want {
194- t.Fatalf("View().Row(%d) = %q, want %q", i, got, want)
195- }
196- }
197-}
198-
199-func TestHistoryViewRowBounds(t *testing.T) {
200- sb := NewScrollback(1)
201- sb.Append(row("aa"))
202- view := sb.View()
203-
204- for _, i := range []int{-1, 1} {
205- if got := view.Row(i); got != nil {
206- t.Errorf("View().Row(%d) = %v, want nil", i, got)
207- }
208- }
209-}
D internal/usecase/copy/snapshot_test.go +0 -85
@@ -1,85 +0,0 @@
1-package copy
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {
10- sb := NewScrollback(1)
11- sb.Append(row("old"))
12- screen := renderer.NewFrame(3, 1)
13- copy(screen.Row(0), row("one"))
14-
15- snapshot := NewSnapshot(sb, screen)
16- if &snapshot.Row(0)[0] != &sb.View().Row(0)[0] {
17- t.Fatal("NewSnapshot() deep-copied immutable history")
18- }
19- sb.Append(row("new"))
20- screen.Set(0, 0, renderer.Cell{Rune: 'x'})
21-
22- if got := snapshot.Len(); got != 2 {
23- t.Fatalf("Len() = %d, want 2", got)
24- }
25- for i, want := range []string{"old", "one"} {
26- if got := rowText(snapshot.Row(i)); got != want {
27- t.Errorf("Row(%d) = %q, want %q", i, got, want)
28- }
29- }
30- for _, i := range []int{-1, 2} {
31- if got := snapshot.Row(i); got != nil {
32- t.Errorf("Row(%d) = %v, want nil", i, got)
33- }
34- }
35-}
36-
37-func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
38- rows := [][]renderer.Cell{row("one")}
39- snapshot := NewSnapshotFromRows(rows, 3, 1)
40- rows[0][0].Rune = 'x'
41- if got := rowText(snapshot.Row(0)); got != "one" {
42- t.Fatalf("Row(0) = %q, want deep-owned caller row", got)
43- }
44-}
45-
46-// This scaling gate deliberately compares equal row counts rather than an
47-// absolute budget, which varies between Go releases. A full history-row clone
48-// grows with row width; the immutable HistoryView allocation does not.
49-func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
50- if testing.Short() {
51- t.Skip("skipping benchmark-driven allocation gate in short mode")
52- }
53- narrow := benchmarkNewSnapshotBytes(16)
54- wide := benchmarkNewSnapshotBytes(512)
55- assertWidthIndependentAllocations(t, "NewSnapshot", narrow, wide)
56-}
57-
58-func benchmarkNewSnapshotBytes(width int) int64 {
59- const historyRows = 256
60-
61- scrollback := NewScrollback(historyRows)
62- for range historyRows {
63- scrollback.Append(make([]renderer.Cell, width))
64- }
65- // Keep visible-frame work constant so the comparison isolates history rows.
66- screen := renderer.NewFrame(8, 4)
67- result := testing.Benchmark(func(b *testing.B) {
68- b.ReportAllocs()
69- b.ResetTimer()
70- for b.Loop() {
71- benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
72- }
73- })
74- return result.AllocedBytesPerOp()
75-}
76-
77-func assertWidthIndependentAllocations(t *testing.T, operation string, narrow, wide int64) {
78- t.Helper()
79- // Two times permits allocator noise while decisively rejecting a cell-row
80- // copy, whose allocation grows by 32x for the widths above.
81- const conservativeWidthTolerance = 2
82- if wide > narrow*conservativeWidthTolerance {
83- t.Fatalf("%s B/op scaled with row width: narrow=%d wide=%d (limit=%d)", operation, narrow, wide, narrow*conservativeWidthTolerance)
84- }
85-}
A internal/usecase/daemon/copy_enter_allocation_budget_race_test.go +8 -0
@@ -0,0 +1,8 @@
1+//go:build race
2+
3+package daemon
4+
5+// Race instrumentation allocations are not part of the parent benchmark's
6+// allocation baseline. TestCopyEnterAllocationBudget still exercises copy
7+// entry and validates its result under -race.
8+const copyEnterAllocationBudgetEnabled = false
A internal/usecase/daemon/copy_enter_allocation_budget_test.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build !race
2+
3+package daemon
4+
5+// copyEnterAllocationBudgetEnabled keeps the allocation gate aligned with the
6+// non-race parent benchmark, whose allocation baseline defines the budget.
7+const copyEnterAllocationBudgetEnabled = true
M internal/usecase/daemon/copymode.go +1 -1
@@ -66,7 +66,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
6666 // Capture the live pane under pane.mu; all subsequent copy interaction uses
6767 // the immutable document while holding only copyMu.
6868 p.mu.Lock()
69- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
69+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
7070 p.mu.Unlock()
7171 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
7272 return
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
@@ -54,7 +54,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
5454 sess.client = ac
5555 ac.setSession(sess)
5656 closing.mu.Lock()
57- document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame)
57+ document := scopy.NewSnapshot(closing.history, closing.screen.Frame)
5858 closing.mu.Unlock()
5959 ac.overlays.copyMu.Lock()
6060 ac.overlays.copyPane = closing
@@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
9494 tb := sess.activeTab()
9595 p := tb.focusedPane()
9696 p.mu.Lock()
97- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
97+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
9898 p.mu.Unlock()
9999
100100 // Hold session validation after publication and make the captured target
@@ -157,7 +157,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
157157 tb := sess.activeTab()
158158 p := tb.focusedPane()
159159 p.mu.Lock()
160- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
160+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
161161 p.mu.Unlock()
162162 tc.invalidate(sess, tb, p)
163163
M internal/usecase/daemon/copymode_test.go +23 -23
@@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112112 tb := sess.activeTab()
113113 pane := tb.focusedPane()
114114 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
115+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116116 pane.mu.Unlock()
117117 mode := scopy.NewMode(snap)
118118 bars := barState{status: sess.statusSegments(true)}
@@ -147,7 +147,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
147147 tb := sess.activeTab()
148148 tb.focusedPane().screen = vt.NewScreen(12, 3)
149149 tb.focusedPane().screen.Write([]byte("live"))
150- snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
150+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame)
151151 mode := scopy.NewMode(snap)
152152
153153 bars := barState{status: sess.statusSegments(true)}
@@ -166,7 +166,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
166166 writes := make(chan []byte, 1)
167167 p, _ := newBlockingPTYWithWrites(t, writes)
168168 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
169- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
169+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
170170 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
171171
172172 d.handleInput(sess, ac, []byte("\x1b "))
@@ -293,8 +293,8 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
293293 p, _ := newBlockingPTY(t)
294294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
295295 pane := sess.tabs[0].focusedPane()
296- pane.scrollback = scopy.NewScrollback(4)
297- pane.scrollback.Append(testRow("old alpha"))
296+ installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
297+ pane.history.Append(testRow("old alpha"))
298298 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
299299
300300 d.enterCopyMode(sess, ac)
@@ -322,9 +322,9 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
322322 writes := make(chan []byte, 1)
323323 p, _ := newBlockingPTYWithWrites(t, writes)
324324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
326- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
327- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
325+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
326+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
327+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
328328 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
329329
330330 d.enterCopyMode(sess, ac)
@@ -445,7 +445,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
445445 }
446446 p.mu.Lock()
447447 defer p.mu.Unlock()
448- return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12
448+ return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12
449449 }, 2*time.Second, 5*time.Millisecond)
450450
451451 sess := firstSession(d)
@@ -481,7 +481,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
481481 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
482482 p, _ := newBlockingPTY(t)
483483 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
484- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
484+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
485485 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
486486
487487 d.enterCopyMode(sess, ac)
@@ -515,9 +515,9 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
515515 t.Run(tc.name, func(t *testing.T) {
516516 p, _ := newBlockingPTY(t)
517517 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
518- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
519- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
520- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
518+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
519+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
520+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
521521 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
522522
523523 d.enterCopyMode(sess, ac)
@@ -535,7 +535,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
535535 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
536536 p, _ := newBlockingPTY(t)
537537 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
538- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1)
538+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1})
539539 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
540540 sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
541541 copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
@@ -560,7 +560,7 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
560560 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
561561 clk := &signalClock{timers: make(chan *signalTimer, 1)}
562562 d.clock = clk
563- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
563+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
564564 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
565565
566566 d.enterCopyMode(sess, ac)
@@ -584,7 +584,7 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
584584 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
585585 clk := &signalClock{timers: make(chan *signalTimer, 1)}
586586 d.clock = clk
587- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
587+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
588588 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
589589
590590 d.enterCopyMode(sess, ac)
@@ -627,7 +627,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
627627 t.Run(tc.name, func(t *testing.T) {
628628 p, _ := newBlockingPTY(t)
629629 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
630- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
630+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
631631 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
632632
633633 d.enterCopyMode(sess, ac)
@@ -676,7 +676,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
676676 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
677677 p, _ := newBlockingPTY(t)
678678 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
679- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
679+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
680680 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
681681
682682 done := make(chan struct{})
@@ -750,11 +750,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
750750 var want *pane
751751 if tc.useFloating {
752752 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
753- want.scrollback.Append(testRow("flt-old"))
753+ want.history.Append(testRow("flt-old"))
754754 want.screen.Write([]byte("flt-live"))
755755 } else {
756756 want = main
757- want.scrollback.Append(testRow("one-old"))
757+ want.history.Append(testRow("one-old"))
758758 want.screen.Write([]byte("one-live"))
759759 }
760760
@@ -791,11 +791,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
791791 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
792792 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
793793 for i := range 30 {
794- fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i)))
794+ fp.history.Append(testRow(fmt.Sprintf("old-%02d", i)))
795795 }
796796 fp.screen.Write([]byte("live"))
797797 fp.mu.Lock()
798- total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len()
798+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len()
799799 fp.mu.Unlock()
800800
801801 d.enterCopyMode(sess, ac)
@@ -817,7 +817,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
817817 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
818818 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
819819 for i := range 5 {
820- fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i)))
820+ fp.history.Append(testRow(fmt.Sprintf("old-%d", i)))
821821 }
822822 fp.screen.Write([]byte("live"))
823823
M internal/usecase/daemon/daemon.go +23 -0
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/vev/internal/persist"
1717 "github.com/bnema/vev/internal/ports"
1818 "github.com/bnema/vev/internal/usecase/keys"
19+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1920 )
2021
2122 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -101,6 +102,21 @@ type Daemon struct {
101102 persistEnabled bool
102103 snaps ports.SnapshotStore
103104 snapsEnabled bool
105+ snapshotMarshal func(snapcodec.Session) ([]byte, error)
106+ snapshotJobs chan *snapshotCapture
107+ snapshotWorkerMu sync.Mutex
108+ snapshotWorkerID uint64
109+ snapshotWorkerCtx context.Context
110+ snapshotWorkerCancel context.CancelFunc
111+ snapshotWorkerDone chan struct{}
112+ snapshotWorkerFlush chan struct{}
113+ snapshotWorkerFinalWake chan struct{}
114+ // snapshotFinalJobs retains immutable terminal captures rejected by the
115+ // bounded regular queue. It is only populated during session teardown and
116+ // drained by the same worker, so persistence remains outside daemon locks.
117+ snapshotFinalJobs []*snapshotCapture
118+ snapshotWorkerClosing bool
119+ snapshotWorkerInFlight *snapshotCapture
104120 restoreDone chan struct{}
105121 restoreOnce sync.Once
106122 procCwd func(int) (string, error)
@@ -285,6 +301,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
285301 done: make(chan struct{}),
286302 restoreDone: make(chan struct{}),
287303 animWake: make(chan struct{}, 1),
304+ snapshotMarshal: snapcodec.Marshal,
305+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
288306 resumeParkGrace: defaultResumeParkGrace,
289307 barScripts: &barScriptState{
290308 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -355,6 +373,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
355373 })
356374 }
357375 if d.snapsEnabled {
376+ d.startSnapshotEncodeWorker()
358377 d.sessWg.Go(func() {
359378 d.snapshotSaver(d.serveCtx)
360379 })
@@ -403,7 +422,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
403422 d.waitNotifies()
404423 d.hardCancel()
405424 d.serveCancel()
425+ // Wait for handlers before flushing snapshots: a handler that already
426+ // entered killSession may still submit its terminal capture after the
427+ // registry snapshot has been removed.
406428 d.connWg.Wait()
429+ d.stopSnapshotEncodeWorker()
407430 d.shutdownAll(ports.ReasonServerShutdown)
408431 d.sessWg.Wait()
409432 d.waitNotifies()
A internal/usecase/daemon/history_test_helper_test.go +10 -0
@@ -0,0 +1,10 @@
1+package daemon
2+
3+import "github.com/bnema/vev/pkg/vt"
4+
5+func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
6+
7+func installTestHistory(p *pane, config vt.HistoryConfig) {
8+ p.screen = vt.NewScreenWithHistory(p.screen.Frame.Width, p.screen.Frame.Height, config)
9+ p.history = p.screen.History()
10+}
M internal/usecase/daemon/input.go +3 -3
@@ -202,8 +202,8 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
202202 mouseMode, mouseSGR := p.screen.MouseMode()
203203 altScreen := p.screen.AltScreenActive()
204204 scrollbackRows := 0
205- if p.scrollback != nil {
206- scrollbackRows = p.scrollback.Len()
205+ if p.history != nil {
206+ scrollbackRows = p.history.Len()
207207 }
208208 p.mu.Unlock()
209209
@@ -249,7 +249,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
249249
250250 // The immutable snapshot is the only live-pane access needed for drag entry.
251251 p.mu.Lock()
252- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
252+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
253253 p.mu.Unlock()
254254 tb := sess.activeTab()
255255 if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {
M internal/usecase/daemon/input_test.go +3 -3
@@ -14,10 +14,10 @@ import (
1414 "github.com/bnema/vev/internal/domain"
1515 "github.com/bnema/vev/internal/ports"
1616 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17- scopy "github.com/bnema/vev/internal/usecase/copy"
1817 "github.com/bnema/vev/internal/usecase/keys"
1918 "github.com/bnema/vev/internal/usecase/layout"
2019 "github.com/bnema/vev/internal/usecase/picker"
20+ "github.com/bnema/vev/pkg/vt"
2121 )
2222
2323 // --- test doubles -----------------------------------------------------------
@@ -931,7 +931,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
931931 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
932932 p, _ := newBlockingPTY(t)
933933 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
934- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50)
934+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50})
935935 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
936936
937937 // Press on a content row while scrollback is empty: the anchor is
@@ -941,7 +941,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
941941 // Simulate 5 lines evicted into scrollback between the Press and the
942942 // first Motion (e.g. the child kept producing output).
943943 for range 5 {
944- sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))
944+ sess.tabs[0].focusedPane().history.Append(testRow("evicted"))
945945 }
946946
947947 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
@@ -173,7 +173,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.
173173 func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {
174174 ac := &attachedClient{}
175175 ac.initOverlays()
176- history := scopy.NewScrollback(1)
176+ history := newTestHistory(1)
177177 history.Append(testRow("before"))
178178 document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))
179179
M internal/usecase/daemon/pane.go +17 -20
@@ -6,7 +6,6 @@ import (
66
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
9- scopy "github.com/bnema/vev/internal/usecase/copy"
109 "github.com/bnema/vev/internal/usecase/layout"
1110 "github.com/bnema/vev/pkg/vt"
1211 )
@@ -16,15 +15,15 @@ import (
1615 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
1716 // The PTY reader takes only pane.mu, so child output never waits on client IO.
1817 type pane struct {
19- id layout.PaneID
20- stableID string
21- pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
23- resizeMu sync.Mutex // serializes PTY resizes without holding mu
24- screen *vt.Screen
25- scrollback *scopy.Scrollback
26- syncGen uint64
27- rect domain.Rect
18+ id layout.PaneID
19+ stableID string
20+ pty ports.PTY
21+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
22+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
23+ screen *vt.Screen
24+ history *vt.History
25+ syncGen uint64
26+ rect domain.Rect
2827 // resizeApplying gates VT parsing across PTY.Resize. The reader continues
2928 // draining into resizePending so output is replayed against the target (or
3029 // retained old) screen only after apply resolves.
@@ -48,16 +47,14 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
4847 }
4948
5049 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
51- sb := scopy.NewScrollback(defaultScrollbackRows)
52- screen := vt.NewScreen(sz.Cols, sz.Rows)
53- screen.OnLineEvicted = sb.Append
50+ screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows})
5451 return &pane{
55- id: id,
56- stableID: stableID,
57- pty: pty,
58- screen: screen,
59- scrollback: sb,
60- rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
61- title: paneTitleState{displayFallback: "sh"},
52+ id: id,
53+ stableID: stableID,
54+ pty: pty,
55+ screen: screen,
56+ history: screen.History(),
57+ rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
58+ title: paneTitleState{displayFallback: "sh"},
6259 }
6360 }
M internal/usecase/daemon/performance_benchmark_test.go +291 -17
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "fmt"
55 "io"
6+ "runtime"
67 "sync"
78 "testing"
89 "time"
@@ -145,11 +146,29 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
145146 for _, tb := range fixture.sess.tabs {
146147 require.Len(t, tb.panes, 4)
147148 for _, p := range tb.panes {
148- require.Equal(t, 10_000, p.scrollback.Len())
149+ require.Equal(t, 10_000, p.history.Len())
149150 }
150151 }
151152 }
152153
154+func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) {
155+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000})
156+
157+ first, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
158+ require.True(t, ok)
159+ require.Len(t, first.tabs, 1)
160+ require.Len(t, first.tabs[0].panes, 2)
161+ require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len())
162+ require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount())
163+
164+ second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)
165+ require.True(t, ok)
166+ secondPane := capturePaneByID(second, first.tabs[0].panes[0].id)
167+ if secondPane == nil || first.tabs[0].panes[0].history.Chunk(0) != secondPane.history.Chunk(0) {
168+ t.Fatal("unchanged sealed history chunk was not reused across captures")
169+ }
170+}
171+
153172 func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) {
154173 for _, topology := range daemonHistoryTopologies {
155174 t.Run(topology.name, func(t *testing.T) {
@@ -179,12 +198,41 @@ var daemonHistoryTopologies = []daemonHistoryTopology{
179198 {name: "8tabs-1pane", tabs: 8, panes: 1},
180199 }
181200
201+// v3 bounds a decoded snapshot to 256 MiB. One 120-column 10k-row tab is
202+// representative and valid; multi-tab fixtures exceed that durable budget, so
203+// snapshot capture/encode benchmark only the valid one-tab topologies.
204+var daemonSnapshotTopologies = []daemonHistoryTopology{
205+ {name: "1tab-1pane-control", tabs: 1, panes: 1},
206+ {name: "1tab-4panes", tabs: 1, panes: 4},
207+}
208+
182209 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
183210 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
184211 }
212+
213+// BenchmarkDaemonHistorySnapshotCapture measures the synchronous immutable
214+// capture boundary only. Encoding and the bounded worker queue are deliberately
215+// excluded so scheduler latency cannot affect this benchmark.
185216 func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) {
186- benchmarkDaemonLargeHistory(b, "snapshot-capture", func(f *performanceFixture) { f.captureSnapshot() })
217+ benchmarkDaemonSnapshotCapture(b)
218+}
219+
220+// BenchmarkDaemonHistorySnapshotEncode measures v3 encoding from an already
221+// immutable capture, without queueing or persistence.
222+func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) {
223+ benchmarkDaemonSnapshotEncode(b)
187224 }
225+
226+// BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration
227+// from a validated v3 pane manifest without spawning PTY or session workers.
228+func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) {
229+ benchmarkDaemonSnapshotRestore(b)
230+}
231+
232+// BenchmarkDaemonHistoryCopyEnter uses the parent benchmark's repeated-entry
233+// semantics: each operation replaces the active copy document. Exit cleanup is
234+// intentionally outside this benchmark, so its allocations cannot be reported
235+// as copy-entry work.
188236 func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
189237 benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) })
190238 }
@@ -223,8 +271,12 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
223271 fixture := newPerformanceFixture(b, performanceConfig{
224272 size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
225273 })
274+ if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
275+ b.Fatal("invalid daemon history fixture")
276+ }
226277 if workload == "copy-search" {
227- fixture.d.enterCopyMode(fixture.sess, fixture.ac)
278+ benchmarkDaemonCopySearch(b, fixture, run)
279+ return
228280 }
229281 b.ReportAllocs()
230282 fixture.resetMetrics()
@@ -238,26 +290,165 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
238290 b.Fatalf("decode last output: %v", err)
239291 }
240292 }
241- if workload == "snapshot-capture" {
242- payload := fixture.snaps.lastPayload()
243- if payload == nil {
244- b.Fatal("snapshot capture retained no snapshot payload")
245- }
246- if _, err := snapcodec.Unmarshal(payload); err != nil {
247- b.Fatalf("decode last snapshot: %v", err)
248- }
249- }
250293 if workload == "live-paint" && metrics.outputFrames != uint64(b.N) {
251294 b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N)
252295 }
253- if workload == "snapshot-capture" && metrics.snapshotWrites != uint64(b.N) {
254- b.Fatalf("snapshot capture wrote %d snapshots for %d operations", metrics.snapshotWrites, b.N)
255- }
256296 benchmarkReportMetrics(b, metrics, b.N, 10_000)
257297 })
258298 }
259299 }
260300
301+func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) {
302+ b.Helper()
303+ fixture.d.enterCopyMode(fixture.sess, fixture.ac)
304+ fixture.ac.ackOutputState(fixture.ac.output.next)
305+ b.ReportAllocs()
306+ b.ResetTimer()
307+ for range b.N {
308+ run(fixture)
309+ fixture.ac.ackOutputState(fixture.ac.output.next)
310+ }
311+ b.StopTimer()
312+ if fixture.searchMatches() == 0 {
313+ b.Fatal("copy search produced no deterministic matches")
314+ }
315+ b.ReportMetric(1, "copyoperations/op")
316+}
317+
318+func benchmarkDaemonSnapshotCapture(b *testing.B) {
319+ for _, topology := range daemonSnapshotTopologies {
320+ b.Run(topology.name, func(b *testing.B) {
321+ fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000})
322+ if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
323+ b.Fatal("invalid daemon snapshot capture fixture")
324+ }
325+ first, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
326+ if !ok || !validBenchmarkCapture(first) {
327+ b.Fatal("invalid snapshot capture fixture")
328+ }
329+ firstPane := first.tabs[0].panes[0]
330+ firstChunk := firstPane.history.Chunk(0)
331+ captures := 0
332+ var last *snapshotCapture
333+ b.ReportAllocs()
334+ b.ResetTimer()
335+ for range b.N {
336+ last, ok = fixture.d.captureSnapshotState(fixture.sess, uint64(captures+2))
337+ if !ok {
338+ b.Fatal("snapshot capture unexpectedly rejected fixture")
339+ }
340+ captures++
341+ }
342+ b.StopTimer()
343+ lastPane := capturePaneByID(last, firstPane.id)
344+ if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.history.Chunk(0) != firstChunk {
345+ b.Fatal("snapshot capture did not preserve immutable sealed history")
346+ }
347+ b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op")
348+ })
349+ }
350+}
351+
352+func benchmarkDaemonSnapshotEncode(b *testing.B) {
353+ for _, topology := range daemonSnapshotTopologies {
354+ b.Run(topology.name, func(b *testing.B) {
355+ fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000})
356+ if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
357+ b.Fatal("invalid daemon snapshot encode fixture")
358+ }
359+ capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
360+ if !ok || !validBenchmarkCapture(capture) {
361+ b.Fatal("invalid snapshot encode fixture")
362+ }
363+ encoded, err := fixture.d.encodeSnapshotCapture(capture)
364+ if err != nil {
365+ b.Fatal(err)
366+ }
367+ if _, err := snapcodec.Unmarshal(encoded); err != nil {
368+ b.Fatalf("invalid v3 snapshot encode fixture: %v", err)
369+ }
370+ var last []byte
371+ b.ReportAllocs()
372+ b.SetBytes(int64(len(encoded)))
373+ b.ResetTimer()
374+ for range b.N {
375+ last, err = fixture.d.encodeSnapshotCapture(capture)
376+ if err != nil {
377+ b.Fatal(err)
378+ }
379+ }
380+ b.StopTimer()
381+ if _, err := snapcodec.Unmarshal(last); err != nil {
382+ b.Fatalf("snapshot encoder produced invalid v3 data: %v", err)
383+ }
384+ b.ReportMetric(float64(len(last)), "snapshotbytes/op")
385+ })
386+ }
387+}
388+
389+func benchmarkDaemonSnapshotRestore(b *testing.B) {
390+ fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
391+ if !fixture.hasHistoryTopology(1, 1, 10_000) {
392+ b.Fatal("invalid daemon snapshot restore fixture")
393+ }
394+ capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
395+ if !ok || !validBenchmarkCapture(capture) {
396+ b.Fatal("invalid snapshot restore fixture")
397+ }
398+ encoded, err := fixture.d.encodeSnapshotCapture(capture)
399+ if err != nil {
400+ b.Fatal(err)
401+ }
402+ snapshot, err := snapcodec.Unmarshal(encoded)
403+ if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 {
404+ b.Fatalf("invalid v3 snapshot restore fixture: %v", err)
405+ }
406+ paneSnapshot := snapshot.Tabs[0].Panes[0]
407+ b.ReportAllocs()
408+ b.SetBytes(int64(len(encoded)))
409+ b.ResetTimer()
410+ for range b.N {
411+ if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil {
412+ b.Fatal(err)
413+ }
414+ }
415+ b.StopTimer()
416+ fixture.activePane.mu.Lock()
417+ restoredRows := fixture.activePane.history.Len()
418+ fixture.activePane.mu.Unlock()
419+ if restoredRows != 10_000 {
420+ b.Fatalf("snapshot restore retained %d history rows, want 10000", restoredRows)
421+ }
422+ b.ReportMetric(float64(restoredRows), "historyrows/restore")
423+}
424+
425+func validBenchmarkCapture(capture *snapshotCapture) bool {
426+ return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].history.Len() == 10_000 && capture.tabs[0].panes[0].history.ChunkCount() > 0
427+}
428+
429+func capturePaneCount(capture *snapshotCapture) int {
430+ count := 0
431+ for _, tab := range capture.tabs {
432+ count += len(tab.panes)
433+ }
434+ return count
435+}
436+
437+func capturePaneByID(capture *snapshotCapture, id layout.PaneID) *snapshotCapturePane {
438+ if capture == nil {
439+ return nil
440+ }
441+ for tabIndex := range capture.tabs {
442+ for paneIndex := range capture.tabs[tabIndex].panes {
443+ pane := &capture.tabs[tabIndex].panes[paneIndex]
444+ if pane.id == id {
445+ return pane
446+ }
447+ }
448+ }
449+ return nil
450+}
451+
261452 func TestCoordinatorCoalescingRatioReportsInvalidationsPerWake(t *testing.T) {
262453 require.Equal(t, float64(3), coordinatorCoalescingRatio(performanceMetrics{
263454 coordinatorWakes: 1,
@@ -331,6 +522,42 @@ func TestLivePaintAllocationBudget(t *testing.T) {
331522 }
332523 }
333524
525+// TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy
526+// rendering borrows sealed VT history rows; allocating a copy per viewport row
527+// is a production regression even though the capture itself remains immutable.
528+func TestCopyEnterAllocationBudget(t *testing.T) {
529+ for _, tt := range []struct {
530+ name string
531+ tabs, panes, max int
532+ }{
533+ {name: "1tab-1pane", tabs: 1, panes: 1, max: 38},
534+ {name: "1tab-4panes", tabs: 1, panes: 4, max: 43},
535+ {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42},
536+ {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48},
537+ {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50},
538+ } {
539+ t.Run(tt.name, func(t *testing.T) {
540+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
541+ run := func() {
542+ fixture.d.enterCopyMode(fixture.sess, fixture.ac)
543+ fixture.ac.ackOutputState(fixture.ac.output.next)
544+ }
545+
546+ // Always exercise and validate copy entry. The race detector adds
547+ // instrumentation allocations which are not reported by the parent
548+ // benchmark, so its allocation count cannot represent this budget.
549+ run()
550+ require.True(t, fixture.copyModeActive(), "copy entry must install a history-backed mode")
551+ if !copyEnterAllocationBudgetEnabled {
552+ return
553+ }
554+
555+ allocs := testing.AllocsPerRun(20, run)
556+ require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10%% of the parent allocation baseline")
557+ })
558+ }
559+}
560+
334561 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
335562 fixture := newPerformanceFixture(t, performanceConfig{})
336563
@@ -501,13 +728,15 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
501728 sess: sess,
502729 ac: ac,
503730 output: output,
504- snaps: &countingSnapshotStore{},
731+ snaps: &countingSnapshotStore{done: make(chan struct{}, 1)},
505732 pty: pty,
506733 clock: clock,
507734 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
508735 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
509736 }
510737 WithSnapshotStore(fixture.snaps)(d)
738+ d.startSnapshotEncodeWorker()
739+ t.Cleanup(d.stopSnapshotEncodeWorker)
511740 for tabIndex, tb := range sess.tabs {
512741 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
513742 }
@@ -550,7 +779,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
550779 for _, p := range tb.panes {
551780 p.mu.Lock()
552781 width := p.screen.Frame.Width
553- for row := 0; p.scrollback.Len() < historyRows; row++ {
782+ for row := 0; p.history.Len() < historyRows; row++ {
554783 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
555784 }
556785 p.mu.Unlock()
@@ -594,6 +823,17 @@ func (f *performanceFixture) paintLive() {
594823 func (f *performanceFixture) captureSnapshot() {
595824 f.t.Helper()
596825 require.True(f.t, f.d.captureSession(f.sess))
826+ <-f.snaps.done
827+ for range 4096 {
828+ f.sess.snapshotMu.Lock()
829+ pending := f.sess.snapshotPending
830+ f.sess.snapshotMu.Unlock()
831+ if !pending {
832+ return
833+ }
834+ runtime.Gosched()
835+ }
836+ f.t.Fatal("snapshot worker did not finish performance capture")
597837 }
598838
599839 func (f *performanceFixture) enterCopySearch() {
@@ -602,6 +842,30 @@ func (f *performanceFixture) enterCopySearch() {
602842 f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))
603843 }
604844
845+func (f *performanceFixture) hasHistoryTopology(tabs, panes, historyRows int) bool {
846+ if len(f.sess.tabs) != tabs {
847+ return false
848+ }
849+ for _, tb := range f.sess.tabs {
850+ tb.mu.Lock()
851+ if len(tb.panes) != panes {
852+ tb.mu.Unlock()
853+ return false
854+ }
855+ for _, p := range tb.panes {
856+ p.mu.Lock()
857+ rows := p.history.Len()
858+ p.mu.Unlock()
859+ if rows != historyRows {
860+ tb.mu.Unlock()
861+ return false
862+ }
863+ }
864+ tb.mu.Unlock()
865+ }
866+ return true
867+}
868+
605869 func (f *performanceFixture) searchMatches() int {
606870 f.ac.overlays.copyMu.Lock()
607871 defer f.ac.overlays.copyMu.Unlock()
@@ -611,6 +875,12 @@ func (f *performanceFixture) searchMatches() int {
611875 return len(f.ac.overlays.copyMode.Searches)
612876 }
613877
878+func (f *performanceFixture) copyModeActive() bool {
879+ f.ac.overlays.copyMu.Lock()
880+ defer f.ac.overlays.copyMu.Unlock()
881+ return f.ac.overlays.copyMode != nil && f.ac.overlays.copySnapshot != nil && f.ac.overlays.copySnapshot.Len() >= 10_000
882+}
883+
614884 func (f *performanceFixture) resize() {
615885 f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])
616886 }
@@ -754,6 +1024,7 @@ type countingSnapshotStore struct {
7541024 mu sync.Mutex
7551025 countingSnapshotMetrics
7561026 last []byte
1027+ done chan struct{}
7571028 }
7581029
7591030 func (s *countingSnapshotStore) Write(_ string, data []byte) error {
@@ -762,6 +1033,9 @@ func (s *countingSnapshotStore) Write(_ string, data []byte) error {
7621033 s.bytes += uint64(len(data))
7631034 s.last = data
7641035 s.mu.Unlock()
1036+ if s.done != nil {
1037+ s.done <- struct{}{}
1038+ }
7651039 return nil
7661040 }
7671041 func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
M internal/usecase/daemon/render_test.go +8 -8
@@ -572,14 +572,14 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
572572 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
573573 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
574574 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
575- require.Equal(t, 2, win.focusedPane().scrollback.Len())
576- require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0)))
577- require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1)))
575+ require.Equal(t, 2, win.focusedPane().history.Len())
576+ require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0)))
577+ require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1)))
578578
579579 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
580580 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
581581 require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))
582- require.Equal(t, 2, win.focusedPane().scrollback.Len())
582+ require.Equal(t, 2, win.focusedPane().history.Len())
583583 }
584584
585585 func frameRowString(f renderer.Frame, y int) string {
@@ -911,7 +911,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
911911 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },
912912 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },
913913 prepareScreen: func(sess *session) {
914- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
914+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
915915 },
916916 visible: "[SCROLL]",
917917 notVisible: "[SCROLL]",
@@ -923,10 +923,10 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
923923 p, release := newBlockingPTY(t)
924924 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
925925 defer release()
926- sess.tabs[0].focusedPane().screen.Write([]byte("live"))
927926 if tt.prepareScreen != nil {
928927 tt.prepareScreen(sess)
929928 }
929+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
930930 if tt.wantRestored == "" {
931931 tt.wantRestored = "live"
932932 }
@@ -1295,7 +1295,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
12951295 }
12961296 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
12971297 p.screen.Write([]byte("ab\r\ncd"))
1298- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
1298+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
12991299 mode := scopy.NewMode(document)
13001300 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
13011301
@@ -1355,7 +1355,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
13551355 defer releaseFloating()
13561356 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
13571357 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1358- fp.scrollback.Append(testRow("flt-old"))
1358+ fp.history.Append(testRow("flt-old"))
13591359 fp.screen.Write([]byte("flt-live"))
13601360 installTestFloating(sess.activeTab(), fp, true)
13611361
M internal/usecase/daemon/scrollback_test.go +15 -6
@@ -14,29 +14,38 @@ func TestNewTabInitializesScrollback(t *testing.T) {
1414 wantRow string
1515 }{
1616 {
17- name: "tab owns scrollback wired to vt eviction callback",
17+ name: "primary evictions enter screen owned history",
1818 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
1919 wantLen: 1,
2020 wantRow: "AAAA",
2121 },
22+ {
23+ name: "alternate screen evictions do not enter pane history",
24+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"),
25+ wantLen: 1,
26+ wantRow: "AAAA",
27+ },
2228 }
2329
2430 for _, tt := range tests {
2531 t.Run(tt.name, func(t *testing.T) {
2632 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.focusedPane().scrollback == nil {
33+ if win.focusedPane().history == nil {
2834 t.Fatal("scrollback is nil")
2935 }
30- if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {
31- t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)
36+ if win.focusedPane().history != win.focusedPane().screen.History() {
37+ t.Fatal("pane history is not owned by its screen")
38+ }
39+ if win.focusedPane().history.Cap() != defaultScrollbackRows {
40+ t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows)
3241 }
3342
3443 win.focusedPane().screen.Write(tt.write)
3544
36- if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
45+ if got := win.focusedPane().history.Len(); got != tt.wantLen {
3746 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
3847 }
39- row := win.focusedPane().scrollback.View().Row(0)
48+ row := win.focusedPane().history.View().Row(0)
4049 runes := make([]rune, len(row))
4150 for i, c := range row {
4251 runes[i] = c.Rune
M internal/usecase/daemon/session.go +9 -1
@@ -44,6 +44,13 @@ type session struct {
4444 mruAt atomic.Uint64
4545 snapDirty atomic.Bool
4646 snapEligible atomic.Bool
47+ // snapshotMu serializes the dirty generation with worker completion. It is
48+ // intentionally independent from mu: persistence never holds session state
49+ // locks while encoding or writing.
50+ snapshotMu sync.Mutex
51+ snapshotGeneration uint64
52+ snapshotPending bool
53+ snapshotChanged chan struct{}
4754 // syncGen makes synchronized-output watchdog generations unique across all
4855 // panes in this session.
4956 syncGen atomic.Uint64
@@ -671,7 +678,8 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
671678 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
672679 }
673680 } else {
674- d.captureSession(sess)
681+ markSnapshotDirty(sess)
682+ d.scheduleFinalSnapshot(sess)
675683 }
676684 }
677685 d.mu.Lock()
M internal/usecase/daemon/snapshot.go +415 -78
@@ -4,17 +4,53 @@ import (
44 "context"
55 "fmt"
66 "math"
7+ "sync"
78 "time"
89
910 "github.com/bnema/vev/internal/domain"
10- scopy "github.com/bnema/vev/internal/usecase/copy"
1111 "github.com/bnema/vev/internal/usecase/layout"
1212 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1313 "github.com/bnema/vev/pkg/renderer"
1414 "github.com/bnema/vev/pkg/vt"
1515 )
1616
17-const snapshotInterval = 30 * time.Second
17+// snapshotQueueCapacity bounds retained immutable captures. A full queue never
18+// stalls session producers; the session remains dirty for the next interval.
19+const snapshotQueueCapacity = 1
20+
21+type snapshotCapture struct {
22+ session *session
23+ generation uint64
24+ name string
25+ createdAt uint64
26+ active uint16
27+ tabs []snapshotCaptureTab
28+ finishOnce sync.Once
29+}
30+
31+type snapshotCaptureTab struct {
32+ stableID string
33+ cols uint16
34+ rows uint16
35+ nextPaneID uint64
36+ focus layout.PaneID
37+ tree *layout.Tree
38+ panes []snapshotCapturePane
39+}
40+
41+type snapshotCapturePane struct {
42+ id layout.PaneID
43+ stableID string
44+ cwd string
45+ history vt.HistoryView
46+ visible renderer.Frame
47+ process *snapcodec.Process
48+}
49+
50+const (
51+ snapshotInterval = 30 * time.Second
52+ snapshotFinalFlushTimeout = time.Second
53+)
1854
1955 func (d *Daemon) closeRestoreDone() {
2056 d.restoreOnce.Do(func() { close(d.restoreDone) })
@@ -51,6 +87,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
5187 if err := ctx.Err(); err != nil {
5288 return err
5389 }
90+ if len(snap.Tabs) == 0 {
91+ if snap.Active != 0 {
92+ return fmt.Errorf("snapshot: active tab out of range")
93+ }
94+ } else if int(snap.Active) >= len(snap.Tabs) {
95+ return fmt.Errorf("snapshot: active tab out of range")
96+ }
5497 if snap.Name == "" {
5598 return fmt.Errorf("snapshot: empty session name")
5699 }
@@ -149,7 +192,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
149192 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
150193 p.ctx, p.cancel = context.WithCancel(tb.ctx)
151194 p.rect = contentRect
152- seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
195+ if err := restorePaneTerminal(p, paneSnap); err != nil {
196+ // p is not in tb.panes yet, so closeOpened cannot reach it.
197+ p.cancel()
198+ _ = pty.Close()
199+ closeOpened()
200+ return err
201+ }
153202 if restoreCommand != "" {
154203 if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
155204 d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
@@ -164,9 +213,6 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
164213 }
165214 createdAt := int64(snap.CreatedAt)
166215 sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}
167- if sess.active < 0 || sess.active >= len(sess.tabs) {
168- sess.active = 0
169- }
170216 sess.snapEligible.Store(true)
171217 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
172218 sess.cwd = snap.Tabs[0].Panes[0].Cwd
@@ -201,36 +247,51 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
201247 }
202248
203249 func markSnapshotDirty(sess *session) {
204- if sess == nil {
250+ if sess == nil || !sess.snapEligible.Load() {
205251 return
206252 }
207- if sess.snapEligible.Load() {
208- sess.snapDirty.Store(true)
253+ sess.snapshotMu.Lock()
254+ sess.snapshotGeneration++
255+ sess.snapDirty.Store(true)
256+ sess.signalSnapshotChangedLocked()
257+ sess.snapshotMu.Unlock()
258+}
259+
260+// snapshotChangeLocked returns a channel closed by the next snapshot state
261+// transition. snapshotMu must be held.
262+func (sess *session) snapshotChangeLocked() chan struct{} {
263+ if sess.snapshotChanged == nil {
264+ sess.snapshotChanged = make(chan struct{})
209265 }
266+ return sess.snapshotChanged
210267 }
211268
212-func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) {
269+// signalSnapshotChangedLocked wakes waiters after a snapshot state transition.
270+// snapshotMu must be held.
271+func (sess *session) signalSnapshotChangedLocked() {
272+ close(sess.snapshotChangeLocked())
273+ sess.snapshotChanged = make(chan struct{})
274+}
275+
276+func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
277+ if len(snap.Tail) == 0 {
278+ return fmt.Errorf("snapshot history: missing tail blob")
279+ }
280+ if len(snap.Visible) == 0 {
281+ return fmt.Errorf("snapshot visible: missing visible blob")
282+ }
213283 p.mu.Lock()
214284 defer p.mu.Unlock()
215- p.scrollback = scopy.NewScrollback(defaultScrollbackRows)
216- for _, row := range scrollback {
217- p.scrollback.Append(row)
218- }
219- if len(visible) > 0 {
220- p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
221- for y, row := range visible {
222- if y >= p.screen.Frame.Height {
223- break
224- }
225- for x, cell := range row {
226- if x >= p.screen.Frame.Width {
227- break
228- }
229- p.screen.Frame.Set(x, y, cell)
230- }
231- }
285+ screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
286+ if err != nil {
287+ return fmt.Errorf("snapshot history: %w", err)
288+ }
289+ if err := screen.RestorePrimaryVisible(snap.Visible); err != nil {
290+ return fmt.Errorf("snapshot visible: %w", err)
232291 }
233- p.screen.OnLineEvicted = p.scrollback.Append
292+ p.screen = screen
293+ p.history = screen.History()
294+ return nil
234295 }
235296
236297 func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
@@ -251,35 +312,112 @@ func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
251312 return domain.Size{Cols: cols, Rows: rows}
252313 }
253314
254-func (d *Daemon) captureSession(sess *session) bool {
255- if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil {
315+// scheduleSnapshot captures a session only once while an immutable capture is
316+// queued or being written. It never waits for the bounded worker queue.
317+func (d *Daemon) scheduleSnapshot(sess *session) bool {
318+ return d.scheduleSnapshotWithFinalFallback(sess, false)
319+}
320+
321+// scheduleFinalSnapshot retains a terminal capture rejected by the regular
322+// bounded queue. A removed session cannot be retried by snapshotSaver, so the
323+// worker drains this fallback before it stops.
324+func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
325+ return d.scheduleSnapshotWithFinalFallback(sess, true)
326+}
327+
328+func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
329+ if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() {
256330 return true
257331 }
332+ sess.snapshotMu.Lock()
333+ if !sess.snapDirty.Load() || sess.snapshotPending {
334+ sess.snapshotMu.Unlock()
335+ return true
336+ }
337+ generation := sess.snapshotGeneration
338+ sess.snapshotPending = true
339+ sess.signalSnapshotChangedLocked()
340+ sess.snapshotMu.Unlock()
341+
342+ capture, ok := d.captureSnapshotState(sess, generation)
343+ if !ok {
344+ d.finishSnapshotCapture(capture, false)
345+ return false
346+ }
347+ if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
348+ return true
349+ }
350+ // Coalesce under saturation or shutdown: the latest state stays dirty and
351+ // will be captured on a later tick once an active worker has room.
352+ d.finishSnapshotCapture(capture, false)
353+ return false
354+}
355+
356+// enqueueSnapshotCapture serializes worker shutdown with the non-blocking
357+// queue send. The queue is deliberately never closed: producers can race
358+// shutdown without risking a send-on-closed panic.
359+func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
360+ d.snapshotWorkerMu.Lock()
361+ defer d.snapshotWorkerMu.Unlock()
362+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
363+ return false
364+ }
365+ select {
366+ case d.snapshotJobs <- capture:
367+ return true
368+ default:
369+ return false
370+ }
371+}
372+
373+// enqueueFinalSnapshotCapture hands terminal captures to the existing worker
374+// without making session teardown wait behind its normal bounded queue.
375+func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
376+ d.snapshotWorkerMu.Lock()
377+ defer d.snapshotWorkerMu.Unlock()
378+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
379+ return false
380+ }
381+ d.snapshotFinalJobs = append(d.snapshotFinalJobs, capture)
382+ select {
383+ case d.snapshotWorkerFinalWake <- struct{}{}:
384+ default:
385+ }
386+ return true
387+}
258388
389+// captureSession rotates history tails and clones visible frames while holding
390+// each pane lock. The returned capture contains only immutable state; encoding
391+// and persistence are deliberately deferred to snapshotEncodeWorker.
392+func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
259393 sess.mu.Lock()
260- name := sess.name
394+ capture := &snapshotCapture{
395+ session: sess,
396+ generation: generation,
397+ name: sess.name,
398+ createdAt: uint64(sess.createdAt),
399+ active: uint16(max(sess.active, 0)),
400+ }
261401 ephemeral := sess.ephemeral
262- createdAt := sess.createdAt
263- active := sess.active
264402 fallbackCwd := sess.cwd
265403 tabs := append([]*tab(nil), sess.tabs...)
266404 sess.mu.Unlock()
267- if ephemeral || name == "" {
268- return true
405+ if ephemeral || capture.name == "" {
406+ return capture, false
269407 }
270408
271- snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))}
409+ capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
272410 for _, tb := range tabs {
273411 tb.mu.Lock()
274- tabSnap := snapcodec.Tab{
275- StableID: tb.stableID,
276- Cols: uint16(max(tb.size.Cols, 0)),
277- Rows: uint16(max(tb.size.Rows, 0)),
278- NextPaneID: uint64(max(tb.nextPaneID, 0)),
279- Tree: tb.tree.Clone(),
412+ tabCapture := snapshotCaptureTab{
413+ stableID: tb.stableID,
414+ cols: uint16(max(tb.size.Cols, 0)),
415+ rows: uint16(max(tb.size.Rows, 0)),
416+ nextPaneID: uint64(max(tb.nextPaneID, 0)),
417+ tree: tb.tree.Clone(),
280418 }
281419 if tb.tree != nil {
282- tabSnap.Focus = tb.tree.Focus
420+ tabCapture.focus = tb.tree.Focus
283421 }
284422 panes := make([]*pane, 0, len(tb.panes))
285423 for _, p := range tb.panes {
@@ -287,51 +425,263 @@ func (d *Daemon) captureSession(sess *session) bool {
287425 }
288426 tb.mu.Unlock()
289427
290- paneIDs := make(map[layout.PaneID]struct{}, len(panes))
291- paneSnaps := make([]snapcodec.Pane, 0, len(panes))
428+ tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
292429 for _, p := range panes {
293430 p.mu.Lock()
294- pid := 0
295431 pty := p.pty
432+ pid := 0
296433 if pty != nil {
297434 pid = pty.Pid()
298435 }
299- paneSnap := snapcodec.Pane{
300- ID: p.id,
301- StableID: p.stableID,
302- Scrollback: cloneRows(p.scrollback.Snapshot()),
303- Visible: cloneRows(p.screen.PrimaryVisibleRows()),
436+ paneCapture := snapshotCapturePane{
437+ id: p.id,
438+ stableID: p.stableID,
439+ history: p.history.SealAndView(),
440+ visible: p.screen.PrimaryVisibleFrame(),
304441 }
305442 p.mu.Unlock()
306- paneSnap.Cwd = fallbackCwd
443+ paneCapture.cwd = fallbackCwd
307444 if d.procCwd != nil && pid > 0 {
308445 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
309- paneSnap.Cwd = cwd
446+ paneCapture.cwd = cwd
310447 }
311448 }
312- paneSnap.Process = d.capturePaneProcess(pty, pid)
313- paneIDs[paneSnap.ID] = struct{}{}
314- paneSnaps = append(paneSnaps, paneSnap)
449+ paneCapture.process = d.capturePaneProcess(pty, pid)
450+ tabCapture.panes = append(tabCapture.panes, paneCapture)
451+ }
452+ capture.tabs = append(capture.tabs, tabCapture)
453+ }
454+ return capture, true
455+}
456+
457+// captureSession remains the synchronous producer-facing trigger for callers
458+// such as teardown and benchmarks; the actual encoding and Write stay async.
459+func (d *Daemon) captureSession(sess *session) bool {
460+ markSnapshotDirty(sess)
461+ return d.scheduleSnapshot(sess)
462+}
463+
464+func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) {
465+ snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))}
466+ for _, tabCapture := range capture.tabs {
467+ tabSnap := snapcodec.Tab{
468+ StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows,
469+ NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree,
470+ Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)),
471+ }
472+ paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes))
473+ for _, paneCapture := range tabCapture.panes {
474+ sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history)
475+ visible, visibleErr := vt.MarshalVisible(paneCapture.visible)
476+ if historyErr != nil || visibleErr != nil {
477+ return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, visibleErr)
478+ }
479+ tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: paneCapture.process})
480+ paneIDs[paneCapture.id] = struct{}{}
315481 }
316482 if tabSnap.Tree != nil {
317483 pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
318484 }
319- tabSnap.Panes = paneSnaps
320485 snap.Tabs = append(snap.Tabs, tabSnap)
321486 }
487+ return d.snapshotMarshal(snap)
488+}
322489
323- data, err := snapcodec.Marshal(snap)
324- if err != nil {
325- d.log.Warn("marshaling session snapshot failed", "err", err, "session", name)
326- return false
490+func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {
491+ if capture == nil {
492+ return
493+ }
494+ capture.finishOnce.Do(func() {
495+ capture.session.snapshotMu.Lock()
496+ capture.session.snapshotPending = false
497+ if succeeded && capture.session.snapshotGeneration == capture.generation {
498+ capture.session.snapDirty.Store(false)
499+ } else if !succeeded {
500+ capture.session.snapDirty.Store(true)
501+ }
502+ capture.session.signalSnapshotChangedLocked()
503+ capture.session.snapshotMu.Unlock()
504+ })
505+}
506+
507+func (d *Daemon) startSnapshotEncodeWorker() {
508+ if d == nil {
509+ return
510+ }
511+ d.snapshotWorkerMu.Lock()
512+ if d.snapshotWorkerCancel != nil {
513+ d.snapshotWorkerMu.Unlock()
514+ return
515+ }
516+ // Shutdown owns the worker lifetime so it can flush captures after Serve
517+ // cancels its parent context. The worker is always stopped explicitly.
518+ workerCtx, cancel := context.WithCancel(context.Background())
519+ d.snapshotWorkerID++
520+ workerID := d.snapshotWorkerID
521+ d.snapshotWorkerCtx = workerCtx
522+ d.snapshotWorkerCancel = cancel
523+ d.snapshotWorkerDone = make(chan struct{})
524+ d.snapshotWorkerFlush = make(chan struct{})
525+ d.snapshotWorkerFinalWake = make(chan struct{}, 1)
526+ d.snapshotFinalJobs = nil
527+ d.snapshotWorkerClosing = false
528+ d.snapshotWorkerInFlight = nil
529+ done := d.snapshotWorkerDone
530+ flush := d.snapshotWorkerFlush
531+ finalWake := d.snapshotWorkerFinalWake
532+ d.snapshotWorkerMu.Unlock()
533+ go func() {
534+ defer close(done)
535+ write := func(capture *snapshotCapture) bool {
536+ if workerCtx.Err() != nil || !d.setSnapshotWorkerInFlight(workerID, capture) {
537+ d.finishSnapshotCapture(capture, false)
538+ return false
539+ }
540+ data, err := d.encodeSnapshotCapture(capture)
541+ if err == nil && workerCtx.Err() == nil {
542+ err = d.snaps.Write(capture.name, data)
543+ }
544+ d.clearSnapshotWorkerInFlight(workerID, capture)
545+ if err != nil && workerCtx.Err() == nil {
546+ d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name)
547+ }
548+ d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil)
549+ return workerCtx.Err() == nil
550+ }
551+ drainFinal := func() bool {
552+ for {
553+ capture := d.takeFinalSnapshotCapture()
554+ if capture == nil {
555+ return true
556+ }
557+ if !write(capture) {
558+ return false
559+ }
560+ }
561+ }
562+ flushQueued := func() {
563+ for {
564+ select {
565+ case capture := <-d.snapshotJobs:
566+ if !write(capture) {
567+ return
568+ }
569+ default:
570+ _ = drainFinal()
571+ return
572+ }
573+ }
574+ }
575+ for {
576+ select {
577+ case <-workerCtx.Done():
578+ return
579+ case <-flush:
580+ flushQueued()
581+ return
582+ case <-finalWake:
583+ if !drainFinal() {
584+ return
585+ }
586+ case capture := <-d.snapshotJobs:
587+ if !write(capture) {
588+ return
589+ }
590+ }
591+ }
592+ }()
593+}
594+
595+func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
596+ d.snapshotWorkerMu.Lock()
597+ defer d.snapshotWorkerMu.Unlock()
598+ if len(d.snapshotFinalJobs) == 0 {
599+ return nil
327600 }
328- if err := d.snaps.Write(name, data); err != nil {
329- d.log.Warn("writing session snapshot failed", "err", err, "session", name)
601+ capture := d.snapshotFinalJobs[0]
602+ d.snapshotFinalJobs[0] = nil
603+ d.snapshotFinalJobs = d.snapshotFinalJobs[1:]
604+ return capture
605+}
606+
607+func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
608+ d.snapshotWorkerMu.Lock()
609+ defer d.snapshotWorkerMu.Unlock()
610+ if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
330611 return false
331612 }
613+ d.snapshotWorkerInFlight = capture
332614 return true
333615 }
334616
617+func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) {
618+ d.snapshotWorkerMu.Lock()
619+ defer d.snapshotWorkerMu.Unlock()
620+ if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture {
621+ d.snapshotWorkerInFlight = nil
622+ }
623+}
624+
625+func (d *Daemon) stopSnapshotEncodeWorker() {
626+ if d == nil {
627+ return
628+ }
629+ d.snapshotWorkerMu.Lock()
630+ cancel := d.snapshotWorkerCancel
631+ if cancel == nil || d.snapshotWorkerClosing {
632+ d.snapshotWorkerMu.Unlock()
633+ return
634+ }
635+ // Stop accepting producers, then let the single worker drain its bounded
636+ // queue. A non-context-aware store may still block indefinitely, so the
637+ // injected clock bounds how long teardown waits before detaching it.
638+ d.snapshotWorkerClosing = true
639+ flush := d.snapshotWorkerFlush
640+ done := d.snapshotWorkerDone
641+ close(flush)
642+ timer := d.clock.NewTimer(snapshotFinalFlushTimeout)
643+ d.snapshotWorkerMu.Unlock()
644+
645+ select {
646+ case <-done:
647+ timer.Stop()
648+ d.finishStoppedSnapshotWorker(false)
649+ case <-timer.C():
650+ cancel()
651+ d.finishStoppedSnapshotWorker(true)
652+ }
653+}
654+
655+func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
656+ d.snapshotWorkerMu.Lock()
657+ inFlight := d.snapshotWorkerInFlight
658+ d.snapshotWorkerCtx = nil
659+ d.snapshotWorkerCancel = nil
660+ d.snapshotWorkerDone = nil
661+ d.snapshotWorkerFlush = nil
662+ d.snapshotWorkerFinalWake = nil
663+ d.snapshotWorkerClosing = false
664+ d.snapshotWorkerInFlight = nil
665+ queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs))
666+ queued = append(queued, d.snapshotFinalJobs...)
667+ d.snapshotFinalJobs = nil
668+ for {
669+ select {
670+ case capture := <-d.snapshotJobs:
671+ queued = append(queued, capture)
672+ default:
673+ d.snapshotWorkerMu.Unlock()
674+ if abandoned {
675+ d.finishSnapshotCapture(inFlight, false)
676+ }
677+ for _, capture := range queued {
678+ d.finishSnapshotCapture(capture, false)
679+ }
680+ return
681+ }
682+ }
683+}
684+
335685 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
336686 if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
337687 return nil
@@ -368,27 +718,14 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
368718 sessions := d.sessionsSnapshotLocked()
369719 d.mu.Unlock()
370720 for _, sess := range sessions {
371- if sess.snapEligible.Load() && sess.snapDirty.Swap(false) {
372- if !d.captureSession(sess) {
373- sess.snapDirty.Store(true)
374- }
721+ if sess.snapEligible.Load() && sess.snapDirty.Load() {
722+ d.scheduleSnapshot(sess)
375723 }
376724 }
377725 t.Reset(snapshotInterval)
378726 }
379727 }
380728
381-func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell {
382- if len(rows) == 0 {
383- return nil
384- }
385- out := make([][]renderer.Cell, len(rows))
386- for i, row := range rows {
387- out[i] = append([]renderer.Cell(nil), row...)
388- }
389- return out
390-}
391-
392729 func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
393730 if n == nil {
394731 return false
A internal/usecase/daemon/snapshot_acceptance_test.go +249 -0
@@ -0,0 +1,249 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13+ "github.com/bnema/vev/internal/usecase/visualsearch"
14+ "github.com/bnema/vev/pkg/renderer"
15+ "github.com/bnema/vev/pkg/vt"
16+)
17+
18+func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) {
19+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
20+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
21+ WithSnapshotStore(store)(d)
22+ startSnapshotEncodeWorker(t, d)
23+
24+ sess := newSnapshotTestSession(t, "acceptance", false, "/work")
25+ first := sess.tabs[0]
26+ first.size = domain.Size{Cols: 80, Rows: 24}
27+ first.stableID = "tab-one"
28+ first.nextPaneID = 3
29+ first.panes["pane-1"].stableID = "pane-one"
30+ secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24})
31+ secondPane.history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'w'}, {Rune: 'o'}})
32+ secondPane.screen.Write([]byte("two"))
33+ first.panes["pane-2"] = secondPane
34+ first.tree = &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
35+ layout.NewLeaf("pane-1"),
36+ layout.NewLeaf("pane-2"),
37+ }}}
38+
39+ second := newTab(snapshotAcceptancePTY(t), domain.Size{Cols: 90, Rows: 30})
40+ second.stableID = "tab-two"
41+ second.nextPaneID = 2
42+ second.panes["pane-1"].stableID = "pane-three"
43+ second.panes["pane-1"].history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'h'}, {Rune: 'r'}, {Rune: 'e'}, {Rune: 'e'}})
44+ second.panes["pane-1"].screen.Write([]byte("three"))
45+ sess.tabs = []*tab{first, second}
46+ sess.active = 1
47+
48+ require.True(t, d.captureSession(sess))
49+ encoded := <-store.writes
50+ captured, err := snapcodec.Unmarshal(encoded)
51+ require.NoError(t, err)
52+ assertCapturedSessionTopology(t, captured)
53+
54+ restoreFactory := &restorePTYFactory{}
55+ restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{})
56+ require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured))
57+ restoredDaemon.mu.Lock()
58+ restored := restoredDaemon.findByNameLocked("acceptance")
59+ restoredDaemon.mu.Unlock()
60+ require.NotNil(t, restored)
61+ require.Equal(t, 1, restored.active)
62+ require.Len(t, restored.tabs, 2)
63+
64+ for _, tt := range []struct {
65+ name string
66+ tab int
67+ pane layout.PaneID
68+ wantRow string
69+ wantFocus layout.PaneID
70+ }{
71+ {name: "first tab focused second pane", tab: 0, pane: "pane-2", wantRow: "two", wantFocus: "pane-2"},
72+ {name: "second tab focused only pane", tab: 1, pane: "pane-1", wantRow: "three", wantFocus: "pane-1"},
73+ } {
74+ t.Run(tt.name, func(t *testing.T) {
75+ tab := restored.tabs[tt.tab]
76+ tab.mu.Lock()
77+ defer tab.mu.Unlock()
78+ require.Equal(t, tt.wantFocus, tab.tree.Focus)
79+ pane := tab.panes[tt.pane]
80+ require.NotNil(t, pane)
81+ pane.mu.Lock()
82+ defer pane.mu.Unlock()
83+ require.Equal(t, tt.wantRow, rowText(pane.history.View().Row(0)))
84+ })
85+ }
86+}
87+
88+func TestSnapshotCaptureRetainsImmutableValuesAfterTailMutationAndEviction(t *testing.T) {
89+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
90+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
91+ WithSnapshotStore(store)(d)
92+ startSnapshotEncodeWorker(t, d)
93+
94+ entered := make(chan struct{})
95+ release := make(chan struct{})
96+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
97+ close(entered)
98+ <-release
99+ return snapcodec.Marshal(s)
100+ }
101+ sess := newSnapshotTestSession(t, "frozen", false, "/work")
102+ pane := sess.tabs[0].panes["pane-1"]
103+ pane.screen = vt.NewScreenWithHistory(8, 3, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
104+ pane.history = pane.screen.History()
105+ pane.history.Append([]renderer.Cell{{Rune: 'o'}, {Rune: 'l'}, {Rune: 'd'}})
106+ pane.screen.Write([]byte("before"))
107+
108+ require.True(t, d.captureSession(sess))
109+ <-entered
110+ pane.mu.Lock()
111+ pane.history.Append([]renderer.Cell{{Rune: 'n'}, {Rune: 'e'}, {Rune: 'w'}})
112+ pane.history.Append([]renderer.Cell{{Rune: 'e'}, {Rune: 'v'}, {Rune: 'i'}, {Rune: 'c'}, {Rune: 't'}})
113+ pane.screen.Write([]byte("\rafter"))
114+ pane.mu.Unlock()
115+ close(release)
116+
117+ captured, err := snapcodec.Unmarshal(<-store.writes)
118+ require.NoError(t, err)
119+ restoredHistory, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}, captured.Tabs[0].Panes[0].SealedChunks, captured.Tabs[0].Panes[0].Tail)
120+ require.NoError(t, err)
121+ require.Equal(t, "old", rowText(restoredHistory.View().Row(0)))
122+ frame, err := vt.UnmarshalVisible(captured.Tabs[0].Panes[0].Visible)
123+ require.NoError(t, err)
124+ require.Contains(t, rowText(frame.Row(0)), "before")
125+}
126+
127+func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) {
128+ sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work")
129+ pane := sess.tabs[0].panes["pane-1"]
130+ pane.screen = vt.NewScreenWithHistory(8, 1, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
131+ pane.history = pane.screen.History()
132+ for _, text := range []string{"first", "second"} {
133+ pane.history.Append(cells(text))
134+ }
135+ pane.screen.Write([]byte("screen"))
136+
137+ document := scopy.NewSnapshot(pane.history, pane.screen.Frame)
138+ for _, text := range []string{"third", "fourth"} {
139+ pane.history.Append(cells(text))
140+ }
141+
142+ for _, tt := range []struct {
143+ name string
144+ rows func() [][]renderer.Cell
145+ want []string
146+ }{
147+ {name: "bounded live history is oldest first after eviction", rows: func() [][]renderer.Cell {
148+ view := pane.history.View()
149+ rows := make([][]renderer.Cell, view.Len())
150+ for i := range rows {
151+ rows[i] = view.Row(i)
152+ }
153+ return rows
154+ }, want: []string{"third", "fourth"}},
155+ {name: "captured document retains evicted rows", rows: func() [][]renderer.Cell {
156+ rows := make([][]renderer.Cell, document.Len()-document.Height)
157+ for i := range rows {
158+ rows[i] = document.Row(i)
159+ }
160+ return rows
161+ }, want: []string{"first", "second"}},
162+ } {
163+ t.Run(tt.name, func(t *testing.T) {
164+ rows := tt.rows()
165+ got := make([]string, len(rows))
166+ for i, row := range rows {
167+ got[i] = rowText(row)
168+ }
169+ require.Equal(t, tt.want, got)
170+ })
171+ }
172+
173+ search := visualsearch.New(document)
174+ for _, r := range "first" {
175+ search.Insert(r)
176+ }
177+ match, ok := search.Selected()
178+ require.True(t, ok)
179+ require.Equal(t, "first", match.Text)
180+}
181+
182+func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY {
183+ t.Helper()
184+ pty := portsmocks.NewMockPTY(t)
185+ pty.EXPECT().Pid().Return(0).Maybe()
186+ pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
187+ return pty
188+}
189+
190+func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) {
191+ t.Helper()
192+ require.Equal(t, "acceptance", got.Name)
193+ require.Equal(t, uint16(1), got.Active)
194+ require.Len(t, got.Tabs, 2)
195+ for _, tt := range []struct {
196+ name string
197+ tab int
198+ stableID string
199+ focus layout.PaneID
200+ nextPaneID uint64
201+ paneIDs []layout.PaneID
202+ wantSplit bool
203+ }{
204+ {name: "first split tab", tab: 0, stableID: "tab-one", focus: "pane-2", nextPaneID: 3, paneIDs: []layout.PaneID{"pane-1", "pane-2"}, wantSplit: true},
205+ {name: "second leaf tab", tab: 1, stableID: "tab-two", focus: "pane-1", nextPaneID: 2, paneIDs: []layout.PaneID{"pane-1"}},
206+ } {
207+ t.Run(tt.name, func(t *testing.T) {
208+ tab := got.Tabs[tt.tab]
209+ require.Equal(t, tt.stableID, tab.StableID)
210+ require.Equal(t, tt.focus, tab.Focus)
211+ require.Equal(t, tt.nextPaneID, tab.NextPaneID)
212+ require.NotNil(t, tab.Tree)
213+ require.Equal(t, tt.focus, tab.Tree.Focus)
214+ if tt.wantSplit {
215+ require.Equal(t, layout.Split, tab.Tree.Root.Kind)
216+ }
217+ ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
218+ for _, pane := range tab.Panes {
219+ ids[pane.ID] = struct{}{}
220+ }
221+ for _, id := range tt.paneIDs {
222+ _, ok := ids[id]
223+ require.True(t, ok, "missing pane %q", id)
224+ }
225+ _, focused := ids[tab.Focus]
226+ require.True(t, focused, "focus %q is dangling", tab.Focus)
227+ assertTreeReferencesCapturedPanes(t, tab.Tree.Root, ids)
228+ })
229+ }
230+}
231+
232+func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) {
233+ t.Helper()
234+ if node == nil {
235+ return
236+ }
237+ if node.Kind == layout.Leaf {
238+ _, ok := panes[node.Leaf]
239+ require.True(t, ok, "tree leaf %q is dangling", node.Leaf)
240+ return
241+ }
242+ if node.Kind == layout.Stack && node.Expanded != "" {
243+ _, ok := panes[node.Expanded]
244+ require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded)
245+ }
246+ for _, child := range node.Children {
247+ assertTreeReferencesCapturedPanes(t, child, panes)
248+ }
249+}
M internal/usecase/daemon/snapshot_restore_test.go +136 -34
@@ -18,8 +18,74 @@ import (
1818 "github.com/bnema/vev/internal/usecase/layout"
1919 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2020 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
2122 )
2223
24+func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane {
25+ t.Helper()
26+ h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
27+ for _, row := range historyRows {
28+ h.Append(row)
29+ }
30+ sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
31+ require.NoError(t, err)
32+ frame := renderer.NewFrame(40, 24)
33+ for y, row := range visibleRows {
34+ copy(frame.Row(y), row)
35+ }
36+ visible, err := vt.MarshalVisible(frame)
37+ require.NoError(t, err)
38+ return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible}
39+}
40+
41+func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) {
42+ valid := terminalPane(t, "pane", "p", "/work", nil, nil)
43+ tests := []struct {
44+ name string
45+ mutate func(*snapcodec.Pane)
46+ }{
47+ {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }},
48+ {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }},
49+ {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }},
50+ {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }},
51+ }
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ snap := valid
55+ tt.mutate(&snap)
56+ p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24})
57+ require.Error(t, restorePaneTerminal(p, snap))
58+ })
59+ }
60+}
61+
62+func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) {
63+ factory := &restorePTYFactory{}
64+ d := newTestDaemon(t, factory, stubClock{})
65+
66+ err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{
67+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
68+ }}})
69+
70+ require.Error(t, err)
71+ require.Empty(t, factory.opens)
72+}
73+
74+func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) {
75+ factory := &restorePTYFactory{}
76+ d := newTestDaemon(t, factory, stubClock{})
77+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
78+ pane.Tail = []byte("malformed")
79+
80+ err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad", Tabs: []snapcodec.Tab{{
81+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane},
82+ }}})
83+
84+ require.Error(t, err)
85+ require.Len(t, factory.opens, 1)
86+ require.Equal(t, 1, factory.opens[0].pty.closeCount())
87+}
88+
2389 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
2490 store := &restoreSnapshotStore{}
2591 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -37,8 +103,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
37103 layout.NewLeaf("pane-2"),
38104 }}},
39105 Panes: []snapcodec.Pane{
40- {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
41- {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
106+ terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}),
107+ terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}),
42108 },
43109 }},
44110 })}}
@@ -77,10 +143,11 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
77143 p := tb.panes["pane-2"]
78144 tb.mu.Unlock()
79145 p.mu.Lock()
80- require.Equal(t, "old2", rowText(p.scrollback.View().Row(0)))
81- require.Equal(t, 1, p.scrollback.Len())
146+ require.Same(t, p.screen.History(), p.history)
147+ require.Equal(t, "old2", rowText(p.history.View().Row(0)))
148+ require.Equal(t, 1, p.history.Len())
82149 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
83- copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
150+ copySnap := scopy.NewSnapshot(p.history, p.screen.Frame)
84151 require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
85152 require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
86153 p.mu.Unlock()
@@ -108,8 +175,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
108175 func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
109176 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
110177 Name: "work", Active: 0, Tabs: []snapcodec.Tab{
111- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}},
112- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}},
178+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}},
179+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}},
113180 },
114181 })}}}
115182 factory := &restorePTYFactory{}
@@ -138,17 +205,15 @@ func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testi
138205
139206 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
140207 t.Helper()
208+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
209+ pane.Process = proc
141210 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
142211 Name: name,
143212 Tabs: []snapcodec.Tab{{
144- Cols: 80,
145- Rows: 24,
146- Tree: layout.NewTree("pane-1"),
147- Panes: []snapcodec.Pane{{
148- ID: "pane-1",
149- Cwd: "/tmp",
150- Process: proc,
151- }},
213+ Cols: 80,
214+ Rows: 24,
215+ Tree: layout.NewTree("pane-1"),
216+ Panes: []snapcodec.Pane{pane},
152217 }},
153218 })}}}
154219 }
@@ -185,20 +250,17 @@ func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
185250 Cols: 80,
186251 Rows: 24,
187252 Tree: layout.NewTree("pane-1"),
188- Panes: []snapcodec.Pane{{
189- ID: "pane-1",
190- Cwd: "/one",
191- Process: lessProc,
192- }},
253+ Panes: func() []snapcodec.Pane {
254+ pane := emptyTerminalPane(t, "pane-1", "/one")
255+ pane.Process = lessProc
256+ return []snapcodec.Pane{pane}
257+ }(),
193258 },
194259 {
195- Cols: 80,
196- Rows: 24,
197- Tree: layout.NewTree("pane-1"),
198- Panes: []snapcodec.Pane{{
199- ID: "pane-1",
200- Cwd: "/two",
201- }},
260+ Cols: 80,
261+ Rows: 24,
262+ Tree: layout.NewTree("pane-1"),
263+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")},
202264 },
203265 },
204266 })}}},
@@ -246,7 +308,9 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
246308 d := newTestDaemon(t, factory, stubClock{})
247309 d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
248310
249- require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}}))
311+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
312+ pane.Process = proc
313+ require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}}))
250314 require.Len(t, factory.opens, 1)
251315 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
252316 }
@@ -258,7 +322,7 @@ func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
258322 defer cancel()
259323
260324 require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
261- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp"}},
325+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
262326 }}}))
263327 require.Len(t, factory.opens, 1)
264328 require.Same(t, ctx, factory.opens[0].ctx)
@@ -273,7 +337,11 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
273337 Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
274338 layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
275339 }}},
276- Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}},
340+ Panes: []snapcodec.Pane{
341+ emptyTerminalPane(t, "a", "/a"),
342+ emptyTerminalPane(t, "b", "/b"),
343+ emptyTerminalPane(t, "c", "/c"),
344+ },
277345 }},
278346 })}}}
279347 factory := &restorePTYFactory{}
@@ -303,7 +371,16 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
303371 }
304372
305373 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
306- valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})
374+ valid := mustSnapshotBytes(t, snapcodec.Session{
375+ Name: "live",
376+ CreatedAt: 1,
377+ Tabs: []snapcodec.Tab{{
378+ Cols: 80,
379+ Rows: 24,
380+ Tree: layout.NewTree("pane-1"),
381+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")},
382+ }},
383+ })
307384 badVersion := append([]byte(nil), valid...)
308385 binary.BigEndian.PutUint16(badVersion[4:6], 1)
309386 tests := []struct {
@@ -340,7 +417,7 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
340417 Cols: 80,
341418 Rows: 24,
342419 Tree: layout.NewTree("pane-1"),
343- Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}},
420+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")},
344421 }},
345422 })}}}
346423 factory := &restorePTYFactory{}
@@ -366,7 +443,15 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
366443 loadFn: func() ([]ports.SnapshotBlob, error) {
367444 close(loaded)
368445 <-releaseLoad
369- return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/restored"}}}}})}}, nil
446+ return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
447+ Name: "work",
448+ Tabs: []snapcodec.Tab{{
449+ Cols: 80,
450+ Rows: 24,
451+ Tree: layout.NewTree("pane-1"),
452+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")},
453+ }},
454+ })}}, nil
370455 },
371456 }
372457 factory := &restorePTYFactory{}
@@ -415,6 +500,11 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
415500 require.Equal(t, ports.ErrServerShutdown, perr.code)
416501 }
417502
503+func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane {
504+ t.Helper()
505+ return terminalPane(t, id, "", cwd, nil, nil)
506+}
507+
418508 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
419509 t.Helper()
420510 b, err := snapcodec.Marshal(s)
@@ -470,6 +560,7 @@ func (f *restorePTYFactory) Open(ctx context.Context, _ string, _ []string, env
470560 type restorePTY struct {
471561 mu sync.Mutex
472562 writes []string
563+ closes int
473564 writeErr error
474565 done chan struct{}
475566 once sync.Once
@@ -489,7 +580,18 @@ func (p *restorePTY) Write(b []byte) (int, error) {
489580 }
490581 return len(b), nil
491582 }
492-func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }
583+func (p *restorePTY) Close() error {
584+ p.mu.Lock()
585+ p.closes++
586+ p.mu.Unlock()
587+ p.once.Do(func() { close(p.done) })
588+ return nil
589+}
590+func (p *restorePTY) closeCount() int {
591+ p.mu.Lock()
592+ defer p.mu.Unlock()
593+ return p.closes
594+}
493595 func (p *restorePTY) Resize(domain.Size) error { return nil }
494596 func (p *restorePTY) Pid() int { return 0 }
495597 func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil }
M internal/usecase/daemon/snapshot_test.go +531 -7
@@ -18,14 +18,423 @@ import (
1818 "github.com/bnema/vev/internal/usecase/layout"
1919 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2020 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
2122 )
2223
24+func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) {
25+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
26+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
27+ WithSnapshotStore(store)(d)
28+ t.Cleanup(d.stopSnapshotEncodeWorker)
29+ d.startSnapshotEncodeWorker()
30+
31+ entered := make(chan struct{})
32+ release := make(chan struct{})
33+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
34+ close(entered)
35+ <-release
36+ return snapcodec.Marshal(s)
37+ }
38+ sess := newSnapshotTestSession(t, "work", false, "/work")
39+ markSnapshotDirty(sess)
40+ require.True(t, d.scheduleSnapshot(sess))
41+ <-entered
42+
43+ paneLocked := make(chan struct{})
44+ releasePane := make(chan struct{})
45+ go func() {
46+ p := sess.tabs[0].panes["pane-1"]
47+ p.mu.Lock()
48+ close(paneLocked)
49+ <-releasePane
50+ p.mu.Unlock()
51+ }()
52+ <-paneLocked
53+ close(releasePane)
54+ close(release)
55+ <-store.writes
56+}
57+
58+func TestSnapshotEncodeWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) {
59+ exerciseBlockedSnapshotWorker(t, true)
60+}
61+
62+func TestSnapshotStoreWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) {
63+ exerciseBlockedSnapshotWorker(t, false)
64+}
65+
66+func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
67+ t.Helper()
68+ store := newGatedSnapshotStore()
69+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
70+ WithSnapshotStore(store)(d)
71+ d.startSnapshotEncodeWorker()
72+ t.Cleanup(func() {
73+ store.unblock()
74+ d.stopSnapshotEncodeWorker()
75+ })
76+ if blockEncode {
77+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
78+ store.enter()
79+ <-store.release
80+ return snapcodec.Marshal(s)
81+ }
82+ }
83+
84+ sess := newSnapshotTestSession(t, "live", false, "/live")
85+ d.sessions[sess.id] = sess
86+ sess.tabs[0].size = domain.Size{Cols: 80, Rows: 24}
87+ p := sess.tabs[0].panes["pane-1"]
88+ tr, sends, _ := newConn(t, ports.Frame{})
89+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 8, Rows: 3}}
90+ ac.setSession(sess)
91+ sess.mu.Lock()
92+ sess.client = ac
93+ sess.mu.Unlock()
94+ rendered := make(chan struct{}, 1)
95+ rc := newRenderCoordinator(renderCoordinatorOptions{
96+ clock: stubClock{},
97+ onInvalidate: func(renderInvalidation) {
98+ d.paint(sess, ac, true)
99+ rendered <- struct{}{}
100+ },
101+ })
102+ sess.installRenderCoordinator(rc)
103+ rc.attach(ac)
104+ require.True(t, rc.markAttachmentReady(ac))
105+ t.Cleanup(rc.noteSessionTeardown)
106+
107+ markSnapshotDirty(sess)
108+ require.True(t, d.scheduleSnapshot(sess))
109+ <-store.entered
110+
111+ paneLocked := make(chan struct{})
112+ releasePane := make(chan struct{})
113+ go func() {
114+ p.mu.Lock()
115+ close(paneLocked)
116+ <-releasePane
117+ p.mu.Unlock()
118+ }()
119+ <-paneLocked
120+ close(releasePane)
121+
122+ reader := portsmocks.NewMockPTY(t)
123+ var reads atomic.Int32
124+ reader.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
125+ if reads.Add(1) == 1 {
126+ return copy(buf, []byte("ok")), nil
127+ }
128+ return 0, io.EOF
129+ }).Twice()
130+ reader.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
131+ p.mu.Lock()
132+ p.pty = reader
133+ p.onExit = func() {}
134+ p.mu.Unlock()
135+ d.sessWg.Add(1)
136+ go d.ptyReader(sess, sess.tabs[0], p)
137+ d.sessWg.Wait()
138+ <-rendered
139+ _ = awaitFrame(t, sends, ports.MsgOutput)
140+ p.mu.Lock()
141+ require.Contains(t, screenLineText(p.screen, 0), "ok")
142+ p.mu.Unlock()
143+
144+ store.unblock()
145+ awaitSnapshotIdle(t, sess)
146+ require.True(t, sess.snapDirty.Load(), "PTY output published while blocked must remain retryable")
147+}
148+
149+func TestFinalSnapshotFlushPersistsNewestNamedSessionBeforeWorkerTeardown(t *testing.T) {
150+ clock := newFinalFlushClock()
151+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
152+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
153+ WithSnapshotStore(store)(d)
154+ d.startSnapshotEncodeWorker()
155+
156+ entered := make(chan struct{})
157+ release := make(chan struct{})
158+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
159+ close(entered)
160+ <-release
161+ return snapcodec.Marshal(s)
162+ }
163+ sess := newSnapshotTestSession(t, "final", false, "/work")
164+ pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
165+ require.True(t, ok)
166+ pty.EXPECT().Close().Return(nil).Once()
167+ sess.tabs[0].panes["pane-1"].screen.Write([]byte("\rnewest"))
168+ d.sessions[sess.id] = sess
169+
170+ require.NoError(t, d.killSession(sess, 0, false))
171+ <-entered
172+ stopped := make(chan struct{})
173+ go func() {
174+ d.stopSnapshotEncodeWorker()
175+ close(stopped)
176+ }()
177+ close(release)
178+ <-stopped
179+ awaitSnapshotIdle(t, sess)
180+
181+ select {
182+ case data := <-store.writes:
183+ snap, err := snapcodec.Unmarshal(data)
184+ require.NoError(t, err)
185+ frame, err := vt.UnmarshalVisible(snap.Tabs[0].Panes[0].Visible)
186+ require.NoError(t, err)
187+ require.Contains(t, rowText(frame.Row(0)), "newest")
188+ default:
189+ t.Fatal("final snapshot was dropped during worker teardown")
190+ }
191+}
192+
193+func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) {
194+ store := newNeverReturningSnapshotStore()
195+ clock := newFinalFlushClock()
196+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
197+ WithSnapshotStore(store)(d)
198+ d.startSnapshotEncodeWorker()
199+
200+ sess := newSnapshotTestSession(t, "stuck-write", false, "/work")
201+ require.True(t, d.captureSession(sess))
202+ <-store.entered
203+
204+ stopped := make(chan struct{})
205+ go func() {
206+ d.stopSnapshotEncodeWorker()
207+ close(stopped)
208+ }()
209+ timer := <-clock.timers
210+ timer.ch <- time.Time{}
211+ <-stopped
212+ awaitSnapshotIdle(t, sess)
213+ require.True(t, sess.snapDirty.Load(), "an abandoned write must remain retryable")
214+ require.NotPanics(t, func() { require.False(t, d.scheduleSnapshot(sess)) })
215+}
216+
217+func TestRejectedSnapshotCaptureLeavesSessionRetryable(t *testing.T) {
218+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
219+ WithSnapshotStore(&channelSnapshotStore{writes: make(chan []byte, 1)})(d)
220+ sess := newSnapshotTestSession(t, "rejected", false, "/work")
221+ sess.name = ""
222+ sess.snapEligible.Store(true)
223+
224+ markSnapshotDirty(sess)
225+ require.False(t, d.scheduleSnapshot(sess))
226+ awaitSnapshotIdle(t, sess)
227+ require.True(t, sess.snapDirty.Load())
228+}
229+
230+func TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) {
231+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
232+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
233+ WithSnapshotStore(store)(d)
234+ d.startSnapshotEncodeWorker()
235+ d.stopSnapshotEncodeWorker()
236+
237+ sess := newSnapshotTestSession(t, "stopped", false, "/work")
238+ markSnapshotDirty(sess)
239+ require.False(t, d.scheduleSnapshot(sess), "a stopped worker must not accept a capture")
240+ awaitSnapshotIdle(t, sess)
241+ require.True(t, sess.snapDirty.Load(), "an unsubmitted capture must remain retryable")
242+}
243+
244+func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) {
245+ store := &channelSnapshotStore{writes: make(chan []byte, 3)}
246+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
247+ WithSnapshotStore(store)(d)
248+ startSnapshotEncodeWorker(t, d)
249+ entered := make(chan struct{})
250+ release := make(chan struct{})
251+ var calls atomic.Int32
252+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
253+ if calls.Add(1) == 1 {
254+ close(entered)
255+ <-release
256+ }
257+ return snapcodec.Marshal(s)
258+ }
259+ first := newSnapshotTestSession(t, "first", false, "/work")
260+ second := newSnapshotTestSession(t, "second", false, "/work")
261+ third := newSnapshotTestSession(t, "third", false, "/work")
262+ markSnapshotDirty(first)
263+ markSnapshotDirty(second)
264+ markSnapshotDirty(third)
265+ require.True(t, d.scheduleSnapshot(first))
266+ <-entered
267+ require.True(t, d.scheduleSnapshot(second), "one capture may wait in the bounded queue")
268+ require.False(t, d.scheduleSnapshot(third), "full queue must not block the producer")
269+ require.True(t, third.snapDirty.Load())
270+
271+ close(release)
272+ awaitSnapshotClean(t, first)
273+ awaitSnapshotClean(t, second)
274+ require.True(t, d.scheduleSnapshot(third))
275+ <-store.writes
276+ awaitSnapshotClean(t, third)
277+}
278+
279+func TestNamedFinalSnapshotSurvivesSaturatedQueue(t *testing.T) {
280+ for _, tc := range []struct {
281+ name string
282+ stop func(t *testing.T, d *Daemon, sess *session)
283+ }{
284+ {
285+ name: "kill session",
286+ stop: func(t *testing.T, d *Daemon, sess *session) {
287+ t.Helper()
288+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
289+ },
290+ },
291+ {
292+ name: "shutdown all",
293+ stop: func(t *testing.T, d *Daemon, _ *session) {
294+ t.Helper()
295+ d.shutdownAll(ports.ReasonServerShutdown)
296+ },
297+ },
298+ } {
299+ t.Run(tc.name, func(t *testing.T) {
300+ store := &channelSnapshotStore{writes: make(chan []byte, 3)}
301+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
302+ WithSnapshotStore(store)(d)
303+ startSnapshotEncodeWorker(t, d)
304+
305+ entered := make(chan struct{})
306+ release := make(chan struct{})
307+ var calls atomic.Int32
308+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
309+ if calls.Add(1) == 1 {
310+ close(entered)
311+ <-release
312+ }
313+ return snapcodec.Marshal(s)
314+ }
315+ first := newSnapshotTestSession(t, "first", false, "/work")
316+ second := newSnapshotTestSession(t, "second", false, "/work")
317+ final := newSnapshotTestSession(t, "final", false, "/work")
318+ finalPTY, ok := final.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
319+ require.True(t, ok)
320+ finalPTY.EXPECT().Close().Return(nil).Once()
321+ markSnapshotDirty(first)
322+ markSnapshotDirty(second)
323+ require.True(t, d.scheduleSnapshot(first))
324+ <-entered
325+ require.True(t, d.scheduleSnapshot(second))
326+ d.sessions[final.id] = final
327+
328+ tc.stop(t, d, final)
329+ require.NotContains(t, d.sessions, final.id, "final capture must survive session removal")
330+ close(release)
331+
332+ written := make(map[string]bool, 3)
333+ deadline := time.NewTimer(time.Second)
334+ defer deadline.Stop()
335+ for len(written) < 3 {
336+ select {
337+ case data := <-store.writes:
338+ snap, err := snapcodec.Unmarshal(data)
339+ require.NoError(t, err)
340+ written[snap.Name] = true
341+ case <-deadline.C:
342+ t.Fatalf("final snapshot was dropped: wrote %v", written)
343+ }
344+ }
345+ require.True(t, written[final.name])
346+ })
347+ }
348+}
349+
350+func TestSnapshotCompletionSignalsWaiters(t *testing.T) {
351+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
352+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
353+ WithSnapshotStore(store)(d)
354+ startSnapshotEncodeWorker(t, d)
355+
356+ entered := make(chan struct{})
357+ release := make(chan struct{})
358+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
359+ close(entered)
360+ <-release
361+ return snapcodec.Marshal(s)
362+ }
363+ sess := newSnapshotTestSession(t, "completion", false, "/work")
364+ require.True(t, d.captureSession(sess))
365+ <-entered
366+
367+ sess.snapshotMu.Lock()
368+ changed := sess.snapshotChangeLocked()
369+ sess.snapshotMu.Unlock()
370+ close(release)
371+ <-changed
372+ awaitSnapshotClean(t, sess)
373+}
374+
375+func TestSnapshotSuccessDoesNotClearNewerGeneration(t *testing.T) {
376+ store := &channelSnapshotStore{writes: make(chan []byte, 2)}
377+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
378+ WithSnapshotStore(store)(d)
379+ startSnapshotEncodeWorker(t, d)
380+ entered := make(chan struct{})
381+ release := make(chan struct{})
382+ var calls atomic.Int32
383+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
384+ if calls.Add(1) == 1 {
385+ close(entered)
386+ <-release
387+ }
388+ return snapcodec.Marshal(s)
389+ }
390+ sess := newSnapshotTestSession(t, "generation", false, "/work")
391+
392+ markSnapshotDirty(sess)
393+ require.True(t, d.scheduleSnapshot(sess))
394+ <-entered
395+ markSnapshotDirty(sess)
396+ close(release)
397+ <-store.writes
398+ awaitSnapshotIdle(t, sess)
399+ require.True(t, sess.snapDirty.Load(), "a stale successful capture must not clear newer state")
400+
401+ require.True(t, d.scheduleSnapshot(sess))
402+ <-store.writes
403+ awaitSnapshotClean(t, sess)
404+}
405+
406+func TestSnapshotEncodeFailureLeavesSessionDirtyAndRetries(t *testing.T) {
407+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
408+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
409+ WithSnapshotStore(store)(d)
410+ startSnapshotEncodeWorker(t, d)
411+ sess := newSnapshotTestSession(t, "retry-encode", false, "/work")
412+ failed := make(chan struct{}, 1)
413+ var calls atomic.Int32
414+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
415+ if calls.Add(1) == 1 {
416+ failed <- struct{}{}
417+ return nil, errors.New("encode failed")
418+ }
419+ return snapcodec.Marshal(s)
420+ }
421+
422+ require.True(t, d.captureSession(sess))
423+ <-failed
424+ awaitSnapshotIdle(t, sess)
425+ require.True(t, sess.snapDirty.Load())
426+ require.True(t, d.scheduleSnapshot(sess))
427+ <-store.writes
428+ awaitSnapshotClean(t, sess)
429+}
430+
23431 func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
24432 clock := newManualSnapshotClock()
25433 store := portsmocks.NewMockSnapshotStore(t)
26434 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
27435 WithSnapshotStore(store)(d)
28436 d.procCwd = func(int) (string, error) { return "/pane", nil }
437+ startSnapshotEncodeWorker(t, d)
29438
30439 dirty := newSnapshotTestSession(t, "dirty", false, "/fallback")
31440 clean := newSnapshotTestSession(t, "clean", false, "/fallback")
@@ -67,6 +476,7 @@ func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
67476 store := portsmocks.NewMockSnapshotStore(t)
68477 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69478 WithSnapshotStore(store)(d)
479+ startSnapshotEncodeWorker(t, d)
70480 sess := newSnapshotTestSession(t, "work", false, "/fallback")
71481 tb := sess.tabs[0]
72482 floatingPTY, _ := newBlockingPTY(t)
@@ -85,12 +495,14 @@ func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
85495 return nil
86496 }).Once()
87497 require.True(t, d.captureSession(sess))
498+ awaitSnapshotClean(t, sess)
88499 }
89500
90501 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
91502 store := portsmocks.NewMockSnapshotStore(t)
92503 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
93504 WithSnapshotStore(store)(d)
505+ startSnapshotEncodeWorker(t, d)
94506 d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) {
95507 require.Equal(t, 4321, pgid)
96508 require.Equal(t, 1234, shellPid)
@@ -113,12 +525,14 @@ func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
113525 }).Once()
114526
115527 require.True(t, d.captureSession(sess))
528+ awaitSnapshotClean(t, sess)
116529 }
117530
118531 func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
119532 store := portsmocks.NewMockSnapshotStore(t)
120533 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
121534 WithSnapshotStore(store)(d)
535+ startSnapshotEncodeWorker(t, d)
122536 d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") }
123537
124538 sess := newSnapshotTestSession(t, "work", false, "/fallback")
@@ -133,12 +547,14 @@ func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
133547 }).Once()
134548
135549 require.True(t, d.captureSession(sess))
550+ awaitSnapshotClean(t, sess)
136551 }
137552
138553 func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
139554 store := portsmocks.NewMockSnapshotStore(t)
140555 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
141556 WithSnapshotStore(store)(d)
557+ startSnapshotEncodeWorker(t, d)
142558 d.procGroupArgv = func(int, int) ([]string, error) {
143559 t.Fatal("bare shell should not inspect argv")
144560 return nil, nil
@@ -156,35 +572,37 @@ func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
156572 }).Once()
157573
158574 require.True(t, d.captureSession(sess))
575+ awaitSnapshotClean(t, sess)
159576 }
160577
161578 func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {
162579 store := portsmocks.NewMockSnapshotStore(t)
163580 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
164581 WithSnapshotStore(store)(d)
582+ startSnapshotEncodeWorker(t, d)
165583 d.procCwd = func(int) (string, error) { return "/live", nil }
166584
167585 sess := newSnapshotTestSession(t, "work", false, "/fallback")
168586 d.sessions[sess.id] = sess
169587
170- var wrote atomic.Bool
588+ var closed atomic.Bool
171589 store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
172590 snap, err := snapcodec.Unmarshal(data)
173591 require.NoError(t, err)
174592 require.Equal(t, "work", snap.Name)
175593 require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd)
176- wrote.Store(true)
594+ require.True(t, closed.Load(), "snapshot persistence must not delay pane close")
177595 return nil
178596 }).Once()
179597 mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
180598 require.True(t, ok)
181599 mockPTY.EXPECT().Close().RunAndReturn(func() error {
182- require.True(t, wrote.Load(), "snapshot Write must happen before pane Close")
600+ closed.Store(true)
183601 return nil
184602 }).Once()
185603
186604 require.NoError(t, d.killSession(sess, 0, false))
187- require.True(t, wrote.Load())
605+ awaitSnapshotClean(t, sess)
188606 }
189607
190608 func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
@@ -241,12 +659,17 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
241659 store := portsmocks.NewMockSnapshotStore(t)
242660 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
243661 WithSnapshotStore(store)(d)
662+ startSnapshotEncodeWorker(t, d)
244663
245664 sess := newSnapshotTestSession(t, "retry", false, "/fallback")
246665 sess.snapDirty.Store(true)
247666 d.sessions[sess.id] = sess
248667
249- store.EXPECT().Write("retry", mock.Anything).Return(errors.New("boom")).Once()
668+ failed := make(chan struct{}, 1)
669+ store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
670+ failed <- struct{}{}
671+ return errors.New("boom")
672+ }).Once()
250673 wrote := make(chan struct{}, 1)
251674 store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
252675 wrote <- struct{}{}
@@ -257,7 +680,9 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
257680 defer cancel()
258681 go d.snapshotSaver(ctx)
259682 clock.fireNext(t)
260- require.Eventually(t, func() bool { return sess.snapDirty.Load() }, time.Second, time.Millisecond)
683+ <-failed
684+ awaitSnapshotIdle(t, sess)
685+ require.True(t, sess.snapDirty.Load())
261686 clock.fireNext(t)
262687 select {
263688 case <-wrote:
@@ -365,6 +790,7 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
365790 factory := portsmocks.NewMockPTYFactory(t)
366791 d := newTestDaemon(t, factory, clock)
367792 WithSnapshotStore(store)(d)
793+ startSnapshotEncodeWorker(t, d)
368794
369795 sess := newSnapshotTestSession(t, "work", false, "/work")
370796 tctx, tcancel := context.WithCancel(context.Background())
@@ -434,7 +860,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
434860 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
435861 p := tb.panes["pane-1"]
436862 p.screen.Write([]byte("hello"))
437- p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
863+ p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
438864 sess := &session{
439865 id: domain.SessionID("sess-" + name),
440866 name: name,
@@ -450,6 +876,91 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
450876 return sess
451877 }
452878
879+func awaitSnapshotIdle(t *testing.T, sess *session) {
880+ t.Helper()
881+ for {
882+ sess.snapshotMu.Lock()
883+ pending := sess.snapshotPending
884+ changed := sess.snapshotChangeLocked()
885+ sess.snapshotMu.Unlock()
886+ if !pending {
887+ return
888+ }
889+ <-changed
890+ }
891+}
892+
893+func awaitSnapshotClean(t *testing.T, sess *session) {
894+ t.Helper()
895+ for {
896+ sess.snapshotMu.Lock()
897+ clean := !sess.snapDirty.Load()
898+ changed := sess.snapshotChangeLocked()
899+ sess.snapshotMu.Unlock()
900+ if clean {
901+ return
902+ }
903+ <-changed
904+ }
905+}
906+
907+func startSnapshotEncodeWorker(t *testing.T, d *Daemon) {
908+ t.Helper()
909+ d.startSnapshotEncodeWorker()
910+ t.Cleanup(d.stopSnapshotEncodeWorker)
911+}
912+
913+type gatedSnapshotStore struct {
914+ entered chan struct{}
915+ release chan struct{}
916+ once sync.Once
917+ releaseOnce sync.Once
918+}
919+
920+func newGatedSnapshotStore() *gatedSnapshotStore {
921+ return &gatedSnapshotStore{entered: make(chan struct{}), release: make(chan struct{})}
922+}
923+
924+func (s *gatedSnapshotStore) enter() { s.once.Do(func() { close(s.entered) }) }
925+func (s *gatedSnapshotStore) unblock() { s.releaseOnce.Do(func() { close(s.release) }) }
926+
927+func (s *gatedSnapshotStore) Write(_ string, _ []byte) error {
928+ s.enter()
929+ <-s.release
930+ return nil
931+}
932+func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
933+func (*gatedSnapshotStore) Delete(string) error { return nil }
934+
935+type neverReturningSnapshotStore struct {
936+ entered chan struct{}
937+ once sync.Once
938+ block chan struct{}
939+}
940+
941+func newNeverReturningSnapshotStore() *neverReturningSnapshotStore {
942+ return &neverReturningSnapshotStore{entered: make(chan struct{}), block: make(chan struct{})}
943+}
944+
945+func (s *neverReturningSnapshotStore) Write(string, []byte) error {
946+ s.once.Do(func() { close(s.entered) })
947+ <-s.block
948+ return nil
949+}
950+func (*neverReturningSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
951+func (*neverReturningSnapshotStore) Delete(string) error { return nil }
952+
953+type channelSnapshotStore struct {
954+ writes chan []byte
955+}
956+
957+func (s *channelSnapshotStore) Write(_ string, data []byte) error {
958+ s.writes <- append([]byte(nil), data...)
959+ return nil
960+}
961+func (*channelSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
962+func (*channelSnapshotStore) Delete(string) error { return nil }
963+
453964 type manualSnapshotClock struct {
454965 mu sync.Mutex
455966 timers []*manualSnapshotTimer
@@ -487,6 +998,19 @@ func (c *manualSnapshotClock) fireNext(t *testing.T) {
487998 }
488999 }
4891000
1001+type finalFlushClock struct{ timers chan *manualSnapshotTimer }
1002+
1003+func newFinalFlushClock() *finalFlushClock {
1004+ return &finalFlushClock{timers: make(chan *manualSnapshotTimer, 1)}
1005+}
1006+
1007+func (c *finalFlushClock) Now() time.Time { return time.Unix(0, 0) }
1008+func (c *finalFlushClock) NewTimer(time.Duration) ports.Timer {
1009+ t := &manualSnapshotTimer{ch: make(chan time.Time, 1)}
1010+ c.timers <- t
1011+ return t
1012+}
1013+
4901014 type manualSnapshotTimer struct{ ch chan time.Time }
4911015
4921016 func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch }
A internal/usecase/snapshot/benchmark_test.go +122 -0
@@ -0,0 +1,122 @@
1+package snapshot
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/bnema/vev/pkg/vt"
9+)
10+
11+var (
12+ benchmarkV3BytesSink []byte
13+ benchmarkV3SessionSink Session
14+)
15+
16+func BenchmarkSnapshotV3Marshal10KRows(b *testing.B) {
17+ session, encoded := benchmarkV3Session(b)
18+ b.ReportAllocs()
19+ b.SetBytes(int64(len(encoded)))
20+ b.ResetTimer()
21+ for b.Loop() {
22+ var err error
23+ benchmarkV3BytesSink, err = Marshal(session)
24+ if err != nil {
25+ b.Fatal(err)
26+ }
27+ }
28+ b.StopTimer()
29+ if _, err := Unmarshal(benchmarkV3BytesSink); err != nil {
30+ b.Fatalf("marshal produced an invalid v3 snapshot: %v", err)
31+ }
32+}
33+
34+func BenchmarkSnapshotV3Unmarshal10KRows(b *testing.B) {
35+ _, encoded := benchmarkV3Session(b)
36+ b.ReportAllocs()
37+ b.SetBytes(int64(len(encoded)))
38+ b.ResetTimer()
39+ for b.Loop() {
40+ var err error
41+ benchmarkV3SessionSink, err = Unmarshal(encoded)
42+ if err != nil {
43+ b.Fatal(err)
44+ }
45+ }
46+ b.StopTimer()
47+ if len(benchmarkV3SessionSink.Tabs) != 1 || len(benchmarkV3SessionSink.Tabs[0].Panes) != 1 || len(benchmarkV3SessionSink.Tabs[0].Panes[0].SealedChunks) != 40 {
48+ b.Fatal("unmarshal lost canonical 10k-row snapshot data")
49+ }
50+}
51+
52+func benchmarkV3Session(t testing.TB) (Session, []byte) {
53+ t.Helper()
54+ const (
55+ cols = 120
56+ rows = 40
57+ historyRows = 10_000
58+ chunkRows = 256
59+ )
60+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: historyRows, ChunkRows: chunkRows})
61+ for row := range historyRows {
62+ cells := make([]renderer.Cell, cols)
63+ for col := range cells {
64+ cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)}
65+ }
66+ history.Append(cells)
67+ }
68+ sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ visibleFrame := renderer.NewFrame(cols, rows)
73+ for y := range rows {
74+ for x := range cols {
75+ visibleFrame.Set(x, y, renderer.Cell{Rune: rune('A' + (x+y)%26)})
76+ }
77+ }
78+ visible, err := vt.MarshalVisible(visibleFrame)
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ session := Session{
83+ Name: "benchmark",
84+ CreatedAt: 1,
85+ Tabs: []Tab{{
86+ StableID: "tab-1",
87+ Cols: cols,
88+ Rows: rows,
89+ NextPaneID: 2,
90+ Focus: "pane-1",
91+ Tree: layout.NewTree("pane-1"),
92+ Panes: []Pane{{
93+ ID: "pane-1",
94+ StableID: "pane-1",
95+ Cwd: "/benchmark",
96+ SealedChunks: sealed,
97+ Tail: tail,
98+ Visible: visible,
99+ }},
100+ }},
101+ }
102+ encoded, err := Marshal(session)
103+ if err != nil {
104+ t.Fatal(err)
105+ }
106+ decoded, err := Unmarshal(encoded)
107+ if err != nil {
108+ t.Fatal(err)
109+ }
110+ paneCount := 0
111+ decodedChunks := 0
112+ if len(decoded.Tabs) == 1 {
113+ paneCount = len(decoded.Tabs[0].Panes)
114+ if paneCount == 1 {
115+ decodedChunks = len(decoded.Tabs[0].Panes[0].SealedChunks)
116+ }
117+ }
118+ if len(sealed) != (historyRows+chunkRows-1)/chunkRows || paneCount != 1 || decodedChunks != len(sealed) {
119+ t.Fatalf("invalid v3 benchmark fixture: chunks=%d tabs=%d panes=%d decoded chunks=%d", len(sealed), len(decoded.Tabs), paneCount, decodedChunks)
120+ }
121+ return session, encoded
122+}
M internal/usecase/snapshot/codec.go +84 -313
@@ -1,17 +1,13 @@
11 package snapshot
22
33 import (
4- "bytes"
5- "compress/flate"
64 "encoding/binary"
75 "errors"
86 "fmt"
97 "hash/crc32"
10- "io"
118 "math"
129
1310 "github.com/bnema/vev/internal/usecase/layout"
14- "github.com/bnema/vev/pkg/renderer"
1511 )
1612
1713 var (
@@ -20,60 +16,44 @@ var (
2016 ErrBadCRC = errors.New("snapshot: bad crc")
2117 ErrShortPayload = errors.New("snapshot: payload too short")
2218 ErrTrailingBytes = errors.New("snapshot: trailing bytes")
23- ErrStyleOOB = errors.New("snapshot: style index out of range")
2419 ErrUnknownPane = errors.New("snapshot: tree references unknown pane")
2520 ErrInvalidData = errors.New("snapshot: invalid data")
2621 )
2722
2823 const (
29- magic = "VEVS"
30- version = uint16(2)
31- flagFlate = uint16(1 << 0)
32- flateCutoff = 4 << 10
33- maxDecodedBodySize = 256 << 20
34- maxDecodedRows = 1 << 20
35- maxDecodedRuns = 16 << 20
36- maxDecodedCells = 16 << 20
24+ magic = "VEVS"
25+ version = uint16(3)
26+ maxDecodedBodySize = 256 << 20
27+ maxSnapshotBlobs = 1 << 16
28+ maxSnapshotBytes = 256 << 20
29+ maxSnapshotRows = 1 << 20
30+ maxSnapshotCells = 16 << 20
31+ maxSnapshotTreeNodes = 1 << 15
32+ // 256 MiB accommodates a full 10k-row history at practical terminal widths
33+ // while bounding hostile manifests before decoded slices are allocated.
34+ maxSnapshotDecodedAllocation = 256 << 20
3735 )
3836
39-// Marshal encodes a v2 durable snapshot.
37+// Marshal encodes the strict v3, flags-zero durable envelope.
4038 func Marshal(s Session) ([]byte, error) {
4139 var w payloadWriter
4240 if err := writeSession(&w, s); err != nil {
4341 return nil, err
4442 }
45- body := w.b
46- flags := uint16(0)
47- if len(body) > flateCutoff {
48- var compressed bytes.Buffer
49- fw, err := flate.NewWriter(&compressed, flate.DefaultCompression)
50- if err != nil {
51- return nil, err
52- }
53- if _, err := fw.Write(body); err != nil {
54- _ = fw.Close()
55- return nil, err
56- }
57- if err := fw.Close(); err != nil {
58- return nil, err
59- }
60- body = compressed.Bytes()
61- flags |= flagFlate
62- }
63- if len(body) > math.MaxUint32 {
43+ if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 {
6444 return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
6545 }
66- out := make([]byte, 16+len(body))
46+ out := make([]byte, 16+len(w.b))
6747 copy(out[:4], magic)
6848 binary.BigEndian.PutUint16(out[4:6], version)
69- binary.BigEndian.PutUint16(out[6:8], flags)
70- binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
71- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
72- copy(out[16:], body)
49+ binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b)))
50+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b))
51+ copy(out[16:], w.b)
7352 return out, nil
7453 }
7554
76-// Unmarshal decodes a strict v2 durable snapshot.
55+// Unmarshal validates the complete manifest and all VT blobs without
56+// allocations first, then decodes the already preflighted data.
7757 func Unmarshal(b []byte) (Session, error) {
7858 if len(b) < 16 {
7959 return Session{}, ErrShortPayload
@@ -84,38 +64,25 @@ func Unmarshal(b []byte) (Session, error) {
8464 if binary.BigEndian.Uint16(b[4:6]) != version {
8565 return Session{}, ErrBadVersion
8666 }
87- flags := binary.BigEndian.Uint16(b[6:8])
88- if flags&^flagFlate != 0 {
89- return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData)
67+ if binary.BigEndian.Uint16(b[6:8]) != 0 {
68+ return Session{}, fmt.Errorf("%w: flags", ErrInvalidData)
9069 }
91- bodyLen := int(binary.BigEndian.Uint32(b[8:12]))
70+ bodyLen := binary.BigEndian.Uint32(b[8:12])
9271 if bodyLen > maxDecodedBodySize {
9372 return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData)
9473 }
95- if len(b) < 16+bodyLen {
74+ if uint64(len(b)) < 16+uint64(bodyLen) {
9675 return Session{}, ErrShortPayload
9776 }
98- if len(b) != 16+bodyLen {
77+ if uint64(len(b)) != 16+uint64(bodyLen) {
9978 return Session{}, ErrTrailingBytes
10079 }
10180 body := b[16:]
10281 if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) {
10382 return Session{}, ErrBadCRC
10483 }
105- if flags&flagFlate != 0 {
106- fr := flate.NewReader(bytes.NewReader(body))
107- decompressed, err := io.ReadAll(io.LimitReader(fr, maxDecodedBodySize+1))
108- cerr := fr.Close()
109- if err != nil {
110- return Session{}, err
111- }
112- if cerr != nil {
113- return Session{}, cerr
114- }
115- if len(decompressed) > maxDecodedBodySize {
116- return Session{}, fmt.Errorf("%w: decompressed body too large", ErrInvalidData)
117- }
118- body = decompressed
84+ if err := preflightSession(body); err != nil {
85+ return Session{}, err
11986 }
12087 r := payloadReader{b: body}
12188 s, err := readSession(&r)
@@ -240,6 +207,18 @@ func (r *payloadReader) getStrings() ([]string, error) {
240207 }
241208 return out, nil
242209 }
210+func (r *payloadReader) getBlob() ([]byte, error) {
211+ n, err := r.getUint32()
212+ if err != nil {
213+ return nil, err
214+ }
215+ if uint64(n) > uint64(len(r.b)) {
216+ return nil, ErrShortPayload
217+ }
218+ blob := append([]byte(nil), r.b[:n]...)
219+ r.b = r.b[n:]
220+ return blob, nil
221+}
243222 func (r *payloadReader) done() error {
244223 if len(r.b) != 0 {
245224 return ErrTrailingBytes
@@ -248,14 +227,17 @@ func (r *payloadReader) done() error {
248227 }
249228
250229 func writeSession(w *payloadWriter, s Session) error {
230+ if len(s.Tabs) > math.MaxUint16 {
231+ return fmt.Errorf("%w: too many tabs", ErrInvalidData)
232+ }
233+ if err := validateActive(s.Active, len(s.Tabs)); err != nil {
234+ return err
235+ }
251236 if err := w.putString(s.Name); err != nil {
252237 return err
253238 }
254239 w.putUint64(s.CreatedAt)
255240 w.putUint16(s.Active)
256- if len(s.Tabs) > math.MaxUint16 {
257- return fmt.Errorf("%w: too many tabs", ErrInvalidData)
258- }
259241 w.putUint16(uint16(len(s.Tabs)))
260242 for _, t := range s.Tabs {
261243 if err := writeTab(w, t); err != nil {
@@ -282,6 +264,9 @@ func readSession(r *payloadReader) (Session, error) {
282264 if err != nil {
283265 return Session{}, err
284266 }
267+ if err := validateActive(active, int(nt)); err != nil {
268+ return Session{}, err
269+ }
285270 s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)}
286271 for i := range s.Tabs {
287272 t, err := readTab(r)
@@ -464,24 +449,27 @@ func writePane(w *payloadWriter, p Pane) error {
464449 if err := w.putString(p.Cwd); err != nil {
465450 return err
466451 }
467- styles := styleTable(p)
468- if len(styles) > math.MaxUint16 {
469- return fmt.Errorf("%w: too many styles", ErrInvalidData)
470- }
471- w.putUint16(uint16(len(styles)))
472- for _, s := range styles {
473- writeStyle(w, s)
452+ if len(p.SealedChunks) > maxSnapshotBlobs {
453+ return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
474454 }
475- idx := make(map[renderer.Style]uint16, len(styles))
476- for i, s := range styles {
477- idx[s] = uint16(i)
455+ w.putUint32(uint32(len(p.SealedChunks)))
456+ for _, blob := range p.SealedChunks {
457+ if len(blob) == 0 || len(blob) > math.MaxUint32 {
458+ return fmt.Errorf("%w: sealed chunk length", ErrInvalidData)
459+ }
460+ w.putUint32(uint32(len(blob)))
461+ w.b = append(w.b, blob...)
478462 }
479- if err := writeRows(w, p.Scrollback, idx, false); err != nil {
480- return err
463+ if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 {
464+ return fmt.Errorf("%w: tail length", ErrInvalidData)
481465 }
482- if err := writeRows(w, p.Visible, idx, true); err != nil {
483- return err
466+ w.putUint32(uint32(len(p.Tail)))
467+ w.b = append(w.b, p.Tail...)
468+ if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {
469+ return fmt.Errorf("%w: visible length", ErrInvalidData)
484470 }
471+ w.putUint32(uint32(len(p.Visible)))
472+ w.b = append(w.b, p.Visible...)
485473 return writeProcess(w, p.Process)
486474 }
487475
@@ -498,24 +486,24 @@ func readPane(r *payloadReader) (Pane, error) {
498486 if err != nil {
499487 return Pane{}, err
500488 }
501- ns, err := r.getUint16()
489+ n, err := r.getUint32()
502490 if err != nil {
503491 return Pane{}, err
504492 }
505- styles := make([]renderer.Style, ns)
506- for i := range styles {
507- s, err := readStyle(r)
508- if err != nil {
493+ if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
494+ return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData)
495+ }
496+ sealed := make([][]byte, n)
497+ for i := range sealed {
498+ if sealed[i], err = r.getBlob(); err != nil {
509499 return Pane{}, err
510500 }
511- styles[i] = s
512501 }
513- budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns}
514- sb, err := readRows(r, styles, &budget)
502+ tail, err := r.getBlob()
515503 if err != nil {
516504 return Pane{}, err
517505 }
518- vis, err := readRows(r, styles, &budget)
506+ visible, err := r.getBlob()
519507 if err != nil {
520508 return Pane{}, err
521509 }
@@ -523,7 +511,7 @@ func readPane(r *payloadReader) (Pane, error) {
523511 if err != nil {
524512 return Pane{}, err
525513 }
526- return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil
514+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil
527515 }
528516
529517 func writeProcess(w *payloadWriter, p *Process) error {
@@ -573,238 +561,21 @@ func readProcess(r *payloadReader) (*Process, error) {
573561 return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil
574562 }
575563
576-func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {
577- clean := trimRows(rows, trimBlankRows)
578- if len(clean) > math.MaxUint32 {
579- return fmt.Errorf("%w: too many rows", ErrInvalidData)
580- }
581- w.putUint32(uint32(len(clean)))
582- for _, row := range clean {
583- runs := makeRuns(row)
584- if len(runs) > math.MaxUint16 {
585- return fmt.Errorf("%w: too many runs", ErrInvalidData)
564+func validateActive(active uint16, tabCount int) error {
565+ if tabCount == 0 {
566+ if active == 0 {
567+ return nil
586568 }
587- w.putUint16(uint16(len(runs)))
588- for _, run := range runs {
589- w.putUint16(uint16(run.len))
590- w.putUint32(uint32(run.cell.Rune))
591- w.putUint16(idx[run.cell.Style])
592- var flags uint8
593- if run.cell.Continuation {
594- flags |= 1
595- }
596- w.putUint8(flags)
597- }
598- }
599- return nil
600-}
601-
602-type rowBudget struct {
603- cells int
604- runs int
605-}
606-
607-func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) {
608- n, err := r.getUint32()
609- if err != nil {
610- return nil, err
569+ return fmt.Errorf("%w: active tab", ErrInvalidData)
611570 }
612- if n > maxDecodedRows {
613- return nil, fmt.Errorf("%w: too many rows", ErrInvalidData)
571+ if int(active) >= tabCount {
572+ return fmt.Errorf("%w: active tab", ErrInvalidData)
614573 }
615- if uint64(n) > uint64(len(r.b))/2 {
616- return nil, ErrShortPayload
617- }
618- rows := make([][]renderer.Cell, int(n))
619- for i := range rows {
620- nr, err := r.getUint16()
621- if err != nil {
622- return nil, err
623- }
624- if budget != nil {
625- if int(nr) > budget.runs {
626- return nil, fmt.Errorf("%w: too many runs", ErrInvalidData)
627- }
628- budget.runs -= int(nr)
629- }
630- if uint64(nr) > uint64(len(r.b))/9 {
631- return nil, ErrShortPayload
632- }
633- rowCells := 0
634- for j := 0; j < int(nr); j++ {
635- ln, err := r.getUint16()
636- if err != nil {
637- return nil, err
638- }
639- rn, err := r.getUint32()
640- if err != nil {
641- return nil, err
642- }
643- si, err := r.getUint16()
644- if err != nil {
645- return nil, err
646- }
647- flags, err := r.getUint8()
648- if err != nil {
649- return nil, err
650- }
651- if int(si) >= len(styles) {
652- return nil, ErrStyleOOB
653- }
654- if flags&^1 != 0 {
655- return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData)
656- }
657- if budget != nil {
658- if int(ln) > budget.cells {
659- return nil, fmt.Errorf("%w: too many cells", ErrInvalidData)
660- }
661- budget.cells -= int(ln)
662- }
663- cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0}
664- rowCells += int(ln)
665- rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...)
666- for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ {
667- rows[i][k] = cell
668- }
669- }
670- if rowCells == 0 {
671- rows[i] = nil
672- }
673- }
674- return rows, nil
675-}
676-
677-type cellRun struct {
678- len int
679- cell renderer.Cell
680-}
681-
682-func makeRuns(row []renderer.Cell) []cellRun {
683- if len(row) == 0 {
684- return nil
685- }
686- runs := []cellRun{{len: 1, cell: row[0]}}
687- for _, c := range row[1:] {
688- last := &runs[len(runs)-1]
689- if last.cell.Equal(c) && last.len < math.MaxUint16 {
690- last.len++
691- } else {
692- runs = append(runs, cellRun{len: 1, cell: c})
693- }
694- }
695- return runs
696-}
697-
698-func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell {
699- out := make([][]renderer.Cell, len(rows))
700- for i, row := range rows {
701- out[i] = trimTrailingBlankCells(row)
702- }
703- if trimBlankRows {
704- for len(out) > 0 && len(out[len(out)-1]) == 0 {
705- out = out[:len(out)-1]
706- }
707- }
708- return out
709-}
710-
711-func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell {
712- end := len(row)
713- for end > 0 && isBlank(row[end-1]) {
714- end--
715- }
716- return append([]renderer.Cell(nil), row[:end]...)
717-}
718-func isBlank(c renderer.Cell) bool {
719- return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle())
720-}
721-
722-func styleTable(p Pane) []renderer.Style {
723- seen := map[renderer.Style]bool{}
724- var styles []renderer.Style
725- addRows := func(rows [][]renderer.Cell, trimBlankRows bool) {
726- for _, row := range trimRows(rows, trimBlankRows) {
727- for _, c := range row {
728- if !seen[c.Style] {
729- seen[c.Style] = true
730- styles = append(styles, c.Style)
731- }
732- }
733- }
734- }
735- addRows(p.Scrollback, false)
736- addRows(p.Visible, true)
737- return styles
738-}
739-
740-func writeStyle(w *payloadWriter, s renderer.Style) {
741- var flags uint8
742- if s.Bold {
743- flags |= 1
744- }
745- if s.Inverse {
746- flags |= 2
747- }
748- if s.HasForegroundRGB {
749- flags |= 4
750- }
751- if s.HasBackgroundRGB {
752- flags |= 8
753- }
754- if s.Italic {
755- flags |= 16
756- }
757- w.putUint8(flags)
758- w.putUint32(uint32(int32(s.Foreground)))
759- w.putUint32(uint32(int32(s.Background)))
760- w.putUint8(s.ForegroundRGB.R)
761- w.putUint8(s.ForegroundRGB.G)
762- w.putUint8(s.ForegroundRGB.B)
763- w.putUint8(s.BackgroundRGB.R)
764- w.putUint8(s.BackgroundRGB.G)
765- w.putUint8(s.BackgroundRGB.B)
766-}
767-
768-func readStyle(r *payloadReader) (renderer.Style, error) {
769- flags, err := r.getUint8()
770- if err != nil {
771- return renderer.Style{}, err
772- }
773- fg, err := r.getUint32()
774- if err != nil {
775- return renderer.Style{}, err
776- }
777- bg, err := r.getUint32()
778- if err != nil {
779- return renderer.Style{}, err
780- }
781- fr, err := r.getUint8()
782- if err != nil {
783- return renderer.Style{}, err
784- }
785- fgc, err := r.getUint8()
786- if err != nil {
787- return renderer.Style{}, err
788- }
789- fb, err := r.getUint8()
790- if err != nil {
791- return renderer.Style{}, err
792- }
793- br, err := r.getUint8()
794- if err != nil {
795- return renderer.Style{}, err
796- }
797- bgc, err := r.getUint8()
798- if err != nil {
799- return renderer.Style{}, err
800- }
801- bb, err := r.getUint8()
802- if err != nil {
803- return renderer.Style{}, err
804- }
805- return renderer.Style{Bold: flags&1 != 0, Italic: flags&16 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil
574+ return nil
806575 }
807576
577+// validateTree checks that every pane reference in the layout tree resolves
578+// to a pane declared by the enclosing tab.
808579 func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
809580 if t == nil || t.Root == nil {
810581 return nil
M internal/usecase/snapshot/codec_test.go +390 -312
@@ -6,154 +6,292 @@ import (
66 "errors"
77 "hash/crc32"
88 "math"
9+ "strconv"
910 "testing"
1011
1112 "github.com/bnema/vev/internal/usecase/layout"
1213 "github.com/bnema/vev/pkg/renderer"
14+ "github.com/bnema/vev/pkg/vt"
1315 )
1416
15-func TestMarshalMinimalGolden(t *testing.T) {
16- got, err := Marshal(Session{Name: "s", CreatedAt: 7})
17+func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
18+ indexed := renderer.DefaultStyle()
19+ indexed.Bold, indexed.Inverse = true, true
20+ indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough
21+ indexed.Foreground, indexed.Background = 196, 17
22+ indexed.UnderlineStyle, indexed.HasUnderlineColor, indexed.UnderlineColor = renderer.UnderlineCurly, true, 203
23+ rgb := renderer.DefaultStyle()
24+ rgb.Italic, rgb.HasForegroundRGB, rgb.ForegroundRGB = true, true, renderer.RGB{R: 1, G: 2, B: 3}
25+ rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6}
26+ rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9}
27+ sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}})
28+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}})
29+ sealed2, tail2 := historyBlobs(t, [][]renderer.Cell{{{Rune: 'S', Style: rgb}}, {{Rune: '2', Style: indexed}}})
30+ visible2 := visibleBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}})
31+ want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{
32+ {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},
33+ {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},
34+ }}}}
35+ encoded, err := Marshal(want)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ requireV3Envelope(t, encoded)
40+ got, err := Unmarshal(encoded)
1741 if err != nil {
18- t.Fatalf("Marshal() error = %v", err)
42+ t.Fatal(err)
43+ }
44+ if len(got.Tabs) != 1 || len(got.Tabs[0].Panes) != 2 || got.Tabs[0].Panes[0].ID != "p" || got.Tabs[0].Panes[1].ID != "p2" {
45+ t.Fatalf("manifest round trip = %#v", got)
46+ }
47+ for i, wantPane := range want.Tabs[0].Panes {
48+ gotPane := got.Tabs[0].Panes[i]
49+ if len(gotPane.SealedChunks) != len(wantPane.SealedChunks) {
50+ t.Fatalf("pane %q sealed chunks = %d, want %d", wantPane.ID, len(gotPane.SealedChunks), len(wantPane.SealedChunks))
51+ }
52+ for j := range wantPane.SealedChunks {
53+ if !bytes.Equal(gotPane.SealedChunks[j], wantPane.SealedChunks[j]) {
54+ t.Fatalf("pane %q sealed chunk %d changed during round trip", wantPane.ID, j)
55+ }
56+ }
57+ if !bytes.Equal(gotPane.Tail, wantPane.Tail) {
58+ t.Fatalf("pane %q tail changed during round trip", wantPane.ID)
59+ }
60+ if !bytes.Equal(gotPane.Visible, wantPane.Visible) {
61+ t.Fatalf("pane %q visible blob changed during round trip", wantPane.ID)
62+ }
63+ }
64+ if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil {
65+ t.Fatalf("history decode: %v", err)
1966 }
20- want := []byte{
21- 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
22- 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,
67+ frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible)
68+ if err != nil {
69+ t.Fatal(err)
2370 }
24- if !bytes.Equal(got, want) {
25- t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want)
71+ if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation {
72+ t.Fatalf("visible terminal data lost: %#v", frame.Row(0))
2673 }
2774 }
2875
29-func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
30- cases := []struct {
31- name string
32- tab Tab
76+func TestActiveTabReferenceMustBeExact(t *testing.T) {
77+ validTab := Tab{}
78+ for _, tc := range []struct {
79+ name string
80+ s Session
81+ valid bool
3382 }{
34- {name: "nil tree", tab: Tab{Tree: nil}},
35- {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
36- }
37- for _, tc := range cases {
38- t.Run(tc.name, func(t *testing.T) {
39- b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
40- if err != nil {
41- t.Fatalf("Marshal() error = %v", err)
83+ {name: "zero tabs active zero", s: Session{Active: 0}, valid: true},
84+ {name: "zero tabs active nonzero", s: Session{Active: 1}},
85+ {name: "nonempty tabs active in range", s: Session{Active: 0, Tabs: []Tab{validTab}}, valid: true},
86+ {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}},
87+ } {
88+ t.Run("marshal "+tc.name, func(t *testing.T) {
89+ _, err := Marshal(tc.s)
90+ if tc.valid {
91+ requireNoError(t, err)
92+ return
4293 }
43- got, err := Unmarshal(b)
44- if err != nil {
45- t.Fatalf("Unmarshal() error = %v", err)
46- }
47- if len(got.Tabs) != 1 {
48- t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
49- }
50- if got.Tabs[0].Tree == nil {
51- t.Fatalf("Tree = nil, want tree with nil root")
52- }
53- if got.Tabs[0].Tree.Root != nil {
54- t.Fatalf("Root = %#v, want nil", got.Tabs[0].Tree.Root)
94+ if !errors.Is(err, ErrInvalidData) {
95+ t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
5596 }
5697 })
5798 }
99+
100+ for _, tc := range []struct {
101+ name string
102+ data []byte
103+ }{
104+ {name: "zero tabs active nonzero", data: v3ActiveManifest(1, 0)},
105+ {name: "nonempty tabs active past end", data: v3ActiveManifest(1, 1)},
106+ } {
107+ t.Run("unmarshal "+tc.name, func(t *testing.T) {
108+ reject(t, v3Envelope(version, 0, tc.data), ErrInvalidData)
109+ })
110+ }
58111 }
59112
60-func TestRoundTripSessions(t *testing.T) {
61- boldRGB := renderer.DefaultStyle()
62- boldRGB.Bold = true
63- boldRGB.HasForegroundRGB = true
64- boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
65- indexed := renderer.DefaultStyle()
66- indexed.Foreground = 2
67- indexed.Background = 4
68- indexed.Inverse = true
69- indexed.Italic = true
70- deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
71- layout.NewLeaf("1"),
72- &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}},
73- }}}
74- cjkVisible := [][]renderer.Cell{{
75- {Rune: '好', Style: boldRGB},
76- {Continuation: true, Style: boldRGB},
77- {Rune: 'x', Style: indexed},
78- }}
79- multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
80- {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}},
81- {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
82- }}
83- empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
84- large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}}
85- cases := []struct {
113+func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {
114+ for _, version := range []uint16{1, 2} {
115+ if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
116+ t.Fatalf("version %d: %v", version, err)
117+ }
118+ }
119+}
120+
121+func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {
122+ body := v3Manifest(0, nil, nil, 0, nil, 0, nil)
123+ valid := v3Envelope(version, 0, body)
124+ badCRC := append([]byte(nil), valid...)
125+ badCRC[15] ^= 1
126+ for _, tc := range []struct {
86127 name string
87- s Session
128+ data []byte
129+ want error
88130 }{
89- {name: "multi tab deep tree", s: multi},
90- {name: "empty and all blank", s: empty},
91- {name: "large scrollback", s: large},
131+ {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic},
132+ {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData},
133+ {"crc", badCRC, ErrBadCRC}, {"body", v3EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData},
134+ } {
135+ t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) })
92136 }
93- for _, tc := range cases {
137+}
138+
139+func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testing.T) {
140+ for _, tc := range []struct {
141+ name string
142+ body []byte
143+ }{
144+ {"count", v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},
145+ {"sealed", v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},
146+ {"tail", v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},
147+ {"visible", v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},
148+ } {
149+ t.Run(tc.name, func(t *testing.T) { reject(t, v3Envelope(version, 0, tc.body), ErrInvalidData) })
150+ }
151+}
152+
153+func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
154+ sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})
155+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
156+ encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ for n := range len(encoded) {
161+ reject(t, encoded[:n], nil)
162+ }
163+ reject(t, append(encoded, 0), ErrTrailingBytes)
164+}
165+
166+func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
167+ sealed, tail := historyBlobs(t, nil)
168+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
169+ for _, tc := range []struct {
170+ name string
171+ panes []Pane
172+ }{
173+ {name: "empty", panes: []Pane{{ID: "", Tail: tail, Visible: visible}}},
174+ {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}},
175+ } {
94176 t.Run(tc.name, func(t *testing.T) {
95- b, err := Marshal(tc.s)
96- if err != nil {
97- t.Fatalf("Marshal() error = %v", err)
98- }
99- got, err := Unmarshal(b)
100- if err != nil {
101- t.Fatalf("Unmarshal() error = %v", err)
102- }
103- if !equalSession(got, trimSession(tc.s)) {
104- t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s))
105- }
177+ data, err := Marshal(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})
178+ requireNoError(t, err)
179+ reject(t, data, ErrInvalidData)
106180 })
107181 }
108182 }
109183
110-func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
111- good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})
184+func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
185+ sealed, tail := historyBlobs(t, nil)
186+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
187+ encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
112188 if err != nil {
113189 t.Fatal(err)
114190 }
115- cases := []struct {
191+ if _, err := Unmarshal(encoded); !errors.Is(err, ErrUnknownPane) {
192+ t.Fatalf("error = %v", err)
193+ }
194+}
195+
196+func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) {
197+ sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}})
198+ if len(sealed) == 0 {
199+ t.Fatal("expected a sealed chunk")
200+ }
201+ multiChunk := append([]byte(nil), sealed[0][:5]...)
202+ multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2)
203+ multiChunk = append(multiChunk, sealed[0][9:]...)
204+ multiChunk = append(multiChunk, sealed[0][9:]...)
205+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
206+ emptyVisible := visibleBlob(t, nil)
207+ for _, tc := range []struct {
116208 name string
117- data []byte
209+ tab Tab
118210 }{
119- {"bad magic", append([]byte("NOPE"), good[4:]...)},
120- {"bad version", replaceU16(good, 4, 1)},
121- {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
122- {"trailing", append(append([]byte(nil), good...), 0)},
123- {"body len overrun", replaceU32(good, 8, 999999)},
124- {"prefix", good[:len(good)/2]},
125- {"style oob", styleOOBSnapshot(t)},
126- {"unknown pane in tree", unknownPaneSnapshot(t)},
127- }
128- for _, tc := range cases {
211+ {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: tail, Visible: visible}}}},
212+ {name: "nonempty tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: sealed[0], Visible: visible}}}},
213+ {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: tail, Visible: emptyVisible}}}},
214+ } {
129215 t.Run(tc.name, func(t *testing.T) {
130- defer func() {
131- if r := recover(); r != nil {
132- t.Fatalf("Unmarshal panicked: %v", r)
133- }
134- }()
135- if _, err := Unmarshal(tc.data); err == nil {
136- t.Fatalf("Unmarshal() error = nil")
216+ data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
217+ requireNoError(t, err)
218+ reject(t, data, ErrInvalidData)
219+ })
220+ }
221+}
222+
223+func TestUnmarshalRejectsMalformedTerminalBlobWithoutPanic(t *testing.T) {
224+ tail := mustHistoryTail(t)
225+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
226+ // The visible cell's underline-style byte is outside the renderer enum.
227+ visible[13+29] = 0xff
228+ body := v3Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(visible)), visible)
229+ reject(t, v3Envelope(version, 0, body), ErrInvalidData)
230+}
231+
232+func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
233+ for _, tc := range []struct {
234+ name string
235+ tab Tab
236+ }{
237+ {name: "nil tree", tab: Tab{Tree: nil}},
238+ {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
239+ } {
240+ t.Run(tc.name, func(t *testing.T) {
241+ data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
242+ requireNoError(t, err)
243+ got, err := Unmarshal(data)
244+ requireNoError(t, err)
245+ if got.Tabs[0].Tree == nil || got.Tabs[0].Tree.Root != nil {
246+ t.Fatalf("tree = %#v, want nonnil tree with nil root", got.Tabs[0].Tree)
137247 }
138248 })
139249 }
140- for i := 0; i < len(good); i++ {
141- if _, err := Unmarshal(good[:i]); err == nil {
142- t.Fatalf("prefix length %d unexpectedly succeeded", i)
143- }
250+}
251+
252+func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
253+ sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}, {{Rune: 'i'}}})
254+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
255+ want := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
256+ {
257+ StableID: "t1", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2",
258+ Tree: &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
259+ layout.NewLeaf("1"), layout.NewLeaf("2"),
260+ }}},
261+ Panes: []Pane{
262+ {ID: "1", StableID: "p1", Cwd: "/one", SealedChunks: sealed, Tail: tail, Visible: visible, Process: &Process{Argv: []string{"pi", "--resume"}, Strategy: "pi", Opts: ProcessOpts{AgentSessionID: "agent-123"}}},
263+ {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Visible: visible},
264+ },
265+ },
266+ {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}},
267+ }}
268+ data, err := Marshal(want)
269+ requireNoError(t, err)
270+ got, err := Unmarshal(data)
271+ requireNoError(t, err)
272+ if got.Name != want.Name || got.CreatedAt != want.CreatedAt || got.Active != want.Active || len(got.Tabs) != 2 {
273+ t.Fatalf("session metadata round trip = %#v", got)
274+ }
275+ pane := got.Tabs[0].Panes[0]
276+ if pane.ID != "1" || pane.StableID != "p1" || pane.Cwd != "/one" || pane.Process == nil || pane.Process.Opts.AgentSessionID != "agent-123" || got.Tabs[0].Tree.Root.Kind != layout.Split {
277+ t.Fatalf("pane metadata round trip = %#v", pane)
144278 }
145279 }
146280
147281 func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
148- _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}})
282+ _, err := Marshal(Session{Name: "s", Tabs: []Tab{
283+ {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}},
284+ }})
149285 if !errors.Is(err, ErrInvalidData) {
150286 t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
151287 }
152288 }
153289
154290 func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
291+ tail := mustHistoryTail(t)
292+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
155293 var w payloadWriter
156- _ = w.putString("x")
294+ _ = w.putString("s")
157295 w.putUint64(0)
158296 w.putUint16(0)
159297 w.putUint16(1)
@@ -165,269 +303,209 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
165303 _ = writeNode(&w, layout.NewLeaf("p"))
166304 w.putUint16(1)
167305 _ = w.putString("p")
168- _ = w.putString("p_stable")
169306 _ = w.putString("")
170- w.putUint16(0)
171- w.putUint32(0)
307+ _ = w.putString("")
172308 w.putUint32(0)
309+ w.putUint32(uint32(len(tail)))
310+ w.b = append(w.b, tail...)
311+ w.putUint32(uint32(len(visible)))
312+ w.b = append(w.b, visible...)
173313 w.putUint8(1)
174- w.putUint16(0)
314+ w.putUint16(0) // argv count
175315 _ = w.putString("generic")
176316 _ = w.putString("")
177- _, err := Unmarshal(reheader(w.b, 0))
178- if !errors.Is(err, ErrInvalidData) {
317+ if _, err := Unmarshal(v3Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) {
179318 t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
180319 }
181320 }
182321
183-func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
184- body := []byte{0}
185- oversizedRaw := reheader(body, 0)
186- binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1))
187- oversizedFlate := reheader(body, flagFlate)
188- binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1))
189- cases := []struct {
190- name string
191- data []byte
192- }{
193- {name: "raw", data: oversizedRaw},
194- {name: "flate", data: oversizedFlate},
195- }
196- for _, tc := range cases {
197- t.Run(tc.name, func(t *testing.T) {
198- _, err := Unmarshal(tc.data)
199- if !errors.Is(err, ErrInvalidData) {
200- t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
201- }
202- })
203- }
204-}
322+func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
323+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})
324+ for range 256 {
325+ history.Append([]renderer.Cell{{Rune: 'x'}})
326+ }
327+ sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
328+ requireNoError(t, err)
329+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
205330
206-func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) {
207- cases := []struct {
208- name string
209- data []byte
210- want error
211- }{
212- {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData},
213- {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload},
331+ const paneCount = maxSnapshotRows/256 + 1
332+ tabs := make([]Tab, paneCount)
333+ for i := range tabs {
334+ tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}
214335 }
215- for _, tc := range cases {
216- t.Run(tc.name, func(t *testing.T) {
217- defer func() {
218- if r := recover(); r != nil {
219- t.Fatalf("Unmarshal panicked: %v", r)
220- }
221- }()
222- _, err := Unmarshal(tc.data)
223- if !errors.Is(err, tc.want) {
224- t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want)
225- }
226- })
336+ data, err := Marshal(Session{Name: "over-budget", Tabs: tabs})
337+ requireNoError(t, err)
338+
339+ allocs := testing.AllocsPerRun(3, func() {
340+ _, err := Unmarshal(data)
341+ if !errors.Is(err, ErrInvalidData) {
342+ panic(err)
343+ }
344+ })
345+ // The error and testing harness may allocate a small fixed amount. Rejecting
346+ // before semantic maps/slices are built must not scale with paneCount.
347+ if allocs > 8 {
348+ t.Fatalf("Unmarshal() allocations = %f, want fixed overhead only", allocs)
227349 }
228350 }
229351
230-func TestFlateOnOff(t *testing.T) {
231- small, err := Marshal(Session{Name: "small"})
232- if err != nil {
233- t.Fatal(err)
234- }
235- if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 {
236- t.Fatalf("small flags = %#x, want 0", flags)
237- }
238- large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}})
239- if err != nil {
240- t.Fatal(err)
241- }
242- if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 {
243- t.Fatalf("large flags = %#x, want flate bit", flags)
352+func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) {
353+ budget := preflightBudget{alloc: maxSnapshotDecodedAllocation - 1}
354+ if budget.addProduct(1, 2) {
355+ t.Fatal("allocation budget accepted an over-limit aggregate")
244356 }
245357 }
246358
247-func BenchmarkMarshal10KRows(b *testing.B) {
248- s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}}
249- b.ReportAllocs()
250- for i := 0; i < b.N; i++ {
251- if _, err := Marshal(s); err != nil {
252- b.Fatal(err)
253- }
359+func TestPreflightRejectsBroadTreeBeforeSecondPassAllocation(t *testing.T) {
360+ body := v3WideTreeManifest(32_769)
361+ if err := preflightSession(body); !errors.Is(err, ErrInvalidData) {
362+ t.Fatalf("preflightSession() error = %v, want %v", err, ErrInvalidData)
254363 }
255364 }
256365
257-func rows(lines ...string) [][]renderer.Cell {
258- out := make([][]renderer.Cell, len(lines))
259- for i, line := range lines {
260- for _, r := range line {
261- out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
262- }
263- }
264- return out
366+func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) {
367+ body := v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)
368+ reject(t, v3Envelope(version, 0, body), ErrInvalidData)
265369 }
266370
267-func manyRows(n int) [][]renderer.Cell {
268- out := make([][]renderer.Cell, n)
269- for i := range out {
270- out[i] = rows("row data row data row data")[0]
271- }
272- return out
371+func mustHistoryTail(t *testing.T) []byte {
372+ t.Helper()
373+ _, tail := historyBlobs(t, nil)
374+ return tail
273375 }
274376
275-func equalSession(a, b Session) bool {
276- if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {
277- return false
278- }
279- for i := range a.Tabs {
280- if !equalTab(a.Tabs[i], b.Tabs[i]) {
281- return false
282- }
377+func requireNoError(t *testing.T, err error) {
378+ t.Helper()
379+ if err != nil {
380+ t.Fatal(err)
283381 }
284- return true
285382 }
286383
287-func equalTab(a, b Tab) bool {
288- if a.StableID != b.StableID || a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
289- return false
384+func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
385+ t.Helper()
386+ h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
387+ for _, row := range rows {
388+ h.Append(row)
290389 }
291- for i := range a.Panes {
292- if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].StableID != b.Panes[i].StableID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalProcess(a.Panes[i].Process, b.Panes[i].Process) || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
293- return false
294- }
390+ sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
391+ if err != nil {
392+ t.Fatal(err)
295393 }
296- return true
394+ return sealed, tail
297395 }
298-
299-func equalProcess(a, b *Process) bool {
300- if a == nil || b == nil {
301- return a == b
396+func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte {
397+ t.Helper()
398+ width := 0
399+ for _, row := range rows {
400+ width = max(width, len(row))
302401 }
303- if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) {
304- return false
402+ f := renderer.NewFrame(width, len(rows))
403+ for y, row := range rows {
404+ copy(f.Row(y), row)
305405 }
306- for i := range a.Argv {
307- if a.Argv[i] != b.Argv[i] {
308- return false
309- }
406+ b, err := vt.MarshalVisible(f)
407+ if err != nil {
408+ t.Fatal(err)
310409 }
311- return true
410+ return b
312411 }
313-
314-func treeRoot(t *layout.Tree) *layout.Node {
315- if t == nil {
316- return nil
412+func requireV3Envelope(t *testing.T, data []byte) {
413+ t.Helper()
414+ if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 {
415+ t.Fatalf("not v3 envelope: % x", data)
317416 }
318- return t.Root
319417 }
320-
321-func equalNode(a, b *layout.Node) bool {
322- if a == nil || b == nil {
323- return a == b
324- }
325- if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) {
326- return false
327- }
328- for i := range a.Children {
329- if !equalNode(a.Children[i], b.Children[i]) {
330- return false
418+func reject(t *testing.T, data []byte, want error) {
419+ t.Helper()
420+ defer func() {
421+ if r := recover(); r != nil {
422+ t.Fatalf("panic: %v", r)
331423 }
424+ }()
425+ _, err := Unmarshal(data)
426+ if err == nil {
427+ t.Fatal("accepted malformed input")
332428 }
333- return true
334-}
335-
336-func equalRows(a, b [][]renderer.Cell) bool {
337- if len(a) != len(b) {
338- return false
429+ if want != nil && !errors.Is(err, want) {
430+ t.Fatalf("error = %v, want %v", err, want)
339431 }
340- for i := range a {
341- if len(a[i]) != len(b[i]) {
342- return false
343- }
344- for j := range a[i] {
345- if !a[i][j].Equal(b[i][j]) {
346- return false
347- }
348- }
349- }
350- return true
351432 }
352-
353-func trimSession(s Session) Session {
354- for ti := range s.Tabs {
355- for pi := range s.Tabs[ti].Panes {
356- s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false)
357- s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true)
358- }
359- }
360- return s
433+func v3Envelope(v, flags uint16, body []byte) []byte {
434+ return v3EnvelopeWithLength(v, flags, body, uint32(len(body)))
361435 }
362-
363-func reheader(body []byte, flags uint16) []byte {
436+func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
364437 out := make([]byte, 16+len(body))
365- copy(out[:4], []byte("VEVS"))
366- binary.BigEndian.PutUint16(out[4:6], version)
438+ copy(out, magic)
439+ binary.BigEndian.PutUint16(out[4:6], v)
367440 binary.BigEndian.PutUint16(out[6:8], flags)
368- binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
441+ binary.BigEndian.PutUint32(out[8:12], length)
369442 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
370443 copy(out[16:], body)
371444 return out
372445 }
373446
374-func replaceU16(b []byte, off int, v uint16) []byte {
375- out := append([]byte(nil), b...)
376- binary.BigEndian.PutUint16(out[off:], v)
377- return out
378-}
379-func replaceU32(b []byte, off int, v uint32) []byte {
380- out := append([]byte(nil), b...)
381- binary.BigEndian.PutUint32(out[off:], v)
382- return out
383-}
384-
385-func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
386- var w payloadWriter
387- _ = w.putString("x")
388- w.putUint64(0)
389- w.putUint16(0)
390- w.putUint16(1)
391- _ = w.putString("")
392- w.putUint16(0)
393- w.putUint16(0)
394- w.putUint64(0)
395- _ = w.putString("p")
396- _ = writeNode(&w, layout.NewLeaf("p"))
397- w.putUint16(1)
398- _ = w.putString("p")
399- _ = w.putString("")
400- _ = w.putString("")
401- w.putUint16(0)
402- w.putUint32(rowCount)
403- if rowCount > 0 {
404- w.putUint16(runCount)
447+func v3ActiveManifest(active, tabs uint16) []byte {
448+ var b []byte
449+ putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
450+ putS("s")
451+ b = append(b, make([]byte, 8)...)
452+ b = binary.BigEndian.AppendUint16(b, active)
453+ b = binary.BigEndian.AppendUint16(b, tabs)
454+ for range tabs {
455+ putS("")
456+ b = append(b, make([]byte, 2+2+8)...)
457+ putS("")
458+ b = append(b, 3) // nil root
459+ b = binary.BigEndian.AppendUint16(b, 0)
405460 }
406- return reheader(w.b, 0)
461+ return b
407462 }
408463
409-func styleOOBSnapshot(t *testing.T) []byte {
410- b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}})
411- if err != nil {
412- t.Fatal(err)
413- }
414- bodyLen := int(binary.BigEndian.Uint32(b[8:12]))
415- body := append([]byte(nil), b[16:16+bodyLen]...)
416- for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0
417- if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' {
418- body[i+6] = 0
419- body[i+7] = 2
420- return reheader(body, 0)
421- }
422- }
423- t.Fatal("run not found")
424- return nil
464+// v3Manifest creates just enough session/tab/pane framing for hostile declarations.
465+// v3WideTreeManifest declares a broad but compact tree without allocating a
466+// matching layout.Node graph. It exercises preflight's manifest budget.
467+func v3WideTreeManifest(children uint16) []byte {
468+ var b []byte
469+ putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
470+ putS("s")
471+ b = append(b, make([]byte, 8+2)...)
472+ b = binary.BigEndian.AppendUint16(b, 1)
473+ putS("")
474+ b = append(b, make([]byte, 2+2+8)...)
475+ putS("")
476+ b = append(b, 1, byte(layout.Horizontal)) // split node
477+ b = binary.BigEndian.AppendUint16(b, children)
478+ for range children {
479+ b = append(b, 0) // leaf
480+ putS("")
481+ }
482+ b = binary.BigEndian.AppendUint16(b, 0) // panes
483+ return b
425484 }
426485
427-func unknownPaneSnapshot(t *testing.T) []byte {
428- b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}})
429- if err != nil {
430- t.Fatal(err)
431- }
486+func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte {
487+ var b []byte
488+ putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
489+ putS("s")
490+ b = append(b, make([]byte, 8+2)...)
491+ b = binary.BigEndian.AppendUint16(b, 1)
492+ putS("")
493+ b = append(b, make([]byte, 2+2+8)...)
494+ putS("p")
495+ b = append(b, 0)
496+ putS("p")
497+ b = binary.BigEndian.AppendUint16(b, 1)
498+ putS("p")
499+ putS("")
500+ putS("")
501+ b = binary.BigEndian.AppendUint32(b, sealed)
502+ for _, l := range sealedLens {
503+ b = binary.BigEndian.AppendUint32(b, l)
504+ }
505+ b = append(b, sealedData...)
506+ b = binary.BigEndian.AppendUint32(b, tailLen)
507+ b = append(b, tailData...)
508+ b = binary.BigEndian.AppendUint32(b, visibleLen)
509+ b = append(b, visibleData...)
432510 return b
433511 }
A internal/usecase/snapshot/preflight.go +449 -0
@@ -0,0 +1,449 @@
1+package snapshot
2+
3+import (
4+ "fmt"
5+ "math"
6+
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/pkg/vt"
9+)
10+
11+func preflightSession(body []byte) error {
12+ if err := preflightSessionStructure(body); err != nil {
13+ return err
14+ }
15+ return preflightSessionReferences(body)
16+}
17+
18+func preflightSessionStructure(body []byte) error {
19+ r := payloadReader{b: body}
20+ if err := skipString(&r); err != nil {
21+ return err
22+ }
23+ if _, err := r.getUint64(); err != nil {
24+ return err
25+ }
26+ active, err := r.getUint16()
27+ if err != nil {
28+ return err
29+ }
30+ tabs, err := r.getUint16()
31+ if err != nil {
32+ return err
33+ }
34+ if err := validateActive(active, int(tabs)); err != nil {
35+ return err
36+ }
37+ if uint64(tabs) > uint64(len(r.b))/2 {
38+ return ErrShortPayload
39+ }
40+
41+ var totals vt.DecodeStats
42+ blobs := uint64(0)
43+ budget := preflightBudget{}
44+ for range tabs {
45+ if err := preflightTabStructure(&r, &totals, &blobs, &budget); err != nil {
46+ return fmt.Errorf("preflight tab: %w", err)
47+ }
48+ }
49+ if err := r.done(); err != nil {
50+ return err
51+ }
52+ if totals.Rows > maxSnapshotRows || totals.Cells > maxSnapshotCells || totals.Bytes > maxSnapshotBytes {
53+ return fmt.Errorf("%w: global VT budget", ErrInvalidData)
54+ }
55+ return nil
56+}
57+
58+func skipString(r *payloadReader) error {
59+ n, err := r.getUint16()
60+ if err != nil {
61+ return err
62+ }
63+ if uint64(n) > uint64(len(r.b)) {
64+ return ErrShortPayload
65+ }
66+ r.b = r.b[n:]
67+ return nil
68+}
69+
70+type preflightBudget struct {
71+ nodes uint64
72+ alloc uint64
73+}
74+
75+func (b *preflightBudget) addAllocation(amount uint64) bool {
76+ if amount > maxSnapshotDecodedAllocation-b.alloc {
77+ return false
78+ }
79+ b.alloc += amount
80+ return true
81+}
82+
83+func (b *preflightBudget) addProduct(count, size uint64) bool {
84+ if count != 0 && size > math.MaxUint64/count {
85+ return false
86+ }
87+ return b.addAllocation(count * size)
88+}
89+
90+func (b *preflightBudget) addNode() bool {
91+ if b.nodes >= maxSnapshotTreeNodes || !b.addAllocation(128) {
92+ return false
93+ }
94+ b.nodes++
95+ return true
96+}
97+
98+func preflightTabStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) error {
99+ if !budget.addAllocation(256) {
100+ return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
101+ }
102+ if err := skipString(r); err != nil {
103+ return err
104+ }
105+ if _, err := r.getUint16(); err != nil {
106+ return err
107+ }
108+ if _, err := r.getUint16(); err != nil {
109+ return err
110+ }
111+ if _, err := r.getUint64(); err != nil {
112+ return err
113+ }
114+ if err := skipString(r); err != nil {
115+ return err
116+ }
117+ if err := preflightNodeStructure(r, 0, true, budget); err != nil {
118+ return err
119+ }
120+ panes, err := r.getUint16()
121+ if err != nil {
122+ return err
123+ }
124+ if uint64(panes) > uint64(len(r.b))/2 {
125+ return ErrShortPayload
126+ }
127+ for range panes {
128+ if err := preflightPaneStructure(r, totals, blobs, budget); err != nil {
129+ return err
130+ }
131+ }
132+ return nil
133+}
134+
135+func preflightNodeStructure(r *payloadReader, depth int, root bool, budget *preflightBudget) error {
136+ if depth > 64 {
137+ return fmt.Errorf("%w: tree depth", ErrInvalidData)
138+ }
139+ if !budget.addNode() {
140+ return fmt.Errorf("%w: tree node or decoded allocation budget", ErrInvalidData)
141+ }
142+ kind, err := r.getUint8()
143+ if err != nil {
144+ return err
145+ }
146+ switch kind {
147+ case 0:
148+ return skipString(r)
149+ case 1:
150+ dir, err := r.getUint8()
151+ if err != nil {
152+ return err
153+ }
154+ if layout.SplitDir(dir) != layout.Horizontal && layout.SplitDir(dir) != layout.Vertical {
155+ return fmt.Errorf("%w: split dir", ErrInvalidData)
156+ }
157+ case 2:
158+ if err := skipString(r); err != nil {
159+ return err
160+ }
161+ case 3:
162+ if !root {
163+ return fmt.Errorf("%w: nil child node", ErrInvalidData)
164+ }
165+ return nil
166+ default:
167+ return fmt.Errorf("%w: node kind", ErrInvalidData)
168+ }
169+ children, err := r.getUint16()
170+ if err != nil {
171+ return err
172+ }
173+ if uint64(children) > uint64(len(r.b)) {
174+ return ErrShortPayload
175+ }
176+ for range children {
177+ if err := preflightNodeStructure(r, depth+1, false, budget); err != nil {
178+ return err
179+ }
180+ }
181+ return nil
182+}
183+
184+func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) error {
185+ if !budget.addAllocation(256) {
186+ return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
187+ }
188+ if err := skipString(r); err != nil {
189+ return err
190+ }
191+ if err := skipString(r); err != nil {
192+ return err
193+ }
194+ if err := skipString(r); err != nil {
195+ return err
196+ }
197+ n, err := r.getUint32()
198+ if err != nil {
199+ return err
200+ }
201+ if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
202+ return fmt.Errorf("%w: sealed chunks", ErrInvalidData)
203+ }
204+ for range n {
205+ stats, err := preflightBlob(r, totals, blobs, true, budget)
206+ if err != nil {
207+ return err
208+ }
209+ if stats.Chunks != 1 || stats.Rows == 0 {
210+ return fmt.Errorf("%w: sealed blob role", ErrInvalidData)
211+ }
212+ }
213+ tail, err := preflightBlob(r, totals, blobs, true, budget)
214+ if err != nil {
215+ return err
216+ }
217+ if tail.Chunks != 0 || tail.Rows != 0 || tail.Cells != 0 {
218+ return fmt.Errorf("%w: tail blob role", ErrInvalidData)
219+ }
220+ visible, err := preflightBlob(r, totals, blobs, false, budget)
221+ if err != nil {
222+ return err
223+ }
224+ if visible.Rows == 0 || visible.Cells == 0 {
225+ return fmt.Errorf("%w: visible geometry", ErrInvalidData)
226+ }
227+ return preflightProcess(r)
228+}
229+
230+func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, history bool, budget *preflightBudget) (vt.DecodeStats, error) {
231+ n, err := r.getUint32()
232+ if err != nil {
233+ return vt.DecodeStats{}, err
234+ }
235+ if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes {
236+ return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData)
237+ }
238+ if *blobs >= maxSnapshotBlobs {
239+ return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData)
240+ }
241+ blob := r.b[:n]
242+ r.b = r.b[n:]
243+ var stats vt.DecodeStats
244+ if history {
245+ stats, err = vt.PreflightHistoryBlob(blob)
246+ } else {
247+ stats, err = vt.PreflightVisibleBlob(blob)
248+ }
249+ if err != nil {
250+ return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData)
251+ }
252+ if !totals.Add(stats) {
253+ return vt.DecodeStats{}, fmt.Errorf("%w: VT budget overflow", ErrInvalidData)
254+ }
255+ if !budget.addAllocation(uint64(n)) || !budget.addProduct(stats.Chunks, 64) || !budget.addProduct(stats.Rows, 32) || !budget.addProduct(stats.Cells, 64) {
256+ return vt.DecodeStats{}, fmt.Errorf("%w: decoded allocation budget", ErrInvalidData)
257+ }
258+ *blobs++
259+ return stats, nil
260+}
261+
262+func preflightProcess(r *payloadReader) error {
263+ present, err := r.getUint8()
264+ if err != nil {
265+ return err
266+ }
267+ if present == 0 {
268+ return nil
269+ }
270+ if present != 1 {
271+ return fmt.Errorf("%w: process presence", ErrInvalidData)
272+ }
273+ n, err := r.getUint16()
274+ if err != nil {
275+ return err
276+ }
277+ if n == 0 {
278+ return fmt.Errorf("%w: process argv empty", ErrInvalidData)
279+ }
280+ for range n {
281+ if err := skipString(r); err != nil {
282+ return err
283+ }
284+ }
285+ if err := skipString(r); err != nil {
286+ return err
287+ }
288+ return skipString(r)
289+}
290+
291+// preflightSessionReferences validates duplicate, empty, and dangling pane
292+// references only after the structural pass has admitted the complete payload.
293+func preflightSessionReferences(body []byte) error {
294+ r := payloadReader{b: body}
295+ if err := skipString(&r); err != nil {
296+ return err
297+ }
298+ if _, err := r.getUint64(); err != nil {
299+ return err
300+ }
301+ if _, err := r.getUint16(); err != nil {
302+ return err
303+ }
304+ tabs, err := r.getUint16()
305+ if err != nil {
306+ return err
307+ }
308+ for range tabs {
309+ if err := preflightTabReferences(&r); err != nil {
310+ return fmt.Errorf("preflight tab references: %w", err)
311+ }
312+ }
313+ return r.done()
314+}
315+
316+func preflightTabReferences(r *payloadReader) error {
317+ if err := skipString(r); err != nil {
318+ return err
319+ }
320+ if _, err := r.getUint16(); err != nil {
321+ return err
322+ }
323+ if _, err := r.getUint16(); err != nil {
324+ return err
325+ }
326+ if _, err := r.getUint64(); err != nil {
327+ return err
328+ }
329+ focus, err := r.getString()
330+ if err != nil {
331+ return err
332+ }
333+ references := preflightTreeReferences{}
334+ if err := preflightNodeReferences(r, &references); err != nil {
335+ return err
336+ }
337+ panes, err := r.getUint16()
338+ if err != nil {
339+ return err
340+ }
341+ ids := make(map[layout.PaneID]struct{}, panes)
342+ for range panes {
343+ id, err := r.getString()
344+ if err != nil {
345+ return err
346+ }
347+ if id == "" {
348+ return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
349+ }
350+ paneID := layout.PaneID(id)
351+ if _, exists := ids[paneID]; exists {
352+ return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
353+ }
354+ ids[paneID] = struct{}{}
355+ if err := skipString(r); err != nil {
356+ return err
357+ }
358+ if err := skipString(r); err != nil {
359+ return err
360+ }
361+ n, err := r.getUint32()
362+ if err != nil {
363+ return err
364+ }
365+ for range n {
366+ if err := skipBlob(r); err != nil {
367+ return err
368+ }
369+ }
370+ if err := skipBlob(r); err != nil {
371+ return err
372+ }
373+ if err := skipBlob(r); err != nil {
374+ return err
375+ }
376+ if err := preflightProcess(r); err != nil {
377+ return err
378+ }
379+ }
380+ if focus != "" {
381+ references.ids = append(references.ids, layout.PaneID(focus))
382+ }
383+ for _, id := range references.ids {
384+ if _, ok := ids[id]; !ok {
385+ return ErrUnknownPane
386+ }
387+ }
388+ return nil
389+}
390+
391+func skipBlob(r *payloadReader) error {
392+ n, err := r.getUint32()
393+ if err != nil {
394+ return err
395+ }
396+ if uint64(n) > uint64(len(r.b)) {
397+ return ErrShortPayload
398+ }
399+ r.b = r.b[n:]
400+ return nil
401+}
402+
403+type preflightTreeReferences struct{ ids []layout.PaneID }
404+
405+func (r *preflightTreeReferences) add(id layout.PaneID) {
406+ r.ids = append(r.ids, id)
407+}
408+
409+func preflightNodeReferences(r *payloadReader, references *preflightTreeReferences) error {
410+ kind, err := r.getUint8()
411+ if err != nil {
412+ return err
413+ }
414+ switch kind {
415+ case 0:
416+ leaf, err := r.getString()
417+ if err != nil {
418+ return err
419+ }
420+ references.add(layout.PaneID(leaf))
421+ return nil
422+ case 1:
423+ if _, err := r.getUint8(); err != nil {
424+ return err
425+ }
426+ case 2:
427+ expanded, err := r.getString()
428+ if err != nil {
429+ return err
430+ }
431+ if expanded != "" {
432+ references.add(layout.PaneID(expanded))
433+ }
434+ case 3:
435+ return nil
436+ default:
437+ return fmt.Errorf("%w: node kind", ErrInvalidData)
438+ }
439+ children, err := r.getUint16()
440+ if err != nil {
441+ return err
442+ }
443+ for range children {
444+ if err := preflightNodeReferences(r, references); err != nil {
445+ return err
446+ }
447+ }
448+ return nil
449+}
A internal/usecase/snapshot/preflight_acceptance_test.go +45 -0
@@ -0,0 +1,45 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) {
12+ sealed, tail := historyBlobs(t, nil)
13+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
14+ for _, tt := range []struct {
15+ name string
16+ tab Tab
17+ }{
18+ {
19+ name: "tree leaf",
20+ tab: Tab{
21+ Focus: "pane",
22+ Tree: layout.NewTree("missing"),
23+ Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}},
24+ },
25+ },
26+ {
27+ name: "tab focus",
28+ tab: Tab{
29+ Focus: "missing",
30+ Tree: layout.NewTree("pane"),
31+ Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}},
32+ },
33+ },
34+ } {
35+ t.Run(tt.name, func(t *testing.T) {
36+ encoded, err := Marshal(Session{Name: "hostile", Tabs: []Tab{tt.tab}})
37+ if err != nil {
38+ t.Fatalf("Marshal: %v", err)
39+ }
40+ if err := preflightSession(encoded[16:]); !errors.Is(err, ErrUnknownPane) {
41+ t.Fatalf("preflightSession() error = %v, want %v", err, ErrUnknownPane)
42+ }
43+ })
44+ }
45+}
M internal/usecase/snapshot/snapshot.go +10 -12
@@ -1,9 +1,6 @@
11 package snapshot
22
3-import (
4- "github.com/bnema/vev/internal/usecase/layout"
5- "github.com/bnema/vev/pkg/renderer"
6-)
3+import "github.com/bnema/vev/internal/usecase/layout"
74
85 // Session is the durable transfer representation for a named daemon session.
96 type Session struct {
@@ -24,15 +21,16 @@ type Tab struct {
2421 Panes []Pane
2522 }
2623
27-// Pane captures terminal state needed to restore a fresh shell in the same cwd
28-// with its scrollback and final visible primary screen.
24+// Pane captures terminal state as opaque, canonical VT blobs. Snapshot owns
25+// their length framing and order; pkg/vt owns their contents and validation.
2926 type Pane struct {
30- ID layout.PaneID
31- StableID string
32- Cwd string
33- Scrollback [][]renderer.Cell
34- Visible [][]renderer.Cell
35- Process *Process
27+ ID layout.PaneID
28+ StableID string
29+ Cwd string
30+ SealedChunks [][]byte // oldest-first, each a self-contained VT blob
31+ Tail []byte // mandatory nonempty canonical VT blob
32+ Visible []byte // mandatory nonempty canonical VT blob
33+ Process *Process
3634 }
3735
3836 // Process captures restorable foreground process metadata.
M internal/usecase/visualsearch/model_test.go +2 -2
@@ -61,14 +61,14 @@ func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
6161 require.Equal(t, "alpha", matches[0].Text)
6262 }
6363
64-func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) {
64+func TestVisualSearchCloneRetainsDocumentAndKeepsUIStateIndependent(t *testing.T) {
6565 m := New(testSnapshot("alpha", "beta alpha"))
6666 for _, r := range "alpha" {
6767 m.Insert(r)
6868 }
6969 clone := m.Clone()
7070
71- require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0])
71+ require.Equal(t, m.snapshot.Row(0), clone.snapshot.Row(0))
7272 clone.Down()
7373 clone.Insert('z')
7474
M pkg/vt/benchmark_test.go +158 -1
@@ -1,6 +1,163 @@
11 package vt
22
3-import "testing"
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+var (
10+ benchmarkHistoryViewSink HistoryView
11+ benchmarkHistoryBlobSink []byte
12+ benchmarkHistoryBlobsSink [][]byte
13+)
14+
15+// TestHistoryViewPartialTailAllocationBounded guards the capture property used
16+// by persistence: retained sealed chunks are shared, while only the small
17+// mutable tail is copied into a view.
18+func TestHistoryViewPartialTailAllocationBounded(t *testing.T) {
19+ const (
20+ width = 120
21+ chunkRows = 64
22+ tailRows = 7
23+ )
24+ sealed := benchmarkHistory(t, chunkRows*4, width, chunkRows)
25+ sealed.SealAndView()
26+ partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows)
27+ partialBefore := partial.View()
28+
29+ sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })
30+ partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })
31+ if partialAllocs > sealedAllocs+tailRows+3 {
32+ t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows)
33+ }
34+ if partial.View().Chunk(0) != partialBefore.Chunk(0) {
35+ t.Fatal("unchanged sealed chunks must retain identity across partial-tail views")
36+ }
37+}
38+
39+func BenchmarkHistoryView10KRowsSealedReuse(b *testing.B) {
40+ history := benchmarkHistory(b, 10_000, 120, 256)
41+ sealed := history.SealAndView()
42+ if sealed.Len() != 10_000 || sealed.ChunkCount() != 40 || sealed.Chunk(0) == nil {
43+ b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", sealed.Len(), sealed.ChunkCount())
44+ }
45+
46+ b.ReportAllocs()
47+ b.ResetTimer()
48+ for b.Loop() {
49+ benchmarkHistoryViewSink = history.View()
50+ }
51+ b.StopTimer()
52+ if got := benchmarkHistoryViewSink; got.Len() != sealed.Len() || got.Chunk(0) != sealed.Chunk(0) {
53+ b.Fatal("unchanged sealed history chunks were not reused")
54+ }
55+ b.ReportMetric(float64(sealed.ChunkCount()), "sealedchunks/view")
56+}
57+
58+func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) {
59+ history := benchmarkHistory(b, 10_000, 120, 256)
60+ before := history.View()
61+ if before.Len() != 10_000 || before.ChunkCount() != 40 || before.Chunk(0) == nil {
62+ b.Fatalf("invalid partial-tail history fixture: rows=%d chunks=%d", before.Len(), before.ChunkCount())
63+ }
64+
65+ b.ReportAllocs()
66+ b.ResetTimer()
67+ for b.Loop() {
68+ benchmarkHistoryViewSink = history.View()
69+ }
70+ b.StopTimer()
71+ if got := benchmarkHistoryViewSink; got.Len() != before.Len() || got.Chunk(0) != before.Chunk(0) {
72+ b.Fatal("partial-tail capture did not reuse its sealed chunks")
73+ }
74+ b.ReportMetric(float64(10_000%256), "tailrows/view")
75+}
76+
77+func BenchmarkMarshalHistoryChunk256x120(b *testing.B) {
78+ history := benchmarkHistory(b, 256, 120, 256)
79+ chunk := history.SealAndView().Chunk(0)
80+ if chunk == nil {
81+ b.Fatal("invalid history chunk fixture")
82+ }
83+
84+ b.ReportAllocs()
85+ b.ResetTimer()
86+ for b.Loop() {
87+ var err error
88+ benchmarkHistoryBlobSink, err = MarshalHistoryChunk(chunk)
89+ if err != nil {
90+ b.Fatal(err)
91+ }
92+ }
93+ b.StopTimer()
94+ if _, err := PreflightHistoryBlob(benchmarkHistoryBlobSink); err != nil {
95+ b.Fatalf("encoded history chunk is invalid: %v", err)
96+ }
97+ b.SetBytes(int64(len(benchmarkHistoryBlobSink)))
98+}
99+
100+func BenchmarkUnmarshalHistoryChunk256x120(b *testing.B) {
101+ history := benchmarkHistory(b, 256, 120, 256)
102+ blob, err := MarshalHistoryChunk(history.SealAndView().Chunk(0))
103+ if err != nil {
104+ b.Fatal(err)
105+ }
106+ if stats, err := PreflightHistoryBlob(blob); err != nil || stats.Rows != 256 || stats.Cells != 256*120 {
107+ b.Fatalf("invalid encoded history chunk fixture: stats=%+v err=%v", stats, err)
108+ }
109+
110+ b.ReportAllocs()
111+ b.SetBytes(int64(len(blob)))
112+ b.ResetTimer()
113+ for b.Loop() {
114+ var err error
115+ benchmarkHistoryViewSink, err = UnmarshalHistory(blob)
116+ if err != nil {
117+ b.Fatal(err)
118+ }
119+ }
120+ b.StopTimer()
121+ if benchmarkHistoryViewSink.Len() != 256 || benchmarkHistoryViewSink.ChunkCount() != 1 {
122+ b.Fatal("decoded history chunk lost rows")
123+ }
124+}
125+
126+func BenchmarkMarshalSealedHistory10KRows(b *testing.B) {
127+ history := benchmarkHistory(b, 10_000, 120, 256)
128+ view := history.SealAndView()
129+ if view.Len() != 10_000 || view.ChunkCount() != 40 {
130+ b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", view.Len(), view.ChunkCount())
131+ }
132+
133+ b.ReportAllocs()
134+ b.ResetTimer()
135+ for b.Loop() {
136+ var err error
137+ benchmarkHistoryBlobsSink, benchmarkHistoryBlobSink, err = MarshalSealedHistory(view)
138+ if err != nil {
139+ b.Fatal(err)
140+ }
141+ }
142+ b.StopTimer()
143+ if len(benchmarkHistoryBlobsSink) != view.ChunkCount() || len(benchmarkHistoryBlobSink) == 0 {
144+ b.Fatal("sealed-history encoder produced an incomplete capture")
145+ }
146+ b.ReportMetric(float64(len(benchmarkHistoryBlobsSink)), "sealedchunks/op")
147+}
148+
149+func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History {
150+ t.Helper()
151+ history := NewHistory(HistoryConfig{MaxRows: rows, ChunkRows: chunkRows})
152+ for row := range rows {
153+ cells := make([]renderer.Cell, width)
154+ for col := range cells {
155+ cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)}
156+ }
157+ history.Append(cells)
158+ }
159+ return history
160+}
4161
5162 func BenchmarkScreenShellRedrawBurst(b *testing.B) {
6163 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")
A pkg/vt/history.go +163 -0
@@ -0,0 +1,163 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// HistoryConfig controls the bounded terminal history retained by a Screen.
6+type HistoryConfig struct {
7+ MaxRows int
8+ ChunkRows int
9+}
10+
11+// History stores terminal rows in immutable chunks. It is intended to be
12+// mutated by the owner of a Screen; views are safe to retain after later
13+// appends.
14+type History struct {
15+ maxRows int
16+ chunkRows int
17+ chunks []*HistoryChunk
18+ tail [][]renderer.Cell
19+ rows int
20+}
21+
22+// HistoryChunk is an immutable group of history rows. Its identity is stable
23+// and can be used by consumers to avoid copying unchanged sealed chunks.
24+type HistoryChunk struct {
25+ rows [][]renderer.Cell
26+}
27+
28+// HistoryView is an immutable snapshot of history. Row returns a copy so the
29+// storage behind a sealed chunk remains owned by VT.
30+type HistoryView struct {
31+ chunks []*HistoryChunk
32+ rows int
33+}
34+
35+func NewHistory(config HistoryConfig) *History {
36+ if config.MaxRows <= 0 {
37+ return &History{}
38+ }
39+ chunkRows := config.ChunkRows
40+ if chunkRows <= 0 {
41+ chunkRows = 256
42+ }
43+ chunkRows = min(chunkRows, 256)
44+ chunkRows = min(chunkRows, config.MaxRows)
45+ return &History{maxRows: config.MaxRows, chunkRows: chunkRows}
46+}
47+
48+// Append records a copy of row. Once a chunk is full it is sealed forever.
49+func (h *History) Append(row []renderer.Cell) {
50+ if h == nil || h.maxRows == 0 {
51+ return
52+ }
53+ h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
54+ h.rows++
55+ if len(h.tail) == h.chunkRows {
56+ h.sealTail()
57+ }
58+ h.evict()
59+}
60+
61+func (h *History) sealTail() {
62+ if len(h.tail) == 0 {
63+ return
64+ }
65+ h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail})
66+ h.tail = nil
67+}
68+
69+func (h *History) evict() {
70+ for h.rows > h.maxRows && len(h.chunks) > 0 {
71+ evicted := h.chunks[0]
72+ h.rows -= len(evicted.rows)
73+ copy(h.chunks, h.chunks[1:])
74+ h.chunks[len(h.chunks)-1] = nil
75+ h.chunks = h.chunks[:len(h.chunks)-1]
76+ }
77+}
78+
79+// SealAndView rotates the mutable tail into an immutable chunk, then captures
80+// the sealed chunks by identity. Callers must synchronize access to History.
81+func (h *History) SealAndView() HistoryView {
82+ if h != nil {
83+ h.sealTail()
84+ }
85+ return h.View()
86+}
87+
88+// View captures the current history. Sealed chunks are shared by identity; a
89+// partially-filled tail is copied into a new immutable chunk for this view.
90+func (h *History) View() HistoryView {
91+ if h == nil || h.rows == 0 {
92+ return HistoryView{}
93+ }
94+ chunks := append([]*HistoryChunk(nil), h.chunks...)
95+ if len(h.tail) > 0 {
96+ rows := make([][]renderer.Cell, len(h.tail))
97+ for i, row := range h.tail {
98+ rows[i] = append([]renderer.Cell(nil), row...)
99+ }
100+ chunks = append(chunks, &HistoryChunk{rows: rows})
101+ }
102+ return HistoryView{chunks: chunks, rows: h.rows}
103+}
104+
105+// Len returns the currently retained history row count.
106+func (h *History) Len() int {
107+ if h == nil {
108+ return 0
109+ }
110+ return h.rows
111+}
112+
113+// Cap returns the configured bounded row capacity.
114+func (h *History) Cap() int {
115+ if h == nil {
116+ return 0
117+ }
118+ return h.maxRows
119+}
120+
121+func (v HistoryView) Len() int { return v.rows }
122+func (v HistoryView) ChunkCount() int { return len(v.chunks) }
123+
124+// Chunk returns the immutable chunk at i, or nil when i is out of range.
125+func (v HistoryView) Chunk(i int) *HistoryChunk {
126+ if i < 0 || i >= len(v.chunks) {
127+ return nil
128+ }
129+ return v.chunks[i]
130+}
131+
132+// Range calls yield for each row in oldest-first order. The row is borrowed
133+// immutable storage: yield must not mutate or retain it after returning.
134+func (v HistoryView) Range(yield func([]renderer.Cell) bool) {
135+ for _, chunk := range v.chunks {
136+ for _, row := range chunk.rows {
137+ if !yield(row) {
138+ return
139+ }
140+ }
141+ }
142+}
143+
144+// Row returns a copy of the row at i, or nil when i is out of range.
145+func (v HistoryView) Row(i int) []renderer.Cell {
146+ return append([]renderer.Cell(nil), v.BorrowedRow(i)...)
147+}
148+
149+// BorrowedRow returns immutable storage for the row at i, or nil when i is out
150+// of range. The caller must not mutate or retain the result after it no longer
151+// retains v. Consumers that need ownership should use Row instead.
152+func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
153+ if i < 0 {
154+ return nil
155+ }
156+ for _, chunk := range v.chunks {
157+ if i < len(chunk.rows) {
158+ return chunk.rows[i]
159+ }
160+ i -= len(chunk.rows)
161+ }
162+ return nil
163+}
A pkg/vt/history_acceptance_test.go +108 -0
@@ -0,0 +1,108 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *testing.T) {
10+ indexed := renderer.Style{
11+ Bold: true,
12+ Italic: true,
13+ Inverse: true,
14+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
15+ Foreground: 196,
16+ Background: 17,
17+ HasUnderlineColor: true,
18+ UnderlineColor: 203,
19+ }
20+ rgb := renderer.Style{
21+ Bold: true,
22+ Italic: true,
23+ Inverse: true,
24+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
25+ HasForegroundRGB: true,
26+ ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
27+ HasBackgroundRGB: true,
28+ BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6},
29+ HasUnderlineColorRGB: true,
30+ UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9},
31+ }
32+ underlines := []renderer.UnderlineStyle{
33+ renderer.UnderlineNone,
34+ renderer.UnderlineSingle,
35+ renderer.UnderlineDouble,
36+ renderer.UnderlineCurly,
37+ renderer.UnderlineDotted,
38+ renderer.UnderlineDashed,
39+ }
40+ makeRow := func(r rune, style renderer.Style) []renderer.Cell {
41+ style.UnderlineStyle = underlines[int(r)%len(underlines)]
42+ return []renderer.Cell{
43+ {Rune: r, Style: style},
44+ {Rune: ' ', Style: renderer.DefaultStyle()},
45+ {Rune: '界', Style: style},
46+ {Continuation: true, Style: style},
47+ }
48+ }
49+
50+ tests := []struct {
51+ name string
52+ run func(*testing.T)
53+ }{
54+ {
55+ name: "history view retains sealed chunks and mutable tail",
56+ run: func(t *testing.T) {
57+ history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
58+ // A through F exercise every underline style; G remains in the
59+ // mutable tail so both history ownership paths are encoded.
60+ want := [][]renderer.Cell{
61+ makeRow('A', indexed), makeRow('B', rgb), makeRow('C', indexed),
62+ makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed),
63+ }
64+ for _, row := range want {
65+ history.Append(row)
66+ }
67+ if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks {
68+ t.Fatalf("chunk count = %d, want three sealed chunks plus copied tail %d", got, wantChunks)
69+ }
70+ encoded, err := MarshalHistory(history.View())
71+ if err != nil {
72+ t.Fatalf("MarshalHistory: %v", err)
73+ }
74+ got, err := UnmarshalHistory(encoded)
75+ if err != nil {
76+ t.Fatalf("UnmarshalHistory: %v", err)
77+ }
78+ assertHistoryRowsEqual(t, got, want)
79+ },
80+ },
81+ {
82+ name: "visible frame retains blanks wide heads continuations and styles",
83+ run: func(t *testing.T) {
84+ want := renderer.NewFrame(4, 2)
85+ copy(want.Row(0), makeRow('D', rgb))
86+ copy(want.Row(1), makeRow('E', indexed))
87+ encoded, err := MarshalVisible(want)
88+ if err != nil {
89+ t.Fatalf("MarshalVisible: %v", err)
90+ }
91+ got, err := UnmarshalVisible(encoded)
92+ if err != nil {
93+ t.Fatalf("UnmarshalVisible: %v", err)
94+ }
95+ for y := range want.Height {
96+ for x, cell := range want.Row(y) {
97+ if !got.At(x, y).Equal(cell) {
98+ t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, got.At(x, y), cell)
99+ }
100+ }
101+ }
102+ },
103+ },
104+ }
105+ for _, tt := range tests {
106+ t.Run(tt.name, tt.run)
107+ }
108+}
A pkg/vt/history_codec.go +304 -0
@@ -0,0 +1,304 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "fmt"
7+ "math"
8+ "unicode/utf8"
9+
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+const (
14+ historyMagic = "VTH1"
15+ historyVersion = 1
16+ historyCellBytes = 41
17+ maxHistoryChunkRows = 256
18+
19+ // The daemon retains 10,000 history rows. Support a 20% row margin and
20+ // 160-cell rows, which leaves headroom above the representative 10k×120
21+ // terminal while bounding snapshot allocation.
22+ maxHistoryChunks = 12_000
23+ maxHistoryRows = 12_000
24+ maxHistoryRowCells = 160
25+ maxHistoryCells = maxHistoryRows * maxHistoryRowCells
26+
27+ maxHistoryDecodeStyles = maxHistoryCells
28+ maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
29+)
30+
31+var errInvalidHistory = errors.New("invalid history payload")
32+
33+// MarshalHistory encodes a HistoryView in a deterministic, self-contained
34+// format. It preserves chunk boundaries as well as every Cell and Style field.
35+func MarshalHistory(view HistoryView) ([]byte, error) {
36+ if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks {
37+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
38+ }
39+ stats := historyDecodeStats{}
40+ for _, chunk := range view.chunks {
41+ if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows ||
42+ !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) {
43+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
44+ }
45+ for _, row := range chunk.rows {
46+ cellCount := uint64(len(row))
47+ cellBytes, ok := historyCellByteCount(cellCount)
48+ if cellCount > maxHistoryRowCells || !ok ||
49+ !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||
50+ !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||
51+ !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
52+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
53+ }
54+ }
55+ }
56+
57+ out := make([]byte, 0, 9)
58+ out = append(out, historyMagic...)
59+ out = append(out, historyVersion)
60+ out = appendUint32(out, uint32(len(view.chunks)))
61+ for _, chunk := range view.chunks {
62+ if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows {
63+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
64+ }
65+ out = appendUint32(out, uint32(len(chunk.rows)))
66+ for _, row := range chunk.rows {
67+ if uint64(len(row)) > math.MaxUint32 {
68+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
69+ }
70+ out = appendUint32(out, uint32(len(row)))
71+ for _, cell := range row {
72+ if !validHistoryCell(cell) {
73+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
74+ }
75+ out = appendHistoryCell(out, cell)
76+ }
77+ }
78+ }
79+ return out, nil
80+}
81+
82+func historyViewRowCount(view HistoryView) int {
83+ n := 0
84+ for _, chunk := range view.chunks {
85+ if chunk != nil && len(chunk.rows) <= math.MaxInt-n {
86+ n += len(chunk.rows)
87+ continue
88+ }
89+ return -1
90+ }
91+ return n
92+}
93+
94+func appendUint32(dst []byte, v uint32) []byte {
95+ return binary.BigEndian.AppendUint32(dst, v)
96+}
97+
98+func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
99+ style := cell.Style
100+ dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune))
101+ flags := byte(0)
102+ if cell.Continuation {
103+ flags |= 1 << 0
104+ }
105+ if style.Bold {
106+ flags |= 1 << 1
107+ }
108+ if style.Italic {
109+ flags |= 1 << 2
110+ }
111+ if style.Inverse {
112+ flags |= 1 << 3
113+ }
114+ if style.HasForegroundRGB {
115+ flags |= 1 << 4
116+ }
117+ if style.HasBackgroundRGB {
118+ flags |= 1 << 5
119+ }
120+ if style.HasUnderlineColor {
121+ flags |= 1 << 6
122+ }
123+ if style.HasUnderlineColorRGB {
124+ flags |= 1 << 7
125+ }
126+ dst = append(dst, flags)
127+ dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs))
128+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground)))
129+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background)))
130+ dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B)
131+ dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B)
132+ dst = append(dst, byte(style.UnderlineStyle))
133+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor)))
134+ return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
135+}
136+
137+// UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
138+// malformed declarations, truncated data, and trailing bytes.
139+func UnmarshalHistory(data []byte) (HistoryView, error) {
140+ if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
141+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
142+ }
143+ if _, ok := preflightHistory(data[5:]); !ok {
144+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
145+ }
146+
147+ p := historyParser{data: data[5:]}
148+ chunkCount, ok := p.uint32()
149+ if !ok || uint64(chunkCount) > uint64(len(p.data))/4 {
150+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
151+ }
152+ chunks := make([]*HistoryChunk, 0, chunkCount)
153+ rowsTotal := 0
154+ for range chunkCount {
155+ rowCount, ok := p.uint32()
156+ if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 {
157+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
158+ }
159+ if rowsTotal > math.MaxInt-int(rowCount) {
160+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
161+ }
162+ rows := make([][]renderer.Cell, 0, rowCount)
163+ for range rowCount {
164+ cellCount, ok := p.uint32()
165+ if !ok || uint64(cellCount) > maxHistoryRowCells || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) {
166+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
167+ }
168+ row := make([]renderer.Cell, cellCount)
169+ for i := range row {
170+ cell, ok := p.cell()
171+ if !ok || !validHistoryCell(cell) {
172+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
173+ }
174+ row[i] = cell
175+ }
176+ rows = append(rows, row)
177+ }
178+ rowsTotal += int(rowCount)
179+ chunks = append(chunks, &HistoryChunk{rows: rows})
180+ }
181+ if len(p.data) != 0 {
182+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
183+ }
184+ return HistoryView{chunks: chunks, rows: rowsTotal}, nil
185+}
186+
187+type historyDecodeStats struct {
188+ chunks uint64
189+ rows uint64
190+ cells uint64
191+ styles uint64
192+ bytes uint64
193+}
194+
195+// preflightHistory validates declarations and aggregate budgets without
196+// allocating decoded history. Its totals can be composed by snapshot decoders.
197+func preflightHistory(data []byte) (historyDecodeStats, bool) {
198+ p := historyParser{data: data}
199+ chunkCount, ok := p.uint32()
200+ if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 {
201+ return historyDecodeStats{}, false
202+ }
203+ stats := historyDecodeStats{chunks: uint64(chunkCount)}
204+ for range chunkCount {
205+ rowCount, ok := p.uint32()
206+ if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 || !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
207+ return historyDecodeStats{}, false
208+ }
209+ for range rowCount {
210+ cellCount, ok := p.uint32()
211+ cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
212+ if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes || uint64(cellCount) > uint64(len(p.data))/historyCellBytes {
213+ return historyDecodeStats{}, false
214+ }
215+ if !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||
216+ !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||
217+ !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
218+ return historyDecodeStats{}, false
219+ }
220+ p.data = p.data[int(cellBytes):]
221+ }
222+ }
223+ return stats, len(p.data) == 0
224+}
225+
226+func historyCellByteCount(cellCount uint64) (uint64, bool) {
227+ if cellCount > math.MaxUint64/historyCellBytes {
228+ return 0, false
229+ }
230+ return cellCount * historyCellBytes, true
231+}
232+
233+func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {
234+ if *total > limit || amount > limit-*total {
235+ return false
236+ }
237+ *total += amount
238+ return true
239+}
240+
241+type historyParser struct{ data []byte }
242+
243+func (p *historyParser) uint32() (uint32, bool) {
244+ if len(p.data) < 4 {
245+ return 0, false
246+ }
247+ v := binary.BigEndian.Uint32(p.data)
248+ p.data = p.data[4:]
249+ return v, true
250+}
251+
252+func (p *historyParser) cell() (renderer.Cell, bool) {
253+ if len(p.data) < historyCellBytes {
254+ return renderer.Cell{}, false
255+ }
256+ b := p.data[:historyCellBytes]
257+ p.data = p.data[historyCellBytes:]
258+ foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15]))
259+ if !ok {
260+ return renderer.Cell{}, false
261+ }
262+ background, ok := historyInt(binary.BigEndian.Uint64(b[15:23]))
263+ if !ok {
264+ return renderer.Cell{}, false
265+ }
266+ underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38]))
267+ if !ok {
268+ return renderer.Cell{}, false
269+ }
270+ flags := b[4]
271+ return renderer.Cell{
272+ Rune: rune(binary.BigEndian.Uint32(b[0:4])),
273+ Continuation: flags&1 != 0,
274+ Style: renderer.Style{
275+ Bold: flags&(1<<1) != 0,
276+ Italic: flags&(1<<2) != 0,
277+ Inverse: flags&(1<<3) != 0,
278+ Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])),
279+ Foreground: foreground,
280+ Background: background,
281+ HasForegroundRGB: flags&(1<<4) != 0,
282+ ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]},
283+ HasBackgroundRGB: flags&(1<<5) != 0,
284+ BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]},
285+ UnderlineStyle: renderer.UnderlineStyle(b[29]),
286+ HasUnderlineColor: flags&(1<<6) != 0,
287+ UnderlineColor: underlineColor,
288+ HasUnderlineColorRGB: flags&(1<<7) != 0,
289+ UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]},
290+ },
291+ }, true
292+}
293+
294+func historyInt(raw uint64) (int, bool) {
295+ v := int64(raw)
296+ if int64(int(v)) != v {
297+ return 0, false
298+ }
299+ return int(v), true
300+}
301+
302+func validHistoryCell(cell renderer.Cell) bool {
303+ return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed
304+}
A pkg/vt/history_codec_test.go +246 -0
@@ -0,0 +1,246 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
11+ indexed := renderer.Style{
12+ Bold: true,
13+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrStrikethrough,
14+ Foreground: 123,
15+ Background: 45,
16+ UnderlineStyle: renderer.UnderlineDashed,
17+ HasUnderlineColor: true,
18+ UnderlineColor: 201,
19+ }
20+ rgb := renderer.Style{
21+ Italic: true,
22+ Inverse: true,
23+ Attrs: renderer.AttrBlink,
24+ HasForegroundRGB: true,
25+ ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
26+ HasBackgroundRGB: true,
27+ BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6},
28+ UnderlineStyle: renderer.UnderlineCurly,
29+ HasUnderlineColorRGB: true,
30+ UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9},
31+ }
32+ tests := []struct {
33+ name string
34+ rows [][]renderer.Cell
35+ }{
36+ {
37+ name: "indexed RGB blank and wide continuation cells are exact",
38+ rows: [][]renderer.Cell{
39+ {
40+ {Rune: 'I', Style: indexed},
41+ {Rune: 'R', Style: rgb},
42+ {Rune: ' ', Style: renderer.DefaultStyle()},
43+ {Rune: '界', Style: rgb},
44+ {Continuation: true, Style: rgb},
45+ },
46+ {
47+ {Rune: ' ', Style: renderer.DefaultStyle()},
48+ {Rune: 'X', Style: indexed},
49+ },
50+ },
51+ },
52+ }
53+
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
57+ for _, row := range tt.rows {
58+ history.Append(row)
59+ }
60+
61+ encoded, err := MarshalHistory(history.View())
62+ if err != nil {
63+ t.Fatalf("marshal history: %v", err)
64+ }
65+ decoded, err := UnmarshalHistory(encoded)
66+ if err != nil {
67+ t.Fatalf("unmarshal history: %v", err)
68+ }
69+ assertHistoryRowsEqual(t, decoded, tt.rows)
70+ })
71+ }
72+}
73+
74+func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
75+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})
76+ history.Append(historyRow("AAAA"))
77+ history.Append(historyRow("BBBB"))
78+ encoded, err := MarshalHistory(history.View())
79+ if err != nil {
80+ t.Fatalf("marshal history: %v", err)
81+ }
82+
83+ tests := []struct {
84+ name string
85+ data []byte
86+ }{
87+ {name: "empty", data: nil},
88+ {name: "truncated prefix", data: encoded[:len(encoded)-1]},
89+ {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)},
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ if _, err := UnmarshalHistory(tt.data); err == nil {
95+ t.Fatal("unmarshal accepted malformed history payload")
96+ }
97+ })
98+ }
99+}
100+
101+func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) {
102+ const (
103+ rows = 10_000
104+ width = 120
105+ )
106+
107+ encoded, err := MarshalHistory(historyViewWithDimensions(rows, width))
108+ if err != nil {
109+ t.Fatalf("marshal representative history: %v", err)
110+ }
111+ decoded, err := UnmarshalHistory(encoded)
112+ if err != nil {
113+ t.Fatalf("unmarshal representative history: %v", err)
114+ }
115+ if got := decoded.Len(); got != rows {
116+ t.Fatalf("decoded rows = %d, want %d", got, rows)
117+ }
118+ for _, rowIndex := range []int{0, rows - 1} {
119+ if got := len(decoded.Row(rowIndex)); got != width {
120+ t.Fatalf("decoded row %d width = %d, want %d", rowIndex, got, width)
121+ }
122+ }
123+}
124+
125+func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
126+ const (
127+ supportedRows = 12_000
128+ supportedWidth = 160
129+ )
130+ tests := []struct {
131+ name string
132+ view HistoryView
133+ data []byte
134+ }{
135+ {
136+ name: "too many rows",
137+ view: historyViewWithDimensions(supportedRows+1, 0),
138+ data: historyPayloadWithDimensions(supportedRows+1, 0),
139+ },
140+ {
141+ name: "too many cells in a row",
142+ view: historyViewWithDimensions(1, supportedWidth+1),
143+ data: historyPayloadWithDimensions(1, supportedWidth+1),
144+ },
145+ }
146+
147+ for _, tt := range tests {
148+ t.Run(tt.name, func(t *testing.T) {
149+ if _, err := MarshalHistory(tt.view); err == nil {
150+ t.Fatal("marshal accepted dimensions beyond the supported limits")
151+ }
152+ if _, err := UnmarshalHistory(tt.data); err == nil {
153+ t.Fatal("unmarshal accepted dimensions beyond the supported limits")
154+ }
155+ })
156+ }
157+}
158+
159+func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {
160+ tests := []struct {
161+ name string
162+ chunkCount int
163+ rowCount int
164+ }{
165+ {name: "more than twelve thousand chunks", chunkCount: 12_001, rowCount: 1},
166+ {name: "more than twelve thousand rows", chunkCount: 47, rowCount: maxHistoryChunkRows},
167+ }
168+
169+ for _, tt := range tests {
170+ t.Run(tt.name, func(t *testing.T) {
171+ if _, err := UnmarshalHistory(hostileHistoryDeclarations(tt.chunkCount, tt.rowCount)); err == nil {
172+ t.Fatal("unmarshal accepted resource-exhausting history declarations")
173+ }
174+ })
175+ }
176+}
177+
178+func historyViewWithDimensions(rowCount, width int) HistoryView {
179+ totalRows := rowCount
180+ row := make([]renderer.Cell, width)
181+ for i := range row {
182+ row[i].Rune = 'x'
183+ }
184+ chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows)
185+ for rowCount > 0 {
186+ chunkRows := min(rowCount, maxHistoryChunkRows)
187+ rows := make([][]renderer.Cell, chunkRows)
188+ for i := range rows {
189+ rows[i] = row
190+ }
191+ chunks = append(chunks, &HistoryChunk{rows: rows})
192+ rowCount -= chunkRows
193+ }
194+ return HistoryView{chunks: chunks, rows: totalRows}
195+}
196+
197+func historyPayloadWithDimensions(rowCount, width int) []byte {
198+ data := make([]byte, 9)
199+ copy(data, historyMagic)
200+ data[4] = historyVersion
201+ chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows
202+ binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
203+ for rowCount > 0 {
204+ chunkRows := min(rowCount, maxHistoryChunkRows)
205+ data = binary.BigEndian.AppendUint32(data, uint32(chunkRows))
206+ for range chunkRows {
207+ data = binary.BigEndian.AppendUint32(data, uint32(width))
208+ //nolint:makezero // The header prefix is retained while zeroed cell records are appended.
209+ data = append(data, make([]byte, width*historyCellBytes)...)
210+ }
211+ rowCount -= chunkRows
212+ }
213+ return data
214+}
215+
216+func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
217+ data := make([]byte, 9, 9+chunkCount*(4+rowCount*4))
218+ copy(data, historyMagic)
219+ data[4] = historyVersion
220+ binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
221+ for range chunkCount {
222+ data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
223+ for range rowCount {
224+ data = binary.BigEndian.AppendUint32(data, 0)
225+ }
226+ }
227+ return data
228+}
229+
230+func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) {
231+ t.Helper()
232+ if got := view.Len(); got != len(want) {
233+ t.Fatalf("row count = %d, want %d", got, len(want))
234+ }
235+ for y, wantRow := range want {
236+ gotRow := view.Row(y)
237+ if len(gotRow) != len(wantRow) {
238+ t.Fatalf("row %d width = %d, want %d", y, len(gotRow), len(wantRow))
239+ }
240+ for x, wantCell := range wantRow {
241+ if !gotRow[x].Equal(wantCell) {
242+ t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, gotRow[x], wantCell)
243+ }
244+ }
245+ }
246+}
A pkg/vt/history_test.go +173 -0
@@ -0,0 +1,173 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ maxRows int
13+ chunkRows int
14+ rows []string
15+ want []string
16+ }{
17+ {
18+ name: "oldest chunks are evicted before newer chunks",
19+ maxRows: 4,
20+ chunkRows: 2,
21+ rows: []string{"aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"},
22+ want: []string{"cccc", "dddd", "eeee", "ffff"},
23+ },
24+ {
25+ name: "nonmultiple capacity evicts a whole oldest immutable chunk",
26+ maxRows: 5,
27+ chunkRows: 2,
28+ rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"},
29+ want: []string{"2222", "3333", "4444", "5555"},
30+ },
31+ {
32+ name: "rows preserve append order across chunk boundaries",
33+ maxRows: 4,
34+ chunkRows: 2,
35+ rows: []string{"0000", "1111", "2222", "3333"},
36+ want: []string{"0000", "1111", "2222", "3333"},
37+ },
38+ }
39+
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows})
43+ for _, text := range tt.rows {
44+ history.Append(historyRow(text))
45+ }
46+
47+ view := history.View()
48+ if got := view.Len(); got > tt.maxRows {
49+ t.Fatalf("retained row count = %d, exceeds capacity %d", got, tt.maxRows)
50+ }
51+ if got := historyViewTexts(view); !equalStrings(got, tt.want) {
52+ t.Fatalf("view rows = %#v, want %#v", got, tt.want)
53+ }
54+ if got, want := view.ChunkCount(), (len(tt.want)+1)/2; got != want {
55+ t.Fatalf("chunk count = %d, want %d", got, want)
56+ }
57+ })
58+ }
59+}
60+
61+func TestHistoryTailRotationAndStableViews(t *testing.T) {
62+ tests := []struct {
63+ name string
64+ }{
65+ {name: "a full tail seals and a later append cannot change an existing view"},
66+ {name: "rows passed to append are copied before the screen recycles them"},
67+ }
68+
69+ for _, tt := range tests {
70+ t.Run(tt.name, func(t *testing.T) {
71+ history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2})
72+ first := historyRow("AAAA")
73+ history.Append(first)
74+ history.Append(historyRow("BBBB"))
75+ view := history.View()
76+ if got, want := view.ChunkCount(), 1; got != want {
77+ t.Fatalf("sealed chunk count = %d, want %d", got, want)
78+ }
79+
80+ first[0].Rune = 'X'
81+ history.Append(historyRow("CCCC"))
82+
83+ if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) {
84+ t.Fatalf("stable view rows = %#v, want %#v", got, want)
85+ }
86+ if got, want := historyViewTexts(history.View()), []string{"AAAA", "BBBB", "CCCC"}; !equalStrings(got, want) {
87+ t.Fatalf("current view rows = %#v, want %#v", got, want)
88+ }
89+ })
90+ }
91+}
92+
93+func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) {
94+ tests := []struct {
95+ name string
96+ }{
97+ {name: "unchanged sealed chunks are shared by successive views"},
98+ }
99+
100+ for _, tt := range tests {
101+ t.Run(tt.name, func(t *testing.T) {
102+ history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
103+ history.Append(historyRow("AAAA"))
104+ history.Append(historyRow("BBBB"))
105+ before := history.View()
106+
107+ history.Append(historyRow("CCCC"))
108+ after := history.View()
109+
110+ if before.ChunkCount() == 0 || after.ChunkCount() == 0 {
111+ t.Fatal("sealed chunk missing from view")
112+ }
113+ if before.Chunk(0) != after.Chunk(0) {
114+ t.Fatal("unchanged sealed chunk was copied instead of reused")
115+ }
116+ })
117+ }
118+}
119+
120+func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) {
121+ tests := []struct {
122+ name string
123+ write []byte
124+ want []string
125+ }{
126+ {
127+ name: "primary screen evictions enter terminal history",
128+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
129+ want: []string{"AAAA"},
130+ },
131+ {
132+ name: "alternate screen scrolling never enters terminal history",
133+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"),
134+ want: []string{"AAAA"},
135+ },
136+ }
137+
138+ for _, tt := range tests {
139+ t.Run(tt.name, func(t *testing.T) {
140+ screen := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2})
141+ screen.Write(tt.write)
142+
143+ if got := historyViewTexts(screen.History().View()); !equalStrings(got, tt.want) {
144+ t.Fatalf("terminal history rows = %#v, want %#v", got, tt.want)
145+ }
146+ })
147+ }
148+}
149+
150+func historyRow(text string) []renderer.Cell {
151+ row := make([]renderer.Cell, len(text))
152+ for i, r := range text {
153+ row[i] = renderer.BlankCell()
154+ row[i].Rune = r
155+ }
156+ return row
157+}
158+
159+func historyViewTexts(view HistoryView) []string {
160+ rows := make([]string, view.Len())
161+ for i := range rows {
162+ rows[i] = rowText(view.Row(i))
163+ }
164+ return rows
165+}
166+
167+func rowText(row []renderer.Cell) string {
168+ runes := make([]rune, len(row))
169+ for i, cell := range row {
170+ runes[i] = cell.Rune
171+ }
172+ return string(runes)
173+}
M pkg/vt/screen.go +26 -7
@@ -85,6 +85,8 @@ type Screen struct {
8585 // never invoke it. Nil disables it.
8686 OnClipboard func(b64 string)
8787
88+ history *History
89+
8890 defaultFG renderer.RGB
8991 defaultBG renderer.RGB
9092 defaultColorsKnown bool
@@ -128,15 +130,23 @@ func NewScreen(width, height int) *Screen {
128130 return s
129131 }
130132
133+// NewScreenWithHistory creates a screen that records rows evicted from its
134+// primary screen into bounded immutable terminal history.
135+func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
136+ s := NewScreen(width, height)
137+ s.history = NewHistory(config)
138+ return s
139+}
140+
141+// History returns this screen's terminal history, or nil when history was not
142+// configured with NewScreenWithHistory.
143+func (s *Screen) History() *History { return s.history }
144+
131145 func (s *Screen) Resize(width, height int) {
132146 if width == s.Frame.Width && height == s.Frame.Height {
133147 return
134148 }
135- evict := func(row []renderer.Cell) {
136- if s.OnLineEvicted != nil {
137- s.OnLineEvicted(append([]renderer.Cell(nil), row...))
138- }
139- }
149+ evict := s.recordEvicted
140150
141151 if s.alternate != nil {
142152 var shift int
@@ -580,11 +590,20 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) {
580590 }
581591
582592 func (s *Screen) emitLineEvicted(top, n int) {
583- if s.OnLineEvicted == nil || s.alternate != nil {
593+ if s.alternate != nil {
584594 return
585595 }
586596 for y := top; y < top+n; y++ {
587- s.OnLineEvicted(append([]renderer.Cell(nil), s.Frame.Row(y)...))
597+ s.recordEvicted(s.Frame.Row(y))
598+ }
599+}
600+
601+func (s *Screen) recordEvicted(row []renderer.Cell) {
602+ if s.history != nil {
603+ s.history.Append(row)
604+ }
605+ if s.OnLineEvicted != nil {
606+ s.OnLineEvicted(append([]renderer.Cell(nil), row...))
588607 }
589608 }
590609
M pkg/vt/snapshot.go +206 -1
@@ -1,6 +1,193 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "math"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+const visibleMagic = "VTV1"
12+
13+// DecodeStats describes resources declared by one canonical VT blob.
14+type DecodeStats struct {
15+ Chunks uint64
16+ Rows uint64
17+ Cells uint64
18+ Styles uint64
19+ Bytes uint64
20+}
21+
22+// Add adds another preflight result with overflow checking.
23+func (s *DecodeStats) Add(other DecodeStats) bool {
24+ for _, totals := range [][2]*uint64{{&s.Chunks, &other.Chunks}, {&s.Rows, &other.Rows}, {&s.Cells, &other.Cells}, {&s.Styles, &other.Styles}, {&s.Bytes, &other.Bytes}} {
25+ if math.MaxUint64-*totals[0] < *totals[1] {
26+ return false
27+ }
28+ *totals[0] += *totals[1]
29+ }
30+ return true
31+}
32+
33+// PreflightHistoryBlob validates one self-contained history blob without
34+// allocating decoded rows.
35+func PreflightHistoryBlob(data []byte) (DecodeStats, error) {
36+ if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
37+ return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory)
38+ }
39+ stats, ok := preflightHistory(data[5:])
40+ if !ok {
41+ return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory)
42+ }
43+ return DecodeStats{Chunks: stats.chunks, Rows: stats.rows, Cells: stats.cells, Styles: stats.styles, Bytes: stats.bytes}, nil
44+}
45+
46+// MarshalHistoryChunk encodes one immutable chunk as a self-contained blob.
47+func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) {
48+ if chunk == nil {
49+ return nil, fmt.Errorf("marshal history chunk: %w", errInvalidHistory)
50+ }
51+ return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: len(chunk.rows)})
52+}
53+
54+// MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob.
55+func MarshalEmptyHistoryTail() ([]byte, error) { return MarshalHistory(HistoryView{}) }
56+
57+// MarshalSealedHistory serializes a SealAndView result as oldest-first,
58+// self-contained sealed blobs plus a mandatory empty canonical tail blob.
59+func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
60+ if view.rows != historyViewRowCount(view) {
61+ return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory)
62+ }
63+ sealed := make([][]byte, len(view.chunks))
64+ for i, chunk := range view.chunks {
65+ blob, err := MarshalHistoryChunk(chunk)
66+ if err != nil {
67+ return nil, nil, err
68+ }
69+ sealed[i] = blob
70+ }
71+ tail, err := MarshalEmptyHistoryTail()
72+ if err != nil {
73+ return nil, nil, err
74+ }
75+ return sealed, tail, nil
76+}
77+
78+// HistoryFromBlobs restores history directly from sealed, oldest-first blobs
79+// and the mandatory tail blob. It never feeds decoded rows through Append.
80+func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
81+ h := NewHistory(config)
82+ for _, blob := range sealed {
83+ view, err := UnmarshalHistory(blob)
84+ if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {
85+ return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
86+ }
87+ h.chunks = append(h.chunks, view.chunks[0])
88+ h.rows += len(view.chunks[0].rows)
89+ }
90+ view, err := UnmarshalHistory(tail)
91+ if err != nil || len(view.chunks) > 1 {
92+ return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)
93+ }
94+ if len(view.chunks) == 1 {
95+ h.tail = view.chunks[0].rows
96+ h.rows += len(h.tail)
97+ }
98+ h.evict()
99+ return h, nil
100+}
101+
102+// NewScreenWithRestoredHistory constructs a screen that owns history restored
103+// directly from canonical blobs. Call RestorePrimaryVisible to install a
104+// persisted primary frame before making the screen live.
105+func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, sealed [][]byte, tail []byte) (*Screen, error) {
106+ history, err := HistoryFromBlobs(config, sealed, tail)
107+ if err != nil {
108+ return nil, err
109+ }
110+ screen := NewScreenWithHistory(width, height, config)
111+ screen.history = history
112+ return screen, nil
113+}
114+
115+// MarshalVisible encodes the exact visible primary frame, including every
116+// blank, row width, and terminal cell attribute.
117+func MarshalVisible(frame renderer.Frame) ([]byte, error) {
118+ if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 {
119+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
120+ }
121+ cells := uint64(frame.Width) * uint64(frame.Height)
122+ bytes, ok := historyCellByteCount(cells)
123+ if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {
124+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
125+ }
126+ out := make([]byte, 0, 13+bytes)
127+ out = append(out, visibleMagic...)
128+ out = append(out, historyVersion)
129+ out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))
130+ out = binary.BigEndian.AppendUint32(out, uint32(frame.Height))
131+ for y := range frame.Height {
132+ for _, cell := range frame.Row(y) {
133+ if !validHistoryCell(cell) {
134+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
135+ }
136+ out = appendHistoryCell(out, cell)
137+ }
138+ }
139+ return out, nil
140+}
141+
142+// PreflightVisibleBlob validates dimensions and cell declarations without
143+// allocating a frame.
144+func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
145+ if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion {
146+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
147+ }
148+ width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))
149+ if width != 0 && height > math.MaxUint64/width {
150+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
151+ }
152+ cells := width * height
153+ bytes, ok := historyCellByteCount(cells)
154+ if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes {
155+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
156+ }
157+ p := historyParser{data: data[13:]}
158+ for range cells {
159+ cell, ok := p.cell()
160+ if !ok || !validHistoryCell(cell) {
161+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
162+ }
163+ }
164+ return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil
165+}
166+
167+// UnmarshalVisible decodes a preflighted exact visible frame.
168+func UnmarshalVisible(data []byte) (renderer.Frame, error) {
169+ if _, err := PreflightVisibleBlob(data); err != nil {
170+ return renderer.Frame{}, err
171+ }
172+ width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13]))
173+ frame := renderer.NewFrame(width, height)
174+ p := historyParser{data: data[13:]}
175+ for y := range height {
176+ for x := range width {
177+ cell, _ := p.cell()
178+ frame.Set(x, y, cell)
179+ }
180+ }
181+ return frame, nil
182+}
183+
184+// PrimaryVisibleFrame returns a deep copy of the exact visible primary frame.
185+func (s *Screen) PrimaryVisibleFrame() renderer.Frame {
186+ if s.alternate != nil {
187+ return s.alternate.frame.Clone()
188+ }
189+ return s.Frame.Clone()
190+}
4191
5192 // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows.
6193 // When the alternate screen is active, it snapshots the saved primary screen
@@ -16,3 +203,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell {
16203 }
17204 return rows
18205 }
206+
207+// RestorePrimaryVisible replaces the primary frame from an exact visible blob.
208+func (s *Screen) RestorePrimaryVisible(blob []byte) error {
209+ frame, err := UnmarshalVisible(blob)
210+ if err != nil {
211+ return err
212+ }
213+ // A persisted primary frame is authoritative: restoring it directly keeps
214+ // exact row widths and height even when a collapsed layout chose a smaller
215+ // initial PTY size. A subsequent resize reconciles it with live geometry.
216+ if s.alternate != nil {
217+ s.alternate.frame = frame
218+ } else {
219+ s.Frame = frame
220+ }
221+ s.fullRedraw()
222+ return nil
223+}

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 12

Tags 11

↑↓ moveenter openesc close