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 (
7 "unicode"7 "unicode"
8 8
9 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
10 )11 )
11 12
12 // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits13 // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits
@@ -17,45 +18,49 @@ import (
17 const OSC52MaxPayloadBytes = 75_00018 const OSC52MaxPayloadBytes = 75_000
18 19
19 // Snapshot is the immutable scrollback-mode document: history followed by the20 // Snapshot is the immutable scrollback-mode document: history followed by the
20-// visible screen. History rows are immutable by Scrollback's append contract;21+// visible screen. VT HistoryView retains immutable chunks; the visible frame is
21-// the visible frame is cloned once when the snapshot is constructed.22+// cloned once when the snapshot is constructed.
22 type Snapshot struct {23 type Snapshot struct {
23- history HistoryView24+ history vt.HistoryView
24 screen renderer.Frame25 screen renderer.Frame
25 Width int26 Width int
26 Height int27 Height int
27 }28 }
28 29
29-// NewSnapshot freezes the current scrollback view and clones the visible screen.30+// NewSnapshot seals the current VT history tail, then freezes that immutable
30-func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {31+// view and clones the visible screen. Sealing prevents repeated copy entry from
31- var history HistoryView32+// cloning every row in a partial tail.
32- if sb != nil {33+func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
33- history = sb.View()34+ var history vt.HistoryView
35+ if historySource != nil {
36+ history = historySource.SealAndView()
34 }37 }
35 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}38 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
36 }39 }
37 40
38 // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.41 // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.
39 // It is intended for callers that already have a complete document rather than42 // 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.
41 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {45 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
42- owned := make([][]renderer.Cell, len(rows))46+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256})
43- for i, row := range rows {47+ for _, row := range rows {
44- owned[i] = append([]renderer.Cell(nil), row...)48+ history.Append(row)
45 }49 }
46- return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height}50+ return Snapshot{history: history.SealAndView(), Width: width, Height: height}
47 }51 }
48 52
49 // Len returns the number of document rows.53 // Len returns the number of document rows.
50 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }54 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
51 55
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.
53 func (s Snapshot) Row(i int) []renderer.Cell {58 func (s Snapshot) Row(i int) []renderer.Cell {
54 if i < 0 {59 if i < 0 {
55 return nil60 return nil
56 }61 }
57 if i < s.history.Len() {62 if i < s.history.Len() {
58- return s.history.Row(i)63+ return s.history.BorrowedRow(i)
59 }64 }
60 i -= s.history.Len()65 i -= s.history.Len()
61 if i >= s.screen.Height {66 if i >= s.screen.Height {
@@ -64,6 +69,27 @@ func (s Snapshot) Row(i int) []renderer.Cell {
64 return s.screen.Row(i)69 return s.screen.Row(i)
65 }70 }
66 71
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+
67 // Mode stores per-client scrollback viewport and line-selection state.93 // Mode stores per-client scrollback viewport and line-selection state.
68 type SearchMatch struct {94 type SearchMatch struct {
69 Row int95 Row int
@@ -102,8 +128,7 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
102 }128 }
103 needle := lowerRunes(query)129 needle := lowerRunes(query)
104 matches := make([]SearchMatch, 0)130 matches := make([]SearchMatch, 0)
105- for row := range s.Len() {131+ s.rangeRows(func(row int, cells []renderer.Cell) bool {
106- cells := s.Row(row)
107 haystack, cellIndexes := searchableCells(cells)132 haystack, cellIndexes := searchableCells(cells)
108 var text string133 var text string
109 for start := 0; start+len(needle) <= len(haystack); {134 for start := 0; start+len(needle) <= len(haystack); {
@@ -122,7 +147,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
122 matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text})147 matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text})
123 start += len(needle)148 start += len(needle)
124 }149 }
125- }150+ return true
151+ })
126 return matches152 return matches
127 }153 }
128 154
M internal/usecase/copy/mode_test.go +128 -3
@@ -7,11 +7,30 @@ import (
7 "testing"7 "testing"
8 8
9 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 )12 )
12 13
13 var benchmarkSnapshotSink Snapshot14 var benchmarkSnapshotSink Snapshot
14 15
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+
15 func snapshot(lines []string, height int) Snapshot {34 func snapshot(lines []string, height int) Snapshot {
16 rows := make([][]renderer.Cell, len(lines))35 rows := make([][]renderer.Cell, len(lines))
17 for i, line := range lines {36 for i, line := range lines {
@@ -355,6 +374,24 @@ func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T)
355 require.Equal(t, 2, matches[0].End)374 require.Equal(t, 2, matches[0].End)
356 }375 }
357 376
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+
358 func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {395 func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
359 row := []renderer.Cell{396 row := []renderer.Cell{
360 {Rune: '界'},397 {Rune: '界'},
@@ -478,6 +515,94 @@ func TestOSC52FromBase64(t *testing.T) {
478 }515 }
479 }516 }
480 517
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+
481 func BenchmarkNewSnapshot10KRows(b *testing.B) {606 func BenchmarkNewSnapshot10KRows(b *testing.B) {
482 const (607 const (
483 width = 120608 width = 120
@@ -485,9 +610,9 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) {
485 historyRows = 10_000610 historyRows = 10_000
486 )611 )
487 612
488- scrollback := NewScrollback(historyRows)613+ history := newHistory(historyRows)
489 for range historyRows {614 for range historyRows {
490- scrollback.Append(row(strings.Repeat("x", width)))615+ history.Append(row(strings.Repeat("x", width)))
491 }616 }
492 screen := renderer.NewFrame(width, height)617 screen := renderer.NewFrame(width, height)
493 for y := range height {618 for y := range height {
@@ -497,7 +622,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) {
497 b.ReportAllocs()622 b.ReportAllocs()
498 b.ResetTimer()623 b.ResetTimer()
499 for b.Loop() {624 for b.Loop() {
500- benchmarkSnapshotSink = NewSnapshot(scrollback, screen)625+ benchmarkSnapshotSink = NewSnapshot(history, screen)
501 }626 }
502 }627 }
503 628
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) {
66 // Capture the live pane under pane.mu; all subsequent copy interaction uses66 // Capture the live pane under pane.mu; all subsequent copy interaction uses
67 // the immutable document while holding only copyMu.67 // the immutable document while holding only copyMu.
68 p.mu.Lock()68 p.mu.Lock()
69- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)69+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
70 p.mu.Unlock()70 p.mu.Unlock()
71 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {71 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
72 return72 return
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
@@ -54,7 +54,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
54 sess.client = ac54 sess.client = ac
55 ac.setSession(sess)55 ac.setSession(sess)
56 closing.mu.Lock()56 closing.mu.Lock()
57- document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame)57+ document := scopy.NewSnapshot(closing.history, closing.screen.Frame)
58 closing.mu.Unlock()58 closing.mu.Unlock()
59 ac.overlays.copyMu.Lock()59 ac.overlays.copyMu.Lock()
60 ac.overlays.copyPane = closing60 ac.overlays.copyPane = closing
@@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
94 tb := sess.activeTab()94 tb := sess.activeTab()
95 p := tb.focusedPane()95 p := tb.focusedPane()
96 p.mu.Lock()96 p.mu.Lock()
97- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)97+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
98 p.mu.Unlock()98 p.mu.Unlock()
99 99
100 // Hold session validation after publication and make the captured target100 // Hold session validation after publication and make the captured target
@@ -157,7 +157,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
157 tb := sess.activeTab()157 tb := sess.activeTab()
158 p := tb.focusedPane()158 p := tb.focusedPane()
159 p.mu.Lock()159 p.mu.Lock()
160- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)160+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
161 p.mu.Unlock()161 p.mu.Unlock()
162 tc.invalidate(sess, tb, p)162 tc.invalidate(sess, tb, p)
163 163
M internal/usecase/daemon/copymode_test.go +23 -23
@@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112 tb := sess.activeTab()112 tb := sess.activeTab()
113 pane := tb.focusedPane()113 pane := tb.focusedPane()
114 pane.mu.Lock()114 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)115+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116 pane.mu.Unlock()116 pane.mu.Unlock()
117 mode := scopy.NewMode(snap)117 mode := scopy.NewMode(snap)
118 bars := barState{status: sess.statusSegments(true)}118 bars := barState{status: sess.statusSegments(true)}
@@ -147,7 +147,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
147 tb := sess.activeTab()147 tb := sess.activeTab()
148 tb.focusedPane().screen = vt.NewScreen(12, 3)148 tb.focusedPane().screen = vt.NewScreen(12, 3)
149 tb.focusedPane().screen.Write([]byte("live"))149 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)
151 mode := scopy.NewMode(snap)151 mode := scopy.NewMode(snap)
152 152
153 bars := barState{status: sess.statusSegments(true)}153 bars := barState{status: sess.statusSegments(true)}
@@ -166,7 +166,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
166 writes := make(chan []byte, 1)166 writes := make(chan []byte, 1)
167 p, _ := newBlockingPTYWithWrites(t, writes)167 p, _ := newBlockingPTYWithWrites(t, writes)
168 d, sess, ac, sends := newManualSessionWithPTYs(t, p)168 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})
170 sess.tabs[0].focusedPane().screen.Write([]byte("live"))170 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
171 171
172 d.handleInput(sess, ac, []byte("\x1b "))172 d.handleInput(sess, ac, []byte("\x1b "))
@@ -293,8 +293,8 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
293 p, _ := newBlockingPTY(t)293 p, _ := newBlockingPTY(t)
294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
295 pane := sess.tabs[0].focusedPane()295 pane := sess.tabs[0].focusedPane()
296- pane.scrollback = scopy.NewScrollback(4)296+ installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
297- pane.scrollback.Append(testRow("old alpha"))297+ pane.history.Append(testRow("old alpha"))
298 copy(pane.screen.Frame.Row(0), testRow("live alpha"))298 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
299 299
300 d.enterCopyMode(sess, ac)300 d.enterCopyMode(sess, ac)
@@ -322,9 +322,9 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
322 writes := make(chan []byte, 1)322 writes := make(chan []byte, 1)
323 p, _ := newBlockingPTYWithWrites(t, writes)323 p, _ := newBlockingPTYWithWrites(t, writes)
324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)325+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
326- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))326+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
327- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))327+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
328 sess.tabs[0].focusedPane().screen.Write([]byte("live"))328 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
329 329
330 d.enterCopyMode(sess, ac)330 d.enterCopyMode(sess, ac)
@@ -445,7 +445,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
445 }445 }
446 p.mu.Lock()446 p.mu.Lock()
447 defer p.mu.Unlock()447 defer p.mu.Unlock()
448- return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12448+ return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12
449 }, 2*time.Second, 5*time.Millisecond)449 }, 2*time.Second, 5*time.Millisecond)
450 450
451 sess := firstSession(d)451 sess := firstSession(d)
@@ -481,7 +481,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
481 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {481 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
482 p, _ := newBlockingPTY(t)482 p, _ := newBlockingPTY(t)
483 d, sess, ac, sends := newManualSessionWithPTYs(t, p)483 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})
485 sess.tabs[0].focusedPane().screen.Write([]byte("live"))485 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
486 486
487 d.enterCopyMode(sess, ac)487 d.enterCopyMode(sess, ac)
@@ -515,9 +515,9 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
515 t.Run(tc.name, func(t *testing.T) {515 t.Run(tc.name, func(t *testing.T) {
516 p, _ := newBlockingPTY(t)516 p, _ := newBlockingPTY(t)
517 d, sess, ac, sends := newManualSessionWithPTYs(t, p)517 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
518- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)518+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
519- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))519+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
520- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))520+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
521 sess.tabs[0].focusedPane().screen.Write([]byte("live"))521 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
522 522
523 d.enterCopyMode(sess, ac)523 d.enterCopyMode(sess, ac)
@@ -535,7 +535,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
535 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {535 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
536 p, _ := newBlockingPTY(t)536 p, _ := newBlockingPTY(t)
537 d, sess, ac, sends := newManualSessionWithPTYs(t, p)537 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})
539 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)539 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
540 sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)540 sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
541 copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))541 copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
@@ -560,7 +560,7 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
560 d, sess, ac, sends := newManualSessionWithPTYs(t, p)560 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
561 clk := &signalClock{timers: make(chan *signalTimer, 1)}561 clk := &signalClock{timers: make(chan *signalTimer, 1)}
562 d.clock = clk562 d.clock = clk
563- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)563+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
564 sess.tabs[0].focusedPane().screen.Write([]byte("live"))564 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
565 565
566 d.enterCopyMode(sess, ac)566 d.enterCopyMode(sess, ac)
@@ -584,7 +584,7 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
584 d, sess, ac, sends := newManualSessionWithPTYs(t, p)584 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
585 clk := &signalClock{timers: make(chan *signalTimer, 1)}585 clk := &signalClock{timers: make(chan *signalTimer, 1)}
586 d.clock = clk586 d.clock = clk
587- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)587+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
588 sess.tabs[0].focusedPane().screen.Write([]byte("live"))588 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
589 589
590 d.enterCopyMode(sess, ac)590 d.enterCopyMode(sess, ac)
@@ -627,7 +627,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
627 t.Run(tc.name, func(t *testing.T) {627 t.Run(tc.name, func(t *testing.T) {
628 p, _ := newBlockingPTY(t)628 p, _ := newBlockingPTY(t)
629 d, sess, ac, sends := newManualSessionWithPTYs(t, p)629 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})
631 sess.tabs[0].focusedPane().screen.Write([]byte("live"))631 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
632 632
633 d.enterCopyMode(sess, ac)633 d.enterCopyMode(sess, ac)
@@ -676,7 +676,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
676 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {676 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
677 p, _ := newBlockingPTY(t)677 p, _ := newBlockingPTY(t)
678 d, sess, ac, sends := newManualSessionWithPTYs(t, p)678 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})
680 sess.tabs[0].focusedPane().screen.Write([]byte("live"))680 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
681 681
682 done := make(chan struct{})682 done := make(chan struct{})
@@ -750,11 +750,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
750 var want *pane750 var want *pane
751 if tc.useFloating {751 if tc.useFloating {
752 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})752 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
753- want.scrollback.Append(testRow("flt-old"))753+ want.history.Append(testRow("flt-old"))
754 want.screen.Write([]byte("flt-live"))754 want.screen.Write([]byte("flt-live"))
755 } else {755 } else {
756 want = main756 want = main
757- want.scrollback.Append(testRow("one-old"))757+ want.history.Append(testRow("one-old"))
758 want.screen.Write([]byte("one-live"))758 want.screen.Write([]byte("one-live"))
759 }759 }
760 760
@@ -791,11 +791,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
791 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)791 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
792 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})792 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
793 for i := range 30 {793 for i := range 30 {
794- fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i)))794+ fp.history.Append(testRow(fmt.Sprintf("old-%02d", i)))
795 }795 }
796 fp.screen.Write([]byte("live"))796 fp.screen.Write([]byte("live"))
797 fp.mu.Lock()797 fp.mu.Lock()
798- total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len()798+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len()
799 fp.mu.Unlock()799 fp.mu.Unlock()
800 800
801 d.enterCopyMode(sess, ac)801 d.enterCopyMode(sess, ac)
@@ -817,7 +817,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
817 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)817 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
818 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})818 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
819 for i := range 5 {819 for i := range 5 {
820- fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i)))820+ fp.history.Append(testRow(fmt.Sprintf("old-%d", i)))
821 }821 }
822 fp.screen.Write([]byte("live"))822 fp.screen.Write([]byte("live"))
823 823
M internal/usecase/daemon/daemon.go +23 -0
@@ -16,6 +16,7 @@ import (
16 "github.com/bnema/vev/internal/persist"16 "github.com/bnema/vev/internal/persist"
17 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18 "github.com/bnema/vev/internal/usecase/keys"18 "github.com/bnema/vev/internal/usecase/keys"
19+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
19 )20 )
20 21
21 // Scheduler debounce bounds. Idle updates use the minimum for low latency;22 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -101,6 +102,21 @@ type Daemon struct {
101 persistEnabled bool102 persistEnabled bool
102 snaps ports.SnapshotStore103 snaps ports.SnapshotStore
103 snapsEnabled bool104 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
104 restoreDone chan struct{}120 restoreDone chan struct{}
105 restoreOnce sync.Once121 restoreOnce sync.Once
106 procCwd func(int) (string, error)122 procCwd func(int) (string, error)
@@ -285,6 +301,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
285 done: make(chan struct{}),301 done: make(chan struct{}),
286 restoreDone: make(chan struct{}),302 restoreDone: make(chan struct{}),
287 animWake: make(chan struct{}, 1),303 animWake: make(chan struct{}, 1),
304+ snapshotMarshal: snapcodec.Marshal,
305+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
288 resumeParkGrace: defaultResumeParkGrace,306 resumeParkGrace: defaultResumeParkGrace,
289 barScripts: &barScriptState{307 barScripts: &barScriptState{
290 cfg: barConfigFromDomain(domain.Defaults().Bar),308 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -355,6 +373,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
355 })373 })
356 }374 }
357 if d.snapsEnabled {375 if d.snapsEnabled {
376+ d.startSnapshotEncodeWorker()
358 d.sessWg.Go(func() {377 d.sessWg.Go(func() {
359 d.snapshotSaver(d.serveCtx)378 d.snapshotSaver(d.serveCtx)
360 })379 })
@@ -403,7 +422,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
403 d.waitNotifies()422 d.waitNotifies()
404 d.hardCancel()423 d.hardCancel()
405 d.serveCancel()424 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.
406 d.connWg.Wait()428 d.connWg.Wait()
429+ d.stopSnapshotEncodeWorker()
407 d.shutdownAll(ports.ReasonServerShutdown)430 d.shutdownAll(ports.ReasonServerShutdown)
408 d.sessWg.Wait()431 d.sessWg.Wait()
409 d.waitNotifies()432 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,
202 mouseMode, mouseSGR := p.screen.MouseMode()202 mouseMode, mouseSGR := p.screen.MouseMode()
203 altScreen := p.screen.AltScreenActive()203 altScreen := p.screen.AltScreenActive()
204 scrollbackRows := 0204 scrollbackRows := 0
205- if p.scrollback != nil {205+ if p.history != nil {
206- scrollbackRows = p.scrollback.Len()206+ scrollbackRows = p.history.Len()
207 }207 }
208 p.mu.Unlock()208 p.mu.Unlock()
209 209
@@ -249,7 +249,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
249 249
250 // The immutable snapshot is the only live-pane access needed for drag entry.250 // The immutable snapshot is the only live-pane access needed for drag entry.
251 p.mu.Lock()251 p.mu.Lock()
252- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)252+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
253 p.mu.Unlock()253 p.mu.Unlock()
254 tb := sess.activeTab()254 tb := sess.activeTab()
255 if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {255 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 (
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17- scopy "github.com/bnema/vev/internal/usecase/copy"
18 "github.com/bnema/vev/internal/usecase/keys"17 "github.com/bnema/vev/internal/usecase/keys"
19 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/bnema/vev/internal/usecase/picker"19 "github.com/bnema/vev/internal/usecase/picker"
20+ "github.com/bnema/vev/pkg/vt"
21 )21 )
22 22
23 // --- test doubles -----------------------------------------------------------23 // --- test doubles -----------------------------------------------------------
@@ -931,7 +931,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
931 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {931 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
932 p, _ := newBlockingPTY(t)932 p, _ := newBlockingPTY(t)
933 d, sess, ac, _ := newManualSessionWithPTYs(t, p)933 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})
935 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")935 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
936 936
937 // Press on a content row while scrollback is empty: the anchor is937 // Press on a content row while scrollback is empty: the anchor is
@@ -941,7 +941,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
941 // Simulate 5 lines evicted into scrollback between the Press and the941 // Simulate 5 lines evicted into scrollback between the Press and the
942 // first Motion (e.g. the child kept producing output).942 // first Motion (e.g. the child kept producing output).
943 for range 5 {943 for range 5 {
944- sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))944+ sess.tabs[0].focusedPane().history.Append(testRow("evicted"))
945 }945 }
946 946
947 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).947 // 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.
173 func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {173 func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {
174 ac := &attachedClient{}174 ac := &attachedClient{}
175 ac.initOverlays()175 ac.initOverlays()
176- history := scopy.NewScrollback(1)176+ history := newTestHistory(1)
177 history.Append(testRow("before"))177 history.Append(testRow("before"))
178 document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))178 document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))
179 179
M internal/usecase/daemon/pane.go +17 -20
@@ -6,7 +6,6 @@ import (
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9- scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/bnema/vev/pkg/vt"10 "github.com/bnema/vev/pkg/vt"
12 )11 )
@@ -16,15 +15,15 @@ import (
16 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.15 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
17 // The PTY reader takes only pane.mu, so child output never waits on client IO.16 // The PTY reader takes only pane.mu, so child output never waits on client IO.
18 type pane struct {17 type pane struct {
19- id layout.PaneID18+ id layout.PaneID
20- stableID string19+ stableID string
21- pty ports.PTY20+ pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title21+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
23- resizeMu sync.Mutex // serializes PTY resizes without holding mu22+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
24- screen *vt.Screen23+ screen *vt.Screen
25- scrollback *scopy.Scrollback24+ history *vt.History
26- syncGen uint6425+ syncGen uint64
27- rect domain.Rect26+ rect domain.Rect
28 // resizeApplying gates VT parsing across PTY.Resize. The reader continues27 // resizeApplying gates VT parsing across PTY.Resize. The reader continues
29 // draining into resizePending so output is replayed against the target (or28 // draining into resizePending so output is replayed against the target (or
30 // retained old) screen only after apply resolves.29 // retained old) screen only after apply resolves.
@@ -48,16 +47,14 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
48 }47 }
49 48
50 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {49 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
51- sb := scopy.NewScrollback(defaultScrollbackRows)50+ screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows})
52- screen := vt.NewScreen(sz.Cols, sz.Rows)
53- screen.OnLineEvicted = sb.Append
54 return &pane{51 return &pane{
55- id: id,52+ id: id,
56- stableID: stableID,53+ stableID: stableID,
57- pty: pty,54+ pty: pty,
58- screen: screen,55+ screen: screen,
59- scrollback: sb,56+ history: screen.History(),
60- rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},57+ rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
61- title: paneTitleState{displayFallback: "sh"},58+ title: paneTitleState{displayFallback: "sh"},
62 }59 }
63 }60 }
M internal/usecase/daemon/performance_benchmark_test.go +291 -17
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "fmt"4 "fmt"
5 "io"5 "io"
6+ "runtime"
6 "sync"7 "sync"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -145,11 +146,29 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
145 for _, tb := range fixture.sess.tabs {146 for _, tb := range fixture.sess.tabs {
146 require.Len(t, tb.panes, 4)147 require.Len(t, tb.panes, 4)
147 for _, p := range tb.panes {148 for _, p := range tb.panes {
148- require.Equal(t, 10_000, p.scrollback.Len())149+ require.Equal(t, 10_000, p.history.Len())
149 }150 }
150 }151 }
151 }152 }
152 153
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+
153 func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) {172 func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) {
154 for _, topology := range daemonHistoryTopologies {173 for _, topology := range daemonHistoryTopologies {
155 t.Run(topology.name, func(t *testing.T) {174 t.Run(topology.name, func(t *testing.T) {
@@ -179,12 +198,41 @@ var daemonHistoryTopologies = []daemonHistoryTopology{
179 {name: "8tabs-1pane", tabs: 8, panes: 1},198 {name: "8tabs-1pane", tabs: 8, panes: 1},
180 }199 }
181 200
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+
182 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {209 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
183 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })210 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
184 }211 }
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.
185 func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) {216 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)
187 }224 }
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.
188 func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {236 func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
189 benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) })237 benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) })
190 }238 }
@@ -223,8 +271,12 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
223 fixture := newPerformanceFixture(b, performanceConfig{271 fixture := newPerformanceFixture(b, performanceConfig{
224 size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,272 size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
225 })273 })
274+ if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
275+ b.Fatal("invalid daemon history fixture")
276+ }
226 if workload == "copy-search" {277 if workload == "copy-search" {
227- fixture.d.enterCopyMode(fixture.sess, fixture.ac)278+ benchmarkDaemonCopySearch(b, fixture, run)
279+ return
228 }280 }
229 b.ReportAllocs()281 b.ReportAllocs()
230 fixture.resetMetrics()282 fixture.resetMetrics()
@@ -238,26 +290,165 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
238 b.Fatalf("decode last output: %v", err)290 b.Fatalf("decode last output: %v", err)
239 }291 }
240 }292 }
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- }
250 if workload == "live-paint" && metrics.outputFrames != uint64(b.N) {293 if workload == "live-paint" && metrics.outputFrames != uint64(b.N) {
251 b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N)294 b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N)
252 }295 }
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- }
256 benchmarkReportMetrics(b, metrics, b.N, 10_000)296 benchmarkReportMetrics(b, metrics, b.N, 10_000)
257 })297 })
258 }298 }
259 }299 }
260 300
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+
261 func TestCoordinatorCoalescingRatioReportsInvalidationsPerWake(t *testing.T) {452 func TestCoordinatorCoalescingRatioReportsInvalidationsPerWake(t *testing.T) {
262 require.Equal(t, float64(3), coordinatorCoalescingRatio(performanceMetrics{453 require.Equal(t, float64(3), coordinatorCoalescingRatio(performanceMetrics{
263 coordinatorWakes: 1,454 coordinatorWakes: 1,
@@ -331,6 +522,42 @@ func TestLivePaintAllocationBudget(t *testing.T) {
331 }522 }
332 }523 }
333 524
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+
334 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {561 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
335 fixture := newPerformanceFixture(t, performanceConfig{})562 fixture := newPerformanceFixture(t, performanceConfig{})
336 563
@@ -501,13 +728,15 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
501 sess: sess,728 sess: sess,
502 ac: ac,729 ac: ac,
503 output: output,730 output: output,
504- snaps: &countingSnapshotStore{},731+ snaps: &countingSnapshotStore{done: make(chan struct{}, 1)},
505 pty: pty,732 pty: pty,
506 clock: clock,733 clock: clock,
507 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},734 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
508 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},735 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
509 }736 }
510 WithSnapshotStore(fixture.snaps)(d)737 WithSnapshotStore(fixture.snaps)(d)
738+ d.startSnapshotEncodeWorker()
739+ t.Cleanup(d.stopSnapshotEncodeWorker)
511 for tabIndex, tb := range sess.tabs {740 for tabIndex, tb := range sess.tabs {
512 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))741 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
513 }742 }
@@ -550,7 +779,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
550 for _, p := range tb.panes {779 for _, p := range tb.panes {
551 p.mu.Lock()780 p.mu.Lock()
552 width := p.screen.Frame.Width781 width := p.screen.Frame.Width
553- for row := 0; p.scrollback.Len() < historyRows; row++ {782+ for row := 0; p.history.Len() < historyRows; row++ {
554 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))783 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
555 }784 }
556 p.mu.Unlock()785 p.mu.Unlock()
@@ -594,6 +823,17 @@ func (f *performanceFixture) paintLive() {
594 func (f *performanceFixture) captureSnapshot() {823 func (f *performanceFixture) captureSnapshot() {
595 f.t.Helper()824 f.t.Helper()
596 require.True(f.t, f.d.captureSession(f.sess))825 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")
597 }837 }
598 838
599 func (f *performanceFixture) enterCopySearch() {839 func (f *performanceFixture) enterCopySearch() {
@@ -602,6 +842,30 @@ func (f *performanceFixture) enterCopySearch() {
602 f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))842 f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))
603 }843 }
604 844
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+
605 func (f *performanceFixture) searchMatches() int {869 func (f *performanceFixture) searchMatches() int {
606 f.ac.overlays.copyMu.Lock()870 f.ac.overlays.copyMu.Lock()
607 defer f.ac.overlays.copyMu.Unlock()871 defer f.ac.overlays.copyMu.Unlock()
@@ -611,6 +875,12 @@ func (f *performanceFixture) searchMatches() int {
611 return len(f.ac.overlays.copyMode.Searches)875 return len(f.ac.overlays.copyMode.Searches)
612 }876 }
613 877
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+
614 func (f *performanceFixture) resize() {884 func (f *performanceFixture) resize() {
615 f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])885 f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])
616 }886 }
@@ -754,6 +1024,7 @@ type countingSnapshotStore struct {
754 mu sync.Mutex1024 mu sync.Mutex
755 countingSnapshotMetrics1025 countingSnapshotMetrics
756 last []byte1026 last []byte
1027+ done chan struct{}
757 }1028 }
758 1029
759 func (s *countingSnapshotStore) Write(_ string, data []byte) error {1030 func (s *countingSnapshotStore) Write(_ string, data []byte) error {
@@ -762,6 +1033,9 @@ func (s *countingSnapshotStore) Write(_ string, data []byte) error {
762 s.bytes += uint64(len(data))1033 s.bytes += uint64(len(data))
763 s.last = data1034 s.last = data
764 s.mu.Unlock()1035 s.mu.Unlock()
1036+ if s.done != nil {
1037+ s.done <- struct{}{}
1038+ }
765 return nil1039 return nil
766 }1040 }
767 func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }1041 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) {
572 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})572 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
573 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))573 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
574 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))574 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
575- require.Equal(t, 2, win.focusedPane().scrollback.Len())575+ require.Equal(t, 2, win.focusedPane().history.Len())
576- require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0)))576+ require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0)))
577- require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1)))577+ require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1)))
578 578
579 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})579 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
580 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))580 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
581 require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))581 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())
583 }583 }
584 584
585 func frameRowString(f renderer.Frame, y int) string {585 func frameRowString(f renderer.Frame, y int) string {
@@ -911,7 +911,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
911 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },911 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },
912 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },912 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },
913 prepareScreen: func(sess *session) {913 prepareScreen: func(sess *session) {
914- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)914+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
915 },915 },
916 visible: "[SCROLL]",916 visible: "[SCROLL]",
917 notVisible: "[SCROLL]",917 notVisible: "[SCROLL]",
@@ -923,10 +923,10 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
923 p, release := newBlockingPTY(t)923 p, release := newBlockingPTY(t)
924 d, sess, ac, sends := newManualSessionWithPTYs(t, p)924 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
925 defer release()925 defer release()
926- sess.tabs[0].focusedPane().screen.Write([]byte("live"))
927 if tt.prepareScreen != nil {926 if tt.prepareScreen != nil {
928 tt.prepareScreen(sess)927 tt.prepareScreen(sess)
929 }928 }
929+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
930 if tt.wantRestored == "" {930 if tt.wantRestored == "" {
931 tt.wantRestored = "live"931 tt.wantRestored = "live"
932 }932 }
@@ -1295,7 +1295,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1295 }1295 }
1296 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})1296 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1297 p.screen.Write([]byte("ab\r\ncd"))1297 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)
1299 mode := scopy.NewMode(document)1299 mode := scopy.NewMode(document)
1300 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1300 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1301 1301
@@ -1355,7 +1355,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1355 defer releaseFloating()1355 defer releaseFloating()
1356 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)1356 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
1357 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})1357 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1358- fp.scrollback.Append(testRow("flt-old"))1358+ fp.history.Append(testRow("flt-old"))
1359 fp.screen.Write([]byte("flt-live"))1359 fp.screen.Write([]byte("flt-live"))
1360 installTestFloating(sess.activeTab(), fp, true)1360 installTestFloating(sess.activeTab(), fp, true)
1361 1361
M internal/usecase/daemon/scrollback_test.go +15 -6
@@ -14,29 +14,38 @@ func TestNewTabInitializesScrollback(t *testing.T) {
14 wantRow string14 wantRow string
15 }{15 }{
16 {16 {
17- name: "tab owns scrollback wired to vt eviction callback",17+ name: "primary evictions enter screen owned history",
18 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),18 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
19 wantLen: 1,19 wantLen: 1,
20 wantRow: "AAAA",20 wantRow: "AAAA",
21 },21 },
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+ },
22 }28 }
23 29
24 for _, tt := range tests {30 for _, tt := range tests {
25 t.Run(tt.name, func(t *testing.T) {31 t.Run(tt.name, func(t *testing.T) {
26 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})32 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.focusedPane().scrollback == nil {33+ if win.focusedPane().history == nil {
28 t.Fatal("scrollback is nil")34 t.Fatal("scrollback is nil")
29 }35 }
30- if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {36+ if win.focusedPane().history != win.focusedPane().screen.History() {
31- t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)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)
32 }41 }
33 42
34 win.focusedPane().screen.Write(tt.write)43 win.focusedPane().screen.Write(tt.write)
35 44
36- if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {45+ if got := win.focusedPane().history.Len(); got != tt.wantLen {
37 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)46 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
38 }47 }
39- row := win.focusedPane().scrollback.View().Row(0)48+ row := win.focusedPane().history.View().Row(0)
40 runes := make([]rune, len(row))49 runes := make([]rune, len(row))
41 for i, c := range row {50 for i, c := range row {
42 runes[i] = c.Rune51 runes[i] = c.Rune
M internal/usecase/daemon/session.go +9 -1
@@ -44,6 +44,13 @@ type session struct {
44 mruAt atomic.Uint6444 mruAt atomic.Uint64
45 snapDirty atomic.Bool45 snapDirty atomic.Bool
46 snapEligible atomic.Bool46 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{}
47 // syncGen makes synchronized-output watchdog generations unique across all54 // syncGen makes synchronized-output watchdog generations unique across all
48 // panes in this session.55 // panes in this session.
49 syncGen atomic.Uint6456 syncGen atomic.Uint64
@@ -671,7 +678,8 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
671 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)678 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
672 }679 }
673 } else {680 } else {
674- d.captureSession(sess)681+ markSnapshotDirty(sess)
682+ d.scheduleFinalSnapshot(sess)
675 }683 }
676 }684 }
677 d.mu.Lock()685 d.mu.Lock()
M internal/usecase/daemon/snapshot.go +415 -78
@@ -4,17 +4,53 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "math"6 "math"
7+ "sync"
7 "time"8 "time"
8 9
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10- scopy "github.com/bnema/vev/internal/usecase/copy"
11 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13 "github.com/bnema/vev/pkg/renderer"13 "github.com/bnema/vev/pkg/renderer"
14 "github.com/bnema/vev/pkg/vt"14 "github.com/bnema/vev/pkg/vt"
15 )15 )
16 16
17-const snapshotInterval = 30 * time.Second17+// 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+)
18 54
19 func (d *Daemon) closeRestoreDone() {55 func (d *Daemon) closeRestoreDone() {
20 d.restoreOnce.Do(func() { close(d.restoreDone) })56 d.restoreOnce.Do(func() { close(d.restoreDone) })
@@ -51,6 +87,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
51 if err := ctx.Err(); err != nil {87 if err := ctx.Err(); err != nil {
52 return err88 return err
53 }89 }
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+ }
54 if snap.Name == "" {97 if snap.Name == "" {
55 return fmt.Errorf("snapshot: empty session name")98 return fmt.Errorf("snapshot: empty session name")
56 }99 }
@@ -149,7 +192,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
149 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)192 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
150 p.ctx, p.cancel = context.WithCancel(tb.ctx)193 p.ctx, p.cancel = context.WithCancel(tb.ctx)
151 p.rect = contentRect194 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+ }
153 if restoreCommand != "" {202 if restoreCommand != "" {
154 if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {203 if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
155 d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)204 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
164 }213 }
165 createdAt := int64(snap.CreatedAt)214 createdAt := int64(snap.CreatedAt)
166 sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}215 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- }
170 sess.snapEligible.Store(true)216 sess.snapEligible.Store(true)
171 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {217 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
172 sess.cwd = snap.Tabs[0].Panes[0].Cwd218 sess.cwd = snap.Tabs[0].Panes[0].Cwd
@@ -201,36 +247,51 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
201 }247 }
202 248
203 func markSnapshotDirty(sess *session) {249 func markSnapshotDirty(sess *session) {
204- if sess == nil {250+ if sess == nil || !sess.snapEligible.Load() {
205 return251 return
206 }252 }
207- if sess.snapEligible.Load() {253+ sess.snapshotMu.Lock()
208- sess.snapDirty.Store(true)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{})
209 }265 }
266+ return sess.snapshotChanged
210 }267 }
211 268
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+ }
213 p.mu.Lock()283 p.mu.Lock()
214 defer p.mu.Unlock()284 defer p.mu.Unlock()
215- p.scrollback = scopy.NewScrollback(defaultScrollbackRows)285+ screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
216- for _, row := range scrollback {286+ if err != nil {
217- p.scrollback.Append(row)287+ return fmt.Errorf("snapshot history: %w", err)
218- }288+ }
219- if len(visible) > 0 {289+ if err := screen.RestorePrimaryVisible(snap.Visible); err != nil {
220- p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)290+ return fmt.Errorf("snapshot visible: %w", err)
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- }
232 }291 }
233- p.screen.OnLineEvicted = p.scrollback.Append292+ p.screen = screen
293+ p.history = screen.History()
294+ return nil
234 }295 }
235 296
236 func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {297 func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
@@ -251,35 +312,112 @@ func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
251 return domain.Size{Cols: cols, Rows: rows}312 return domain.Size{Cols: cols, Rows: rows}
252 }313 }
253 314
254-func (d *Daemon) captureSession(sess *session) bool {315+// scheduleSnapshot captures a session only once while an immutable capture is
255- if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil {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() {
256 return true330 return true
257 }331 }
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+}
258 388
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) {
259 sess.mu.Lock()393 sess.mu.Lock()
260- name := sess.name394+ 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+ }
261 ephemeral := sess.ephemeral401 ephemeral := sess.ephemeral
262- createdAt := sess.createdAt
263- active := sess.active
264 fallbackCwd := sess.cwd402 fallbackCwd := sess.cwd
265 tabs := append([]*tab(nil), sess.tabs...)403 tabs := append([]*tab(nil), sess.tabs...)
266 sess.mu.Unlock()404 sess.mu.Unlock()
267- if ephemeral || name == "" {405+ if ephemeral || capture.name == "" {
268- return true406+ return capture, false
269 }407 }
270 408
271- snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))}409+ capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
272 for _, tb := range tabs {410 for _, tb := range tabs {
273 tb.mu.Lock()411 tb.mu.Lock()
274- tabSnap := snapcodec.Tab{412+ tabCapture := snapshotCaptureTab{
275- StableID: tb.stableID,413+ stableID: tb.stableID,
276- Cols: uint16(max(tb.size.Cols, 0)),414+ cols: uint16(max(tb.size.Cols, 0)),
277- Rows: uint16(max(tb.size.Rows, 0)),415+ rows: uint16(max(tb.size.Rows, 0)),
278- NextPaneID: uint64(max(tb.nextPaneID, 0)),416+ nextPaneID: uint64(max(tb.nextPaneID, 0)),
279- Tree: tb.tree.Clone(),417+ tree: tb.tree.Clone(),
280 }418 }
281 if tb.tree != nil {419 if tb.tree != nil {
282- tabSnap.Focus = tb.tree.Focus420+ tabCapture.focus = tb.tree.Focus
283 }421 }
284 panes := make([]*pane, 0, len(tb.panes))422 panes := make([]*pane, 0, len(tb.panes))
285 for _, p := range tb.panes {423 for _, p := range tb.panes {
@@ -287,51 +425,263 @@ func (d *Daemon) captureSession(sess *session) bool {
287 }425 }
288 tb.mu.Unlock()426 tb.mu.Unlock()
289 427
290- paneIDs := make(map[layout.PaneID]struct{}, len(panes))428+ tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
291- paneSnaps := make([]snapcodec.Pane, 0, len(panes))
292 for _, p := range panes {429 for _, p := range panes {
293 p.mu.Lock()430 p.mu.Lock()
294- pid := 0
295 pty := p.pty431 pty := p.pty
432+ pid := 0
296 if pty != nil {433 if pty != nil {
297 pid = pty.Pid()434 pid = pty.Pid()
298 }435 }
299- paneSnap := snapcodec.Pane{436+ paneCapture := snapshotCapturePane{
300- ID: p.id,437+ id: p.id,
301- StableID: p.stableID,438+ stableID: p.stableID,
302- Scrollback: cloneRows(p.scrollback.Snapshot()),439+ history: p.history.SealAndView(),
303- Visible: cloneRows(p.screen.PrimaryVisibleRows()),440+ visible: p.screen.PrimaryVisibleFrame(),
304 }441 }
305 p.mu.Unlock()442 p.mu.Unlock()
306- paneSnap.Cwd = fallbackCwd443+ paneCapture.cwd = fallbackCwd
307 if d.procCwd != nil && pid > 0 {444 if d.procCwd != nil && pid > 0 {
308 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {445 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
309- paneSnap.Cwd = cwd446+ paneCapture.cwd = cwd
310 }447 }
311 }448 }
312- paneSnap.Process = d.capturePaneProcess(pty, pid)449+ paneCapture.process = d.capturePaneProcess(pty, pid)
313- paneIDs[paneSnap.ID] = struct{}{}450+ tabCapture.panes = append(tabCapture.panes, paneCapture)
314- paneSnaps = append(paneSnaps, paneSnap)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{}{}
315 }481 }
316 if tabSnap.Tree != nil {482 if tabSnap.Tree != nil {
317 pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)483 pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
318 }484 }
319- tabSnap.Panes = paneSnaps
320 snap.Tabs = append(snap.Tabs, tabSnap)485 snap.Tabs = append(snap.Tabs, tabSnap)
321 }486 }
487+ return d.snapshotMarshal(snap)
488+}
322 489
323- data, err := snapcodec.Marshal(snap)490+func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {
324- if err != nil {491+ if capture == nil {
325- d.log.Warn("marshaling session snapshot failed", "err", err, "session", name)492+ return
326- return false493+ }
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
327 }600 }
328- if err := d.snaps.Write(name, data); err != nil {601+ capture := d.snapshotFinalJobs[0]
329- d.log.Warn("writing session snapshot failed", "err", err, "session", name)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 {
330 return false611 return false
331 }612 }
613+ d.snapshotWorkerInFlight = capture
332 return true614 return true
333 }615 }
334 616
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+
335 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {685 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
336 if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {686 if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
337 return nil687 return nil
@@ -368,27 +718,14 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
368 sessions := d.sessionsSnapshotLocked()718 sessions := d.sessionsSnapshotLocked()
369 d.mu.Unlock()719 d.mu.Unlock()
370 for _, sess := range sessions {720 for _, sess := range sessions {
371- if sess.snapEligible.Load() && sess.snapDirty.Swap(false) {721+ if sess.snapEligible.Load() && sess.snapDirty.Load() {
372- if !d.captureSession(sess) {722+ d.scheduleSnapshot(sess)
373- sess.snapDirty.Store(true)
374- }
375 }723 }
376 }724 }
377 t.Reset(snapshotInterval)725 t.Reset(snapshotInterval)
378 }726 }
379 }727 }
380 728
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-
392 func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {729 func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
393 if n == nil {730 if n == nil {
394 return false731 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 (
18 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20 "github.com/bnema/vev/pkg/renderer"20 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
21 )22 )
22 23
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+
23 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {89 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
24 store := &restoreSnapshotStore{}90 store := &restoreSnapshotStore{}
25 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{91 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -37,8 +103,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
37 layout.NewLeaf("pane-2"),103 layout.NewLeaf("pane-2"),
38 }}},104 }}},
39 Panes: []snapcodec.Pane{105 Panes: []snapcodec.Pane{
40- {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},106+ terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}),
41- {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},107+ terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}),
42 },108 },
43 }},109 }},
44 })}}110 })}}
@@ -77,10 +143,11 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
77 p := tb.panes["pane-2"]143 p := tb.panes["pane-2"]
78 tb.mu.Unlock()144 tb.mu.Unlock()
79 p.mu.Lock()145 p.mu.Lock()
80- require.Equal(t, "old2", rowText(p.scrollback.View().Row(0)))146+ require.Same(t, p.screen.History(), p.history)
81- require.Equal(t, 1, p.scrollback.Len())147+ require.Equal(t, "old2", rowText(p.history.View().Row(0)))
148+ require.Equal(t, 1, p.history.Len())
82 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))149 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)
84 require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))151 require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
85 require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))152 require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
86 p.mu.Unlock()153 p.mu.Unlock()
@@ -108,8 +175,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
108 func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {175 func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
109 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{176 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
110 Name: "work", Active: 0, Tabs: []snapcodec.Tab{177 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"}}},178+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}},
112- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}},179+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}},
113 },180 },
114 })}}}181 })}}}
115 factory := &restorePTYFactory{}182 factory := &restorePTYFactory{}
@@ -138,17 +205,15 @@ func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testi
138 205
139 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {206 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
140 t.Helper()207 t.Helper()
208+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
209+ pane.Process = proc
141 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{210 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
142 Name: name,211 Name: name,
143 Tabs: []snapcodec.Tab{{212 Tabs: []snapcodec.Tab{{
144- Cols: 80,213+ Cols: 80,
145- Rows: 24,214+ Rows: 24,
146- Tree: layout.NewTree("pane-1"),215+ Tree: layout.NewTree("pane-1"),
147- Panes: []snapcodec.Pane{{216+ Panes: []snapcodec.Pane{pane},
148- ID: "pane-1",
149- Cwd: "/tmp",
150- Process: proc,
151- }},
152 }},217 }},
153 })}}}218 })}}}
154 }219 }
@@ -185,20 +250,17 @@ func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
185 Cols: 80,250 Cols: 80,
186 Rows: 24,251 Rows: 24,
187 Tree: layout.NewTree("pane-1"),252 Tree: layout.NewTree("pane-1"),
188- Panes: []snapcodec.Pane{{253+ Panes: func() []snapcodec.Pane {
189- ID: "pane-1",254+ pane := emptyTerminalPane(t, "pane-1", "/one")
190- Cwd: "/one",255+ pane.Process = lessProc
191- Process: lessProc,256+ return []snapcodec.Pane{pane}
192- }},257+ }(),
193 },258 },
194 {259 {
195- Cols: 80,260+ Cols: 80,
196- Rows: 24,261+ Rows: 24,
197- Tree: layout.NewTree("pane-1"),262+ Tree: layout.NewTree("pane-1"),
198- Panes: []snapcodec.Pane{{263+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")},
199- ID: "pane-1",
200- Cwd: "/two",
201- }},
202 },264 },
203 },265 },
204 })}}},266 })}}},
@@ -246,7 +308,9 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
246 d := newTestDaemon(t, factory, stubClock{})308 d := newTestDaemon(t, factory, stubClock{})
247 d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})309 d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
248 310
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}}}}))
250 require.Len(t, factory.opens, 1)314 require.Len(t, factory.opens, 1)
251 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)315 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
252 }316 }
@@ -258,7 +322,7 @@ func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
258 defer cancel()322 defer cancel()
259 323
260 require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{324 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")},
262 }}}))326 }}}))
263 require.Len(t, factory.opens, 1)327 require.Len(t, factory.opens, 1)
264 require.Same(t, ctx, factory.opens[0].ctx)328 require.Same(t, ctx, factory.opens[0].ctx)
@@ -273,7 +337,11 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
273 Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{337 Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
274 layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),338 layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
275 }}},339 }}},
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+ },
277 }},345 }},
278 })}}}346 })}}}
279 factory := &restorePTYFactory{}347 factory := &restorePTYFactory{}
@@ -303,7 +371,16 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
303 }371 }
304 372
305 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {373 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+ })
307 badVersion := append([]byte(nil), valid...)384 badVersion := append([]byte(nil), valid...)
308 binary.BigEndian.PutUint16(badVersion[4:6], 1)385 binary.BigEndian.PutUint16(badVersion[4:6], 1)
309 tests := []struct {386 tests := []struct {
@@ -340,7 +417,7 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
340 Cols: 80,417 Cols: 80,
341 Rows: 24,418 Rows: 24,
342 Tree: layout.NewTree("pane-1"),419 Tree: layout.NewTree("pane-1"),
343- Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}},420+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")},
344 }},421 }},
345 })}}}422 })}}}
346 factory := &restorePTYFactory{}423 factory := &restorePTYFactory{}
@@ -366,7 +443,15 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
366 loadFn: func() ([]ports.SnapshotBlob, error) {443 loadFn: func() ([]ports.SnapshotBlob, error) {
367 close(loaded)444 close(loaded)
368 <-releaseLoad445 <-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"}}}}})}}, nil446+ 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
370 },455 },
371 }456 }
372 factory := &restorePTYFactory{}457 factory := &restorePTYFactory{}
@@ -415,6 +500,11 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
415 require.Equal(t, ports.ErrServerShutdown, perr.code)500 require.Equal(t, ports.ErrServerShutdown, perr.code)
416 }501 }
417 502
503+func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane {
504+ t.Helper()
505+ return terminalPane(t, id, "", cwd, nil, nil)
506+}
507+
418 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {508 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
419 t.Helper()509 t.Helper()
420 b, err := snapcodec.Marshal(s)510 b, err := snapcodec.Marshal(s)
@@ -470,6 +560,7 @@ func (f *restorePTYFactory) Open(ctx context.Context, _ string, _ []string, env
470 type restorePTY struct {560 type restorePTY struct {
471 mu sync.Mutex561 mu sync.Mutex
472 writes []string562 writes []string
563+ closes int
473 writeErr error564 writeErr error
474 done chan struct{}565 done chan struct{}
475 once sync.Once566 once sync.Once
@@ -489,7 +580,18 @@ func (p *restorePTY) Write(b []byte) (int, error) {
489 }580 }
490 return len(b), nil581 return len(b), nil
491 }582 }
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+}
493 func (p *restorePTY) Resize(domain.Size) error { return nil }595 func (p *restorePTY) Resize(domain.Size) error { return nil }
494 func (p *restorePTY) Pid() int { return 0 }596 func (p *restorePTY) Pid() int { return 0 }
495 func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil }597 func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil }
M internal/usecase/daemon/snapshot_test.go +531 -7
@@ -18,14 +18,423 @@ import (
18 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20 "github.com/bnema/vev/pkg/renderer"20 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
21 )22 )
22 23
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+
23 func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {431 func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
24 clock := newManualSnapshotClock()432 clock := newManualSnapshotClock()
25 store := portsmocks.NewMockSnapshotStore(t)433 store := portsmocks.NewMockSnapshotStore(t)
26 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)434 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
27 WithSnapshotStore(store)(d)435 WithSnapshotStore(store)(d)
28 d.procCwd = func(int) (string, error) { return "/pane", nil }436 d.procCwd = func(int) (string, error) { return "/pane", nil }
437+ startSnapshotEncodeWorker(t, d)
29 438
30 dirty := newSnapshotTestSession(t, "dirty", false, "/fallback")439 dirty := newSnapshotTestSession(t, "dirty", false, "/fallback")
31 clean := newSnapshotTestSession(t, "clean", false, "/fallback")440 clean := newSnapshotTestSession(t, "clean", false, "/fallback")
@@ -67,6 +476,7 @@ func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
67 store := portsmocks.NewMockSnapshotStore(t)476 store := portsmocks.NewMockSnapshotStore(t)
68 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})477 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69 WithSnapshotStore(store)(d)478 WithSnapshotStore(store)(d)
479+ startSnapshotEncodeWorker(t, d)
70 sess := newSnapshotTestSession(t, "work", false, "/fallback")480 sess := newSnapshotTestSession(t, "work", false, "/fallback")
71 tb := sess.tabs[0]481 tb := sess.tabs[0]
72 floatingPTY, _ := newBlockingPTY(t)482 floatingPTY, _ := newBlockingPTY(t)
@@ -85,12 +495,14 @@ func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
85 return nil495 return nil
86 }).Once()496 }).Once()
87 require.True(t, d.captureSession(sess))497 require.True(t, d.captureSession(sess))
498+ awaitSnapshotClean(t, sess)
88 }499 }
89 500
90 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {501 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
91 store := portsmocks.NewMockSnapshotStore(t)502 store := portsmocks.NewMockSnapshotStore(t)
92 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})503 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
93 WithSnapshotStore(store)(d)504 WithSnapshotStore(store)(d)
505+ startSnapshotEncodeWorker(t, d)
94 d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) {506 d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) {
95 require.Equal(t, 4321, pgid)507 require.Equal(t, 4321, pgid)
96 require.Equal(t, 1234, shellPid)508 require.Equal(t, 1234, shellPid)
@@ -113,12 +525,14 @@ func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
113 }).Once()525 }).Once()
114 526
115 require.True(t, d.captureSession(sess))527 require.True(t, d.captureSession(sess))
528+ awaitSnapshotClean(t, sess)
116 }529 }
117 530
118 func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {531 func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
119 store := portsmocks.NewMockSnapshotStore(t)532 store := portsmocks.NewMockSnapshotStore(t)
120 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})533 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
121 WithSnapshotStore(store)(d)534 WithSnapshotStore(store)(d)
535+ startSnapshotEncodeWorker(t, d)
122 d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") }536 d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") }
123 537
124 sess := newSnapshotTestSession(t, "work", false, "/fallback")538 sess := newSnapshotTestSession(t, "work", false, "/fallback")
@@ -133,12 +547,14 @@ func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
133 }).Once()547 }).Once()
134 548
135 require.True(t, d.captureSession(sess))549 require.True(t, d.captureSession(sess))
550+ awaitSnapshotClean(t, sess)
136 }551 }
137 552
138 func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {553 func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
139 store := portsmocks.NewMockSnapshotStore(t)554 store := portsmocks.NewMockSnapshotStore(t)
140 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})555 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
141 WithSnapshotStore(store)(d)556 WithSnapshotStore(store)(d)
557+ startSnapshotEncodeWorker(t, d)
142 d.procGroupArgv = func(int, int) ([]string, error) {558 d.procGroupArgv = func(int, int) ([]string, error) {
143 t.Fatal("bare shell should not inspect argv")559 t.Fatal("bare shell should not inspect argv")
144 return nil, nil560 return nil, nil
@@ -156,35 +572,37 @@ func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
156 }).Once()572 }).Once()
157 573
158 require.True(t, d.captureSession(sess))574 require.True(t, d.captureSession(sess))
575+ awaitSnapshotClean(t, sess)
159 }576 }
160 577
161 func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {578 func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {
162 store := portsmocks.NewMockSnapshotStore(t)579 store := portsmocks.NewMockSnapshotStore(t)
163 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})580 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
164 WithSnapshotStore(store)(d)581 WithSnapshotStore(store)(d)
582+ startSnapshotEncodeWorker(t, d)
165 d.procCwd = func(int) (string, error) { return "/live", nil }583 d.procCwd = func(int) (string, error) { return "/live", nil }
166 584
167 sess := newSnapshotTestSession(t, "work", false, "/fallback")585 sess := newSnapshotTestSession(t, "work", false, "/fallback")
168 d.sessions[sess.id] = sess586 d.sessions[sess.id] = sess
169 587
170- var wrote atomic.Bool588+ var closed atomic.Bool
171 store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {589 store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
172 snap, err := snapcodec.Unmarshal(data)590 snap, err := snapcodec.Unmarshal(data)
173 require.NoError(t, err)591 require.NoError(t, err)
174 require.Equal(t, "work", snap.Name)592 require.Equal(t, "work", snap.Name)
175 require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd)593 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")
177 return nil595 return nil
178 }).Once()596 }).Once()
179 mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)597 mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
180 require.True(t, ok)598 require.True(t, ok)
181 mockPTY.EXPECT().Close().RunAndReturn(func() error {599 mockPTY.EXPECT().Close().RunAndReturn(func() error {
182- require.True(t, wrote.Load(), "snapshot Write must happen before pane Close")600+ closed.Store(true)
183 return nil601 return nil
184 }).Once()602 }).Once()
185 603
186 require.NoError(t, d.killSession(sess, 0, false))604 require.NoError(t, d.killSession(sess, 0, false))
187- require.True(t, wrote.Load())605+ awaitSnapshotClean(t, sess)
188 }606 }
189 607
190 func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {608 func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
@@ -241,12 +659,17 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
241 store := portsmocks.NewMockSnapshotStore(t)659 store := portsmocks.NewMockSnapshotStore(t)
242 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)660 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
243 WithSnapshotStore(store)(d)661 WithSnapshotStore(store)(d)
662+ startSnapshotEncodeWorker(t, d)
244 663
245 sess := newSnapshotTestSession(t, "retry", false, "/fallback")664 sess := newSnapshotTestSession(t, "retry", false, "/fallback")
246 sess.snapDirty.Store(true)665 sess.snapDirty.Store(true)
247 d.sessions[sess.id] = sess666 d.sessions[sess.id] = sess
248 667
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()
250 wrote := make(chan struct{}, 1)673 wrote := make(chan struct{}, 1)
251 store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {674 store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
252 wrote <- struct{}{}675 wrote <- struct{}{}
@@ -257,7 +680,9 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
257 defer cancel()680 defer cancel()
258 go d.snapshotSaver(ctx)681 go d.snapshotSaver(ctx)
259 clock.fireNext(t)682 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())
261 clock.fireNext(t)686 clock.fireNext(t)
262 select {687 select {
263 case <-wrote:688 case <-wrote:
@@ -365,6 +790,7 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
365 factory := portsmocks.NewMockPTYFactory(t)790 factory := portsmocks.NewMockPTYFactory(t)
366 d := newTestDaemon(t, factory, clock)791 d := newTestDaemon(t, factory, clock)
367 WithSnapshotStore(store)(d)792 WithSnapshotStore(store)(d)
793+ startSnapshotEncodeWorker(t, d)
368 794
369 sess := newSnapshotTestSession(t, "work", false, "/work")795 sess := newSnapshotTestSession(t, "work", false, "/work")
370 tctx, tcancel := context.WithCancel(context.Background())796 tctx, tcancel := context.WithCancel(context.Background())
@@ -434,7 +860,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
434 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})860 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
435 p := tb.panes["pane-1"]861 p := tb.panes["pane-1"]
436 p.screen.Write([]byte("hello"))862 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'}})
438 sess := &session{864 sess := &session{
439 id: domain.SessionID("sess-" + name),865 id: domain.SessionID("sess-" + name),
440 name: name,866 name: name,
@@ -450,6 +876,91 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
450 return sess876 return sess
451 }877 }
452 878
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+
453 type manualSnapshotClock struct {964 type manualSnapshotClock struct {
454 mu sync.Mutex965 mu sync.Mutex
455 timers []*manualSnapshotTimer966 timers []*manualSnapshotTimer
@@ -487,6 +998,19 @@ func (c *manualSnapshotClock) fireNext(t *testing.T) {
487 }998 }
488 }999 }
489 1000
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+
490 type manualSnapshotTimer struct{ ch chan time.Time }1014 type manualSnapshotTimer struct{ ch chan time.Time }
491 1015
492 func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch }1016 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 @@
1 package snapshot1 package snapshot
2 2
3 import (3 import (
4- "bytes"
5- "compress/flate"
6 "encoding/binary"4 "encoding/binary"
7 "errors"5 "errors"
8 "fmt"6 "fmt"
9 "hash/crc32"7 "hash/crc32"
10- "io"
11 "math"8 "math"
12 9
13 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
14- "github.com/bnema/vev/pkg/renderer"
15 )11 )
16 12
17 var (13 var (
@@ -20,60 +16,44 @@ var (
20 ErrBadCRC = errors.New("snapshot: bad crc")16 ErrBadCRC = errors.New("snapshot: bad crc")
21 ErrShortPayload = errors.New("snapshot: payload too short")17 ErrShortPayload = errors.New("snapshot: payload too short")
22 ErrTrailingBytes = errors.New("snapshot: trailing bytes")18 ErrTrailingBytes = errors.New("snapshot: trailing bytes")
23- ErrStyleOOB = errors.New("snapshot: style index out of range")
24 ErrUnknownPane = errors.New("snapshot: tree references unknown pane")19 ErrUnknownPane = errors.New("snapshot: tree references unknown pane")
25 ErrInvalidData = errors.New("snapshot: invalid data")20 ErrInvalidData = errors.New("snapshot: invalid data")
26 )21 )
27 22
28 const (23 const (
29- magic = "VEVS"24+ magic = "VEVS"
30- version = uint16(2)25+ version = uint16(3)
31- flagFlate = uint16(1 << 0)26+ maxDecodedBodySize = 256 << 20
32- flateCutoff = 4 << 1027+ maxSnapshotBlobs = 1 << 16
33- maxDecodedBodySize = 256 << 2028+ maxSnapshotBytes = 256 << 20
34- maxDecodedRows = 1 << 2029+ maxSnapshotRows = 1 << 20
35- maxDecodedRuns = 16 << 2030+ maxSnapshotCells = 16 << 20
36- maxDecodedCells = 16 << 2031+ 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
37 )35 )
38 36
39-// Marshal encodes a v2 durable snapshot.37+// Marshal encodes the strict v3, flags-zero durable envelope.
40 func Marshal(s Session) ([]byte, error) {38 func Marshal(s Session) ([]byte, error) {
41 var w payloadWriter39 var w payloadWriter
42 if err := writeSession(&w, s); err != nil {40 if err := writeSession(&w, s); err != nil {
43 return nil, err41 return nil, err
44 }42 }
45- body := w.b43+ if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 {
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 {
64 return nil, fmt.Errorf("%w: body too large", ErrInvalidData)44 return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
65 }45 }
66- out := make([]byte, 16+len(body))46+ out := make([]byte, 16+len(w.b))
67 copy(out[:4], magic)47 copy(out[:4], magic)
68 binary.BigEndian.PutUint16(out[4:6], version)48 binary.BigEndian.PutUint16(out[4:6], version)
69- binary.BigEndian.PutUint16(out[6:8], flags)49+ binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b)))
70- binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))50+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b))
71- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))51+ copy(out[16:], w.b)
72- copy(out[16:], body)
73 return out, nil52 return out, nil
74 }53 }
75 54
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.
77 func Unmarshal(b []byte) (Session, error) {57 func Unmarshal(b []byte) (Session, error) {
78 if len(b) < 16 {58 if len(b) < 16 {
79 return Session{}, ErrShortPayload59 return Session{}, ErrShortPayload
@@ -84,38 +64,25 @@ func Unmarshal(b []byte) (Session, error) {
84 if binary.BigEndian.Uint16(b[4:6]) != version {64 if binary.BigEndian.Uint16(b[4:6]) != version {
85 return Session{}, ErrBadVersion65 return Session{}, ErrBadVersion
86 }66 }
87- flags := binary.BigEndian.Uint16(b[6:8])67+ if binary.BigEndian.Uint16(b[6:8]) != 0 {
88- if flags&^flagFlate != 0 {68+ return Session{}, fmt.Errorf("%w: flags", ErrInvalidData)
89- return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData)
90 }69 }
91- bodyLen := int(binary.BigEndian.Uint32(b[8:12]))70+ bodyLen := binary.BigEndian.Uint32(b[8:12])
92 if bodyLen > maxDecodedBodySize {71 if bodyLen > maxDecodedBodySize {
93 return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData)72 return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData)
94 }73 }
95- if len(b) < 16+bodyLen {74+ if uint64(len(b)) < 16+uint64(bodyLen) {
96 return Session{}, ErrShortPayload75 return Session{}, ErrShortPayload
97 }76 }
98- if len(b) != 16+bodyLen {77+ if uint64(len(b)) != 16+uint64(bodyLen) {
99 return Session{}, ErrTrailingBytes78 return Session{}, ErrTrailingBytes
100 }79 }
101 body := b[16:]80 body := b[16:]
102 if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) {81 if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) {
103 return Session{}, ErrBadCRC82 return Session{}, ErrBadCRC
104 }83 }
105- if flags&flagFlate != 0 {84+ if err := preflightSession(body); err != nil {
106- fr := flate.NewReader(bytes.NewReader(body))85+ return Session{}, err
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
119 }86 }
120 r := payloadReader{b: body}87 r := payloadReader{b: body}
121 s, err := readSession(&r)88 s, err := readSession(&r)
@@ -240,6 +207,18 @@ func (r *payloadReader) getStrings() ([]string, error) {
240 }207 }
241 return out, nil208 return out, nil
242 }209 }
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+}
243 func (r *payloadReader) done() error {222 func (r *payloadReader) done() error {
244 if len(r.b) != 0 {223 if len(r.b) != 0 {
245 return ErrTrailingBytes224 return ErrTrailingBytes
@@ -248,14 +227,17 @@ func (r *payloadReader) done() error {
248 }227 }
249 228
250 func writeSession(w *payloadWriter, s Session) error {229 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+ }
251 if err := w.putString(s.Name); err != nil {236 if err := w.putString(s.Name); err != nil {
252 return err237 return err
253 }238 }
254 w.putUint64(s.CreatedAt)239 w.putUint64(s.CreatedAt)
255 w.putUint16(s.Active)240 w.putUint16(s.Active)
256- if len(s.Tabs) > math.MaxUint16 {
257- return fmt.Errorf("%w: too many tabs", ErrInvalidData)
258- }
259 w.putUint16(uint16(len(s.Tabs)))241 w.putUint16(uint16(len(s.Tabs)))
260 for _, t := range s.Tabs {242 for _, t := range s.Tabs {
261 if err := writeTab(w, t); err != nil {243 if err := writeTab(w, t); err != nil {
@@ -282,6 +264,9 @@ func readSession(r *payloadReader) (Session, error) {
282 if err != nil {264 if err != nil {
283 return Session{}, err265 return Session{}, err
284 }266 }
267+ if err := validateActive(active, int(nt)); err != nil {
268+ return Session{}, err
269+ }
285 s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)}270 s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)}
286 for i := range s.Tabs {271 for i := range s.Tabs {
287 t, err := readTab(r)272 t, err := readTab(r)
@@ -464,24 +449,27 @@ func writePane(w *payloadWriter, p Pane) error {
464 if err := w.putString(p.Cwd); err != nil {449 if err := w.putString(p.Cwd); err != nil {
465 return err450 return err
466 }451 }
467- styles := styleTable(p)452+ if len(p.SealedChunks) > maxSnapshotBlobs {
468- if len(styles) > math.MaxUint16 {453+ return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
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)
474 }454 }
475- idx := make(map[renderer.Style]uint16, len(styles))455+ w.putUint32(uint32(len(p.SealedChunks)))
476- for i, s := range styles {456+ for _, blob := range p.SealedChunks {
477- idx[s] = uint16(i)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...)
478 }462 }
479- if err := writeRows(w, p.Scrollback, idx, false); err != nil {463+ if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 {
480- return err464+ return fmt.Errorf("%w: tail length", ErrInvalidData)
481 }465 }
482- if err := writeRows(w, p.Visible, idx, true); err != nil {466+ w.putUint32(uint32(len(p.Tail)))
483- return err467+ 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)
484 }470 }
471+ w.putUint32(uint32(len(p.Visible)))
472+ w.b = append(w.b, p.Visible...)
485 return writeProcess(w, p.Process)473 return writeProcess(w, p.Process)
486 }474 }
487 475
@@ -498,24 +486,24 @@ func readPane(r *payloadReader) (Pane, error) {
498 if err != nil {486 if err != nil {
499 return Pane{}, err487 return Pane{}, err
500 }488 }
501- ns, err := r.getUint16()489+ n, err := r.getUint32()
502 if err != nil {490 if err != nil {
503 return Pane{}, err491 return Pane{}, err
504 }492 }
505- styles := make([]renderer.Style, ns)493+ if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
506- for i := range styles {494+ return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData)
507- s, err := readStyle(r)495+ }
508- if err != nil {496+ sealed := make([][]byte, n)
497+ for i := range sealed {
498+ if sealed[i], err = r.getBlob(); err != nil {
509 return Pane{}, err499 return Pane{}, err
510 }500 }
511- styles[i] = s
512 }501 }
513- budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns}502+ tail, err := r.getBlob()
514- sb, err := readRows(r, styles, &budget)
515 if err != nil {503 if err != nil {
516 return Pane{}, err504 return Pane{}, err
517 }505 }
518- vis, err := readRows(r, styles, &budget)506+ visible, err := r.getBlob()
519 if err != nil {507 if err != nil {
520 return Pane{}, err508 return Pane{}, err
521 }509 }
@@ -523,7 +511,7 @@ func readPane(r *payloadReader) (Pane, error) {
523 if err != nil {511 if err != nil {
524 return Pane{}, err512 return Pane{}, err
525 }513 }
526- return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil514+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil
527 }515 }
528 516
529 func writeProcess(w *payloadWriter, p *Process) error {517 func writeProcess(w *payloadWriter, p *Process) error {
@@ -573,238 +561,21 @@ func readProcess(r *payloadReader) (*Process, error) {
573 return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil561 return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil
574 }562 }
575 563
576-func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {564+func validateActive(active uint16, tabCount int) error {
577- clean := trimRows(rows, trimBlankRows)565+ if tabCount == 0 {
578- if len(clean) > math.MaxUint32 {566+ if active == 0 {
579- return fmt.Errorf("%w: too many rows", ErrInvalidData)567+ return nil
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)
586 }568 }
587- w.putUint16(uint16(len(runs)))569+ return fmt.Errorf("%w: active tab", ErrInvalidData)
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
611 }570 }
612- if n > maxDecodedRows {571+ if int(active) >= tabCount {
613- return nil, fmt.Errorf("%w: too many rows", ErrInvalidData)572+ return fmt.Errorf("%w: active tab", ErrInvalidData)
614 }573 }
615- if uint64(n) > uint64(len(r.b))/2 {574+ return nil
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
806 }575 }
807 576
577+// validateTree checks that every pane reference in the layout tree resolves
578+// to a pane declared by the enclosing tab.
808 func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {579 func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
809 if t == nil || t.Root == nil {580 if t == nil || t.Root == nil {
810 return nil581 return nil
M internal/usecase/snapshot/codec_test.go +390 -312
@@ -6,154 +6,292 @@ import (
6 "errors"6 "errors"
7 "hash/crc32"7 "hash/crc32"
8 "math"8 "math"
9+ "strconv"
9 "testing"10 "testing"
10 11
11 "github.com/bnema/vev/internal/usecase/layout"12 "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/bnema/vev/pkg/renderer"13 "github.com/bnema/vev/pkg/renderer"
14+ "github.com/bnema/vev/pkg/vt"
13 )15 )
14 16
15-func TestMarshalMinimalGolden(t *testing.T) {17+func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
16- got, err := Marshal(Session{Name: "s", CreatedAt: 7})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)
17 if err != nil {41 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)
19 }66 }
20- want := []byte{67+ frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible)
21- 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,68+ if err != nil {
22- 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,69+ t.Fatal(err)
23 }70 }
24- if !bytes.Equal(got, want) {71+ if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation {
25- t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want)72+ t.Fatalf("visible terminal data lost: %#v", frame.Row(0))
26 }73 }
27 }74 }
28 75
29-func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {76+func TestActiveTabReferenceMustBeExact(t *testing.T) {
30- cases := []struct {77+ validTab := Tab{}
31- name string78+ for _, tc := range []struct {
32- tab Tab79+ name string
80+ s Session
81+ valid bool
33 }{82 }{
34- {name: "nil tree", tab: Tab{Tree: nil}},83+ {name: "zero tabs active zero", s: Session{Active: 0}, valid: true},
35- {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},84+ {name: "zero tabs active nonzero", s: Session{Active: 1}},
36- }85+ {name: "nonempty tabs active in range", s: Session{Active: 0, Tabs: []Tab{validTab}}, valid: true},
37- for _, tc := range cases {86+ {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}},
38- t.Run(tc.name, func(t *testing.T) {87+ } {
39- b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})88+ t.Run("marshal "+tc.name, func(t *testing.T) {
40- if err != nil {89+ _, err := Marshal(tc.s)
41- t.Fatalf("Marshal() error = %v", err)90+ if tc.valid {
91+ requireNoError(t, err)
92+ return
42 }93 }
43- got, err := Unmarshal(b)94+ if !errors.Is(err, ErrInvalidData) {
44- if err != nil {95+ t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
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)
55 }96 }
56 })97 })
57 }98 }
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+ }
58 }111 }
59 112
60-func TestRoundTripSessions(t *testing.T) {113+func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {
61- boldRGB := renderer.DefaultStyle()114+ for _, version := range []uint16{1, 2} {
62- boldRGB.Bold = true115+ if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
63- boldRGB.HasForegroundRGB = true116+ t.Fatalf("version %d: %v", version, err)
64- boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}117+ }
65- indexed := renderer.DefaultStyle()118+ }
66- indexed.Foreground = 2119+}
67- indexed.Background = 4120+
68- indexed.Inverse = true121+func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {
69- indexed.Italic = true122+ body := v3Manifest(0, nil, nil, 0, nil, 0, nil)
70- deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{123+ valid := v3Envelope(version, 0, body)
71- layout.NewLeaf("1"),124+ badCRC := append([]byte(nil), valid...)
72- &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}},125+ badCRC[15] ^= 1
73- }}}126+ for _, tc := range []struct {
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 {
86 name string127 name string
87- s Session128+ data []byte
129+ want error
88 }{130 }{
89- {name: "multi tab deep tree", s: multi},131+ {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic},
90- {name: "empty and all blank", s: empty},132+ {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData},
91- {name: "large scrollback", s: large},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) })
92 }136 }
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+ } {
94 t.Run(tc.name, func(t *testing.T) {176 t.Run(tc.name, func(t *testing.T) {
95- b, err := Marshal(tc.s)177+ data, err := Marshal(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})
96- if err != nil {178+ requireNoError(t, err)
97- t.Fatalf("Marshal() error = %v", err)179+ reject(t, data, ErrInvalidData)
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- }
106 })180 })
107 }181 }
108 }182 }
109 183
110-func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {184+func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
111- good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})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}}}}})
112 if err != nil {188 if err != nil {
113 t.Fatal(err)189 t.Fatal(err)
114 }190 }
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 {
116 name string208 name string
117- data []byte209+ tab Tab
118 }{210 }{
119- {"bad magic", append([]byte("NOPE"), good[4:]...)},211+ {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: tail, Visible: visible}}}},
120- {"bad version", replaceU16(good, 4, 1)},212+ {name: "nonempty tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: sealed[0], Visible: visible}}}},
121- {"bad crc", append([]byte(nil), good[:len(good)-1]...)},213+ {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: tail, Visible: emptyVisible}}}},
122- {"trailing", append(append([]byte(nil), good...), 0)},214+ } {
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 {
129 t.Run(tc.name, func(t *testing.T) {215 t.Run(tc.name, func(t *testing.T) {
130- defer func() {216+ data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
131- if r := recover(); r != nil {217+ requireNoError(t, err)
132- t.Fatalf("Unmarshal panicked: %v", r)218+ reject(t, data, ErrInvalidData)
133- }219+ })
134- }()220+ }
135- if _, err := Unmarshal(tc.data); err == nil {221+}
136- t.Fatalf("Unmarshal() error = nil")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)
137 }247 }
138 })248 })
139 }249 }
140- for i := 0; i < len(good); i++ {250+}
141- if _, err := Unmarshal(good[:i]); err == nil {251+
142- t.Fatalf("prefix length %d unexpectedly succeeded", i)252+func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
143- }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)
144 }278 }
145 }279 }
146 280
147 func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {281 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+ }})
149 if !errors.Is(err, ErrInvalidData) {285 if !errors.Is(err, ErrInvalidData) {
150 t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)286 t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
151 }287 }
152 }288 }
153 289
154 func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {290 func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
291+ tail := mustHistoryTail(t)
292+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
155 var w payloadWriter293 var w payloadWriter
156- _ = w.putString("x")294+ _ = w.putString("s")
157 w.putUint64(0)295 w.putUint64(0)
158 w.putUint16(0)296 w.putUint16(0)
159 w.putUint16(1)297 w.putUint16(1)
@@ -165,269 +303,209 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
165 _ = writeNode(&w, layout.NewLeaf("p"))303 _ = writeNode(&w, layout.NewLeaf("p"))
166 w.putUint16(1)304 w.putUint16(1)
167 _ = w.putString("p")305 _ = w.putString("p")
168- _ = w.putString("p_stable")
169 _ = w.putString("")306 _ = w.putString("")
170- w.putUint16(0)307+ _ = w.putString("")
171- w.putUint32(0)
172 w.putUint32(0)308 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...)
173 w.putUint8(1)313 w.putUint8(1)
174- w.putUint16(0)314+ w.putUint16(0) // argv count
175 _ = w.putString("generic")315 _ = w.putString("generic")
176 _ = w.putString("")316 _ = w.putString("")
177- _, err := Unmarshal(reheader(w.b, 0))317+ if _, err := Unmarshal(v3Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) {
178- if !errors.Is(err, ErrInvalidData) {
179 t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)318 t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
180 }319 }
181 }320 }
182 321
183-func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {322+func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
184- body := []byte{0}323+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})
185- oversizedRaw := reheader(body, 0)324+ for range 256 {
186- binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1))325+ history.Append([]renderer.Cell{{Rune: 'x'}})
187- oversizedFlate := reheader(body, flagFlate)326+ }
188- binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1))327+ sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
189- cases := []struct {328+ requireNoError(t, err)
190- name string329+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
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-}
205 330
206-func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) {331+ const paneCount = maxSnapshotRows/256 + 1
207- cases := []struct {332+ tabs := make([]Tab, paneCount)
208- name string333+ for i := range tabs {
209- data []byte334+ tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}
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},
214 }335 }
215- for _, tc := range cases {336+ data, err := Marshal(Session{Name: "over-budget", Tabs: tabs})
216- t.Run(tc.name, func(t *testing.T) {337+ requireNoError(t, err)
217- defer func() {338+
218- if r := recover(); r != nil {339+ allocs := testing.AllocsPerRun(3, func() {
219- t.Fatalf("Unmarshal panicked: %v", r)340+ _, err := Unmarshal(data)
220- }341+ if !errors.Is(err, ErrInvalidData) {
221- }()342+ panic(err)
222- _, err := Unmarshal(tc.data)343+ }
223- if !errors.Is(err, tc.want) {344+ })
224- t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want)345+ // The error and testing harness may allocate a small fixed amount. Rejecting
225- }346+ // before semantic maps/slices are built must not scale with paneCount.
226- })347+ if allocs > 8 {
348+ t.Fatalf("Unmarshal() allocations = %f, want fixed overhead only", allocs)
227 }349 }
228 }350 }
229 351
230-func TestFlateOnOff(t *testing.T) {352+func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) {
231- small, err := Marshal(Session{Name: "small"})353+ budget := preflightBudget{alloc: maxSnapshotDecodedAllocation - 1}
232- if err != nil {354+ if budget.addProduct(1, 2) {
233- t.Fatal(err)355+ t.Fatal("allocation budget accepted an over-limit aggregate")
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)
244 }356 }
245 }357 }
246 358
247-func BenchmarkMarshal10KRows(b *testing.B) {359+func TestPreflightRejectsBroadTreeBeforeSecondPassAllocation(t *testing.T) {
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")}}}}}360+ body := v3WideTreeManifest(32_769)
249- b.ReportAllocs()361+ if err := preflightSession(body); !errors.Is(err, ErrInvalidData) {
250- for i := 0; i < b.N; i++ {362+ t.Fatalf("preflightSession() error = %v, want %v", err, ErrInvalidData)
251- if _, err := Marshal(s); err != nil {
252- b.Fatal(err)
253- }
254 }363 }
255 }364 }
256 365
257-func rows(lines ...string) [][]renderer.Cell {366+func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) {
258- out := make([][]renderer.Cell, len(lines))367+ body := v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)
259- for i, line := range lines {368+ reject(t, v3Envelope(version, 0, body), ErrInvalidData)
260- for _, r := range line {
261- out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
262- }
263- }
264- return out
265 }369 }
266 370
267-func manyRows(n int) [][]renderer.Cell {371+func mustHistoryTail(t *testing.T) []byte {
268- out := make([][]renderer.Cell, n)372+ t.Helper()
269- for i := range out {373+ _, tail := historyBlobs(t, nil)
270- out[i] = rows("row data row data row data")[0]374+ return tail
271- }
272- return out
273 }375 }
274 376
275-func equalSession(a, b Session) bool {377+func requireNoError(t *testing.T, err error) {
276- if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {378+ t.Helper()
277- return false379+ if err != nil {
278- }380+ t.Fatal(err)
279- for i := range a.Tabs {
280- if !equalTab(a.Tabs[i], b.Tabs[i]) {
281- return false
282- }
283 }381 }
284- return true
285 }382 }
286 383
287-func equalTab(a, b Tab) bool {384+func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
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)) {385+ t.Helper()
289- return false386+ h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
387+ for _, row := range rows {
388+ h.Append(row)
290 }389 }
291- for i := range a.Panes {390+ sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
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) {391+ if err != nil {
293- return false392+ t.Fatal(err)
294- }
295 }393 }
296- return true394+ return sealed, tail
297 }395 }
298-396+func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte {
299-func equalProcess(a, b *Process) bool {397+ t.Helper()
300- if a == nil || b == nil {398+ width := 0
301- return a == b399+ for _, row := range rows {
400+ width = max(width, len(row))
302 }401 }
303- if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) {402+ f := renderer.NewFrame(width, len(rows))
304- return false403+ for y, row := range rows {
404+ copy(f.Row(y), row)
305 }405 }
306- for i := range a.Argv {406+ b, err := vt.MarshalVisible(f)
307- if a.Argv[i] != b.Argv[i] {407+ if err != nil {
308- return false408+ t.Fatal(err)
309- }
310 }409 }
311- return true410+ return b
312 }411 }
313-412+func requireV3Envelope(t *testing.T, data []byte) {
314-func treeRoot(t *layout.Tree) *layout.Node {413+ t.Helper()
315- if t == nil {414+ if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 {
316- return nil415+ t.Fatalf("not v3 envelope: % x", data)
317 }416 }
318- return t.Root
319 }417 }
320-418+func reject(t *testing.T, data []byte, want error) {
321-func equalNode(a, b *layout.Node) bool {419+ t.Helper()
322- if a == nil || b == nil {420+ defer func() {
323- return a == b421+ if r := recover(); r != nil {
324- }422+ t.Fatalf("panic: %v", r)
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
331 }423 }
424+ }()
425+ _, err := Unmarshal(data)
426+ if err == nil {
427+ t.Fatal("accepted malformed input")
332 }428 }
333- return true429+ if want != nil && !errors.Is(err, want) {
334-}430+ t.Fatalf("error = %v, want %v", err, want)
335-
336-func equalRows(a, b [][]renderer.Cell) bool {
337- if len(a) != len(b) {
338- return false
339 }431 }
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
351 }432 }
352-433+func v3Envelope(v, flags uint16, body []byte) []byte {
353-func trimSession(s Session) Session {434+ return v3EnvelopeWithLength(v, flags, body, uint32(len(body)))
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
361 }435 }
362-436+func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
363-func reheader(body []byte, flags uint16) []byte {
364 out := make([]byte, 16+len(body))437 out := make([]byte, 16+len(body))
365- copy(out[:4], []byte("VEVS"))438+ copy(out, magic)
366- binary.BigEndian.PutUint16(out[4:6], version)439+ binary.BigEndian.PutUint16(out[4:6], v)
367 binary.BigEndian.PutUint16(out[6:8], flags)440 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)
369 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))442 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
370 copy(out[16:], body)443 copy(out[16:], body)
371 return out444 return out
372 }445 }
373 446
374-func replaceU16(b []byte, off int, v uint16) []byte {447+func v3ActiveManifest(active, tabs uint16) []byte {
375- out := append([]byte(nil), b...)448+ var b []byte
376- binary.BigEndian.PutUint16(out[off:], v)449+ putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
377- return out450+ putS("s")
378-}451+ b = append(b, make([]byte, 8)...)
379-func replaceU32(b []byte, off int, v uint32) []byte {452+ b = binary.BigEndian.AppendUint16(b, active)
380- out := append([]byte(nil), b...)453+ b = binary.BigEndian.AppendUint16(b, tabs)
381- binary.BigEndian.PutUint32(out[off:], v)454+ for range tabs {
382- return out455+ putS("")
383-}456+ b = append(b, make([]byte, 2+2+8)...)
384-457+ putS("")
385-func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {458+ b = append(b, 3) // nil root
386- var w payloadWriter459+ b = binary.BigEndian.AppendUint16(b, 0)
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)
405 }460 }
406- return reheader(w.b, 0)461+ return b
407 }462 }
408 463
409-func styleOOBSnapshot(t *testing.T) []byte {464+// v3Manifest creates just enough session/tab/pane framing for hostile declarations.
410- b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}})465+// v3WideTreeManifest declares a broad but compact tree without allocating a
411- if err != nil {466+// matching layout.Node graph. It exercises preflight's manifest budget.
412- t.Fatal(err)467+func v3WideTreeManifest(children uint16) []byte {
413- }468+ var b []byte
414- bodyLen := int(binary.BigEndian.Uint32(b[8:12]))469+ putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
415- body := append([]byte(nil), b[16:16+bodyLen]...)470+ putS("s")
416- for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0471+ b = append(b, make([]byte, 8+2)...)
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' {472+ b = binary.BigEndian.AppendUint16(b, 1)
418- body[i+6] = 0473+ putS("")
419- body[i+7] = 2474+ b = append(b, make([]byte, 2+2+8)...)
420- return reheader(body, 0)475+ putS("")
421- }476+ b = append(b, 1, byte(layout.Horizontal)) // split node
422- }477+ b = binary.BigEndian.AppendUint16(b, children)
423- t.Fatal("run not found")478+ for range children {
424- return nil479+ b = append(b, 0) // leaf
480+ putS("")
481+ }
482+ b = binary.BigEndian.AppendUint16(b, 0) // panes
483+ return b
425 }484 }
426 485
427-func unknownPaneSnapshot(t *testing.T) []byte {486+func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte {
428- b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}})487+ var b []byte
429- if err != nil {488+ putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
430- t.Fatal(err)489+ putS("s")
431- }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...)
432 return b510 return b
433 }511 }
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 @@
1 package snapshot1 package snapshot
2 2
3-import (3+import "github.com/bnema/vev/internal/usecase/layout"
4- "github.com/bnema/vev/internal/usecase/layout"
5- "github.com/bnema/vev/pkg/renderer"
6-)
7 4
8 // Session is the durable transfer representation for a named daemon session.5 // Session is the durable transfer representation for a named daemon session.
9 type Session struct {6 type Session struct {
@@ -24,15 +21,16 @@ type Tab struct {
24 Panes []Pane21 Panes []Pane
25 }22 }
26 23
27-// Pane captures terminal state needed to restore a fresh shell in the same cwd24+// Pane captures terminal state as opaque, canonical VT blobs. Snapshot owns
28-// with its scrollback and final visible primary screen.25+// their length framing and order; pkg/vt owns their contents and validation.
29 type Pane struct {26 type Pane struct {
30- ID layout.PaneID27+ ID layout.PaneID
31- StableID string28+ StableID string
32- Cwd string29+ Cwd string
33- Scrollback [][]renderer.Cell30+ SealedChunks [][]byte // oldest-first, each a self-contained VT blob
34- Visible [][]renderer.Cell31+ Tail []byte // mandatory nonempty canonical VT blob
35- Process *Process32+ Visible []byte // mandatory nonempty canonical VT blob
33+ Process *Process
36 }34 }
37 35
38 // Process captures restorable foreground process metadata.36 // Process captures restorable foreground process metadata.
M internal/usecase/visualsearch/model_test.go +2 -2
@@ -61,14 +61,14 @@ func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
61 require.Equal(t, "alpha", matches[0].Text)61 require.Equal(t, "alpha", matches[0].Text)
62 }62 }
63 63
64-func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) {64+func TestVisualSearchCloneRetainsDocumentAndKeepsUIStateIndependent(t *testing.T) {
65 m := New(testSnapshot("alpha", "beta alpha"))65 m := New(testSnapshot("alpha", "beta alpha"))
66 for _, r := range "alpha" {66 for _, r := range "alpha" {
67 m.Insert(r)67 m.Insert(r)
68 }68 }
69 clone := m.Clone()69 clone := m.Clone()
70 70
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))
72 clone.Down()72 clone.Down()
73 clone.Insert('z')73 clone.Insert('z')
74 74
M pkg/vt/benchmark_test.go +158 -1
@@ -1,6 +1,163 @@
1 package vt1 package vt
2 2
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+}
4 161
5 func BenchmarkScreenShellRedrawBurst(b *testing.B) {162 func BenchmarkScreenShellRedrawBurst(b *testing.B) {
6 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")163 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 {
85 // never invoke it. Nil disables it.85 // never invoke it. Nil disables it.
86 OnClipboard func(b64 string)86 OnClipboard func(b64 string)
87 87
88+ history *History
89+
88 defaultFG renderer.RGB90 defaultFG renderer.RGB
89 defaultBG renderer.RGB91 defaultBG renderer.RGB
90 defaultColorsKnown bool92 defaultColorsKnown bool
@@ -128,15 +130,23 @@ func NewScreen(width, height int) *Screen {
128 return s130 return s
129 }131 }
130 132
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+
131 func (s *Screen) Resize(width, height int) {145 func (s *Screen) Resize(width, height int) {
132 if width == s.Frame.Width && height == s.Frame.Height {146 if width == s.Frame.Width && height == s.Frame.Height {
133 return147 return
134 }148 }
135- evict := func(row []renderer.Cell) {149+ evict := s.recordEvicted
136- if s.OnLineEvicted != nil {
137- s.OnLineEvicted(append([]renderer.Cell(nil), row...))
138- }
139- }
140 150
141 if s.alternate != nil {151 if s.alternate != nil {
142 var shift int152 var shift int
@@ -580,11 +590,20 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) {
580 }590 }
581 591
582 func (s *Screen) emitLineEvicted(top, n int) {592 func (s *Screen) emitLineEvicted(top, n int) {
583- if s.OnLineEvicted == nil || s.alternate != nil {593+ if s.alternate != nil {
584 return594 return
585 }595 }
586 for y := top; y < top+n; y++ {596 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...))
588 }607 }
589 }608 }
590 609
M pkg/vt/snapshot.go +206 -1
@@ -1,6 +1,193 @@
1 package vt1 package vt
2 2
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+}
4 191
5 // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows.192 // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows.
6 // When the alternate screen is active, it snapshots the saved primary screen193 // When the alternate screen is active, it snapshots the saved primary screen
@@ -16,3 +203,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell {
16 }203 }
17 return rows204 return rows
18 }205 }
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