refactor(snapshot): replace v2 snapshots with v3 chunks
- commit
- 89d8fa0303d60ba9b6cc5733b9aaf6de13260ae3
- author
- brice · · 2026-07-12 15:58 UTC
- parent
- d2b05f9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
25 files · +732 -1336
M internal/usecase/copy/mode.go +13 -12
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "unicode" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | + | "github.com/bnema/vev/pkg/vt" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits | |
| @@ -17,20 +18,20 @@ import ( | |||
| 17 | 18 | const OSC52MaxPayloadBytes = 75_000 | |
| 18 | 19 | ||
| 19 | 20 | // Snapshot is the immutable scrollback-mode document: history followed by the | |
| 20 | - | // visible screen. History rows are immutable by Scrollback's append contract; | |
| 21 | - | // the visible frame is cloned once when the snapshot is constructed. | |
| 21 | + | // visible screen. VT HistoryView retains immutable chunks; the visible frame is | |
| 22 | + | // cloned once when the snapshot is constructed. | |
| 22 | 23 | type Snapshot struct { | |
| 23 | - | history HistoryView | |
| 24 | + | history vt.HistoryView | |
| 24 | 25 | screen renderer.Frame | |
| 25 | 26 | Width int | |
| 26 | 27 | Height int | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | - | // NewSnapshot freezes the current scrollback view and clones the visible screen. | |
| 30 | - | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | |
| 31 | - | var history HistoryView | |
| 32 | - | if sb != nil { | |
| 33 | - | history = sb.View() | |
| 30 | + | // NewSnapshot freezes the current VT history view and clones the visible screen. | |
| 31 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |
| 32 | + | var history vt.HistoryView | |
| 33 | + | if historySource != nil { | |
| 34 | + | history = historySource.View() | |
| 34 | 35 | } | |
| 35 | 36 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | |
| 36 | 37 | } | |
| @@ -39,11 +40,11 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | |||
| 39 | 40 | // It is intended for callers that already have a complete document rather than | |
| 40 | 41 | // a scrollback and visible frame. | |
| 41 | 42 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |
| 42 | - | owned := make([][]renderer.Cell, len(rows)) | |
| 43 | - | for i, row := range rows { | |
| 44 | - | owned[i] = append([]renderer.Cell(nil), row...) | |
| 43 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256}) | |
| 44 | + | for _, row := range rows { | |
| 45 | + | history.Append(row) | |
| 45 | 46 | } | |
| 46 | - | return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height} | |
| 47 | + | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | // Len returns the number of document rows. | |
| @@ -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 emits | 13 | // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits | ||
| @@ -17,20 +18,20 @@ import ( | |||||
| 17 | const OSC52MaxPayloadBytes = 75_000 | 18 | const OSC52MaxPayloadBytes = 75_000 | ||
| 18 | 19 | ||||
| 19 | // Snapshot is the immutable scrollback-mode document: history followed by the | 20 | // 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 HistoryView | 24 | + | history vt.HistoryView |
| 24 | screen renderer.Frame | 25 | screen renderer.Frame | ||
| 25 | Width int | 26 | Width int | ||
| 26 | Height int | 27 | Height int | ||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 29 | - | // NewSnapshot freezes the current scrollback view and clones the visible screen. | 30 | + | // NewSnapshot freezes the current VT history view and clones the visible screen. |
| 30 | - | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | 31 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { |
| 31 | - | var history HistoryView | 32 | + | var history vt.HistoryView |
| 32 | - | if sb != nil { | 33 | + | if historySource != nil { |
| 33 | - | history = sb.View() | 34 | + | history = historySource.View() |
| 34 | } | 35 | } | ||
| 35 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | 36 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | ||
| 36 | } | 37 | } | ||
| @@ -39,11 +40,11 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | |||||
| 39 | // It is intended for callers that already have a complete document rather than | 40 | // It is intended for callers that already have a complete document rather than | ||
| 40 | // a scrollback and visible frame. | 41 | // a scrollback and visible frame. | ||
| 41 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | 42 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | ||
| 42 | - | owned := make([][]renderer.Cell, len(rows)) | 43 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256}) |
| 43 | - | for i, row := range rows { | 44 | + | for _, row := range rows { |
| 44 | - | owned[i] = append([]renderer.Cell(nil), row...) | 45 | + | history.Append(row) |
| 45 | } | 46 | } | ||
| 46 | - | return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height} | 47 | + | return Snapshot{history: history.SealAndView(), Width: width, Height: height} |
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | // Len returns the number of document rows. | 50 | // Len returns the number of document rows. | ||
M internal/usecase/copy/mode_test.go +12 -1
| @@ -7,11 +7,22 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | + | "github.com/bnema/vev/pkg/vt" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | 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 | + | ||
| 15 | 26 | func snapshot(lines []string, height int) Snapshot { | |
| 16 | 27 | rows := make([][]renderer.Cell, len(lines)) | |
| 17 | 28 | for i, line := range lines { | |
| @@ -485,7 +496,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) { | |||
| 485 | 496 | historyRows = 10_000 | |
| 486 | 497 | ) | |
| 487 | 498 | ||
| 488 | - | scrollback := NewScrollback(historyRows) | |
| 499 | + | scrollback := newHistory(historyRows) | |
| 489 | 500 | for range historyRows { | |
| 490 | 501 | scrollback.Append(row(strings.Repeat("x", width))) | |
| 491 | 502 | } | |
| @@ -7,11 +7,22 @@ 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 Snapshot | 14 | 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 | + | ||||
| 15 | func snapshot(lines []string, height int) Snapshot { | 26 | func snapshot(lines []string, height int) Snapshot { | ||
| 16 | rows := make([][]renderer.Cell, len(lines)) | 27 | rows := make([][]renderer.Cell, len(lines)) | ||
| 17 | for i, line := range lines { | 28 | for i, line := range lines { | ||
| @@ -485,7 +496,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) { | |||||
| 485 | historyRows = 10_000 | 496 | historyRows = 10_000 | ||
| 486 | ) | 497 | ) | ||
| 487 | 498 | ||||
| 488 | - | scrollback := NewScrollback(historyRows) | 499 | + | scrollback := newHistory(historyRows) |
| 489 | for range historyRows { | 500 | for range historyRows { | ||
| 490 | scrollback.Append(row(strings.Repeat("x", width))) | 501 | scrollback.Append(row(strings.Repeat("x", width))) | ||
| 491 | } | 502 | } | ||
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 | - | } | |
| @@ -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 | - | } | |
| @@ -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 | - | } | |
| @@ -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 | - | } | |||
M internal/usecase/daemon/copymode.go +1 -1
| @@ -66,7 +66,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 66 | 66 | // Capture the live pane under pane.mu; all subsequent copy interaction uses | |
| 67 | 67 | // the immutable document while holding only copyMu. | |
| 68 | 68 | p.mu.Lock() | |
| 69 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 69 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 70 | 70 | p.mu.Unlock() | |
| 71 | 71 | if !d.publishCopyMode(sess, ac, tb, p, document, nil) { | |
| 72 | 72 | return | |
| @@ -66,7 +66,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 66 | // Capture the live pane under pane.mu; all subsequent copy interaction uses | 66 | // 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 | return | 72 | return | ||
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
| @@ -54,7 +54,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 54 | 54 | sess.client = ac | |
| 55 | 55 | ac.setSession(sess) | |
| 56 | 56 | closing.mu.Lock() | |
| 57 | - | document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame) | |
| 57 | + | document := scopy.NewSnapshot(closing.history, closing.screen.Frame) | |
| 58 | 58 | closing.mu.Unlock() | |
| 59 | 59 | ac.overlays.copyMu.Lock() | |
| 60 | 60 | ac.overlays.copyPane = closing | |
| @@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 94 | 94 | tb := sess.activeTab() | |
| 95 | 95 | p := tb.focusedPane() | |
| 96 | 96 | p.mu.Lock() | |
| 97 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 97 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 98 | 98 | p.mu.Unlock() | |
| 99 | 99 | ||
| 100 | 100 | // Hold session validation after publication and make the captured target | |
| @@ -157,7 +157,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 157 | 157 | tb := sess.activeTab() | |
| 158 | 158 | p := tb.focusedPane() | |
| 159 | 159 | p.mu.Lock() | |
| 160 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 160 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 161 | 161 | p.mu.Unlock() | |
| 162 | 162 | tc.invalidate(sess, tb, p) | |
| 163 | 163 | ||
| @@ -54,7 +54,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||||
| 54 | sess.client = ac | 54 | 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 = closing | 60 | 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 target | 100 | // 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 | 112 | tb := sess.activeTab() | |
| 113 | 113 | pane := tb.focusedPane() | |
| 114 | 114 | pane.mu.Lock() | |
| 115 | - | snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame) | |
| 115 | + | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |
| 116 | 116 | pane.mu.Unlock() | |
| 117 | 117 | mode := scopy.NewMode(snap) | |
| 118 | 118 | bars := barState{status: sess.statusSegments(true)} | |
| @@ -147,7 +147,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 147 | 147 | tb := sess.activeTab() | |
| 148 | 148 | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 149 | 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 | 151 | mode := scopy.NewMode(snap) | |
| 152 | 152 | ||
| 153 | 153 | bars := barState{status: sess.statusSegments(true)} | |
| @@ -166,7 +166,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |||
| 166 | 166 | writes := make(chan []byte, 1) | |
| 167 | 167 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 168 | 168 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 169 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 169 | + | sess.tabs[0].focusedPane().history = newTestHistory(4) | |
| 170 | 170 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 171 | 171 | ||
| 172 | 172 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| @@ -293,8 +293,8 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||
| 293 | 293 | p, _ := newBlockingPTY(t) | |
| 294 | 294 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 295 | 295 | pane := sess.tabs[0].focusedPane() | |
| 296 | - | pane.scrollback = scopy.NewScrollback(4) | |
| 297 | - | pane.scrollback.Append(testRow("old alpha")) | |
| 296 | + | pane.history = newTestHistory(4) | |
| 297 | + | pane.history.Append(testRow("old alpha")) | |
| 298 | 298 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | |
| 299 | 299 | ||
| 300 | 300 | d.enterCopyMode(sess, ac) | |
| @@ -322,9 +322,9 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 322 | 322 | writes := make(chan []byte, 1) | |
| 323 | 323 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 324 | 324 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 325 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 326 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 ")) | |
| 327 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 ")) | |
| 325 | + | sess.tabs[0].focusedPane().history = newTestHistory(4) | |
| 326 | + | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) | |
| 327 | + | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) | |
| 328 | 328 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 329 | 329 | ||
| 330 | 330 | d.enterCopyMode(sess, ac) | |
| @@ -445,7 +445,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 445 | 445 | } | |
| 446 | 446 | p.mu.Lock() | |
| 447 | 447 | defer p.mu.Unlock() | |
| 448 | - | return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12 | |
| 448 | + | return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12 | |
| 449 | 449 | }, 2*time.Second, 5*time.Millisecond) | |
| 450 | 450 | ||
| 451 | 451 | sess := firstSession(d) | |
| @@ -481,7 +481,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 481 | 481 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |
| 482 | 482 | p, _ := newBlockingPTY(t) | |
| 483 | 483 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 484 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 484 | + | sess.tabs[0].focusedPane().history = newTestHistory(4) | |
| 485 | 485 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 486 | 486 | ||
| 487 | 487 | d.enterCopyMode(sess, ac) | |
| @@ -515,9 +515,9 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 515 | 515 | t.Run(tc.name, func(t *testing.T) { | |
| 516 | 516 | p, _ := newBlockingPTY(t) | |
| 517 | 517 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 518 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 519 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 ")) | |
| 520 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 ")) | |
| 518 | + | sess.tabs[0].focusedPane().history = newTestHistory(4) | |
| 519 | + | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) | |
| 520 | + | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) | |
| 521 | 521 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 522 | 522 | ||
| 523 | 523 | d.enterCopyMode(sess, ac) | |
| @@ -535,7 +535,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 535 | 535 | func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | |
| 536 | 536 | p, _ := newBlockingPTY(t) | |
| 537 | 537 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 538 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1) | |
| 538 | + | sess.tabs[0].focusedPane().history = newTestHistory(1) | |
| 539 | 539 | longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1) | |
| 540 | 540 | sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1) | |
| 541 | 541 | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine)) | |
| @@ -560,7 +560,7 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 560 | 560 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 561 | 561 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 562 | 562 | d.clock = clk | |
| 563 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 563 | + | sess.tabs[0].focusedPane().history = newTestHistory(4) | |
| 564 | 564 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 565 | 565 | ||
| 566 | 566 | d.enterCopyMode(sess, ac) | |
| @@ -584,7 +584,7 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) { | |||
| 584 | 584 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 585 | 585 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 586 | 586 | d.clock = clk | |
| 587 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 587 | + | sess.tabs[0].focusedPane().history = newTestHistory(4) | |
| 588 | 588 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 589 | 589 | ||
| 590 | 590 | d.enterCopyMode(sess, ac) | |
| @@ -627,7 +627,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||
| 627 | 627 | t.Run(tc.name, func(t *testing.T) { | |
| 628 | 628 | p, _ := newBlockingPTY(t) | |
| 629 | 629 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 630 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 630 | + | sess.tabs[0].focusedPane().history = newTestHistory(4) | |
| 631 | 631 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 632 | 632 | ||
| 633 | 633 | d.enterCopyMode(sess, ac) | |
| @@ -676,7 +676,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) { | |||
| 676 | 676 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |
| 677 | 677 | p, _ := newBlockingPTY(t) | |
| 678 | 678 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 679 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 679 | + | sess.tabs[0].focusedPane().history = newTestHistory(4) | |
| 680 | 680 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 681 | 681 | ||
| 682 | 682 | done := make(chan struct{}) | |
| @@ -750,11 +750,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||
| 750 | 750 | var want *pane | |
| 751 | 751 | if tc.useFloating { | |
| 752 | 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 | 754 | want.screen.Write([]byte("flt-live")) | |
| 755 | 755 | } else { | |
| 756 | 756 | want = main | |
| 757 | - | want.scrollback.Append(testRow("one-old")) | |
| 757 | + | want.history.Append(testRow("one-old")) | |
| 758 | 758 | want.screen.Write([]byte("one-live")) | |
| 759 | 759 | } | |
| 760 | 760 | ||
| @@ -791,11 +791,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 791 | 791 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 792 | 792 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 793 | 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 | 796 | fp.screen.Write([]byte("live")) | |
| 797 | 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 | 799 | fp.mu.Unlock() | |
| 800 | 800 | ||
| 801 | 801 | d.enterCopyMode(sess, ac) | |
| @@ -817,7 +817,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 817 | 817 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 818 | 818 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 819 | 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 | 822 | fp.screen.Write([]byte("live")) | |
| 823 | 823 | ||
| @@ -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 | + | sess.tabs[0].focusedPane().history = newTestHistory(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 | + | pane.history = newTestHistory(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 | + | sess.tabs[0].focusedPane().history = newTestHistory(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() >= 12 | 448 | + | 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 | + | sess.tabs[0].focusedPane().history = newTestHistory(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 | + | sess.tabs[0].focusedPane().history = newTestHistory(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 | + | sess.tabs[0].focusedPane().history = newTestHistory(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 = clk | 562 | d.clock = clk | ||
| 563 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 563 | + | sess.tabs[0].focusedPane().history = newTestHistory(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 = clk | 586 | d.clock = clk | ||
| 587 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 587 | + | sess.tabs[0].focusedPane().history = newTestHistory(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 | + | sess.tabs[0].focusedPane().history = newTestHistory(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 | + | sess.tabs[0].focusedPane().history = newTestHistory(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 *pane | 750 | 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 = main | 756 | 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 | ||||
A internal/usecase/daemon/history_test_helper_test.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 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}) } | |
| @@ -0,0 +1,5 @@ | |||||
| 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}) } | |||
M internal/usecase/daemon/input.go +3 -3
| @@ -202,8 +202,8 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 202 | 202 | mouseMode, mouseSGR := p.screen.MouseMode() | |
| 203 | 203 | altScreen := p.screen.AltScreenActive() | |
| 204 | 204 | scrollbackRows := 0 | |
| 205 | - | if p.scrollback != nil { | |
| 206 | - | scrollbackRows = p.scrollback.Len() | |
| 205 | + | if p.history != nil { | |
| 206 | + | scrollbackRows = p.history.Len() | |
| 207 | 207 | } | |
| 208 | 208 | p.mu.Unlock() | |
| 209 | 209 | ||
| @@ -249,7 +249,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 249 | 249 | ||
| 250 | 250 | // The immutable snapshot is the only live-pane access needed for drag entry. | |
| 251 | 251 | p.mu.Lock() | |
| 252 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 252 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 253 | 253 | p.mu.Unlock() | |
| 254 | 254 | tb := sess.activeTab() | |
| 255 | 255 | if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) { | |
| @@ -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 := 0 | 204 | 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 +2 -3
| @@ -14,7 +14,6 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | 17 | "github.com/bnema/vev/internal/usecase/keys" | |
| 19 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | 19 | "github.com/bnema/vev/internal/usecase/picker" | |
| @@ -931,7 +930,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||
| 931 | 930 | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |
| 932 | 931 | p, _ := newBlockingPTY(t) | |
| 933 | 932 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 934 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50) | |
| 933 | + | sess.tabs[0].focusedPane().history = newTestHistory(50) | |
| 935 | 934 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 936 | 935 | ||
| 937 | 936 | // Press on a content row while scrollback is empty: the anchor is | |
| @@ -941,7 +940,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||
| 941 | 940 | // Simulate 5 lines evicted into scrollback between the Press and the | |
| 942 | 941 | // first Motion (e.g. the child kept producing output). | |
| 943 | 942 | for range 5 { | |
| 944 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted")) | |
| 943 | + | sess.tabs[0].focusedPane().history.Append(testRow("evicted")) | |
| 945 | 944 | } | |
| 946 | 945 | ||
| 947 | 946 | // Motion lands on wire row 3 (0-based row 2 of the *current* screen). | |
| @@ -14,7 +14,6 @@ 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" | ||
| @@ -931,7 +930,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||||
| 931 | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | 930 | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | ||
| 932 | p, _ := newBlockingPTY(t) | 931 | p, _ := newBlockingPTY(t) | ||
| 933 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 932 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 934 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50) | 933 | + | sess.tabs[0].focusedPane().history = newTestHistory(50) |
| 935 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | 934 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | ||
| 936 | 935 | ||||
| 937 | // Press on a content row while scrollback is empty: the anchor is | 936 | // Press on a content row while scrollback is empty: the anchor is | ||
| @@ -941,7 +940,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||||
| 941 | // Simulate 5 lines evicted into scrollback between the Press and the | 940 | // Simulate 5 lines evicted into scrollback between the Press and the | ||
| 942 | // first Motion (e.g. the child kept producing output). | 941 | // first Motion (e.g. the child kept producing output). | ||
| 943 | for range 5 { | 942 | for range 5 { | ||
| 944 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted")) | 943 | + | sess.tabs[0].focusedPane().history.Append(testRow("evicted")) |
| 945 | } | 944 | } | ||
| 946 | 945 | ||||
| 947 | // Motion lands on wire row 3 (0-based row 2 of the *current* screen). | 946 | // 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 | 173 | func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) { | |
| 174 | 174 | ac := &attachedClient{} | |
| 175 | 175 | ac.initOverlays() | |
| 176 | - | history := scopy.NewScrollback(1) | |
| 176 | + | history := newTestHistory(1) | |
| 177 | 177 | history.Append(testRow("before")) | |
| 178 | 178 | document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1)) | |
| 179 | 179 | ||
| @@ -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 +18 -19
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 10 | "github.com/bnema/vev/pkg/vt" | |
| 12 | 11 | ) | |
| @@ -16,15 +15,15 @@ import ( | |||
| 16 | 15 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 17 | 16 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | |
| 18 | 17 | type pane struct { | |
| 19 | - | id layout.PaneID | |
| 20 | - | stableID string | |
| 21 | - | pty ports.PTY | |
| 22 | - | mu sync.Mutex // guards screen, scrollback, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title | |
| 23 | - | resizeMu sync.Mutex // serializes PTY resizes without holding mu | |
| 24 | - | screen *vt.Screen | |
| 25 | - | scrollback *scopy.Scrollback | |
| 26 | - | syncGen uint64 | |
| 27 | - | rect domain.Rect | |
| 18 | + | id layout.PaneID | |
| 19 | + | stableID string | |
| 20 | + | pty ports.PTY | |
| 21 | + | mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title | |
| 22 | + | resizeMu sync.Mutex // serializes PTY resizes without holding mu | |
| 23 | + | screen *vt.Screen | |
| 24 | + | history *vt.History | |
| 25 | + | syncGen uint64 | |
| 26 | + | rect domain.Rect | |
| 28 | 27 | // resizeApplying gates VT parsing across PTY.Resize. The reader continues | |
| 29 | 28 | // draining into resizePending so output is replayed against the target (or | |
| 30 | 29 | // retained old) screen only after apply resolves. | |
| @@ -48,16 +47,16 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | |||
| 48 | 47 | } | |
| 49 | 48 | ||
| 50 | 49 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | |
| 51 | - | sb := scopy.NewScrollback(defaultScrollbackRows) | |
| 50 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | |
| 52 | 51 | screen := vt.NewScreen(sz.Cols, sz.Rows) | |
| 53 | - | screen.OnLineEvicted = sb.Append | |
| 52 | + | screen.OnLineEvicted = history.Append | |
| 54 | 53 | return &pane{ | |
| 55 | - | id: id, | |
| 56 | - | stableID: stableID, | |
| 57 | - | pty: pty, | |
| 58 | - | screen: screen, | |
| 59 | - | scrollback: sb, | |
| 60 | - | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |
| 61 | - | title: paneTitleState{displayFallback: "sh"}, | |
| 54 | + | id: id, | |
| 55 | + | stableID: stableID, | |
| 56 | + | pty: pty, | |
| 57 | + | screen: screen, | |
| 58 | + | history: history, | |
| 59 | + | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |
| 60 | + | title: paneTitleState{displayFallback: "sh"}, | |
| 62 | 61 | } | |
| 63 | 62 | } | |
| @@ -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.PaneID | 18 | + | id layout.PaneID |
| 20 | - | stableID string | 19 | + | stableID string |
| 21 | - | pty ports.PTY | 20 | + | pty ports.PTY |
| 22 | - | mu sync.Mutex // guards screen, scrollback, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title | 21 | + | mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title |
| 23 | - | resizeMu sync.Mutex // serializes PTY resizes without holding mu | 22 | + | resizeMu sync.Mutex // serializes PTY resizes without holding mu |
| 24 | - | screen *vt.Screen | 23 | + | screen *vt.Screen |
| 25 | - | scrollback *scopy.Scrollback | 24 | + | history *vt.History |
| 26 | - | syncGen uint64 | 25 | + | syncGen uint64 |
| 27 | - | rect domain.Rect | 26 | + | rect domain.Rect |
| 28 | // resizeApplying gates VT parsing across PTY.Resize. The reader continues | 27 | // resizeApplying gates VT parsing across PTY.Resize. The reader continues | ||
| 29 | // draining into resizePending so output is replayed against the target (or | 28 | // 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,16 @@ 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 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows}) |
| 52 | screen := vt.NewScreen(sz.Cols, sz.Rows) | 51 | screen := vt.NewScreen(sz.Cols, sz.Rows) | ||
| 53 | - | screen.OnLineEvicted = sb.Append | 52 | + | screen.OnLineEvicted = history.Append |
| 54 | return &pane{ | 53 | return &pane{ | ||
| 55 | - | id: id, | 54 | + | id: id, |
| 56 | - | stableID: stableID, | 55 | + | stableID: stableID, |
| 57 | - | pty: pty, | 56 | + | pty: pty, |
| 58 | - | screen: screen, | 57 | + | screen: screen, |
| 59 | - | scrollback: sb, | 58 | + | history: history, |
| 60 | - | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | 59 | + | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, |
| 61 | - | title: paneTitleState{displayFallback: "sh"}, | 60 | + | title: paneTitleState{displayFallback: "sh"}, |
| 62 | } | 61 | } | ||
| 63 | } | 62 | } | ||
M internal/usecase/daemon/performance_benchmark_test.go +2 -2
| @@ -145,7 +145,7 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |||
| 145 | 145 | for _, tb := range fixture.sess.tabs { | |
| 146 | 146 | require.Len(t, tb.panes, 4) | |
| 147 | 147 | for _, p := range tb.panes { | |
| 148 | - | require.Equal(t, 10_000, p.scrollback.Len()) | |
| 148 | + | require.Equal(t, 10_000, p.history.Len()) | |
| 149 | 149 | } | |
| 150 | 150 | } | |
| 151 | 151 | } | |
| @@ -550,7 +550,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||
| 550 | 550 | for _, p := range tb.panes { | |
| 551 | 551 | p.mu.Lock() | |
| 552 | 552 | width := p.screen.Frame.Width | |
| 553 | - | for row := 0; p.scrollback.Len() < historyRows; row++ { | |
| 553 | + | for row := 0; p.history.Len() < historyRows; row++ { | |
| 554 | 554 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | |
| 555 | 555 | } | |
| 556 | 556 | p.mu.Unlock() | |
| @@ -145,7 +145,7 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |||||
| 145 | for _, tb := range fixture.sess.tabs { | 145 | for _, tb := range fixture.sess.tabs { | ||
| 146 | require.Len(t, tb.panes, 4) | 146 | require.Len(t, tb.panes, 4) | ||
| 147 | for _, p := range tb.panes { | 147 | for _, p := range tb.panes { | ||
| 148 | - | require.Equal(t, 10_000, p.scrollback.Len()) | 148 | + | require.Equal(t, 10_000, p.history.Len()) |
| 149 | } | 149 | } | ||
| 150 | } | 150 | } | ||
| 151 | } | 151 | } | ||
| @@ -550,7 +550,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||||
| 550 | for _, p := range tb.panes { | 550 | for _, p := range tb.panes { | ||
| 551 | p.mu.Lock() | 551 | p.mu.Lock() | ||
| 552 | width := p.screen.Frame.Width | 552 | width := p.screen.Frame.Width | ||
| 553 | - | for row := 0; p.scrollback.Len() < historyRows; row++ { | 553 | + | for row := 0; p.history.Len() < historyRows; row++ { |
| 554 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | 554 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | ||
| 555 | } | 555 | } | ||
| 556 | p.mu.Unlock() | 556 | p.mu.Unlock() | ||
M internal/usecase/daemon/render_test.go +7 -7
| @@ -572,14 +572,14 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 572 | 572 | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4}) | |
| 573 | 573 | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | |
| 574 | 574 | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | |
| 575 | - | require.Equal(t, 2, win.focusedPane().scrollback.Len()) | |
| 576 | - | require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0))) | |
| 577 | - | require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1))) | |
| 575 | + | require.Equal(t, 2, win.focusedPane().history.Len()) | |
| 576 | + | require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0))) | |
| 577 | + | require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1))) | |
| 578 | 578 | ||
| 579 | 579 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | |
| 580 | 580 | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | |
| 581 | 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 | 585 | func frameRowString(f renderer.Frame, y int) string { | |
| @@ -911,7 +911,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||
| 911 | 911 | open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) }, | |
| 912 | 912 | close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }, | |
| 913 | 913 | prepareScreen: func(sess *session) { | |
| 914 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 914 | + | sess.tabs[0].focusedPane().history = newTestHistory(4) | |
| 915 | 915 | }, | |
| 916 | 916 | visible: "[SCROLL]", | |
| 917 | 917 | notVisible: "[SCROLL]", | |
| @@ -1295,7 +1295,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1295 | 1295 | } | |
| 1296 | 1296 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |
| 1297 | 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 | 1299 | mode := scopy.NewMode(document) | |
| 1300 | 1300 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |
| 1301 | 1301 | ||
| @@ -1355,7 +1355,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||
| 1355 | 1355 | defer releaseFloating() | |
| 1356 | 1356 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 1357 | 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 | 1359 | fp.screen.Write([]byte("flt-live")) | |
| 1360 | 1360 | installTestFloating(sess.activeTab(), fp, true) | |
| 1361 | 1361 | ||
| @@ -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 | + | sess.tabs[0].focusedPane().history = newTestHistory(4) |
| 915 | }, | 915 | }, | ||
| 916 | visible: "[SCROLL]", | 916 | visible: "[SCROLL]", | ||
| 917 | notVisible: "[SCROLL]", | 917 | notVisible: "[SCROLL]", | ||
| @@ -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 +5 -5
| @@ -24,19 +24,19 @@ func TestNewTabInitializesScrollback(t *testing.T) { | |||
| 24 | 24 | for _, tt := range tests { | |
| 25 | 25 | t.Run(tt.name, func(t *testing.T) { | |
| 26 | 26 | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) | |
| 27 | - | if win.focusedPane().scrollback == nil { | |
| 27 | + | if win.focusedPane().history == nil { | |
| 28 | 28 | t.Fatal("scrollback is nil") | |
| 29 | 29 | } | |
| 30 | - | if win.focusedPane().scrollback.Cap() != defaultScrollbackRows { | |
| 31 | - | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows) | |
| 30 | + | if win.focusedPane().history.Cap() != defaultScrollbackRows { | |
| 31 | + | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows) | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | 34 | win.focusedPane().screen.Write(tt.write) | |
| 35 | 35 | ||
| 36 | - | if got := win.focusedPane().scrollback.Len(); got != tt.wantLen { | |
| 36 | + | if got := win.focusedPane().history.Len(); got != tt.wantLen { | |
| 37 | 37 | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | |
| 38 | 38 | } | |
| 39 | - | row := win.focusedPane().scrollback.View().Row(0) | |
| 39 | + | row := win.focusedPane().history.View().Row(0) | |
| 40 | 40 | runes := make([]rune, len(row)) | |
| 41 | 41 | for i, c := range row { | |
| 42 | 42 | runes[i] = c.Rune | |
| @@ -24,19 +24,19 @@ func TestNewTabInitializesScrollback(t *testing.T) { | |||||
| 24 | for _, tt := range tests { | 24 | for _, tt := range tests { | ||
| 25 | t.Run(tt.name, func(t *testing.T) { | 25 | t.Run(tt.name, func(t *testing.T) { | ||
| 26 | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) | 26 | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) | ||
| 27 | - | if win.focusedPane().scrollback == nil { | 27 | + | if win.focusedPane().history == nil { |
| 28 | t.Fatal("scrollback is nil") | 28 | t.Fatal("scrollback is nil") | ||
| 29 | } | 29 | } | ||
| 30 | - | if win.focusedPane().scrollback.Cap() != defaultScrollbackRows { | 30 | + | if win.focusedPane().history.Cap() != defaultScrollbackRows { |
| 31 | - | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows) | 31 | + | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows) |
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | win.focusedPane().screen.Write(tt.write) | 34 | win.focusedPane().screen.Write(tt.write) | ||
| 35 | 35 | ||||
| 36 | - | if got := win.focusedPane().scrollback.Len(); got != tt.wantLen { | 36 | + | if got := win.focusedPane().history.Len(); got != tt.wantLen { |
| 37 | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | 37 | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | ||
| 38 | } | 38 | } | ||
| 39 | - | row := win.focusedPane().scrollback.View().Row(0) | 39 | + | row := win.focusedPane().history.View().Row(0) |
| 40 | runes := make([]rune, len(row)) | 40 | runes := make([]rune, len(row)) | ||
| 41 | for i, c := range row { | 41 | for i, c := range row { | ||
| 42 | runes[i] = c.Rune | 42 | runes[i] = c.Rune | ||
M internal/usecase/daemon/snapshot.go +37 -39
| @@ -7,10 +7,8 @@ import ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 11 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |
| 14 | 12 | "github.com/bnema/vev/pkg/vt" | |
| 15 | 13 | ) | |
| 16 | 14 | ||
| @@ -149,7 +147,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 149 | 147 | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | |
| 150 | 148 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |
| 151 | 149 | p.rect = contentRect | |
| 152 | - | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | |
| 150 | + | if err := restorePaneTerminal(p, paneSnap); err != nil { | |
| 151 | + | closeOpened() | |
| 152 | + | return err | |
| 153 | + | } | |
| 153 | 154 | if restoreCommand != "" { | |
| 154 | 155 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | |
| 155 | 156 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | |
| @@ -209,28 +210,30 @@ func markSnapshotDirty(sess *session) { | |||
| 209 | 210 | } | |
| 210 | 211 | } | |
| 211 | 212 | ||
| 212 | - | func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) { | |
| 213 | + | func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |
| 213 | 214 | p.mu.Lock() | |
| 214 | 215 | defer p.mu.Unlock() | |
| 215 | - | p.scrollback = scopy.NewScrollback(defaultScrollbackRows) | |
| 216 | - | for _, row := range scrollback { | |
| 217 | - | p.scrollback.Append(row) | |
| 218 | - | } | |
| 219 | - | if len(visible) > 0 { | |
| 220 | - | p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height) | |
| 221 | - | for y, row := range visible { | |
| 222 | - | if y >= p.screen.Frame.Height { | |
| 223 | - | break | |
| 224 | - | } | |
| 225 | - | for x, cell := range row { | |
| 226 | - | if x >= p.screen.Frame.Width { | |
| 227 | - | break | |
| 228 | - | } | |
| 229 | - | p.screen.Frame.Set(x, y, cell) | |
| 230 | - | } | |
| 231 | - | } | |
| 216 | + | // Unmarshal requires both blobs. The empty fallback only supports direct | |
| 217 | + | // in-process construction used by restore callers; it is unreachable for a | |
| 218 | + | // durable snapshot because the v3 manifest rejects missing blobs. | |
| 219 | + | tail := snap.Tail | |
| 220 | + | if len(tail) == 0 { | |
| 221 | + | tail, _ = vt.MarshalEmptyHistoryTail() | |
| 222 | + | } | |
| 223 | + | visible := snap.Visible | |
| 224 | + | if len(visible) == 0 { | |
| 225 | + | visible, _ = vt.MarshalVisible(p.screen.PrimaryVisibleFrame()) | |
| 226 | + | } | |
| 227 | + | history, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, tail) | |
| 228 | + | if err != nil { | |
| 229 | + | return fmt.Errorf("snapshot history: %w", err) | |
| 230 | + | } | |
| 231 | + | if err := p.screen.RestorePrimaryVisible(visible); err != nil { | |
| 232 | + | return fmt.Errorf("snapshot visible: %w", err) | |
| 232 | 233 | } | |
| 233 | - | p.screen.OnLineEvicted = p.scrollback.Append | |
| 234 | + | p.history = history | |
| 235 | + | p.screen.OnLineEvicted = history.Append | |
| 236 | + | return nil | |
| 234 | 237 | } | |
| 235 | 238 | ||
| 236 | 239 | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | |
| @@ -296,13 +299,19 @@ func (d *Daemon) captureSession(sess *session) bool { | |||
| 296 | 299 | if pty != nil { | |
| 297 | 300 | pid = pty.Pid() | |
| 298 | 301 | } | |
| 299 | - | paneSnap := snapcodec.Pane{ | |
| 300 | - | ID: p.id, | |
| 301 | - | StableID: p.stableID, | |
| 302 | - | Scrollback: cloneRows(p.scrollback.Snapshot()), | |
| 303 | - | Visible: cloneRows(p.screen.PrimaryVisibleRows()), | |
| 304 | - | } | |
| 302 | + | historyView := p.history.SealAndView() | |
| 303 | + | visibleFrame := p.screen.PrimaryVisibleFrame() | |
| 304 | + | paneID, paneStableID := p.id, p.stableID | |
| 305 | 305 | p.mu.Unlock() | |
| 306 | + | // Sealed VT chunks are immutable and visibleFrame is the one required | |
| 307 | + | // screen clone, so both encoders run after pane.mu is released. | |
| 308 | + | sealed, tail, historyErr := vt.MarshalSealedHistory(historyView) | |
| 309 | + | visible, visibleErr := vt.MarshalVisible(visibleFrame) | |
| 310 | + | if historyErr != nil || visibleErr != nil { | |
| 311 | + | d.log.Warn("capturing terminal snapshot failed", "session", name, "pane", paneID, "history_err", historyErr, "visible_err", visibleErr) | |
| 312 | + | return false | |
| 313 | + | } | |
| 314 | + | paneSnap := snapcodec.Pane{ID: paneID, StableID: paneStableID, SealedChunks: sealed, Tail: tail, Visible: visible} | |
| 306 | 315 | paneSnap.Cwd = fallbackCwd | |
| 307 | 316 | if d.procCwd != nil && pid > 0 { | |
| 308 | 317 | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |
| @@ -378,17 +387,6 @@ func (d *Daemon) snapshotSaver(ctx context.Context) { | |||
| 378 | 387 | } | |
| 379 | 388 | } | |
| 380 | 389 | ||
| 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 | 390 | func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool { | |
| 393 | 391 | if n == nil { | |
| 394 | 392 | return false | |
| @@ -7,10 +7,8 @@ import ( | |||||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 10 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 11 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 14 | "github.com/bnema/vev/pkg/vt" | 12 | "github.com/bnema/vev/pkg/vt" | ||
| 15 | ) | 13 | ) | ||
| 16 | 14 | ||||
| @@ -149,7 +147,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 149 | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | 147 | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | ||
| 150 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | 148 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | ||
| 151 | p.rect = contentRect | 149 | p.rect = contentRect | ||
| 152 | - | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | 150 | + | if err := restorePaneTerminal(p, paneSnap); err != nil { |
| 151 | + | closeOpened() | |||
| 152 | + | return err | |||
| 153 | + | } | |||
| 153 | if restoreCommand != "" { | 154 | if restoreCommand != "" { | ||
| 154 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | 155 | 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) | 156 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | ||
| @@ -209,28 +210,30 @@ func markSnapshotDirty(sess *session) { | |||||
| 209 | } | 210 | } | ||
| 210 | } | 211 | } | ||
| 211 | 212 | ||||
| 212 | - | func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) { | 213 | + | func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { |
| 213 | p.mu.Lock() | 214 | p.mu.Lock() | ||
| 214 | defer p.mu.Unlock() | 215 | defer p.mu.Unlock() | ||
| 215 | - | p.scrollback = scopy.NewScrollback(defaultScrollbackRows) | 216 | + | // Unmarshal requires both blobs. The empty fallback only supports direct |
| 216 | - | for _, row := range scrollback { | 217 | + | // in-process construction used by restore callers; it is unreachable for a |
| 217 | - | p.scrollback.Append(row) | 218 | + | // durable snapshot because the v3 manifest rejects missing blobs. |
| 218 | - | } | 219 | + | tail := snap.Tail |
| 219 | - | if len(visible) > 0 { | 220 | + | if len(tail) == 0 { |
| 220 | - | p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height) | 221 | + | tail, _ = vt.MarshalEmptyHistoryTail() |
| 221 | - | for y, row := range visible { | 222 | + | } |
| 222 | - | if y >= p.screen.Frame.Height { | 223 | + | visible := snap.Visible |
| 223 | - | break | 224 | + | if len(visible) == 0 { |
| 224 | - | } | 225 | + | visible, _ = vt.MarshalVisible(p.screen.PrimaryVisibleFrame()) |
| 225 | - | for x, cell := range row { | 226 | + | } |
| 226 | - | if x >= p.screen.Frame.Width { | 227 | + | history, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, tail) |
| 227 | - | break | 228 | + | if err != nil { |
| 228 | - | } | 229 | + | return fmt.Errorf("snapshot history: %w", err) |
| 229 | - | p.screen.Frame.Set(x, y, cell) | 230 | + | } |
| 230 | - | } | 231 | + | if err := p.screen.RestorePrimaryVisible(visible); err != nil { |
| 231 | - | } | 232 | + | return fmt.Errorf("snapshot visible: %w", err) |
| 232 | } | 233 | } | ||
| 233 | - | p.screen.OnLineEvicted = p.scrollback.Append | 234 | + | p.history = history |
| 235 | + | p.screen.OnLineEvicted = history.Append | |||
| 236 | + | return nil | |||
| 234 | } | 237 | } | ||
| 235 | 238 | ||||
| 236 | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | 239 | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | ||
| @@ -296,13 +299,19 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 296 | if pty != nil { | 299 | if pty != nil { | ||
| 297 | pid = pty.Pid() | 300 | pid = pty.Pid() | ||
| 298 | } | 301 | } | ||
| 299 | - | paneSnap := snapcodec.Pane{ | 302 | + | historyView := p.history.SealAndView() |
| 300 | - | ID: p.id, | 303 | + | visibleFrame := p.screen.PrimaryVisibleFrame() |
| 301 | - | StableID: p.stableID, | 304 | + | paneID, paneStableID := p.id, p.stableID |
| 302 | - | Scrollback: cloneRows(p.scrollback.Snapshot()), | |||
| 303 | - | Visible: cloneRows(p.screen.PrimaryVisibleRows()), | |||
| 304 | - | } | |||
| 305 | p.mu.Unlock() | 305 | p.mu.Unlock() | ||
| 306 | + | // Sealed VT chunks are immutable and visibleFrame is the one required | |||
| 307 | + | // screen clone, so both encoders run after pane.mu is released. | |||
| 308 | + | sealed, tail, historyErr := vt.MarshalSealedHistory(historyView) | |||
| 309 | + | visible, visibleErr := vt.MarshalVisible(visibleFrame) | |||
| 310 | + | if historyErr != nil || visibleErr != nil { | |||
| 311 | + | d.log.Warn("capturing terminal snapshot failed", "session", name, "pane", paneID, "history_err", historyErr, "visible_err", visibleErr) | |||
| 312 | + | return false | |||
| 313 | + | } | |||
| 314 | + | paneSnap := snapcodec.Pane{ID: paneID, StableID: paneStableID, SealedChunks: sealed, Tail: tail, Visible: visible} | |||
| 306 | paneSnap.Cwd = fallbackCwd | 315 | paneSnap.Cwd = fallbackCwd | ||
| 307 | if d.procCwd != nil && pid > 0 { | 316 | if d.procCwd != nil && pid > 0 { | ||
| 308 | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | 317 | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | ||
| @@ -378,17 +387,6 @@ func (d *Daemon) snapshotSaver(ctx context.Context) { | |||||
| 378 | } | 387 | } | ||
| 379 | } | 388 | } | ||
| 380 | 389 | ||||
| 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 { | 390 | func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool { | ||
| 393 | if n == nil { | 391 | if n == nil { | ||
| 394 | return false | 392 | return false | ||
M internal/usecase/daemon/snapshot_restore_test.go +38 -5
| @@ -18,8 +18,26 @@ import ( | |||
| 18 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 20 | 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 | + | ||
| 23 | 41 | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |
| 24 | 42 | store := &restoreSnapshotStore{} | |
| 25 | 43 | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| @@ -37,8 +55,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 37 | 55 | layout.NewLeaf("pane-2"), | |
| 38 | 56 | }}}, | |
| 39 | 57 | Panes: []snapcodec.Pane{ | |
| 40 | - | {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, | |
| 41 | - | {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}}, | |
| 58 | + | terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}), | |
| 59 | + | terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}), | |
| 42 | 60 | }, | |
| 43 | 61 | }}, | |
| 44 | 62 | })}} | |
| @@ -77,10 +95,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 77 | 95 | p := tb.panes["pane-2"] | |
| 78 | 96 | tb.mu.Unlock() | |
| 79 | 97 | p.mu.Lock() | |
| 80 | - | require.Equal(t, "old2", rowText(p.scrollback.View().Row(0))) | |
| 81 | - | require.Equal(t, 1, p.scrollback.Len()) | |
| 98 | + | require.Equal(t, "old2", rowText(p.history.View().Row(0))) | |
| 99 | + | require.Equal(t, 1, p.history.Len()) | |
| 82 | 100 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | |
| 83 | - | copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 101 | + | copySnap := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 84 | 102 | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | |
| 85 | 103 | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | |
| 86 | 104 | p.mu.Unlock() | |
| @@ -417,6 +435,21 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) { | |||
| 417 | 435 | ||
| 418 | 436 | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | |
| 419 | 437 | t.Helper() | |
| 438 | + | for ti := range s.Tabs { | |
| 439 | + | for pi := range s.Tabs[ti].Panes { | |
| 440 | + | p := &s.Tabs[ti].Panes[pi] | |
| 441 | + | if len(p.Tail) != 0 { | |
| 442 | + | continue | |
| 443 | + | } | |
| 444 | + | h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | |
| 445 | + | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |
| 446 | + | require.NoError(t, err) | |
| 447 | + | frame := renderer.NewFrame(int(s.Tabs[ti].Cols), int(s.Tabs[ti].Rows)) | |
| 448 | + | visible, err := vt.MarshalVisible(frame) | |
| 449 | + | require.NoError(t, err) | |
| 450 | + | p.SealedChunks, p.Tail, p.Visible = sealed, tail, visible | |
| 451 | + | } | |
| 452 | + | } | |
| 420 | 453 | b, err := snapcodec.Marshal(s) | |
| 421 | 454 | require.NoError(t, err) | |
| 422 | 455 | return b | |
| @@ -18,8 +18,26 @@ 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 | + | ||||
| 23 | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | 41 | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | ||
| 24 | store := &restoreSnapshotStore{} | 42 | store := &restoreSnapshotStore{} | ||
| 25 | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | 43 | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
| @@ -37,8 +55,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 37 | layout.NewLeaf("pane-2"), | 55 | layout.NewLeaf("pane-2"), | ||
| 38 | }}}, | 56 | }}}, | ||
| 39 | Panes: []snapcodec.Pane{ | 57 | Panes: []snapcodec.Pane{ | ||
| 40 | - | {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, | 58 | + | 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")}}, | 59 | + | terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}), |
| 42 | }, | 60 | }, | ||
| 43 | }}, | 61 | }}, | ||
| 44 | })}} | 62 | })}} | ||
| @@ -77,10 +95,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 77 | p := tb.panes["pane-2"] | 95 | p := tb.panes["pane-2"] | ||
| 78 | tb.mu.Unlock() | 96 | tb.mu.Unlock() | ||
| 79 | p.mu.Lock() | 97 | p.mu.Lock() | ||
| 80 | - | require.Equal(t, "old2", rowText(p.scrollback.View().Row(0))) | 98 | + | require.Equal(t, "old2", rowText(p.history.View().Row(0))) |
| 81 | - | require.Equal(t, 1, p.scrollback.Len()) | 99 | + | require.Equal(t, 1, p.history.Len()) |
| 82 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | 100 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | ||
| 83 | - | copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 101 | + | copySnap := scopy.NewSnapshot(p.history, p.screen.Frame) |
| 84 | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | 102 | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | ||
| 85 | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | 103 | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | ||
| 86 | p.mu.Unlock() | 104 | p.mu.Unlock() | ||
| @@ -417,6 +435,21 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) { | |||||
| 417 | 435 | ||||
| 418 | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | 436 | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | ||
| 419 | t.Helper() | 437 | t.Helper() | ||
| 438 | + | for ti := range s.Tabs { | |||
| 439 | + | for pi := range s.Tabs[ti].Panes { | |||
| 440 | + | p := &s.Tabs[ti].Panes[pi] | |||
| 441 | + | if len(p.Tail) != 0 { | |||
| 442 | + | continue | |||
| 443 | + | } | |||
| 444 | + | h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | |||
| 445 | + | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |||
| 446 | + | require.NoError(t, err) | |||
| 447 | + | frame := renderer.NewFrame(int(s.Tabs[ti].Cols), int(s.Tabs[ti].Rows)) | |||
| 448 | + | visible, err := vt.MarshalVisible(frame) | |||
| 449 | + | require.NoError(t, err) | |||
| 450 | + | p.SealedChunks, p.Tail, p.Visible = sealed, tail, visible | |||
| 451 | + | } | |||
| 452 | + | } | |||
| 420 | b, err := snapcodec.Marshal(s) | 453 | b, err := snapcodec.Marshal(s) | ||
| 421 | require.NoError(t, err) | 454 | require.NoError(t, err) | ||
| 422 | return b | 455 | return b | ||
M internal/usecase/daemon/snapshot_test.go +1 -1
| @@ -434,7 +434,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||
| 434 | 434 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | |
| 435 | 435 | p := tb.panes["pane-1"] | |
| 436 | 436 | p.screen.Write([]byte("hello")) | |
| 437 | - | p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | |
| 437 | + | p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | |
| 438 | 438 | sess := &session{ | |
| 439 | 439 | id: domain.SessionID("sess-" + name), | |
| 440 | 440 | name: name, | |
| @@ -434,7 +434,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||||
| 434 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | 434 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | ||
| 435 | p := tb.panes["pane-1"] | 435 | p := tb.panes["pane-1"] | ||
| 436 | p.screen.Write([]byte("hello")) | 436 | p.screen.Write([]byte("hello")) | ||
| 437 | - | p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | 437 | + | p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) |
| 438 | sess := &session{ | 438 | sess := &session{ | ||
| 439 | id: domain.SessionID("sess-" + name), | 439 | id: domain.SessionID("sess-" + name), | ||
| 440 | name: name, | 440 | name: name, | ||
M internal/usecase/snapshot/codec.go +225 -267
| @@ -1,17 +1,14 @@ | |||
| 1 | 1 | package snapshot | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "compress/flate" | |
| 6 | 4 | "encoding/binary" | |
| 7 | 5 | "errors" | |
| 8 | 6 | "fmt" | |
| 9 | 7 | "hash/crc32" | |
| 10 | - | "io" | |
| 11 | 8 | "math" | |
| 12 | 9 | ||
| 13 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | + | "github.com/bnema/vev/pkg/vt" | |
| 15 | 12 | ) | |
| 16 | 13 | ||
| 17 | 14 | var ( | |
| @@ -27,53 +24,34 @@ var ( | |||
| 27 | 24 | ||
| 28 | 25 | const ( | |
| 29 | 26 | magic = "VEVS" | |
| 30 | - | version = uint16(2) | |
| 31 | - | flagFlate = uint16(1 << 0) | |
| 32 | - | flateCutoff = 4 << 10 | |
| 27 | + | version = uint16(3) | |
| 33 | 28 | maxDecodedBodySize = 256 << 20 | |
| 34 | - | maxDecodedRows = 1 << 20 | |
| 35 | - | maxDecodedRuns = 16 << 20 | |
| 36 | - | maxDecodedCells = 16 << 20 | |
| 29 | + | maxSnapshotBlobs = 1 << 16 | |
| 30 | + | maxSnapshotBytes = 256 << 20 | |
| 31 | + | maxSnapshotRows = 1 << 20 | |
| 32 | + | maxSnapshotCells = 16 << 20 | |
| 37 | 33 | ) | |
| 38 | 34 | ||
| 39 | - | // Marshal encodes a v2 durable snapshot. | |
| 35 | + | // Marshal encodes the strict v3, flags-zero durable envelope. | |
| 40 | 36 | func Marshal(s Session) ([]byte, error) { | |
| 41 | 37 | var w payloadWriter | |
| 42 | 38 | if err := writeSession(&w, s); err != nil { | |
| 43 | 39 | return nil, err | |
| 44 | 40 | } | |
| 45 | - | body := w.b | |
| 46 | - | flags := uint16(0) | |
| 47 | - | if len(body) > flateCutoff { | |
| 48 | - | var compressed bytes.Buffer | |
| 49 | - | fw, err := flate.NewWriter(&compressed, flate.DefaultCompression) | |
| 50 | - | if err != nil { | |
| 51 | - | return nil, err | |
| 52 | - | } | |
| 53 | - | if _, err := fw.Write(body); err != nil { | |
| 54 | - | _ = fw.Close() | |
| 55 | - | return nil, err | |
| 56 | - | } | |
| 57 | - | if err := fw.Close(); err != nil { | |
| 58 | - | return nil, err | |
| 59 | - | } | |
| 60 | - | body = compressed.Bytes() | |
| 61 | - | flags |= flagFlate | |
| 62 | - | } | |
| 63 | - | if len(body) > math.MaxUint32 { | |
| 41 | + | if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 { | |
| 64 | 42 | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 65 | 43 | } | |
| 66 | - | out := make([]byte, 16+len(body)) | |
| 44 | + | out := make([]byte, 16+len(w.b)) | |
| 67 | 45 | copy(out[:4], magic) | |
| 68 | 46 | binary.BigEndian.PutUint16(out[4:6], version) | |
| 69 | - | binary.BigEndian.PutUint16(out[6:8], flags) | |
| 70 | - | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | |
| 71 | - | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |
| 72 | - | copy(out[16:], body) | |
| 47 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b))) | |
| 48 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b)) | |
| 49 | + | copy(out[16:], w.b) | |
| 73 | 50 | return out, nil | |
| 74 | 51 | } | |
| 75 | 52 | ||
| 76 | - | // Unmarshal decodes a strict v2 durable snapshot. | |
| 53 | + | // Unmarshal validates the complete manifest and all VT blobs without | |
| 54 | + | // allocations first, then decodes the already preflighted data. | |
| 77 | 55 | func Unmarshal(b []byte) (Session, error) { | |
| 78 | 56 | if len(b) < 16 { | |
| 79 | 57 | return Session{}, ErrShortPayload | |
| @@ -84,38 +62,25 @@ func Unmarshal(b []byte) (Session, error) { | |||
| 84 | 62 | if binary.BigEndian.Uint16(b[4:6]) != version { | |
| 85 | 63 | return Session{}, ErrBadVersion | |
| 86 | 64 | } | |
| 87 | - | flags := binary.BigEndian.Uint16(b[6:8]) | |
| 88 | - | if flags&^flagFlate != 0 { | |
| 89 | - | return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData) | |
| 65 | + | if binary.BigEndian.Uint16(b[6:8]) != 0 { | |
| 66 | + | return Session{}, fmt.Errorf("%w: flags", ErrInvalidData) | |
| 90 | 67 | } | |
| 91 | - | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | |
| 68 | + | bodyLen := binary.BigEndian.Uint32(b[8:12]) | |
| 92 | 69 | if bodyLen > maxDecodedBodySize { | |
| 93 | 70 | return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 94 | 71 | } | |
| 95 | - | if len(b) < 16+bodyLen { | |
| 72 | + | if uint64(len(b)) < 16+uint64(bodyLen) { | |
| 96 | 73 | return Session{}, ErrShortPayload | |
| 97 | 74 | } | |
| 98 | - | if len(b) != 16+bodyLen { | |
| 75 | + | if uint64(len(b)) != 16+uint64(bodyLen) { | |
| 99 | 76 | return Session{}, ErrTrailingBytes | |
| 100 | 77 | } | |
| 101 | 78 | body := b[16:] | |
| 102 | 79 | if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) { | |
| 103 | 80 | return Session{}, ErrBadCRC | |
| 104 | 81 | } | |
| 105 | - | if flags&flagFlate != 0 { | |
| 106 | - | fr := flate.NewReader(bytes.NewReader(body)) | |
| 107 | - | decompressed, err := io.ReadAll(io.LimitReader(fr, maxDecodedBodySize+1)) | |
| 108 | - | cerr := fr.Close() | |
| 109 | - | if err != nil { | |
| 110 | - | return Session{}, err | |
| 111 | - | } | |
| 112 | - | if cerr != nil { | |
| 113 | - | return Session{}, cerr | |
| 114 | - | } | |
| 115 | - | if len(decompressed) > maxDecodedBodySize { | |
| 116 | - | return Session{}, fmt.Errorf("%w: decompressed body too large", ErrInvalidData) | |
| 117 | - | } | |
| 118 | - | body = decompressed | |
| 82 | + | if err := preflightSession(body); err != nil { | |
| 83 | + | return Session{}, err | |
| 119 | 84 | } | |
| 120 | 85 | r := payloadReader{b: body} | |
| 121 | 86 | s, err := readSession(&r) | |
| @@ -240,6 +205,18 @@ func (r *payloadReader) getStrings() ([]string, error) { | |||
| 240 | 205 | } | |
| 241 | 206 | return out, nil | |
| 242 | 207 | } | |
| 208 | + | func (r *payloadReader) getBlob() ([]byte, error) { | |
| 209 | + | n, err := r.getUint32() | |
| 210 | + | if err != nil { | |
| 211 | + | return nil, err | |
| 212 | + | } | |
| 213 | + | if uint64(n) > uint64(len(r.b)) { | |
| 214 | + | return nil, ErrShortPayload | |
| 215 | + | } | |
| 216 | + | blob := append([]byte(nil), r.b[:n]...) | |
| 217 | + | r.b = r.b[n:] | |
| 218 | + | return blob, nil | |
| 219 | + | } | |
| 243 | 220 | func (r *payloadReader) done() error { | |
| 244 | 221 | if len(r.b) != 0 { | |
| 245 | 222 | return ErrTrailingBytes | |
| @@ -464,24 +441,27 @@ func writePane(w *payloadWriter, p Pane) error { | |||
| 464 | 441 | if err := w.putString(p.Cwd); err != nil { | |
| 465 | 442 | return err | |
| 466 | 443 | } | |
| 467 | - | styles := styleTable(p) | |
| 468 | - | if len(styles) > math.MaxUint16 { | |
| 469 | - | return fmt.Errorf("%w: too many styles", ErrInvalidData) | |
| 470 | - | } | |
| 471 | - | w.putUint16(uint16(len(styles))) | |
| 472 | - | for _, s := range styles { | |
| 473 | - | writeStyle(w, s) | |
| 444 | + | if len(p.SealedChunks) > maxSnapshotBlobs { | |
| 445 | + | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) | |
| 474 | 446 | } | |
| 475 | - | idx := make(map[renderer.Style]uint16, len(styles)) | |
| 476 | - | for i, s := range styles { | |
| 477 | - | idx[s] = uint16(i) | |
| 447 | + | w.putUint32(uint32(len(p.SealedChunks))) | |
| 448 | + | for _, blob := range p.SealedChunks { | |
| 449 | + | if len(blob) == 0 || len(blob) > math.MaxUint32 { | |
| 450 | + | return fmt.Errorf("%w: sealed chunk length", ErrInvalidData) | |
| 451 | + | } | |
| 452 | + | w.putUint32(uint32(len(blob))) | |
| 453 | + | w.b = append(w.b, blob...) | |
| 478 | 454 | } | |
| 479 | - | if err := writeRows(w, p.Scrollback, idx, false); err != nil { | |
| 480 | - | return err | |
| 455 | + | if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 { | |
| 456 | + | return fmt.Errorf("%w: tail length", ErrInvalidData) | |
| 481 | 457 | } | |
| 482 | - | if err := writeRows(w, p.Visible, idx, true); err != nil { | |
| 483 | - | return err | |
| 458 | + | w.putUint32(uint32(len(p.Tail))) | |
| 459 | + | w.b = append(w.b, p.Tail...) | |
| 460 | + | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | |
| 461 | + | return fmt.Errorf("%w: visible length", ErrInvalidData) | |
| 484 | 462 | } | |
| 463 | + | w.putUint32(uint32(len(p.Visible))) | |
| 464 | + | w.b = append(w.b, p.Visible...) | |
| 485 | 465 | return writeProcess(w, p.Process) | |
| 486 | 466 | } | |
| 487 | 467 | ||
| @@ -498,24 +478,24 @@ func readPane(r *payloadReader) (Pane, error) { | |||
| 498 | 478 | if err != nil { | |
| 499 | 479 | return Pane{}, err | |
| 500 | 480 | } | |
| 501 | - | ns, err := r.getUint16() | |
| 481 | + | n, err := r.getUint32() | |
| 502 | 482 | if err != nil { | |
| 503 | 483 | return Pane{}, err | |
| 504 | 484 | } | |
| 505 | - | styles := make([]renderer.Style, ns) | |
| 506 | - | for i := range styles { | |
| 507 | - | s, err := readStyle(r) | |
| 508 | - | if err != nil { | |
| 485 | + | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { | |
| 486 | + | return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData) | |
| 487 | + | } | |
| 488 | + | sealed := make([][]byte, n) | |
| 489 | + | for i := range sealed { | |
| 490 | + | if sealed[i], err = r.getBlob(); err != nil { | |
| 509 | 491 | return Pane{}, err | |
| 510 | 492 | } | |
| 511 | - | styles[i] = s | |
| 512 | 493 | } | |
| 513 | - | budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns} | |
| 514 | - | sb, err := readRows(r, styles, &budget) | |
| 494 | + | tail, err := r.getBlob() | |
| 515 | 495 | if err != nil { | |
| 516 | 496 | return Pane{}, err | |
| 517 | 497 | } | |
| 518 | - | vis, err := readRows(r, styles, &budget) | |
| 498 | + | visible, err := r.getBlob() | |
| 519 | 499 | if err != nil { | |
| 520 | 500 | return Pane{}, err | |
| 521 | 501 | } | |
| @@ -523,7 +503,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||
| 523 | 503 | if err != nil { | |
| 524 | 504 | return Pane{}, err | |
| 525 | 505 | } | |
| 526 | - | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil | |
| 506 | + | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil | |
| 527 | 507 | } | |
| 528 | 508 | ||
| 529 | 509 | func writeProcess(w *payloadWriter, p *Process) error { | |
| @@ -573,236 +553,214 @@ func readProcess(r *payloadReader) (*Process, error) { | |||
| 573 | 553 | return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil | |
| 574 | 554 | } | |
| 575 | 555 | ||
| 576 | - | func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error { | |
| 577 | - | clean := trimRows(rows, trimBlankRows) | |
| 578 | - | if len(clean) > math.MaxUint32 { | |
| 579 | - | return fmt.Errorf("%w: too many rows", ErrInvalidData) | |
| 580 | - | } | |
| 581 | - | w.putUint32(uint32(len(clean))) | |
| 582 | - | for _, row := range clean { | |
| 583 | - | runs := makeRuns(row) | |
| 584 | - | if len(runs) > math.MaxUint16 { | |
| 585 | - | return fmt.Errorf("%w: too many runs", ErrInvalidData) | |
| 586 | - | } | |
| 587 | - | w.putUint16(uint16(len(runs))) | |
| 588 | - | for _, run := range runs { | |
| 589 | - | w.putUint16(uint16(run.len)) | |
| 590 | - | w.putUint32(uint32(run.cell.Rune)) | |
| 591 | - | w.putUint16(idx[run.cell.Style]) | |
| 592 | - | var flags uint8 | |
| 593 | - | if run.cell.Continuation { | |
| 594 | - | flags |= 1 | |
| 595 | - | } | |
| 596 | - | w.putUint8(flags) | |
| 556 | + | // preflightSession walks framing and opaque VT blobs without constructing a | |
| 557 | + | // Session. All count-derived allocation happens only in the second pass. | |
| 558 | + | func preflightSession(body []byte) error { | |
| 559 | + | r := payloadReader{b: body} | |
| 560 | + | if err := skipString(&r); err != nil { | |
| 561 | + | return err | |
| 562 | + | } | |
| 563 | + | if _, err := r.getUint64(); err != nil { | |
| 564 | + | return err | |
| 565 | + | } | |
| 566 | + | if _, err := r.getUint16(); err != nil { | |
| 567 | + | return err | |
| 568 | + | } | |
| 569 | + | tabs, err := r.getUint16() | |
| 570 | + | if err != nil { | |
| 571 | + | return err | |
| 572 | + | } | |
| 573 | + | if uint64(tabs) > uint64(len(r.b))/2 { | |
| 574 | + | return ErrShortPayload | |
| 575 | + | } | |
| 576 | + | var totals vt.DecodeStats | |
| 577 | + | blobs := uint64(0) | |
| 578 | + | for range tabs { | |
| 579 | + | if err := preflightTab(&r, &totals, &blobs); err != nil { | |
| 580 | + | return fmt.Errorf("preflight tab: %w", err) | |
| 597 | 581 | } | |
| 598 | 582 | } | |
| 583 | + | if err := r.done(); err != nil { | |
| 584 | + | return err | |
| 585 | + | } | |
| 586 | + | if totals.Rows > maxSnapshotRows || totals.Cells > maxSnapshotCells || totals.Bytes > maxSnapshotBytes { | |
| 587 | + | return fmt.Errorf("%w: global VT budget", ErrInvalidData) | |
| 588 | + | } | |
| 599 | 589 | return nil | |
| 600 | 590 | } | |
| 601 | 591 | ||
| 602 | - | type rowBudget struct { | |
| 603 | - | cells int | |
| 604 | - | runs int | |
| 592 | + | func skipString(r *payloadReader) error { | |
| 593 | + | n, err := r.getUint16() | |
| 594 | + | if err != nil { | |
| 595 | + | return err | |
| 596 | + | } | |
| 597 | + | if uint64(n) > uint64(len(r.b)) { | |
| 598 | + | return ErrShortPayload | |
| 599 | + | } | |
| 600 | + | r.b = r.b[n:] | |
| 601 | + | return nil | |
| 605 | 602 | } | |
| 606 | 603 | ||
| 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 | |
| 604 | + | func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64) error { | |
| 605 | + | if err := skipString(r); err != nil { | |
| 606 | + | return err | |
| 611 | 607 | } | |
| 612 | - | if n > maxDecodedRows { | |
| 613 | - | return nil, fmt.Errorf("%w: too many rows", ErrInvalidData) | |
| 608 | + | if _, err := r.getUint16(); err != nil { | |
| 609 | + | return err | |
| 614 | 610 | } | |
| 615 | - | if uint64(n) > uint64(len(r.b))/2 { | |
| 616 | - | return nil, ErrShortPayload | |
| 611 | + | if _, err := r.getUint16(); err != nil { | |
| 612 | + | return err | |
| 617 | 613 | } | |
| 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 | - | } | |
| 614 | + | if _, err := r.getUint64(); err != nil { | |
| 615 | + | return err | |
| 673 | 616 | } | |
| 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 | |
| 617 | + | if err := skipString(r); err != nil { | |
| 618 | + | return err | |
| 619 | + | } | |
| 620 | + | if err := preflightNode(r, 0); err != nil { | |
| 621 | + | return err | |
| 622 | + | } | |
| 623 | + | panes, err := r.getUint16() | |
| 624 | + | if err != nil { | |
| 625 | + | return err | |
| 626 | + | } | |
| 627 | + | if uint64(panes) > uint64(len(r.b))/2 { | |
| 628 | + | return ErrShortPayload | |
| 685 | 629 | } | |
| 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}) | |
| 630 | + | for range panes { | |
| 631 | + | if err := preflightPane(r, totals, blobs); err != nil { | |
| 632 | + | return err | |
| 693 | 633 | } | |
| 694 | 634 | } | |
| 695 | - | return runs | |
| 635 | + | return nil | |
| 696 | 636 | } | |
| 697 | 637 | ||
| 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] | |
| 638 | + | func preflightNode(r *payloadReader, depth int) error { | |
| 639 | + | if depth > 64 { | |
| 640 | + | return fmt.Errorf("%w: tree depth", ErrInvalidData) | |
| 641 | + | } | |
| 642 | + | kind, err := r.getUint8() | |
| 643 | + | if err != nil { | |
| 644 | + | return err | |
| 645 | + | } | |
| 646 | + | switch kind { | |
| 647 | + | case 0: | |
| 648 | + | return skipString(r) | |
| 649 | + | case 1: | |
| 650 | + | if _, err := r.getUint8(); err != nil { | |
| 651 | + | return err | |
| 706 | 652 | } | |
| 653 | + | case 2: | |
| 654 | + | if err := skipString(r); err != nil { | |
| 655 | + | return err | |
| 656 | + | } | |
| 657 | + | case 3: | |
| 658 | + | return nil | |
| 659 | + | default: | |
| 660 | + | return fmt.Errorf("%w: node kind", ErrInvalidData) | |
| 707 | 661 | } | |
| 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-- | |
| 662 | + | children, err := r.getUint16() | |
| 663 | + | if err != nil { | |
| 664 | + | return err | |
| 715 | 665 | } | |
| 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 | - | } | |
| 666 | + | if uint64(children) > uint64(len(r.b)) { | |
| 667 | + | return ErrShortPayload | |
| 668 | + | } | |
| 669 | + | for range children { | |
| 670 | + | if err := preflightNode(r, depth+1); err != nil { | |
| 671 | + | return err | |
| 733 | 672 | } | |
| 734 | 673 | } | |
| 735 | - | addRows(p.Scrollback, false) | |
| 736 | - | addRows(p.Visible, true) | |
| 737 | - | return styles | |
| 674 | + | return nil | |
| 738 | 675 | } | |
| 739 | 676 | ||
| 740 | - | func writeStyle(w *payloadWriter, s renderer.Style) { | |
| 741 | - | var flags uint8 | |
| 742 | - | if s.Bold { | |
| 743 | - | flags |= 1 | |
| 677 | + | func preflightPane(r *payloadReader, totals *vt.DecodeStats, blobs *uint64) error { | |
| 678 | + | if err := skipString(r); err != nil { | |
| 679 | + | return err | |
| 680 | + | } | |
| 681 | + | if err := skipString(r); err != nil { | |
| 682 | + | return err | |
| 683 | + | } | |
| 684 | + | if err := skipString(r); err != nil { | |
| 685 | + | return err | |
| 744 | 686 | } | |
| 745 | - | if s.Inverse { | |
| 746 | - | flags |= 2 | |
| 687 | + | n, err := r.getUint32() | |
| 688 | + | if err != nil { | |
| 689 | + | return err | |
| 747 | 690 | } | |
| 748 | - | if s.HasForegroundRGB { | |
| 749 | - | flags |= 4 | |
| 691 | + | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { | |
| 692 | + | return fmt.Errorf("%w: sealed chunks", ErrInvalidData) | |
| 693 | + | } | |
| 694 | + | for range n { | |
| 695 | + | if err := preflightBlob(r, totals, blobs, true); err != nil { | |
| 696 | + | return err | |
| 697 | + | } | |
| 750 | 698 | } | |
| 751 | - | if s.HasBackgroundRGB { | |
| 752 | - | flags |= 8 | |
| 699 | + | if err := preflightBlob(r, totals, blobs, true); err != nil { | |
| 700 | + | return err | |
| 753 | 701 | } | |
| 754 | - | if s.Italic { | |
| 755 | - | flags |= 16 | |
| 702 | + | if err := preflightBlob(r, totals, blobs, false); err != nil { | |
| 703 | + | return err | |
| 756 | 704 | } | |
| 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) | |
| 705 | + | return preflightProcess(r) | |
| 766 | 706 | } | |
| 767 | 707 | ||
| 768 | - | func readStyle(r *payloadReader) (renderer.Style, error) { | |
| 769 | - | flags, err := r.getUint8() | |
| 708 | + | func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, history bool) error { | |
| 709 | + | n, err := r.getUint32() | |
| 770 | 710 | if err != nil { | |
| 771 | - | return renderer.Style{}, err | |
| 711 | + | return err | |
| 772 | 712 | } | |
| 773 | - | fg, err := r.getUint32() | |
| 774 | - | if err != nil { | |
| 775 | - | return renderer.Style{}, err | |
| 713 | + | if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes { | |
| 714 | + | return fmt.Errorf("%w: VT blob length", ErrInvalidData) | |
| 776 | 715 | } | |
| 777 | - | bg, err := r.getUint32() | |
| 778 | - | if err != nil { | |
| 779 | - | return renderer.Style{}, err | |
| 716 | + | if *blobs >= maxSnapshotBlobs { | |
| 717 | + | return fmt.Errorf("%w: too many VT blobs", ErrInvalidData) | |
| 780 | 718 | } | |
| 781 | - | fr, err := r.getUint8() | |
| 782 | - | if err != nil { | |
| 783 | - | return renderer.Style{}, err | |
| 719 | + | blob := r.b[:n] | |
| 720 | + | r.b = r.b[n:] | |
| 721 | + | var stats vt.DecodeStats | |
| 722 | + | if history { | |
| 723 | + | stats, err = vt.PreflightHistoryBlob(blob) | |
| 724 | + | } else { | |
| 725 | + | stats, err = vt.PreflightVisibleBlob(blob) | |
| 784 | 726 | } | |
| 785 | - | fgc, err := r.getUint8() | |
| 786 | 727 | if err != nil { | |
| 787 | - | return renderer.Style{}, err | |
| 728 | + | return fmt.Errorf("%w: VT blob", ErrInvalidData) | |
| 788 | 729 | } | |
| 789 | - | fb, err := r.getUint8() | |
| 790 | - | if err != nil { | |
| 791 | - | return renderer.Style{}, err | |
| 730 | + | if !totals.Add(stats) { | |
| 731 | + | return fmt.Errorf("%w: VT budget overflow", ErrInvalidData) | |
| 792 | 732 | } | |
| 793 | - | br, err := r.getUint8() | |
| 733 | + | *blobs++ | |
| 734 | + | return nil | |
| 735 | + | } | |
| 736 | + | ||
| 737 | + | func preflightProcess(r *payloadReader) error { | |
| 738 | + | present, err := r.getUint8() | |
| 794 | 739 | if err != nil { | |
| 795 | - | return renderer.Style{}, err | |
| 740 | + | return err | |
| 796 | 741 | } | |
| 797 | - | bgc, err := r.getUint8() | |
| 798 | - | if err != nil { | |
| 799 | - | return renderer.Style{}, err | |
| 742 | + | if present == 0 { | |
| 743 | + | return nil | |
| 744 | + | } | |
| 745 | + | if present != 1 { | |
| 746 | + | return fmt.Errorf("%w: process presence", ErrInvalidData) | |
| 800 | 747 | } | |
| 801 | - | bb, err := r.getUint8() | |
| 748 | + | n, err := r.getUint16() | |
| 802 | 749 | if err != nil { | |
| 803 | - | return renderer.Style{}, err | |
| 750 | + | return err | |
| 751 | + | } | |
| 752 | + | if n == 0 { | |
| 753 | + | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |
| 754 | + | } | |
| 755 | + | for range n { | |
| 756 | + | if err := skipString(r); err != nil { | |
| 757 | + | return err | |
| 758 | + | } | |
| 759 | + | } | |
| 760 | + | if err := skipString(r); err != nil { | |
| 761 | + | return err | |
| 804 | 762 | } | |
| 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 | |
| 763 | + | return skipString(r) | |
| 806 | 764 | } | |
| 807 | 765 | ||
| 808 | 766 | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | |
| @@ -1,17 +1,14 @@ | |||||
| 1 | package snapshot | 1 | 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" | 11 | + | "github.com/bnema/vev/pkg/vt" |
| 15 | ) | 12 | ) | ||
| 16 | 13 | ||||
| 17 | var ( | 14 | var ( | ||
| @@ -27,53 +24,34 @@ var ( | |||||
| 27 | 24 | ||||
| 28 | const ( | 25 | const ( | ||
| 29 | magic = "VEVS" | 26 | magic = "VEVS" | ||
| 30 | - | version = uint16(2) | 27 | + | version = uint16(3) |
| 31 | - | flagFlate = uint16(1 << 0) | |||
| 32 | - | flateCutoff = 4 << 10 | |||
| 33 | maxDecodedBodySize = 256 << 20 | 28 | maxDecodedBodySize = 256 << 20 | ||
| 34 | - | maxDecodedRows = 1 << 20 | 29 | + | maxSnapshotBlobs = 1 << 16 |
| 35 | - | maxDecodedRuns = 16 << 20 | 30 | + | maxSnapshotBytes = 256 << 20 |
| 36 | - | maxDecodedCells = 16 << 20 | 31 | + | maxSnapshotRows = 1 << 20 |
| 32 | + | maxSnapshotCells = 16 << 20 | |||
| 37 | ) | 33 | ) | ||
| 38 | 34 | ||||
| 39 | - | // Marshal encodes a v2 durable snapshot. | 35 | + | // Marshal encodes the strict v3, flags-zero durable envelope. |
| 40 | func Marshal(s Session) ([]byte, error) { | 36 | func Marshal(s Session) ([]byte, error) { | ||
| 41 | var w payloadWriter | 37 | var w payloadWriter | ||
| 42 | if err := writeSession(&w, s); err != nil { | 38 | if err := writeSession(&w, s); err != nil { | ||
| 43 | return nil, err | 39 | return nil, err | ||
| 44 | } | 40 | } | ||
| 45 | - | body := w.b | 41 | + | 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) | 42 | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | ||
| 65 | } | 43 | } | ||
| 66 | - | out := make([]byte, 16+len(body)) | 44 | + | out := make([]byte, 16+len(w.b)) |
| 67 | copy(out[:4], magic) | 45 | copy(out[:4], magic) | ||
| 68 | binary.BigEndian.PutUint16(out[4:6], version) | 46 | binary.BigEndian.PutUint16(out[4:6], version) | ||
| 69 | - | binary.BigEndian.PutUint16(out[6:8], flags) | 47 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b))) |
| 70 | - | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | 48 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b)) |
| 71 | - | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | 49 | + | copy(out[16:], w.b) |
| 72 | - | copy(out[16:], body) | |||
| 73 | return out, nil | 50 | return out, nil | ||
| 74 | } | 51 | } | ||
| 75 | 52 | ||||
| 76 | - | // Unmarshal decodes a strict v2 durable snapshot. | 53 | + | // Unmarshal validates the complete manifest and all VT blobs without |
| 54 | + | // allocations first, then decodes the already preflighted data. | |||
| 77 | func Unmarshal(b []byte) (Session, error) { | 55 | func Unmarshal(b []byte) (Session, error) { | ||
| 78 | if len(b) < 16 { | 56 | if len(b) < 16 { | ||
| 79 | return Session{}, ErrShortPayload | 57 | return Session{}, ErrShortPayload | ||
| @@ -84,38 +62,25 @@ func Unmarshal(b []byte) (Session, error) { | |||||
| 84 | if binary.BigEndian.Uint16(b[4:6]) != version { | 62 | if binary.BigEndian.Uint16(b[4:6]) != version { | ||
| 85 | return Session{}, ErrBadVersion | 63 | return Session{}, ErrBadVersion | ||
| 86 | } | 64 | } | ||
| 87 | - | flags := binary.BigEndian.Uint16(b[6:8]) | 65 | + | if binary.BigEndian.Uint16(b[6:8]) != 0 { |
| 88 | - | if flags&^flagFlate != 0 { | 66 | + | return Session{}, fmt.Errorf("%w: flags", ErrInvalidData) |
| 89 | - | return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData) | |||
| 90 | } | 67 | } | ||
| 91 | - | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | 68 | + | bodyLen := binary.BigEndian.Uint32(b[8:12]) |
| 92 | if bodyLen > maxDecodedBodySize { | 69 | if bodyLen > maxDecodedBodySize { | ||
| 93 | return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData) | 70 | return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData) | ||
| 94 | } | 71 | } | ||
| 95 | - | if len(b) < 16+bodyLen { | 72 | + | if uint64(len(b)) < 16+uint64(bodyLen) { |
| 96 | return Session{}, ErrShortPayload | 73 | return Session{}, ErrShortPayload | ||
| 97 | } | 74 | } | ||
| 98 | - | if len(b) != 16+bodyLen { | 75 | + | if uint64(len(b)) != 16+uint64(bodyLen) { |
| 99 | return Session{}, ErrTrailingBytes | 76 | return Session{}, ErrTrailingBytes | ||
| 100 | } | 77 | } | ||
| 101 | body := b[16:] | 78 | body := b[16:] | ||
| 102 | if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) { | 79 | if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) { | ||
| 103 | return Session{}, ErrBadCRC | 80 | return Session{}, ErrBadCRC | ||
| 104 | } | 81 | } | ||
| 105 | - | if flags&flagFlate != 0 { | 82 | + | if err := preflightSession(body); err != nil { |
| 106 | - | fr := flate.NewReader(bytes.NewReader(body)) | 83 | + | 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 | } | 84 | } | ||
| 120 | r := payloadReader{b: body} | 85 | r := payloadReader{b: body} | ||
| 121 | s, err := readSession(&r) | 86 | s, err := readSession(&r) | ||
| @@ -240,6 +205,18 @@ func (r *payloadReader) getStrings() ([]string, error) { | |||||
| 240 | } | 205 | } | ||
| 241 | return out, nil | 206 | return out, nil | ||
| 242 | } | 207 | } | ||
| 208 | + | func (r *payloadReader) getBlob() ([]byte, error) { | |||
| 209 | + | n, err := r.getUint32() | |||
| 210 | + | if err != nil { | |||
| 211 | + | return nil, err | |||
| 212 | + | } | |||
| 213 | + | if uint64(n) > uint64(len(r.b)) { | |||
| 214 | + | return nil, ErrShortPayload | |||
| 215 | + | } | |||
| 216 | + | blob := append([]byte(nil), r.b[:n]...) | |||
| 217 | + | r.b = r.b[n:] | |||
| 218 | + | return blob, nil | |||
| 219 | + | } | |||
| 243 | func (r *payloadReader) done() error { | 220 | func (r *payloadReader) done() error { | ||
| 244 | if len(r.b) != 0 { | 221 | if len(r.b) != 0 { | ||
| 245 | return ErrTrailingBytes | 222 | return ErrTrailingBytes | ||
| @@ -464,24 +441,27 @@ func writePane(w *payloadWriter, p Pane) error { | |||||
| 464 | if err := w.putString(p.Cwd); err != nil { | 441 | if err := w.putString(p.Cwd); err != nil { | ||
| 465 | return err | 442 | return err | ||
| 466 | } | 443 | } | ||
| 467 | - | styles := styleTable(p) | 444 | + | if len(p.SealedChunks) > maxSnapshotBlobs { |
| 468 | - | if len(styles) > math.MaxUint16 { | 445 | + | 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 | } | 446 | } | ||
| 475 | - | idx := make(map[renderer.Style]uint16, len(styles)) | 447 | + | w.putUint32(uint32(len(p.SealedChunks))) |
| 476 | - | for i, s := range styles { | 448 | + | for _, blob := range p.SealedChunks { |
| 477 | - | idx[s] = uint16(i) | 449 | + | if len(blob) == 0 || len(blob) > math.MaxUint32 { |
| 450 | + | return fmt.Errorf("%w: sealed chunk length", ErrInvalidData) | |||
| 451 | + | } | |||
| 452 | + | w.putUint32(uint32(len(blob))) | |||
| 453 | + | w.b = append(w.b, blob...) | |||
| 478 | } | 454 | } | ||
| 479 | - | if err := writeRows(w, p.Scrollback, idx, false); err != nil { | 455 | + | if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 { |
| 480 | - | return err | 456 | + | return fmt.Errorf("%w: tail length", ErrInvalidData) |
| 481 | } | 457 | } | ||
| 482 | - | if err := writeRows(w, p.Visible, idx, true); err != nil { | 458 | + | w.putUint32(uint32(len(p.Tail))) |
| 483 | - | return err | 459 | + | w.b = append(w.b, p.Tail...) |
| 460 | + | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | |||
| 461 | + | return fmt.Errorf("%w: visible length", ErrInvalidData) | |||
| 484 | } | 462 | } | ||
| 463 | + | w.putUint32(uint32(len(p.Visible))) | |||
| 464 | + | w.b = append(w.b, p.Visible...) | |||
| 485 | return writeProcess(w, p.Process) | 465 | return writeProcess(w, p.Process) | ||
| 486 | } | 466 | } | ||
| 487 | 467 | ||||
| @@ -498,24 +478,24 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 498 | if err != nil { | 478 | if err != nil { | ||
| 499 | return Pane{}, err | 479 | return Pane{}, err | ||
| 500 | } | 480 | } | ||
| 501 | - | ns, err := r.getUint16() | 481 | + | n, err := r.getUint32() |
| 502 | if err != nil { | 482 | if err != nil { | ||
| 503 | return Pane{}, err | 483 | return Pane{}, err | ||
| 504 | } | 484 | } | ||
| 505 | - | styles := make([]renderer.Style, ns) | 485 | + | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { |
| 506 | - | for i := range styles { | 486 | + | return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData) |
| 507 | - | s, err := readStyle(r) | 487 | + | } |
| 508 | - | if err != nil { | 488 | + | sealed := make([][]byte, n) |
| 489 | + | for i := range sealed { | |||
| 490 | + | if sealed[i], err = r.getBlob(); err != nil { | |||
| 509 | return Pane{}, err | 491 | return Pane{}, err | ||
| 510 | } | 492 | } | ||
| 511 | - | styles[i] = s | |||
| 512 | } | 493 | } | ||
| 513 | - | budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns} | 494 | + | tail, err := r.getBlob() |
| 514 | - | sb, err := readRows(r, styles, &budget) | |||
| 515 | if err != nil { | 495 | if err != nil { | ||
| 516 | return Pane{}, err | 496 | return Pane{}, err | ||
| 517 | } | 497 | } | ||
| 518 | - | vis, err := readRows(r, styles, &budget) | 498 | + | visible, err := r.getBlob() |
| 519 | if err != nil { | 499 | if err != nil { | ||
| 520 | return Pane{}, err | 500 | return Pane{}, err | ||
| 521 | } | 501 | } | ||
| @@ -523,7 +503,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 523 | if err != nil { | 503 | if err != nil { | ||
| 524 | return Pane{}, err | 504 | return Pane{}, err | ||
| 525 | } | 505 | } | ||
| 526 | - | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil | 506 | + | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil |
| 527 | } | 507 | } | ||
| 528 | 508 | ||||
| 529 | func writeProcess(w *payloadWriter, p *Process) error { | 509 | func writeProcess(w *payloadWriter, p *Process) error { | ||
| @@ -573,236 +553,214 @@ func readProcess(r *payloadReader) (*Process, error) { | |||||
| 573 | return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil | 553 | return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil | ||
| 574 | } | 554 | } | ||
| 575 | 555 | ||||
| 576 | - | func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error { | 556 | + | // preflightSession walks framing and opaque VT blobs without constructing a |
| 577 | - | clean := trimRows(rows, trimBlankRows) | 557 | + | // Session. All count-derived allocation happens only in the second pass. |
| 578 | - | if len(clean) > math.MaxUint32 { | 558 | + | func preflightSession(body []byte) error { |
| 579 | - | return fmt.Errorf("%w: too many rows", ErrInvalidData) | 559 | + | r := payloadReader{b: body} |
| 580 | - | } | 560 | + | if err := skipString(&r); err != nil { |
| 581 | - | w.putUint32(uint32(len(clean))) | 561 | + | return err |
| 582 | - | for _, row := range clean { | 562 | + | } |
| 583 | - | runs := makeRuns(row) | 563 | + | if _, err := r.getUint64(); err != nil { |
| 584 | - | if len(runs) > math.MaxUint16 { | 564 | + | return err |
| 585 | - | return fmt.Errorf("%w: too many runs", ErrInvalidData) | 565 | + | } |
| 586 | - | } | 566 | + | if _, err := r.getUint16(); err != nil { |
| 587 | - | w.putUint16(uint16(len(runs))) | 567 | + | return err |
| 588 | - | for _, run := range runs { | 568 | + | } |
| 589 | - | w.putUint16(uint16(run.len)) | 569 | + | tabs, err := r.getUint16() |
| 590 | - | w.putUint32(uint32(run.cell.Rune)) | 570 | + | if err != nil { |
| 591 | - | w.putUint16(idx[run.cell.Style]) | 571 | + | return err |
| 592 | - | var flags uint8 | 572 | + | } |
| 593 | - | if run.cell.Continuation { | 573 | + | if uint64(tabs) > uint64(len(r.b))/2 { |
| 594 | - | flags |= 1 | 574 | + | return ErrShortPayload |
| 595 | - | } | 575 | + | } |
| 596 | - | w.putUint8(flags) | 576 | + | var totals vt.DecodeStats |
| 577 | + | blobs := uint64(0) | |||
| 578 | + | for range tabs { | |||
| 579 | + | if err := preflightTab(&r, &totals, &blobs); err != nil { | |||
| 580 | + | return fmt.Errorf("preflight tab: %w", err) | |||
| 597 | } | 581 | } | ||
| 598 | } | 582 | } | ||
| 583 | + | if err := r.done(); err != nil { | |||
| 584 | + | return err | |||
| 585 | + | } | |||
| 586 | + | if totals.Rows > maxSnapshotRows || totals.Cells > maxSnapshotCells || totals.Bytes > maxSnapshotBytes { | |||
| 587 | + | return fmt.Errorf("%w: global VT budget", ErrInvalidData) | |||
| 588 | + | } | |||
| 599 | return nil | 589 | return nil | ||
| 600 | } | 590 | } | ||
| 601 | 591 | ||||
| 602 | - | type rowBudget struct { | 592 | + | func skipString(r *payloadReader) error { |
| 603 | - | cells int | 593 | + | n, err := r.getUint16() |
| 604 | - | runs int | 594 | + | if err != nil { |
| 595 | + | return err | |||
| 596 | + | } | |||
| 597 | + | if uint64(n) > uint64(len(r.b)) { | |||
| 598 | + | return ErrShortPayload | |||
| 599 | + | } | |||
| 600 | + | r.b = r.b[n:] | |||
| 601 | + | return nil | |||
| 605 | } | 602 | } | ||
| 606 | 603 | ||||
| 607 | - | func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) { | 604 | + | func preflightTab(r *payloadReader, totals *vt.DecodeStats, blobs *uint64) error { |
| 608 | - | n, err := r.getUint32() | 605 | + | if err := skipString(r); err != nil { |
| 609 | - | if err != nil { | 606 | + | return err |
| 610 | - | return nil, err | |||
| 611 | } | 607 | } | ||
| 612 | - | if n > maxDecodedRows { | 608 | + | if _, err := r.getUint16(); err != nil { |
| 613 | - | return nil, fmt.Errorf("%w: too many rows", ErrInvalidData) | 609 | + | return err |
| 614 | } | 610 | } | ||
| 615 | - | if uint64(n) > uint64(len(r.b))/2 { | 611 | + | if _, err := r.getUint16(); err != nil { |
| 616 | - | return nil, ErrShortPayload | 612 | + | return err |
| 617 | } | 613 | } | ||
| 618 | - | rows := make([][]renderer.Cell, int(n)) | 614 | + | if _, err := r.getUint64(); err != nil { |
| 619 | - | for i := range rows { | 615 | + | return err |
| 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 | } | 616 | } | ||
| 674 | - | return rows, nil | 617 | + | if err := skipString(r); err != nil { |
| 675 | - | } | 618 | + | return err |
| 676 | - | 619 | + | } | |
| 677 | - | type cellRun struct { | 620 | + | if err := preflightNode(r, 0); err != nil { |
| 678 | - | len int | 621 | + | return err |
| 679 | - | cell renderer.Cell | 622 | + | } |
| 680 | - | } | 623 | + | panes, err := r.getUint16() |
| 681 | - | 624 | + | if err != nil { | |
| 682 | - | func makeRuns(row []renderer.Cell) []cellRun { | 625 | + | return err |
| 683 | - | if len(row) == 0 { | 626 | + | } |
| 684 | - | return nil | 627 | + | if uint64(panes) > uint64(len(r.b))/2 { |
| 628 | + | return ErrShortPayload | |||
| 685 | } | 629 | } | ||
| 686 | - | runs := []cellRun{{len: 1, cell: row[0]}} | 630 | + | for range panes { |
| 687 | - | for _, c := range row[1:] { | 631 | + | if err := preflightPane(r, totals, blobs); err != nil { |
| 688 | - | last := &runs[len(runs)-1] | 632 | + | return err |
| 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 | } | 633 | } | ||
| 694 | } | 634 | } | ||
| 695 | - | return runs | 635 | + | return nil |
| 696 | } | 636 | } | ||
| 697 | 637 | ||||
| 698 | - | func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell { | 638 | + | func preflightNode(r *payloadReader, depth int) error { |
| 699 | - | out := make([][]renderer.Cell, len(rows)) | 639 | + | if depth > 64 { |
| 700 | - | for i, row := range rows { | 640 | + | return fmt.Errorf("%w: tree depth", ErrInvalidData) |
| 701 | - | out[i] = trimTrailingBlankCells(row) | 641 | + | } |
| 702 | - | } | 642 | + | kind, err := r.getUint8() |
| 703 | - | if trimBlankRows { | 643 | + | if err != nil { |
| 704 | - | for len(out) > 0 && len(out[len(out)-1]) == 0 { | 644 | + | return err |
| 705 | - | out = out[:len(out)-1] | 645 | + | } |
| 646 | + | switch kind { | |||
| 647 | + | case 0: | |||
| 648 | + | return skipString(r) | |||
| 649 | + | case 1: | |||
| 650 | + | if _, err := r.getUint8(); err != nil { | |||
| 651 | + | return err | |||
| 706 | } | 652 | } | ||
| 653 | + | case 2: | |||
| 654 | + | if err := skipString(r); err != nil { | |||
| 655 | + | return err | |||
| 656 | + | } | |||
| 657 | + | case 3: | |||
| 658 | + | return nil | |||
| 659 | + | default: | |||
| 660 | + | return fmt.Errorf("%w: node kind", ErrInvalidData) | |||
| 707 | } | 661 | } | ||
| 708 | - | return out | 662 | + | children, err := r.getUint16() |
| 709 | - | } | 663 | + | if err != nil { |
| 710 | - | 664 | + | return err | |
| 711 | - | func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell { | |||
| 712 | - | end := len(row) | |||
| 713 | - | for end > 0 && isBlank(row[end-1]) { | |||
| 714 | - | end-- | |||
| 715 | } | 665 | } | ||
| 716 | - | return append([]renderer.Cell(nil), row[:end]...) | 666 | + | if uint64(children) > uint64(len(r.b)) { |
| 717 | - | } | 667 | + | return ErrShortPayload |
| 718 | - | func isBlank(c renderer.Cell) bool { | 668 | + | } |
| 719 | - | return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle()) | 669 | + | for range children { |
| 720 | - | } | 670 | + | if err := preflightNode(r, depth+1); err != nil { |
| 721 | - | 671 | + | return err | |
| 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 | } | 672 | } | ||
| 734 | } | 673 | } | ||
| 735 | - | addRows(p.Scrollback, false) | 674 | + | return nil |
| 736 | - | addRows(p.Visible, true) | |||
| 737 | - | return styles | |||
| 738 | } | 675 | } | ||
| 739 | 676 | ||||
| 740 | - | func writeStyle(w *payloadWriter, s renderer.Style) { | 677 | + | func preflightPane(r *payloadReader, totals *vt.DecodeStats, blobs *uint64) error { |
| 741 | - | var flags uint8 | 678 | + | if err := skipString(r); err != nil { |
| 742 | - | if s.Bold { | 679 | + | return err |
| 743 | - | flags |= 1 | 680 | + | } |
| 681 | + | if err := skipString(r); err != nil { | |||
| 682 | + | return err | |||
| 683 | + | } | |||
| 684 | + | if err := skipString(r); err != nil { | |||
| 685 | + | return err | |||
| 744 | } | 686 | } | ||
| 745 | - | if s.Inverse { | 687 | + | n, err := r.getUint32() |
| 746 | - | flags |= 2 | 688 | + | if err != nil { |
| 689 | + | return err | |||
| 747 | } | 690 | } | ||
| 748 | - | if s.HasForegroundRGB { | 691 | + | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { |
| 749 | - | flags |= 4 | 692 | + | return fmt.Errorf("%w: sealed chunks", ErrInvalidData) |
| 693 | + | } | |||
| 694 | + | for range n { | |||
| 695 | + | if err := preflightBlob(r, totals, blobs, true); err != nil { | |||
| 696 | + | return err | |||
| 697 | + | } | |||
| 750 | } | 698 | } | ||
| 751 | - | if s.HasBackgroundRGB { | 699 | + | if err := preflightBlob(r, totals, blobs, true); err != nil { |
| 752 | - | flags |= 8 | 700 | + | return err |
| 753 | } | 701 | } | ||
| 754 | - | if s.Italic { | 702 | + | if err := preflightBlob(r, totals, blobs, false); err != nil { |
| 755 | - | flags |= 16 | 703 | + | return err |
| 756 | } | 704 | } | ||
| 757 | - | w.putUint8(flags) | 705 | + | return preflightProcess(r) |
| 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 | } | 706 | } | ||
| 767 | 707 | ||||
| 768 | - | func readStyle(r *payloadReader) (renderer.Style, error) { | 708 | + | func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, history bool) error { |
| 769 | - | flags, err := r.getUint8() | 709 | + | n, err := r.getUint32() |
| 770 | if err != nil { | 710 | if err != nil { | ||
| 771 | - | return renderer.Style{}, err | 711 | + | return err |
| 772 | } | 712 | } | ||
| 773 | - | fg, err := r.getUint32() | 713 | + | if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes { |
| 774 | - | if err != nil { | 714 | + | return fmt.Errorf("%w: VT blob length", ErrInvalidData) |
| 775 | - | return renderer.Style{}, err | |||
| 776 | } | 715 | } | ||
| 777 | - | bg, err := r.getUint32() | 716 | + | if *blobs >= maxSnapshotBlobs { |
| 778 | - | if err != nil { | 717 | + | return fmt.Errorf("%w: too many VT blobs", ErrInvalidData) |
| 779 | - | return renderer.Style{}, err | |||
| 780 | } | 718 | } | ||
| 781 | - | fr, err := r.getUint8() | 719 | + | blob := r.b[:n] |
| 782 | - | if err != nil { | 720 | + | r.b = r.b[n:] |
| 783 | - | return renderer.Style{}, err | 721 | + | var stats vt.DecodeStats |
| 722 | + | if history { | |||
| 723 | + | stats, err = vt.PreflightHistoryBlob(blob) | |||
| 724 | + | } else { | |||
| 725 | + | stats, err = vt.PreflightVisibleBlob(blob) | |||
| 784 | } | 726 | } | ||
| 785 | - | fgc, err := r.getUint8() | |||
| 786 | if err != nil { | 727 | if err != nil { | ||
| 787 | - | return renderer.Style{}, err | 728 | + | return fmt.Errorf("%w: VT blob", ErrInvalidData) |
| 788 | } | 729 | } | ||
| 789 | - | fb, err := r.getUint8() | 730 | + | if !totals.Add(stats) { |
| 790 | - | if err != nil { | 731 | + | return fmt.Errorf("%w: VT budget overflow", ErrInvalidData) |
| 791 | - | return renderer.Style{}, err | |||
| 792 | } | 732 | } | ||
| 793 | - | br, err := r.getUint8() | 733 | + | *blobs++ |
| 734 | + | return nil | |||
| 735 | + | } | |||
| 736 | + | ||||
| 737 | + | func preflightProcess(r *payloadReader) error { | |||
| 738 | + | present, err := r.getUint8() | |||
| 794 | if err != nil { | 739 | if err != nil { | ||
| 795 | - | return renderer.Style{}, err | 740 | + | return err |
| 796 | } | 741 | } | ||
| 797 | - | bgc, err := r.getUint8() | 742 | + | if present == 0 { |
| 798 | - | if err != nil { | 743 | + | return nil |
| 799 | - | return renderer.Style{}, err | 744 | + | } |
| 745 | + | if present != 1 { | |||
| 746 | + | return fmt.Errorf("%w: process presence", ErrInvalidData) | |||
| 800 | } | 747 | } | ||
| 801 | - | bb, err := r.getUint8() | 748 | + | n, err := r.getUint16() |
| 802 | if err != nil { | 749 | if err != nil { | ||
| 803 | - | return renderer.Style{}, err | 750 | + | return err |
| 751 | + | } | |||
| 752 | + | if n == 0 { | |||
| 753 | + | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |||
| 754 | + | } | |||
| 755 | + | for range n { | |||
| 756 | + | if err := skipString(r); err != nil { | |||
| 757 | + | return err | |||
| 758 | + | } | |||
| 759 | + | } | |||
| 760 | + | if err := skipString(r); err != nil { | |||
| 761 | + | return err | |||
| 804 | } | 762 | } | ||
| 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 | 763 | + | return skipString(r) |
| 806 | } | 764 | } | ||
| 807 | 765 | ||||
| 808 | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | 766 | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | ||
M internal/usecase/snapshot/codec_test.go +106 -552
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package snapshot | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | 4 | "encoding/binary" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "hash/crc32" | |
| @@ -10,199 +9,69 @@ import ( | |||
| 10 | 9 | ||
| 11 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func TestMarshalMinimalGolden(t *testing.T) { | |
| 16 | - | got, err := Marshal(Session{Name: "s", CreatedAt: 7}) | |
| 17 | - | if err != nil { | |
| 18 | - | t.Fatalf("Marshal() error = %v", err) | |
| 19 | - | } | |
| 20 | - | want := []byte{ | |
| 21 | - | 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb, | |
| 22 | - | 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, | |
| 23 | - | } | |
| 24 | - | if !bytes.Equal(got, want) { | |
| 25 | - | t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want) | |
| 26 | - | } | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | |
| 30 | - | cases := []struct { | |
| 31 | - | name string | |
| 32 | - | tab Tab | |
| 33 | - | }{ | |
| 34 | - | {name: "nil tree", tab: Tab{Tree: nil}}, | |
| 35 | - | {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}}, | |
| 36 | - | } | |
| 37 | - | for _, tc := range cases { | |
| 38 | - | t.Run(tc.name, func(t *testing.T) { | |
| 39 | - | b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| 40 | - | if err != nil { | |
| 41 | - | t.Fatalf("Marshal() error = %v", err) | |
| 42 | - | } | |
| 43 | - | got, err := Unmarshal(b) | |
| 44 | - | if err != nil { | |
| 45 | - | t.Fatalf("Unmarshal() error = %v", err) | |
| 46 | - | } | |
| 47 | - | if len(got.Tabs) != 1 { | |
| 48 | - | t.Fatalf("tabs len = %d, want 1", len(got.Tabs)) | |
| 49 | - | } | |
| 50 | - | if got.Tabs[0].Tree == nil { | |
| 51 | - | t.Fatalf("Tree = nil, want tree with nil root") | |
| 52 | - | } | |
| 53 | - | if got.Tabs[0].Tree.Root != nil { | |
| 54 | - | t.Fatalf("Root = %#v, want nil", got.Tabs[0].Tree.Root) | |
| 55 | - | } | |
| 56 | - | }) | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func TestRoundTripSessions(t *testing.T) { | |
| 61 | - | boldRGB := renderer.DefaultStyle() | |
| 62 | - | boldRGB.Bold = true | |
| 63 | - | boldRGB.HasForegroundRGB = true | |
| 64 | - | boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |
| 65 | - | indexed := renderer.DefaultStyle() | |
| 66 | - | indexed.Foreground = 2 | |
| 67 | - | indexed.Background = 4 | |
| 68 | - | indexed.Inverse = true | |
| 69 | - | indexed.Italic = true | |
| 70 | - | deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 71 | - | layout.NewLeaf("1"), | |
| 72 | - | &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}}, | |
| 73 | - | }}} | |
| 74 | - | cjkVisible := [][]renderer.Cell{{ | |
| 75 | - | {Rune: '好', Style: boldRGB}, | |
| 76 | - | {Continuation: true, Style: boldRGB}, | |
| 77 | - | {Rune: 'x', Style: indexed}, | |
| 78 | - | }} | |
| 79 | - | multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | |
| 80 | - | {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}}, | |
| 81 | - | {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}}, | |
| 82 | - | }} | |
| 83 | - | empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}} | |
| 84 | - | large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}} | |
| 85 | - | cases := []struct { | |
| 86 | - | name string | |
| 87 | - | s Session | |
| 88 | - | }{ | |
| 89 | - | {name: "multi tab deep tree", s: multi}, | |
| 90 | - | {name: "empty and all blank", s: empty}, | |
| 91 | - | {name: "large scrollback", s: large}, | |
| 92 | - | } | |
| 93 | - | for _, tc := range cases { | |
| 94 | - | t.Run(tc.name, func(t *testing.T) { | |
| 95 | - | b, err := Marshal(tc.s) | |
| 96 | - | if err != nil { | |
| 97 | - | t.Fatalf("Marshal() error = %v", err) | |
| 98 | - | } | |
| 99 | - | got, err := Unmarshal(b) | |
| 100 | - | if err != nil { | |
| 101 | - | t.Fatalf("Unmarshal() error = %v", err) | |
| 102 | - | } | |
| 103 | - | if !equalSession(got, trimSession(tc.s)) { | |
| 104 | - | t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s)) | |
| 105 | - | } | |
| 106 | - | }) | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | // V3 preserves the v2 16-byte big-endian envelope, but replaces its | |
| 111 | - | // compressed row payload with a flags-zero manifest of opaque VT blobs. | |
| 112 | - | // Snapshot owns only the manifest: sealed chunks are oldest-first and all | |
| 113 | - | // chunk, tail, and visible payloads remain self-contained VT bytes. | |
| 114 | - | const ( | |
| 115 | - | snapshotV3Version = uint16(3) | |
| 116 | - | v3EnvelopeSize = 16 | |
| 12 | + | "github.com/bnema/vev/pkg/vt" | |
| 117 | 13 | ) | |
| 118 | 14 | ||
| 119 | 15 | func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | |
| 120 | 16 | indexed := renderer.DefaultStyle() | |
| 121 | - | indexed.Bold = true | |
| 122 | - | indexed.Inverse = true | |
| 17 | + | indexed.Bold, indexed.Inverse = true, true | |
| 123 | 18 | indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough | |
| 124 | - | indexed.Foreground = 196 | |
| 125 | - | indexed.Background = 17 | |
| 126 | - | indexed.UnderlineStyle = renderer.UnderlineCurly | |
| 127 | - | indexed.HasUnderlineColor = true | |
| 128 | - | indexed.UnderlineColor = 203 | |
| 129 | - | ||
| 19 | + | indexed.Foreground, indexed.Background = 196, 17 | |
| 20 | + | indexed.UnderlineStyle, indexed.HasUnderlineColor, indexed.UnderlineColor = renderer.UnderlineCurly, true, 203 | |
| 130 | 21 | rgb := renderer.DefaultStyle() | |
| 131 | - | rgb.Italic = true | |
| 132 | - | rgb.HasForegroundRGB = true | |
| 133 | - | rgb.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |
| 134 | - | rgb.HasBackgroundRGB = true | |
| 135 | - | rgb.BackgroundRGB = renderer.RGB{R: 4, G: 5, B: 6} | |
| 136 | - | rgb.UnderlineStyle = renderer.UnderlineDashed | |
| 137 | - | rgb.HasUnderlineColorRGB = true | |
| 138 | - | rgb.UnderlineColorRGB = renderer.RGB{R: 7, G: 8, B: 9} | |
| 139 | - | ||
| 140 | - | want := Session{Name: "v3 exact", Tabs: []Tab{{ | |
| 141 | - | Focus: "p", | |
| 142 | - | Tree: layout.NewTree("p"), | |
| 143 | - | Panes: []Pane{{ID: "p", Scrollback: [][]renderer.Cell{{ | |
| 144 | - | {Rune: 'I', Style: indexed}, | |
| 145 | - | {Rune: '好', Style: rgb}, | |
| 146 | - | {Continuation: true, Style: rgb}, | |
| 147 | - | }}, Visible: [][]renderer.Cell{{ | |
| 148 | - | {Rune: 'V', Style: rgb}, | |
| 149 | - | {Rune: '界', Style: indexed}, | |
| 150 | - | {Continuation: true, Style: indexed}, | |
| 151 | - | }}}}, | |
| 152 | - | }}} | |
| 153 | - | ||
| 22 | + | rgb.Italic, rgb.HasForegroundRGB, rgb.ForegroundRGB = true, true, renderer.RGB{R: 1, G: 2, B: 3} | |
| 23 | + | rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6} | |
| 24 | + | rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9} | |
| 25 | + | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}}) | |
| 26 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}}) | |
| 27 | + | want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}} | |
| 154 | 28 | encoded, err := Marshal(want) | |
| 155 | 29 | if err != nil { | |
| 156 | - | t.Fatalf("Marshal() error = %v", err) | |
| 30 | + | t.Fatal(err) | |
| 157 | 31 | } | |
| 158 | 32 | requireV3Envelope(t, encoded) | |
| 159 | 33 | got, err := Unmarshal(encoded) | |
| 160 | 34 | if err != nil { | |
| 161 | - | t.Fatalf("Unmarshal() error = %v", err) | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | if len(got.Tabs) != 1 || len(got.Tabs[0].Panes) != 1 || got.Tabs[0].Panes[0].ID != "p" { | |
| 38 | + | t.Fatalf("manifest round trip = %#v", got) | |
| 162 | 39 | } | |
| 163 | - | if !equalSession(got, want) { | |
| 164 | - | t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, want) | |
| 40 | + | if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil { | |
| 41 | + | t.Fatalf("history decode: %v", err) | |
| 42 | + | } | |
| 43 | + | frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible) | |
| 44 | + | if err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation { | |
| 48 | + | t.Fatalf("visible terminal data lost: %#v", frame.Row(0)) | |
| 165 | 49 | } | |
| 166 | 50 | } | |
| 167 | 51 | ||
| 168 | 52 | func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) { | |
| 169 | - | for _, tc := range []struct { | |
| 170 | - | name string | |
| 171 | - | version uint16 | |
| 172 | - | }{ | |
| 173 | - | {name: "v1", version: 1}, | |
| 174 | - | {name: "v2", version: 2}, | |
| 175 | - | } { | |
| 176 | - | t.Run(tc.name, func(t *testing.T) { | |
| 177 | - | _, err := Unmarshal(v3Envelope(tc.version, 0, nil)) | |
| 178 | - | if !errors.Is(err, ErrBadVersion) { | |
| 179 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrBadVersion) | |
| 180 | - | } | |
| 181 | - | }) | |
| 53 | + | for _, version := range []uint16{1, 2} { | |
| 54 | + | if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) { | |
| 55 | + | t.Fatalf("version %d: %v", version, err) | |
| 56 | + | } | |
| 182 | 57 | } | |
| 183 | 58 | } | |
| 184 | 59 | ||
| 185 | 60 | func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) { | |
| 186 | 61 | body := v3Manifest(0, nil, nil, 0, nil, 0, nil) | |
| 187 | - | valid := v3Envelope(snapshotV3Version, 0, body) | |
| 62 | + | valid := v3Envelope(version, 0, body) | |
| 188 | 63 | badCRC := append([]byte(nil), valid...) | |
| 189 | 64 | badCRC[15] ^= 1 | |
| 190 | - | ||
| 191 | 65 | for _, tc := range []struct { | |
| 192 | 66 | name string | |
| 193 | 67 | data []byte | |
| 194 | 68 | want error | |
| 195 | 69 | }{ | |
| 196 | - | {name: "short envelope", data: valid[:v3EnvelopeSize-1], want: ErrShortPayload}, | |
| 197 | - | {name: "bad magic", data: append([]byte("NOPE"), valid[4:]...), want: ErrBadMagic}, | |
| 198 | - | {name: "future version", data: v3Envelope(snapshotV3Version+1, 0, body), want: ErrBadVersion}, | |
| 199 | - | {name: "nonzero flags", data: v3Envelope(snapshotV3Version, 1, body), want: ErrInvalidData}, | |
| 200 | - | {name: "bad checksum", data: badCRC, want: ErrBadCRC}, | |
| 201 | - | {name: "oversized body declaration", data: v3EnvelopeWithLength(snapshotV3Version, 0, nil, math.MaxUint32), want: ErrInvalidData}, | |
| 70 | + | {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic}, | |
| 71 | + | {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData}, | |
| 72 | + | {"crc", badCRC, ErrBadCRC}, {"body", v3EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData}, | |
| 202 | 73 | } { | |
| 203 | - | t.Run(tc.name, func(t *testing.T) { | |
| 204 | - | assertV3RejectsWithoutPanic(t, tc.data, tc.want) | |
| 205 | - | }) | |
| 74 | + | t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) }) | |
| 206 | 75 | } | |
| 207 | 76 | } | |
| 208 | 77 | ||
| @@ -211,442 +80,127 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin | |||
| 211 | 80 | name string | |
| 212 | 81 | body []byte | |
| 213 | 82 | }{ | |
| 214 | - | {name: "sealed chunk count", body: v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)}, | |
| 215 | - | {name: "sealed blob length", body: v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)}, | |
| 216 | - | {name: "tail blob length", body: v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)}, | |
| 217 | - | {name: "visible blob length", body: v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)}, | |
| 218 | - | // Aggregate accounting is snapshot-owned even though each payload is | |
| 219 | - | // VT-owned opaque data; declarations must be bounded before a blob can | |
| 220 | - | // cause a count-driven allocation. | |
| 221 | - | {name: "aggregate opaque blob declarations", body: v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)}, | |
| 83 | + | {"count", v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)}, | |
| 84 | + | {"sealed", v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)}, | |
| 85 | + | {"tail", v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)}, | |
| 86 | + | {"visible", v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)}, | |
| 222 | 87 | } { | |
| 223 | - | t.Run(tc.name, func(t *testing.T) { | |
| 224 | - | assertV3RejectsWithoutPanic(t, v3Envelope(snapshotV3Version, 0, tc.body), ErrInvalidData) | |
| 225 | - | }) | |
| 88 | + | t.Run(tc.name, func(t *testing.T) { reject(t, v3Envelope(version, 0, tc.body), ErrInvalidData) }) | |
| 226 | 89 | } | |
| 227 | 90 | } | |
| 228 | 91 | ||
| 229 | 92 | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | |
| 230 | - | encoded, err := Marshal(Session{Name: "v3 prefixes", Tabs: []Tab{{ | |
| 231 | - | Focus: "p", | |
| 232 | - | Tree: layout.NewTree("p"), | |
| 233 | - | Panes: []Pane{{ID: "p", Scrollback: rows("history"), Visible: rows("visible")}}, | |
| 234 | - | }}}) | |
| 93 | + | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | |
| 94 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 95 | + | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 235 | 96 | if err != nil { | |
| 236 | - | t.Fatalf("Marshal() error = %v", err) | |
| 97 | + | t.Fatal(err) | |
| 237 | 98 | } | |
| 238 | - | requireV3Envelope(t, encoded) | |
| 239 | 99 | for n := range len(encoded) { | |
| 240 | - | assertV3RejectsWithoutPanic(t, encoded[:n], nil) | |
| 100 | + | reject(t, encoded[:n], nil) | |
| 241 | 101 | } | |
| 242 | - | assertV3RejectsWithoutPanic(t, append(append([]byte(nil), encoded...), 0), ErrTrailingBytes) | |
| 102 | + | reject(t, append(encoded, 0), ErrTrailingBytes) | |
| 243 | 103 | } | |
| 244 | 104 | ||
| 245 | - | func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) { | |
| 246 | - | good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}}) | |
| 105 | + | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | |
| 106 | + | sealed, tail := historyBlobs(t, nil) | |
| 107 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 108 | + | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 247 | 109 | if err != nil { | |
| 248 | 110 | t.Fatal(err) | |
| 249 | 111 | } | |
| 250 | - | cases := []struct { | |
| 251 | - | name string | |
| 252 | - | data []byte | |
| 253 | - | }{ | |
| 254 | - | {"bad magic", append([]byte("NOPE"), good[4:]...)}, | |
| 255 | - | {"bad version", replaceU16(good, 4, 1)}, | |
| 256 | - | {"bad crc", append([]byte(nil), good[:len(good)-1]...)}, | |
| 257 | - | {"trailing", append(append([]byte(nil), good...), 0)}, | |
| 258 | - | {"body len overrun", replaceU32(good, 8, 999999)}, | |
| 259 | - | {"prefix", good[:len(good)/2]}, | |
| 260 | - | {"style oob", styleOOBSnapshot(t)}, | |
| 261 | - | {"unknown pane in tree", unknownPaneSnapshot(t)}, | |
| 262 | - | } | |
| 263 | - | for _, tc := range cases { | |
| 264 | - | t.Run(tc.name, func(t *testing.T) { | |
| 265 | - | defer func() { | |
| 266 | - | if r := recover(); r != nil { | |
| 267 | - | t.Fatalf("Unmarshal panicked: %v", r) | |
| 268 | - | } | |
| 269 | - | }() | |
| 270 | - | if _, err := Unmarshal(tc.data); err == nil { | |
| 271 | - | t.Fatalf("Unmarshal() error = nil") | |
| 272 | - | } | |
| 273 | - | }) | |
| 274 | - | } | |
| 275 | - | for i := 0; i < len(good); i++ { | |
| 276 | - | if _, err := Unmarshal(good[:i]); err == nil { | |
| 277 | - | t.Fatalf("prefix length %d unexpectedly succeeded", i) | |
| 278 | - | } | |
| 279 | - | } | |
| 280 | - | } | |
| 281 | - | ||
| 282 | - | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | |
| 283 | - | _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}}) | |
| 284 | - | if !errors.Is(err, ErrInvalidData) { | |
| 285 | - | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | |
| 286 | - | } | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |
| 290 | - | var w payloadWriter | |
| 291 | - | _ = w.putString("x") | |
| 292 | - | w.putUint64(0) | |
| 293 | - | w.putUint16(0) | |
| 294 | - | w.putUint16(1) | |
| 295 | - | _ = w.putString("t") | |
| 296 | - | w.putUint16(80) | |
| 297 | - | w.putUint16(24) | |
| 298 | - | w.putUint64(1) | |
| 299 | - | _ = w.putString("p") | |
| 300 | - | _ = writeNode(&w, layout.NewLeaf("p")) | |
| 301 | - | w.putUint16(1) | |
| 302 | - | _ = w.putString("p") | |
| 303 | - | _ = w.putString("p_stable") | |
| 304 | - | _ = w.putString("") | |
| 305 | - | w.putUint16(0) | |
| 306 | - | w.putUint32(0) | |
| 307 | - | w.putUint32(0) | |
| 308 | - | w.putUint8(1) | |
| 309 | - | w.putUint16(0) | |
| 310 | - | _ = w.putString("generic") | |
| 311 | - | _ = w.putString("") | |
| 312 | - | _, err := Unmarshal(reheader(w.b, 0)) | |
| 313 | - | if !errors.Is(err, ErrInvalidData) { | |
| 314 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | |
| 315 | - | } | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) { | |
| 319 | - | body := []byte{0} | |
| 320 | - | oversizedRaw := reheader(body, 0) | |
| 321 | - | binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1)) | |
| 322 | - | oversizedFlate := reheader(body, flagFlate) | |
| 323 | - | binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1)) | |
| 324 | - | cases := []struct { | |
| 325 | - | name string | |
| 326 | - | data []byte | |
| 327 | - | }{ | |
| 328 | - | {name: "raw", data: oversizedRaw}, | |
| 329 | - | {name: "flate", data: oversizedFlate}, | |
| 330 | - | } | |
| 331 | - | for _, tc := range cases { | |
| 332 | - | t.Run(tc.name, func(t *testing.T) { | |
| 333 | - | _, err := Unmarshal(tc.data) | |
| 334 | - | if !errors.Is(err, ErrInvalidData) { | |
| 335 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | |
| 336 | - | } | |
| 337 | - | }) | |
| 112 | + | if _, err := Unmarshal(encoded); !errors.Is(err, ErrUnknownPane) { | |
| 113 | + | t.Fatalf("error = %v", err) | |
| 338 | 114 | } | |
| 339 | 115 | } | |
| 340 | 116 | ||
| 341 | - | func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) { | |
| 342 | - | cases := []struct { | |
| 343 | - | name string | |
| 344 | - | data []byte | |
| 345 | - | want error | |
| 346 | - | }{ | |
| 347 | - | {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData}, | |
| 348 | - | {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload}, | |
| 349 | - | } | |
| 350 | - | for _, tc := range cases { | |
| 351 | - | t.Run(tc.name, func(t *testing.T) { | |
| 352 | - | defer func() { | |
| 353 | - | if r := recover(); r != nil { | |
| 354 | - | t.Fatalf("Unmarshal panicked: %v", r) | |
| 355 | - | } | |
| 356 | - | }() | |
| 357 | - | _, err := Unmarshal(tc.data) | |
| 358 | - | if !errors.Is(err, tc.want) { | |
| 359 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want) | |
| 360 | - | } | |
| 361 | - | }) | |
| 362 | - | } | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | func TestFlateOnOff(t *testing.T) { | |
| 366 | - | small, err := Marshal(Session{Name: "small"}) | |
| 367 | - | if err != nil { | |
| 368 | - | t.Fatal(err) | |
| 369 | - | } | |
| 370 | - | if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 { | |
| 371 | - | t.Fatalf("small flags = %#x, want 0", flags) | |
| 117 | + | func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) { | |
| 118 | + | t.Helper() | |
| 119 | + | h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2}) | |
| 120 | + | for _, row := range rows { | |
| 121 | + | h.Append(row) | |
| 372 | 122 | } | |
| 373 | - | large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}}) | |
| 123 | + | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |
| 374 | 124 | if err != nil { | |
| 375 | 125 | t.Fatal(err) | |
| 376 | 126 | } | |
| 377 | - | if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 { | |
| 378 | - | t.Fatalf("large flags = %#x, want flate bit", flags) | |
| 379 | - | } | |
| 127 | + | return sealed, tail | |
| 380 | 128 | } | |
| 381 | - | ||
| 382 | - | func BenchmarkMarshal10KRows(b *testing.B) { | |
| 383 | - | s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}} | |
| 384 | - | b.ReportAllocs() | |
| 385 | - | for i := 0; i < b.N; i++ { | |
| 386 | - | if _, err := Marshal(s); err != nil { | |
| 387 | - | b.Fatal(err) | |
| 388 | - | } | |
| 389 | - | } | |
| 390 | - | } | |
| 391 | - | ||
| 392 | - | func rows(lines ...string) [][]renderer.Cell { | |
| 393 | - | out := make([][]renderer.Cell, len(lines)) | |
| 394 | - | for i, line := range lines { | |
| 395 | - | for _, r := range line { | |
| 396 | - | out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 397 | - | } | |
| 398 | - | } | |
| 399 | - | return out | |
| 400 | - | } | |
| 401 | - | ||
| 402 | - | func manyRows(n int) [][]renderer.Cell { | |
| 403 | - | out := make([][]renderer.Cell, n) | |
| 404 | - | for i := range out { | |
| 405 | - | out[i] = rows("row data row data row data")[0] | |
| 406 | - | } | |
| 407 | - | return out | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | func equalSession(a, b Session) bool { | |
| 411 | - | if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) { | |
| 412 | - | return false | |
| 413 | - | } | |
| 414 | - | for i := range a.Tabs { | |
| 415 | - | if !equalTab(a.Tabs[i], b.Tabs[i]) { | |
| 416 | - | return false | |
| 417 | - | } | |
| 418 | - | } | |
| 419 | - | return true | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | func equalTab(a, b Tab) bool { | |
| 423 | - | 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)) { | |
| 424 | - | return false | |
| 425 | - | } | |
| 426 | - | for i := range a.Panes { | |
| 427 | - | 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) { | |
| 428 | - | return false | |
| 429 | - | } | |
| 430 | - | } | |
| 431 | - | return true | |
| 432 | - | } | |
| 433 | - | ||
| 434 | - | func equalProcess(a, b *Process) bool { | |
| 435 | - | if a == nil || b == nil { | |
| 436 | - | return a == b | |
| 437 | - | } | |
| 438 | - | if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) { | |
| 439 | - | return false | |
| 440 | - | } | |
| 441 | - | for i := range a.Argv { | |
| 442 | - | if a.Argv[i] != b.Argv[i] { | |
| 443 | - | return false | |
| 444 | - | } | |
| 445 | - | } | |
| 446 | - | return true | |
| 447 | - | } | |
| 448 | - | ||
| 449 | - | func treeRoot(t *layout.Tree) *layout.Node { | |
| 450 | - | if t == nil { | |
| 451 | - | return nil | |
| 452 | - | } | |
| 453 | - | return t.Root | |
| 454 | - | } | |
| 455 | - | ||
| 456 | - | func equalNode(a, b *layout.Node) bool { | |
| 457 | - | if a == nil || b == nil { | |
| 458 | - | return a == b | |
| 459 | - | } | |
| 460 | - | if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) { | |
| 461 | - | return false | |
| 462 | - | } | |
| 463 | - | for i := range a.Children { | |
| 464 | - | if !equalNode(a.Children[i], b.Children[i]) { | |
| 465 | - | return false | |
| 466 | - | } | |
| 467 | - | } | |
| 468 | - | return true | |
| 469 | - | } | |
| 470 | - | ||
| 471 | - | func equalRows(a, b [][]renderer.Cell) bool { | |
| 472 | - | if len(a) != len(b) { | |
| 473 | - | return false | |
| 474 | - | } | |
| 475 | - | for i := range a { | |
| 476 | - | if len(a[i]) != len(b[i]) { | |
| 477 | - | return false | |
| 478 | - | } | |
| 479 | - | for j := range a[i] { | |
| 480 | - | if !a[i][j].Equal(b[i][j]) { | |
| 481 | - | return false | |
| 482 | - | } | |
| 483 | - | } | |
| 484 | - | } | |
| 485 | - | return true | |
| 486 | - | } | |
| 487 | - | ||
| 488 | - | func trimSession(s Session) Session { | |
| 489 | - | for ti := range s.Tabs { | |
| 490 | - | for pi := range s.Tabs[ti].Panes { | |
| 491 | - | s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false) | |
| 492 | - | s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true) | |
| 493 | - | } | |
| 494 | - | } | |
| 495 | - | return s | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | func reheader(body []byte, flags uint16) []byte { | |
| 499 | - | out := make([]byte, 16+len(body)) | |
| 500 | - | copy(out[:4], []byte("VEVS")) | |
| 501 | - | binary.BigEndian.PutUint16(out[4:6], version) | |
| 502 | - | binary.BigEndian.PutUint16(out[6:8], flags) | |
| 503 | - | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | |
| 504 | - | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |
| 505 | - | copy(out[16:], body) | |
| 506 | - | return out | |
| 507 | - | } | |
| 508 | - | ||
| 509 | - | func replaceU16(b []byte, off int, v uint16) []byte { | |
| 510 | - | out := append([]byte(nil), b...) | |
| 511 | - | binary.BigEndian.PutUint16(out[off:], v) | |
| 512 | - | return out | |
| 513 | - | } | |
| 514 | - | func replaceU32(b []byte, off int, v uint32) []byte { | |
| 515 | - | out := append([]byte(nil), b...) | |
| 516 | - | binary.BigEndian.PutUint32(out[off:], v) | |
| 517 | - | return out | |
| 518 | - | } | |
| 519 | - | ||
| 520 | - | func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte { | |
| 521 | - | var w payloadWriter | |
| 522 | - | _ = w.putString("x") | |
| 523 | - | w.putUint64(0) | |
| 524 | - | w.putUint16(0) | |
| 525 | - | w.putUint16(1) | |
| 526 | - | _ = w.putString("") | |
| 527 | - | w.putUint16(0) | |
| 528 | - | w.putUint16(0) | |
| 529 | - | w.putUint64(0) | |
| 530 | - | _ = w.putString("p") | |
| 531 | - | _ = writeNode(&w, layout.NewLeaf("p")) | |
| 532 | - | w.putUint16(1) | |
| 533 | - | _ = w.putString("p") | |
| 534 | - | _ = w.putString("") | |
| 535 | - | _ = w.putString("") | |
| 536 | - | w.putUint16(0) | |
| 537 | - | w.putUint32(rowCount) | |
| 538 | - | if rowCount > 0 { | |
| 539 | - | w.putUint16(runCount) | |
| 540 | - | } | |
| 541 | - | return reheader(w.b, 0) | |
| 542 | - | } | |
| 543 | - | ||
| 544 | - | func styleOOBSnapshot(t *testing.T) []byte { | |
| 545 | - | b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}}) | |
| 546 | - | if err != nil { | |
| 547 | - | t.Fatal(err) | |
| 129 | + | func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte { | |
| 130 | + | t.Helper() | |
| 131 | + | width := 0 | |
| 132 | + | for _, row := range rows { | |
| 133 | + | width = max(width, len(row)) | |
| 548 | 134 | } | |
| 549 | - | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | |
| 550 | - | body := append([]byte(nil), b[16:16+bodyLen]...) | |
| 551 | - | for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0 | |
| 552 | - | if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' { | |
| 553 | - | body[i+6] = 0 | |
| 554 | - | body[i+7] = 2 | |
| 555 | - | return reheader(body, 0) | |
| 556 | - | } | |
| 135 | + | f := renderer.NewFrame(width, len(rows)) | |
| 136 | + | for y, row := range rows { | |
| 137 | + | copy(f.Row(y), row) | |
| 557 | 138 | } | |
| 558 | - | t.Fatal("run not found") | |
| 559 | - | return nil | |
| 560 | - | } | |
| 561 | - | ||
| 562 | - | func unknownPaneSnapshot(t *testing.T) []byte { | |
| 563 | - | b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}}) | |
| 139 | + | b, err := vt.MarshalVisible(f) | |
| 564 | 140 | if err != nil { | |
| 565 | 141 | t.Fatal(err) | |
| 566 | 142 | } | |
| 567 | 143 | return b | |
| 568 | 144 | } | |
| 569 | - | ||
| 570 | 145 | func requireV3Envelope(t *testing.T, data []byte) { | |
| 571 | 146 | t.Helper() | |
| 572 | - | if len(data) < v3EnvelopeSize { | |
| 573 | - | t.Fatalf("encoded snapshot is %d bytes, want at least %d", len(data), v3EnvelopeSize) | |
| 574 | - | } | |
| 575 | - | if got := binary.BigEndian.Uint16(data[4:6]); got != snapshotV3Version { | |
| 576 | - | t.Fatalf("snapshot version = %d, want v3", got) | |
| 577 | - | } | |
| 578 | - | if got := binary.BigEndian.Uint16(data[6:8]); got != 0 { | |
| 579 | - | t.Fatalf("snapshot flags = %#x, want 0", got) | |
| 147 | + | if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 { | |
| 148 | + | t.Fatalf("not v3 envelope: % x", data) | |
| 580 | 149 | } | |
| 581 | 150 | } | |
| 582 | - | ||
| 583 | - | func assertV3RejectsWithoutPanic(t *testing.T, data []byte, want error) { | |
| 151 | + | func reject(t *testing.T, data []byte, want error) { | |
| 584 | 152 | t.Helper() | |
| 585 | 153 | defer func() { | |
| 586 | 154 | if r := recover(); r != nil { | |
| 587 | - | t.Fatalf("Unmarshal panicked: %v", r) | |
| 155 | + | t.Fatalf("panic: %v", r) | |
| 588 | 156 | } | |
| 589 | 157 | }() | |
| 590 | 158 | _, err := Unmarshal(data) | |
| 591 | 159 | if err == nil { | |
| 592 | - | t.Fatal("Unmarshal() error = nil") | |
| 160 | + | t.Fatal("accepted malformed input") | |
| 593 | 161 | } | |
| 594 | 162 | if want != nil && !errors.Is(err, want) { | |
| 595 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, want) | |
| 163 | + | t.Fatalf("error = %v, want %v", err, want) | |
| 596 | 164 | } | |
| 597 | 165 | } | |
| 598 | - | ||
| 599 | - | func v3Envelope(version, flags uint16, body []byte) []byte { | |
| 600 | - | return v3EnvelopeWithLength(version, flags, body, uint32(len(body))) | |
| 166 | + | func v3Envelope(v, flags uint16, body []byte) []byte { | |
| 167 | + | return v3EnvelopeWithLength(v, flags, body, uint32(len(body))) | |
| 601 | 168 | } | |
| 602 | - | ||
| 603 | - | func v3EnvelopeWithLength(version, flags uint16, body []byte, bodyLen uint32) []byte { | |
| 604 | - | out := make([]byte, v3EnvelopeSize+len(body)) | |
| 605 | - | copy(out[:4], magic) | |
| 606 | - | binary.BigEndian.PutUint16(out[4:6], version) | |
| 169 | + | func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte { | |
| 170 | + | out := make([]byte, 16+len(body)) | |
| 171 | + | copy(out, magic) | |
| 172 | + | binary.BigEndian.PutUint16(out[4:6], v) | |
| 607 | 173 | binary.BigEndian.PutUint16(out[6:8], flags) | |
| 608 | - | binary.BigEndian.PutUint32(out[8:12], bodyLen) | |
| 174 | + | binary.BigEndian.PutUint32(out[8:12], length) | |
| 609 | 175 | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |
| 610 | - | copy(out[v3EnvelopeSize:], body) | |
| 176 | + | copy(out[16:], body) | |
| 611 | 177 | return out | |
| 612 | 178 | } | |
| 613 | 179 | ||
| 614 | - | // v3Manifest writes only snapshot-owned fields. Blob contents are deliberately | |
| 615 | - | // opaque: VT validates their style, row, run, cell, and decoded-byte budgets. | |
| 616 | - | func v3Manifest( | |
| 617 | - | sealedCount uint32, | |
| 618 | - | sealedLengths []uint32, | |
| 619 | - | sealedBlobs [][]byte, | |
| 620 | - | tailLength uint32, | |
| 621 | - | tailBlob []byte, | |
| 622 | - | visibleLength uint32, | |
| 623 | - | visibleBlob []byte, | |
| 624 | - | ) []byte { | |
| 625 | - | var w payloadWriter | |
| 626 | - | _ = w.putString("v3") | |
| 627 | - | w.putUint64(0) | |
| 628 | - | w.putUint16(0) | |
| 629 | - | w.putUint16(1) | |
| 630 | - | _ = w.putString("tab") | |
| 631 | - | w.putUint16(80) | |
| 632 | - | w.putUint16(24) | |
| 633 | - | w.putUint64(1) | |
| 634 | - | _ = w.putString("p") | |
| 635 | - | _ = writeNode(&w, layout.NewLeaf("p")) | |
| 636 | - | w.putUint16(1) | |
| 637 | - | _ = w.putString("p") | |
| 638 | - | _ = w.putString("") | |
| 639 | - | _ = w.putString("") | |
| 640 | - | w.putUint32(sealedCount) | |
| 641 | - | for i := range sealedLengths { | |
| 642 | - | w.putUint32(sealedLengths[i]) | |
| 643 | - | if i < len(sealedBlobs) { | |
| 644 | - | w.b = append(w.b, sealedBlobs[i]...) | |
| 645 | - | } | |
| 646 | - | } | |
| 647 | - | w.putUint32(tailLength) | |
| 648 | - | w.b = append(w.b, tailBlob...) | |
| 649 | - | w.putUint32(visibleLength) | |
| 650 | - | w.b = append(w.b, visibleBlob...) | |
| 651 | - | return w.b | |
| 180 | + | // v3Manifest creates just enough session/tab/pane framing for hostile declarations. | |
| 181 | + | func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte { | |
| 182 | + | var b []byte | |
| 183 | + | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } | |
| 184 | + | putS("s") | |
| 185 | + | b = append(b, make([]byte, 8+2)...) | |
| 186 | + | b = binary.BigEndian.AppendUint16(b, 1) | |
| 187 | + | putS("") | |
| 188 | + | b = append(b, make([]byte, 2+2+8)...) | |
| 189 | + | putS("p") | |
| 190 | + | b = append(b, 0) | |
| 191 | + | putS("p") | |
| 192 | + | b = binary.BigEndian.AppendUint16(b, 1) | |
| 193 | + | putS("p") | |
| 194 | + | putS("") | |
| 195 | + | putS("") | |
| 196 | + | b = binary.BigEndian.AppendUint32(b, sealed) | |
| 197 | + | for _, l := range sealedLens { | |
| 198 | + | b = binary.BigEndian.AppendUint32(b, l) | |
| 199 | + | } | |
| 200 | + | b = append(b, sealedData...) | |
| 201 | + | b = binary.BigEndian.AppendUint32(b, tailLen) | |
| 202 | + | b = append(b, tailData...) | |
| 203 | + | b = binary.BigEndian.AppendUint32(b, visibleLen) | |
| 204 | + | b = append(b, visibleData...) | |
| 205 | + | return b | |
| 652 | 206 | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package snapshot | 1 | package snapshot | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bytes" | |||
| 5 | "encoding/binary" | 4 | "encoding/binary" | ||
| 6 | "errors" | 5 | "errors" | ||
| 7 | "hash/crc32" | 6 | "hash/crc32" | ||
| @@ -10,199 +9,69 @@ import ( | |||||
| 10 | 9 | ||||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 10 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | "github.com/bnema/vev/pkg/renderer" | 11 | "github.com/bnema/vev/pkg/renderer" | ||
| 13 | - | ) | 12 | + | "github.com/bnema/vev/pkg/vt" |
| 14 | - | ||||
| 15 | - | func TestMarshalMinimalGolden(t *testing.T) { | |||
| 16 | - | got, err := Marshal(Session{Name: "s", CreatedAt: 7}) | |||
| 17 | - | if err != nil { | |||
| 18 | - | t.Fatalf("Marshal() error = %v", err) | |||
| 19 | - | } | |||
| 20 | - | want := []byte{ | |||
| 21 | - | 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb, | |||
| 22 | - | 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, | |||
| 23 | - | } | |||
| 24 | - | if !bytes.Equal(got, want) { | |||
| 25 | - | t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want) | |||
| 26 | - | } | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | |||
| 30 | - | cases := []struct { | |||
| 31 | - | name string | |||
| 32 | - | tab Tab | |||
| 33 | - | }{ | |||
| 34 | - | {name: "nil tree", tab: Tab{Tree: nil}}, | |||
| 35 | - | {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}}, | |||
| 36 | - | } | |||
| 37 | - | for _, tc := range cases { | |||
| 38 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 39 | - | b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |||
| 40 | - | if err != nil { | |||
| 41 | - | t.Fatalf("Marshal() error = %v", err) | |||
| 42 | - | } | |||
| 43 | - | got, err := Unmarshal(b) | |||
| 44 | - | if err != nil { | |||
| 45 | - | t.Fatalf("Unmarshal() error = %v", err) | |||
| 46 | - | } | |||
| 47 | - | if len(got.Tabs) != 1 { | |||
| 48 | - | t.Fatalf("tabs len = %d, want 1", len(got.Tabs)) | |||
| 49 | - | } | |||
| 50 | - | if got.Tabs[0].Tree == nil { | |||
| 51 | - | t.Fatalf("Tree = nil, want tree with nil root") | |||
| 52 | - | } | |||
| 53 | - | if got.Tabs[0].Tree.Root != nil { | |||
| 54 | - | t.Fatalf("Root = %#v, want nil", got.Tabs[0].Tree.Root) | |||
| 55 | - | } | |||
| 56 | - | }) | |||
| 57 | - | } | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | func TestRoundTripSessions(t *testing.T) { | |||
| 61 | - | boldRGB := renderer.DefaultStyle() | |||
| 62 | - | boldRGB.Bold = true | |||
| 63 | - | boldRGB.HasForegroundRGB = true | |||
| 64 | - | boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 65 | - | indexed := renderer.DefaultStyle() | |||
| 66 | - | indexed.Foreground = 2 | |||
| 67 | - | indexed.Background = 4 | |||
| 68 | - | indexed.Inverse = true | |||
| 69 | - | indexed.Italic = true | |||
| 70 | - | deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 71 | - | layout.NewLeaf("1"), | |||
| 72 | - | &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}}, | |||
| 73 | - | }}} | |||
| 74 | - | cjkVisible := [][]renderer.Cell{{ | |||
| 75 | - | {Rune: '好', Style: boldRGB}, | |||
| 76 | - | {Continuation: true, Style: boldRGB}, | |||
| 77 | - | {Rune: 'x', Style: indexed}, | |||
| 78 | - | }} | |||
| 79 | - | multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | |||
| 80 | - | {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}}, | |||
| 81 | - | {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}}, | |||
| 82 | - | }} | |||
| 83 | - | empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}} | |||
| 84 | - | large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}} | |||
| 85 | - | cases := []struct { | |||
| 86 | - | name string | |||
| 87 | - | s Session | |||
| 88 | - | }{ | |||
| 89 | - | {name: "multi tab deep tree", s: multi}, | |||
| 90 | - | {name: "empty and all blank", s: empty}, | |||
| 91 | - | {name: "large scrollback", s: large}, | |||
| 92 | - | } | |||
| 93 | - | for _, tc := range cases { | |||
| 94 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 95 | - | b, err := Marshal(tc.s) | |||
| 96 | - | if err != nil { | |||
| 97 | - | t.Fatalf("Marshal() error = %v", err) | |||
| 98 | - | } | |||
| 99 | - | got, err := Unmarshal(b) | |||
| 100 | - | if err != nil { | |||
| 101 | - | t.Fatalf("Unmarshal() error = %v", err) | |||
| 102 | - | } | |||
| 103 | - | if !equalSession(got, trimSession(tc.s)) { | |||
| 104 | - | t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s)) | |||
| 105 | - | } | |||
| 106 | - | }) | |||
| 107 | - | } | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | // V3 preserves the v2 16-byte big-endian envelope, but replaces its | |||
| 111 | - | // compressed row payload with a flags-zero manifest of opaque VT blobs. | |||
| 112 | - | // Snapshot owns only the manifest: sealed chunks are oldest-first and all | |||
| 113 | - | // chunk, tail, and visible payloads remain self-contained VT bytes. | |||
| 114 | - | const ( | |||
| 115 | - | snapshotV3Version = uint16(3) | |||
| 116 | - | v3EnvelopeSize = 16 | |||
| 117 | ) | 13 | ) | ||
| 118 | 14 | ||||
| 119 | func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | 15 | func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | ||
| 120 | indexed := renderer.DefaultStyle() | 16 | indexed := renderer.DefaultStyle() | ||
| 121 | - | indexed.Bold = true | 17 | + | indexed.Bold, indexed.Inverse = true, true |
| 122 | - | indexed.Inverse = true | |||
| 123 | indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough | 18 | indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough | ||
| 124 | - | indexed.Foreground = 196 | 19 | + | indexed.Foreground, indexed.Background = 196, 17 |
| 125 | - | indexed.Background = 17 | 20 | + | indexed.UnderlineStyle, indexed.HasUnderlineColor, indexed.UnderlineColor = renderer.UnderlineCurly, true, 203 |
| 126 | - | indexed.UnderlineStyle = renderer.UnderlineCurly | |||
| 127 | - | indexed.HasUnderlineColor = true | |||
| 128 | - | indexed.UnderlineColor = 203 | |||
| 129 | - | ||||
| 130 | rgb := renderer.DefaultStyle() | 21 | rgb := renderer.DefaultStyle() | ||
| 131 | - | rgb.Italic = true | 22 | + | rgb.Italic, rgb.HasForegroundRGB, rgb.ForegroundRGB = true, true, renderer.RGB{R: 1, G: 2, B: 3} |
| 132 | - | rgb.HasForegroundRGB = true | 23 | + | rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6} |
| 133 | - | rgb.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | 24 | + | rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9} |
| 134 | - | rgb.HasBackgroundRGB = true | 25 | + | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}}) |
| 135 | - | rgb.BackgroundRGB = renderer.RGB{R: 4, G: 5, B: 6} | 26 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}}) |
| 136 | - | rgb.UnderlineStyle = renderer.UnderlineDashed | 27 | + | want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}} |
| 137 | - | rgb.HasUnderlineColorRGB = true | |||
| 138 | - | rgb.UnderlineColorRGB = renderer.RGB{R: 7, G: 8, B: 9} | |||
| 139 | - | ||||
| 140 | - | want := Session{Name: "v3 exact", Tabs: []Tab{{ | |||
| 141 | - | Focus: "p", | |||
| 142 | - | Tree: layout.NewTree("p"), | |||
| 143 | - | Panes: []Pane{{ID: "p", Scrollback: [][]renderer.Cell{{ | |||
| 144 | - | {Rune: 'I', Style: indexed}, | |||
| 145 | - | {Rune: '好', Style: rgb}, | |||
| 146 | - | {Continuation: true, Style: rgb}, | |||
| 147 | - | }}, Visible: [][]renderer.Cell{{ | |||
| 148 | - | {Rune: 'V', Style: rgb}, | |||
| 149 | - | {Rune: '界', Style: indexed}, | |||
| 150 | - | {Continuation: true, Style: indexed}, | |||
| 151 | - | }}}}, | |||
| 152 | - | }}} | |||
| 153 | - | ||||
| 154 | encoded, err := Marshal(want) | 28 | encoded, err := Marshal(want) | ||
| 155 | if err != nil { | 29 | if err != nil { | ||
| 156 | - | t.Fatalf("Marshal() error = %v", err) | 30 | + | t.Fatal(err) |
| 157 | } | 31 | } | ||
| 158 | requireV3Envelope(t, encoded) | 32 | requireV3Envelope(t, encoded) | ||
| 159 | got, err := Unmarshal(encoded) | 33 | got, err := Unmarshal(encoded) | ||
| 160 | if err != nil { | 34 | if err != nil { | ||
| 161 | - | t.Fatalf("Unmarshal() error = %v", err) | 35 | + | t.Fatal(err) |
| 36 | + | } | |||
| 37 | + | if len(got.Tabs) != 1 || len(got.Tabs[0].Panes) != 1 || got.Tabs[0].Panes[0].ID != "p" { | |||
| 38 | + | t.Fatalf("manifest round trip = %#v", got) | |||
| 162 | } | 39 | } | ||
| 163 | - | if !equalSession(got, want) { | 40 | + | if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil { |
| 164 | - | t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, want) | 41 | + | t.Fatalf("history decode: %v", err) |
| 42 | + | } | |||
| 43 | + | frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible) | |||
| 44 | + | if err != nil { | |||
| 45 | + | t.Fatal(err) | |||
| 46 | + | } | |||
| 47 | + | if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation { | |||
| 48 | + | t.Fatalf("visible terminal data lost: %#v", frame.Row(0)) | |||
| 165 | } | 49 | } | ||
| 166 | } | 50 | } | ||
| 167 | 51 | ||||
| 168 | func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) { | 52 | func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) { | ||
| 169 | - | for _, tc := range []struct { | 53 | + | for _, version := range []uint16{1, 2} { |
| 170 | - | name string | 54 | + | if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) { |
| 171 | - | version uint16 | 55 | + | t.Fatalf("version %d: %v", version, err) |
| 172 | - | }{ | 56 | + | } |
| 173 | - | {name: "v1", version: 1}, | |||
| 174 | - | {name: "v2", version: 2}, | |||
| 175 | - | } { | |||
| 176 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 177 | - | _, err := Unmarshal(v3Envelope(tc.version, 0, nil)) | |||
| 178 | - | if !errors.Is(err, ErrBadVersion) { | |||
| 179 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrBadVersion) | |||
| 180 | - | } | |||
| 181 | - | }) | |||
| 182 | } | 57 | } | ||
| 183 | } | 58 | } | ||
| 184 | 59 | ||||
| 185 | func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) { | 60 | func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) { | ||
| 186 | body := v3Manifest(0, nil, nil, 0, nil, 0, nil) | 61 | body := v3Manifest(0, nil, nil, 0, nil, 0, nil) | ||
| 187 | - | valid := v3Envelope(snapshotV3Version, 0, body) | 62 | + | valid := v3Envelope(version, 0, body) |
| 188 | badCRC := append([]byte(nil), valid...) | 63 | badCRC := append([]byte(nil), valid...) | ||
| 189 | badCRC[15] ^= 1 | 64 | badCRC[15] ^= 1 | ||
| 190 | - | ||||
| 191 | for _, tc := range []struct { | 65 | for _, tc := range []struct { | ||
| 192 | name string | 66 | name string | ||
| 193 | data []byte | 67 | data []byte | ||
| 194 | want error | 68 | want error | ||
| 195 | }{ | 69 | }{ | ||
| 196 | - | {name: "short envelope", data: valid[:v3EnvelopeSize-1], want: ErrShortPayload}, | 70 | + | {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic}, |
| 197 | - | {name: "bad magic", data: append([]byte("NOPE"), valid[4:]...), want: ErrBadMagic}, | 71 | + | {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData}, |
| 198 | - | {name: "future version", data: v3Envelope(snapshotV3Version+1, 0, body), want: ErrBadVersion}, | 72 | + | {"crc", badCRC, ErrBadCRC}, {"body", v3EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData}, |
| 199 | - | {name: "nonzero flags", data: v3Envelope(snapshotV3Version, 1, body), want: ErrInvalidData}, | |||
| 200 | - | {name: "bad checksum", data: badCRC, want: ErrBadCRC}, | |||
| 201 | - | {name: "oversized body declaration", data: v3EnvelopeWithLength(snapshotV3Version, 0, nil, math.MaxUint32), want: ErrInvalidData}, | |||
| 202 | } { | 73 | } { | ||
| 203 | - | t.Run(tc.name, func(t *testing.T) { | 74 | + | t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) }) |
| 204 | - | assertV3RejectsWithoutPanic(t, tc.data, tc.want) | |||
| 205 | - | }) | |||
| 206 | } | 75 | } | ||
| 207 | } | 76 | } | ||
| 208 | 77 | ||||
| @@ -211,442 +80,127 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin | |||||
| 211 | name string | 80 | name string | ||
| 212 | body []byte | 81 | body []byte | ||
| 213 | }{ | 82 | }{ | ||
| 214 | - | {name: "sealed chunk count", body: v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)}, | 83 | + | {"count", v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)}, |
| 215 | - | {name: "sealed blob length", body: v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)}, | 84 | + | {"sealed", v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)}, |
| 216 | - | {name: "tail blob length", body: v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)}, | 85 | + | {"tail", v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)}, |
| 217 | - | {name: "visible blob length", body: v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)}, | 86 | + | {"visible", v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)}, |
| 218 | - | // Aggregate accounting is snapshot-owned even though each payload is | |||
| 219 | - | // VT-owned opaque data; declarations must be bounded before a blob can | |||
| 220 | - | // cause a count-driven allocation. | |||
| 221 | - | {name: "aggregate opaque blob declarations", body: v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)}, | |||
| 222 | } { | 87 | } { | ||
| 223 | - | t.Run(tc.name, func(t *testing.T) { | 88 | + | t.Run(tc.name, func(t *testing.T) { reject(t, v3Envelope(version, 0, tc.body), ErrInvalidData) }) |
| 224 | - | assertV3RejectsWithoutPanic(t, v3Envelope(snapshotV3Version, 0, tc.body), ErrInvalidData) | |||
| 225 | - | }) | |||
| 226 | } | 89 | } | ||
| 227 | } | 90 | } | ||
| 228 | 91 | ||||
| 229 | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | 92 | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | ||
| 230 | - | encoded, err := Marshal(Session{Name: "v3 prefixes", Tabs: []Tab{{ | 93 | + | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) |
| 231 | - | Focus: "p", | 94 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 232 | - | Tree: layout.NewTree("p"), | 95 | + | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) |
| 233 | - | Panes: []Pane{{ID: "p", Scrollback: rows("history"), Visible: rows("visible")}}, | |||
| 234 | - | }}}) | |||
| 235 | if err != nil { | 96 | if err != nil { | ||
| 236 | - | t.Fatalf("Marshal() error = %v", err) | 97 | + | t.Fatal(err) |
| 237 | } | 98 | } | ||
| 238 | - | requireV3Envelope(t, encoded) | |||
| 239 | for n := range len(encoded) { | 99 | for n := range len(encoded) { | ||
| 240 | - | assertV3RejectsWithoutPanic(t, encoded[:n], nil) | 100 | + | reject(t, encoded[:n], nil) |
| 241 | } | 101 | } | ||
| 242 | - | assertV3RejectsWithoutPanic(t, append(append([]byte(nil), encoded...), 0), ErrTrailingBytes) | 102 | + | reject(t, append(encoded, 0), ErrTrailingBytes) |
| 243 | } | 103 | } | ||
| 244 | 104 | ||||
| 245 | - | func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) { | 105 | + | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { |
| 246 | - | good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}}) | 106 | + | sealed, tail := historyBlobs(t, nil) |
| 107 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |||
| 108 | + | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |||
| 247 | if err != nil { | 109 | if err != nil { | ||
| 248 | t.Fatal(err) | 110 | t.Fatal(err) | ||
| 249 | } | 111 | } | ||
| 250 | - | cases := []struct { | 112 | + | if _, err := Unmarshal(encoded); !errors.Is(err, ErrUnknownPane) { |
| 251 | - | name string | 113 | + | t.Fatalf("error = %v", err) |
| 252 | - | data []byte | |||
| 253 | - | }{ | |||
| 254 | - | {"bad magic", append([]byte("NOPE"), good[4:]...)}, | |||
| 255 | - | {"bad version", replaceU16(good, 4, 1)}, | |||
| 256 | - | {"bad crc", append([]byte(nil), good[:len(good)-1]...)}, | |||
| 257 | - | {"trailing", append(append([]byte(nil), good...), 0)}, | |||
| 258 | - | {"body len overrun", replaceU32(good, 8, 999999)}, | |||
| 259 | - | {"prefix", good[:len(good)/2]}, | |||
| 260 | - | {"style oob", styleOOBSnapshot(t)}, | |||
| 261 | - | {"unknown pane in tree", unknownPaneSnapshot(t)}, | |||
| 262 | - | } | |||
| 263 | - | for _, tc := range cases { | |||
| 264 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 265 | - | defer func() { | |||
| 266 | - | if r := recover(); r != nil { | |||
| 267 | - | t.Fatalf("Unmarshal panicked: %v", r) | |||
| 268 | - | } | |||
| 269 | - | }() | |||
| 270 | - | if _, err := Unmarshal(tc.data); err == nil { | |||
| 271 | - | t.Fatalf("Unmarshal() error = nil") | |||
| 272 | - | } | |||
| 273 | - | }) | |||
| 274 | - | } | |||
| 275 | - | for i := 0; i < len(good); i++ { | |||
| 276 | - | if _, err := Unmarshal(good[:i]); err == nil { | |||
| 277 | - | t.Fatalf("prefix length %d unexpectedly succeeded", i) | |||
| 278 | - | } | |||
| 279 | - | } | |||
| 280 | - | } | |||
| 281 | - | ||||
| 282 | - | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | |||
| 283 | - | _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}}) | |||
| 284 | - | if !errors.Is(err, ErrInvalidData) { | |||
| 285 | - | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | |||
| 286 | - | } | |||
| 287 | - | } | |||
| 288 | - | ||||
| 289 | - | func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |||
| 290 | - | var w payloadWriter | |||
| 291 | - | _ = w.putString("x") | |||
| 292 | - | w.putUint64(0) | |||
| 293 | - | w.putUint16(0) | |||
| 294 | - | w.putUint16(1) | |||
| 295 | - | _ = w.putString("t") | |||
| 296 | - | w.putUint16(80) | |||
| 297 | - | w.putUint16(24) | |||
| 298 | - | w.putUint64(1) | |||
| 299 | - | _ = w.putString("p") | |||
| 300 | - | _ = writeNode(&w, layout.NewLeaf("p")) | |||
| 301 | - | w.putUint16(1) | |||
| 302 | - | _ = w.putString("p") | |||
| 303 | - | _ = w.putString("p_stable") | |||
| 304 | - | _ = w.putString("") | |||
| 305 | - | w.putUint16(0) | |||
| 306 | - | w.putUint32(0) | |||
| 307 | - | w.putUint32(0) | |||
| 308 | - | w.putUint8(1) | |||
| 309 | - | w.putUint16(0) | |||
| 310 | - | _ = w.putString("generic") | |||
| 311 | - | _ = w.putString("") | |||
| 312 | - | _, err := Unmarshal(reheader(w.b, 0)) | |||
| 313 | - | if !errors.Is(err, ErrInvalidData) { | |||
| 314 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | |||
| 315 | - | } | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) { | |||
| 319 | - | body := []byte{0} | |||
| 320 | - | oversizedRaw := reheader(body, 0) | |||
| 321 | - | binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1)) | |||
| 322 | - | oversizedFlate := reheader(body, flagFlate) | |||
| 323 | - | binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1)) | |||
| 324 | - | cases := []struct { | |||
| 325 | - | name string | |||
| 326 | - | data []byte | |||
| 327 | - | }{ | |||
| 328 | - | {name: "raw", data: oversizedRaw}, | |||
| 329 | - | {name: "flate", data: oversizedFlate}, | |||
| 330 | - | } | |||
| 331 | - | for _, tc := range cases { | |||
| 332 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 333 | - | _, err := Unmarshal(tc.data) | |||
| 334 | - | if !errors.Is(err, ErrInvalidData) { | |||
| 335 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | |||
| 336 | - | } | |||
| 337 | - | }) | |||
| 338 | } | 114 | } | ||
| 339 | } | 115 | } | ||
| 340 | 116 | ||||
| 341 | - | func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) { | 117 | + | func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) { |
| 342 | - | cases := []struct { | 118 | + | t.Helper() |
| 343 | - | name string | 119 | + | h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2}) |
| 344 | - | data []byte | 120 | + | for _, row := range rows { |
| 345 | - | want error | 121 | + | h.Append(row) |
| 346 | - | }{ | |||
| 347 | - | {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData}, | |||
| 348 | - | {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload}, | |||
| 349 | - | } | |||
| 350 | - | for _, tc := range cases { | |||
| 351 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 352 | - | defer func() { | |||
| 353 | - | if r := recover(); r != nil { | |||
| 354 | - | t.Fatalf("Unmarshal panicked: %v", r) | |||
| 355 | - | } | |||
| 356 | - | }() | |||
| 357 | - | _, err := Unmarshal(tc.data) | |||
| 358 | - | if !errors.Is(err, tc.want) { | |||
| 359 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want) | |||
| 360 | - | } | |||
| 361 | - | }) | |||
| 362 | - | } | |||
| 363 | - | } | |||
| 364 | - | ||||
| 365 | - | func TestFlateOnOff(t *testing.T) { | |||
| 366 | - | small, err := Marshal(Session{Name: "small"}) | |||
| 367 | - | if err != nil { | |||
| 368 | - | t.Fatal(err) | |||
| 369 | - | } | |||
| 370 | - | if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 { | |||
| 371 | - | t.Fatalf("small flags = %#x, want 0", flags) | |||
| 372 | } | 122 | } | ||
| 373 | - | large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}}) | 123 | + | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) |
| 374 | if err != nil { | 124 | if err != nil { | ||
| 375 | t.Fatal(err) | 125 | t.Fatal(err) | ||
| 376 | } | 126 | } | ||
| 377 | - | if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 { | 127 | + | return sealed, tail |
| 378 | - | t.Fatalf("large flags = %#x, want flate bit", flags) | |||
| 379 | - | } | |||
| 380 | } | 128 | } | ||
| 381 | - | 129 | + | func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte { | |
| 382 | - | func BenchmarkMarshal10KRows(b *testing.B) { | 130 | + | t.Helper() |
| 383 | - | s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}} | 131 | + | width := 0 |
| 384 | - | b.ReportAllocs() | 132 | + | for _, row := range rows { |
| 385 | - | for i := 0; i < b.N; i++ { | 133 | + | width = max(width, len(row)) |
| 386 | - | if _, err := Marshal(s); err != nil { | |||
| 387 | - | b.Fatal(err) | |||
| 388 | - | } | |||
| 389 | - | } | |||
| 390 | - | } | |||
| 391 | - | ||||
| 392 | - | func rows(lines ...string) [][]renderer.Cell { | |||
| 393 | - | out := make([][]renderer.Cell, len(lines)) | |||
| 394 | - | for i, line := range lines { | |||
| 395 | - | for _, r := range line { | |||
| 396 | - | out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 397 | - | } | |||
| 398 | - | } | |||
| 399 | - | return out | |||
| 400 | - | } | |||
| 401 | - | ||||
| 402 | - | func manyRows(n int) [][]renderer.Cell { | |||
| 403 | - | out := make([][]renderer.Cell, n) | |||
| 404 | - | for i := range out { | |||
| 405 | - | out[i] = rows("row data row data row data")[0] | |||
| 406 | - | } | |||
| 407 | - | return out | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | func equalSession(a, b Session) bool { | |||
| 411 | - | if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) { | |||
| 412 | - | return false | |||
| 413 | - | } | |||
| 414 | - | for i := range a.Tabs { | |||
| 415 | - | if !equalTab(a.Tabs[i], b.Tabs[i]) { | |||
| 416 | - | return false | |||
| 417 | - | } | |||
| 418 | - | } | |||
| 419 | - | return true | |||
| 420 | - | } | |||
| 421 | - | ||||
| 422 | - | func equalTab(a, b Tab) bool { | |||
| 423 | - | 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)) { | |||
| 424 | - | return false | |||
| 425 | - | } | |||
| 426 | - | for i := range a.Panes { | |||
| 427 | - | 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) { | |||
| 428 | - | return false | |||
| 429 | - | } | |||
| 430 | - | } | |||
| 431 | - | return true | |||
| 432 | - | } | |||
| 433 | - | ||||
| 434 | - | func equalProcess(a, b *Process) bool { | |||
| 435 | - | if a == nil || b == nil { | |||
| 436 | - | return a == b | |||
| 437 | - | } | |||
| 438 | - | if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) { | |||
| 439 | - | return false | |||
| 440 | - | } | |||
| 441 | - | for i := range a.Argv { | |||
| 442 | - | if a.Argv[i] != b.Argv[i] { | |||
| 443 | - | return false | |||
| 444 | - | } | |||
| 445 | - | } | |||
| 446 | - | return true | |||
| 447 | - | } | |||
| 448 | - | ||||
| 449 | - | func treeRoot(t *layout.Tree) *layout.Node { | |||
| 450 | - | if t == nil { | |||
| 451 | - | return nil | |||
| 452 | - | } | |||
| 453 | - | return t.Root | |||
| 454 | - | } | |||
| 455 | - | ||||
| 456 | - | func equalNode(a, b *layout.Node) bool { | |||
| 457 | - | if a == nil || b == nil { | |||
| 458 | - | return a == b | |||
| 459 | - | } | |||
| 460 | - | if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) { | |||
| 461 | - | return false | |||
| 462 | - | } | |||
| 463 | - | for i := range a.Children { | |||
| 464 | - | if !equalNode(a.Children[i], b.Children[i]) { | |||
| 465 | - | return false | |||
| 466 | - | } | |||
| 467 | - | } | |||
| 468 | - | return true | |||
| 469 | - | } | |||
| 470 | - | ||||
| 471 | - | func equalRows(a, b [][]renderer.Cell) bool { | |||
| 472 | - | if len(a) != len(b) { | |||
| 473 | - | return false | |||
| 474 | - | } | |||
| 475 | - | for i := range a { | |||
| 476 | - | if len(a[i]) != len(b[i]) { | |||
| 477 | - | return false | |||
| 478 | - | } | |||
| 479 | - | for j := range a[i] { | |||
| 480 | - | if !a[i][j].Equal(b[i][j]) { | |||
| 481 | - | return false | |||
| 482 | - | } | |||
| 483 | - | } | |||
| 484 | - | } | |||
| 485 | - | return true | |||
| 486 | - | } | |||
| 487 | - | ||||
| 488 | - | func trimSession(s Session) Session { | |||
| 489 | - | for ti := range s.Tabs { | |||
| 490 | - | for pi := range s.Tabs[ti].Panes { | |||
| 491 | - | s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false) | |||
| 492 | - | s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true) | |||
| 493 | - | } | |||
| 494 | - | } | |||
| 495 | - | return s | |||
| 496 | - | } | |||
| 497 | - | ||||
| 498 | - | func reheader(body []byte, flags uint16) []byte { | |||
| 499 | - | out := make([]byte, 16+len(body)) | |||
| 500 | - | copy(out[:4], []byte("VEVS")) | |||
| 501 | - | binary.BigEndian.PutUint16(out[4:6], version) | |||
| 502 | - | binary.BigEndian.PutUint16(out[6:8], flags) | |||
| 503 | - | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | |||
| 504 | - | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |||
| 505 | - | copy(out[16:], body) | |||
| 506 | - | return out | |||
| 507 | - | } | |||
| 508 | - | ||||
| 509 | - | func replaceU16(b []byte, off int, v uint16) []byte { | |||
| 510 | - | out := append([]byte(nil), b...) | |||
| 511 | - | binary.BigEndian.PutUint16(out[off:], v) | |||
| 512 | - | return out | |||
| 513 | - | } | |||
| 514 | - | func replaceU32(b []byte, off int, v uint32) []byte { | |||
| 515 | - | out := append([]byte(nil), b...) | |||
| 516 | - | binary.BigEndian.PutUint32(out[off:], v) | |||
| 517 | - | return out | |||
| 518 | - | } | |||
| 519 | - | ||||
| 520 | - | func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte { | |||
| 521 | - | var w payloadWriter | |||
| 522 | - | _ = w.putString("x") | |||
| 523 | - | w.putUint64(0) | |||
| 524 | - | w.putUint16(0) | |||
| 525 | - | w.putUint16(1) | |||
| 526 | - | _ = w.putString("") | |||
| 527 | - | w.putUint16(0) | |||
| 528 | - | w.putUint16(0) | |||
| 529 | - | w.putUint64(0) | |||
| 530 | - | _ = w.putString("p") | |||
| 531 | - | _ = writeNode(&w, layout.NewLeaf("p")) | |||
| 532 | - | w.putUint16(1) | |||
| 533 | - | _ = w.putString("p") | |||
| 534 | - | _ = w.putString("") | |||
| 535 | - | _ = w.putString("") | |||
| 536 | - | w.putUint16(0) | |||
| 537 | - | w.putUint32(rowCount) | |||
| 538 | - | if rowCount > 0 { | |||
| 539 | - | w.putUint16(runCount) | |||
| 540 | - | } | |||
| 541 | - | return reheader(w.b, 0) | |||
| 542 | - | } | |||
| 543 | - | ||||
| 544 | - | func styleOOBSnapshot(t *testing.T) []byte { | |||
| 545 | - | b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}}) | |||
| 546 | - | if err != nil { | |||
| 547 | - | t.Fatal(err) | |||
| 548 | } | 134 | } | ||
| 549 | - | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | 135 | + | f := renderer.NewFrame(width, len(rows)) |
| 550 | - | body := append([]byte(nil), b[16:16+bodyLen]...) | 136 | + | for y, row := range rows { |
| 551 | - | for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0 | 137 | + | copy(f.Row(y), row) |
| 552 | - | if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' { | |||
| 553 | - | body[i+6] = 0 | |||
| 554 | - | body[i+7] = 2 | |||
| 555 | - | return reheader(body, 0) | |||
| 556 | - | } | |||
| 557 | } | 138 | } | ||
| 558 | - | t.Fatal("run not found") | 139 | + | b, err := vt.MarshalVisible(f) |
| 559 | - | return nil | |||
| 560 | - | } | |||
| 561 | - | ||||
| 562 | - | func unknownPaneSnapshot(t *testing.T) []byte { | |||
| 563 | - | b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}}) | |||
| 564 | if err != nil { | 140 | if err != nil { | ||
| 565 | t.Fatal(err) | 141 | t.Fatal(err) | ||
| 566 | } | 142 | } | ||
| 567 | return b | 143 | return b | ||
| 568 | } | 144 | } | ||
| 569 | - | ||||
| 570 | func requireV3Envelope(t *testing.T, data []byte) { | 145 | func requireV3Envelope(t *testing.T, data []byte) { | ||
| 571 | t.Helper() | 146 | t.Helper() | ||
| 572 | - | if len(data) < v3EnvelopeSize { | 147 | + | if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 { |
| 573 | - | t.Fatalf("encoded snapshot is %d bytes, want at least %d", len(data), v3EnvelopeSize) | 148 | + | t.Fatalf("not v3 envelope: % x", data) |
| 574 | - | } | |||
| 575 | - | if got := binary.BigEndian.Uint16(data[4:6]); got != snapshotV3Version { | |||
| 576 | - | t.Fatalf("snapshot version = %d, want v3", got) | |||
| 577 | - | } | |||
| 578 | - | if got := binary.BigEndian.Uint16(data[6:8]); got != 0 { | |||
| 579 | - | t.Fatalf("snapshot flags = %#x, want 0", got) | |||
| 580 | } | 149 | } | ||
| 581 | } | 150 | } | ||
| 582 | - | 151 | + | func reject(t *testing.T, data []byte, want error) { | |
| 583 | - | func assertV3RejectsWithoutPanic(t *testing.T, data []byte, want error) { | |||
| 584 | t.Helper() | 152 | t.Helper() | ||
| 585 | defer func() { | 153 | defer func() { | ||
| 586 | if r := recover(); r != nil { | 154 | if r := recover(); r != nil { | ||
| 587 | - | t.Fatalf("Unmarshal panicked: %v", r) | 155 | + | t.Fatalf("panic: %v", r) |
| 588 | } | 156 | } | ||
| 589 | }() | 157 | }() | ||
| 590 | _, err := Unmarshal(data) | 158 | _, err := Unmarshal(data) | ||
| 591 | if err == nil { | 159 | if err == nil { | ||
| 592 | - | t.Fatal("Unmarshal() error = nil") | 160 | + | t.Fatal("accepted malformed input") |
| 593 | } | 161 | } | ||
| 594 | if want != nil && !errors.Is(err, want) { | 162 | if want != nil && !errors.Is(err, want) { | ||
| 595 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, want) | 163 | + | t.Fatalf("error = %v, want %v", err, want) |
| 596 | } | 164 | } | ||
| 597 | } | 165 | } | ||
| 598 | - | 166 | + | func v3Envelope(v, flags uint16, body []byte) []byte { | |
| 599 | - | func v3Envelope(version, flags uint16, body []byte) []byte { | 167 | + | return v3EnvelopeWithLength(v, flags, body, uint32(len(body))) |
| 600 | - | return v3EnvelopeWithLength(version, flags, body, uint32(len(body))) | |||
| 601 | } | 168 | } | ||
| 602 | - | 169 | + | func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte { | |
| 603 | - | func v3EnvelopeWithLength(version, flags uint16, body []byte, bodyLen uint32) []byte { | 170 | + | out := make([]byte, 16+len(body)) |
| 604 | - | out := make([]byte, v3EnvelopeSize+len(body)) | 171 | + | copy(out, magic) |
| 605 | - | copy(out[:4], magic) | 172 | + | binary.BigEndian.PutUint16(out[4:6], v) |
| 606 | - | binary.BigEndian.PutUint16(out[4:6], version) | |||
| 607 | binary.BigEndian.PutUint16(out[6:8], flags) | 173 | binary.BigEndian.PutUint16(out[6:8], flags) | ||
| 608 | - | binary.BigEndian.PutUint32(out[8:12], bodyLen) | 174 | + | binary.BigEndian.PutUint32(out[8:12], length) |
| 609 | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | 175 | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | ||
| 610 | - | copy(out[v3EnvelopeSize:], body) | 176 | + | copy(out[16:], body) |
| 611 | return out | 177 | return out | ||
| 612 | } | 178 | } | ||
| 613 | 179 | ||||
| 614 | - | // v3Manifest writes only snapshot-owned fields. Blob contents are deliberately | 180 | + | // v3Manifest creates just enough session/tab/pane framing for hostile declarations. |
| 615 | - | // opaque: VT validates their style, row, run, cell, and decoded-byte budgets. | 181 | + | func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte { |
| 616 | - | func v3Manifest( | 182 | + | var b []byte |
| 617 | - | sealedCount uint32, | 183 | + | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } |
| 618 | - | sealedLengths []uint32, | 184 | + | putS("s") |
| 619 | - | sealedBlobs [][]byte, | 185 | + | b = append(b, make([]byte, 8+2)...) |
| 620 | - | tailLength uint32, | 186 | + | b = binary.BigEndian.AppendUint16(b, 1) |
| 621 | - | tailBlob []byte, | 187 | + | putS("") |
| 622 | - | visibleLength uint32, | 188 | + | b = append(b, make([]byte, 2+2+8)...) |
| 623 | - | visibleBlob []byte, | 189 | + | putS("p") |
| 624 | - | ) []byte { | 190 | + | b = append(b, 0) |
| 625 | - | var w payloadWriter | 191 | + | putS("p") |
| 626 | - | _ = w.putString("v3") | 192 | + | b = binary.BigEndian.AppendUint16(b, 1) |
| 627 | - | w.putUint64(0) | 193 | + | putS("p") |
| 628 | - | w.putUint16(0) | 194 | + | putS("") |
| 629 | - | w.putUint16(1) | 195 | + | putS("") |
| 630 | - | _ = w.putString("tab") | 196 | + | b = binary.BigEndian.AppendUint32(b, sealed) |
| 631 | - | w.putUint16(80) | 197 | + | for _, l := range sealedLens { |
| 632 | - | w.putUint16(24) | 198 | + | b = binary.BigEndian.AppendUint32(b, l) |
| 633 | - | w.putUint64(1) | 199 | + | } |
| 634 | - | _ = w.putString("p") | 200 | + | b = append(b, sealedData...) |
| 635 | - | _ = writeNode(&w, layout.NewLeaf("p")) | 201 | + | b = binary.BigEndian.AppendUint32(b, tailLen) |
| 636 | - | w.putUint16(1) | 202 | + | b = append(b, tailData...) |
| 637 | - | _ = w.putString("p") | 203 | + | b = binary.BigEndian.AppendUint32(b, visibleLen) |
| 638 | - | _ = w.putString("") | 204 | + | b = append(b, visibleData...) |
| 639 | - | _ = w.putString("") | 205 | + | return b |
| 640 | - | w.putUint32(sealedCount) | |||
| 641 | - | for i := range sealedLengths { | |||
| 642 | - | w.putUint32(sealedLengths[i]) | |||
| 643 | - | if i < len(sealedBlobs) { | |||
| 644 | - | w.b = append(w.b, sealedBlobs[i]...) | |||
| 645 | - | } | |||
| 646 | - | } | |||
| 647 | - | w.putUint32(tailLength) | |||
| 648 | - | w.b = append(w.b, tailBlob...) | |||
| 649 | - | w.putUint32(visibleLength) | |||
| 650 | - | w.b = append(w.b, visibleBlob...) | |||
| 651 | - | return w.b | |||
| 652 | } | 206 | } | ||
M internal/usecase/snapshot/snapshot.go +10 -12
| @@ -1,9 +1,6 @@ | |||
| 1 | 1 | package snapshot | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | - | ) | |
| 3 | + | import "github.com/bnema/vev/internal/usecase/layout" | |
| 7 | 4 | ||
| 8 | 5 | // Session is the durable transfer representation for a named daemon session. | |
| 9 | 6 | type Session struct { | |
| @@ -24,15 +21,16 @@ type Tab struct { | |||
| 24 | 21 | Panes []Pane | |
| 25 | 22 | } | |
| 26 | 23 | ||
| 27 | - | // Pane captures terminal state needed to restore a fresh shell in the same cwd | |
| 28 | - | // with its scrollback and final visible primary screen. | |
| 24 | + | // Pane captures terminal state as opaque, canonical VT blobs. Snapshot owns | |
| 25 | + | // their length framing and order; pkg/vt owns their contents and validation. | |
| 29 | 26 | type Pane struct { | |
| 30 | - | ID layout.PaneID | |
| 31 | - | StableID string | |
| 32 | - | Cwd string | |
| 33 | - | Scrollback [][]renderer.Cell | |
| 34 | - | Visible [][]renderer.Cell | |
| 35 | - | Process *Process | |
| 27 | + | ID layout.PaneID | |
| 28 | + | StableID string | |
| 29 | + | Cwd string | |
| 30 | + | SealedChunks [][]byte // oldest-first, each a self-contained VT blob | |
| 31 | + | Tail []byte // mandatory nonempty canonical VT blob | |
| 32 | + | Visible []byte // mandatory nonempty canonical VT blob | |
| 33 | + | Process *Process | |
| 36 | 34 | } | |
| 37 | 35 | ||
| 38 | 36 | // Process captures restorable foreground process metadata. | |
| @@ -1,9 +1,6 @@ | |||||
| 1 | package snapshot | 1 | 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 []Pane | 21 | Panes []Pane | ||
| 25 | } | 22 | } | ||
| 26 | 23 | ||||
| 27 | - | // Pane captures terminal state needed to restore a fresh shell in the same cwd | 24 | + | // 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.PaneID | 27 | + | ID layout.PaneID |
| 31 | - | StableID string | 28 | + | StableID string |
| 32 | - | Cwd string | 29 | + | Cwd string |
| 33 | - | Scrollback [][]renderer.Cell | 30 | + | SealedChunks [][]byte // oldest-first, each a self-contained VT blob |
| 34 | - | Visible [][]renderer.Cell | 31 | + | Tail []byte // mandatory nonempty canonical VT blob |
| 35 | - | Process *Process | 32 | + | 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 | 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 | 65 | m := New(testSnapshot("alpha", "beta alpha")) | |
| 66 | 66 | for _, r := range "alpha" { | |
| 67 | 67 | m.Insert(r) | |
| 68 | 68 | } | |
| 69 | 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 | 72 | clone.Down() | |
| 73 | 73 | clone.Insert('z') | |
| 74 | 74 | ||
| @@ -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/history.go +25 -0
| @@ -76,6 +76,15 @@ func (h *History) evict() { | |||
| 76 | 76 | } | |
| 77 | 77 | } | |
| 78 | 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 | + | ||
| 79 | 88 | // View captures the current history. Sealed chunks are shared by identity; a | |
| 80 | 89 | // partially-filled tail is copied into a new immutable chunk for this view. | |
| 81 | 90 | func (h *History) View() HistoryView { | |
| @@ -93,6 +102,22 @@ func (h *History) View() HistoryView { | |||
| 93 | 102 | return HistoryView{chunks: chunks, rows: h.rows} | |
| 94 | 103 | } | |
| 95 | 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 | + | ||
| 96 | 121 | func (v HistoryView) Len() int { return v.rows } | |
| 97 | 122 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | |
| 98 | 123 | ||
| @@ -76,6 +76,15 @@ func (h *History) evict() { | |||||
| 76 | } | 76 | } | ||
| 77 | } | 77 | } | ||
| 78 | 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 | + | ||||
| 79 | // View captures the current history. Sealed chunks are shared by identity; a | 88 | // View captures the current history. Sealed chunks are shared by identity; a | ||
| 80 | // partially-filled tail is copied into a new immutable chunk for this view. | 89 | // partially-filled tail is copied into a new immutable chunk for this view. | ||
| 81 | func (h *History) View() HistoryView { | 90 | func (h *History) View() HistoryView { | ||
| @@ -93,6 +102,22 @@ func (h *History) View() HistoryView { | |||||
| 93 | return HistoryView{chunks: chunks, rows: h.rows} | 102 | return HistoryView{chunks: chunks, rows: h.rows} | ||
| 94 | } | 103 | } | ||
| 95 | 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 | + | ||||
| 96 | func (v HistoryView) Len() int { return v.rows } | 121 | func (v HistoryView) Len() int { return v.rows } | ||
| 97 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | 122 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | ||
| 98 | 123 | ||||
M pkg/vt/snapshot.go +193 -1
| @@ -1,6 +1,180 @@ | |||
| 1 | 1 | 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 | + | // MarshalVisible encodes the exact visible primary frame, including every | |
| 103 | + | // blank, row width, and terminal cell attribute. | |
| 104 | + | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |
| 105 | + | if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 { | |
| 106 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 107 | + | } | |
| 108 | + | cells := uint64(frame.Width) * uint64(frame.Height) | |
| 109 | + | bytes, ok := historyCellByteCount(cells) | |
| 110 | + | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes { | |
| 111 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 112 | + | } | |
| 113 | + | out := make([]byte, 0, 13+bytes) | |
| 114 | + | out = append(out, visibleMagic...) | |
| 115 | + | out = append(out, historyVersion) | |
| 116 | + | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |
| 117 | + | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | |
| 118 | + | for y := range frame.Height { | |
| 119 | + | for _, cell := range frame.Row(y) { | |
| 120 | + | if !validHistoryCell(cell) { | |
| 121 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 122 | + | } | |
| 123 | + | out = appendHistoryCell(out, cell) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | return out, nil | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // PreflightVisibleBlob validates dimensions and cell declarations without | |
| 130 | + | // allocating a frame. | |
| 131 | + | func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |
| 132 | + | if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion { | |
| 133 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 134 | + | } | |
| 135 | + | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | |
| 136 | + | if width != 0 && height > math.MaxUint64/width { | |
| 137 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 138 | + | } | |
| 139 | + | cells := width * height | |
| 140 | + | bytes, ok := historyCellByteCount(cells) | |
| 141 | + | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes { | |
| 142 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 143 | + | } | |
| 144 | + | p := historyParser{data: data[13:]} | |
| 145 | + | for range cells { | |
| 146 | + | cell, ok := p.cell() | |
| 147 | + | if !ok || !validHistoryCell(cell) { | |
| 148 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // UnmarshalVisible decodes a preflighted exact visible frame. | |
| 155 | + | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | |
| 156 | + | if _, err := PreflightVisibleBlob(data); err != nil { | |
| 157 | + | return renderer.Frame{}, err | |
| 158 | + | } | |
| 159 | + | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | |
| 160 | + | frame := renderer.NewFrame(width, height) | |
| 161 | + | p := historyParser{data: data[13:]} | |
| 162 | + | for y := range height { | |
| 163 | + | for x := range width { | |
| 164 | + | cell, _ := p.cell() | |
| 165 | + | frame.Set(x, y, cell) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | return frame, nil | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame. | |
| 172 | + | func (s *Screen) PrimaryVisibleFrame() renderer.Frame { | |
| 173 | + | if s.alternate != nil { | |
| 174 | + | return s.alternate.frame.Clone() | |
| 175 | + | } | |
| 176 | + | return s.Frame.Clone() | |
| 177 | + | } | |
| 4 | 178 | ||
| 5 | 179 | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | |
| 6 | 180 | // When the alternate screen is active, it snapshots the saved primary screen | |
| @@ -16,3 +190,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |||
| 16 | 190 | } | |
| 17 | 191 | return rows | |
| 18 | 192 | } | |
| 193 | + | ||
| 194 | + | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | |
| 195 | + | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |
| 196 | + | frame, err := UnmarshalVisible(blob) | |
| 197 | + | if err != nil { | |
| 198 | + | return err | |
| 199 | + | } | |
| 200 | + | // A persisted primary frame is authoritative: restoring it directly keeps | |
| 201 | + | // exact row widths and height even when a collapsed layout chose a smaller | |
| 202 | + | // initial PTY size. A subsequent resize reconciles it with live geometry. | |
| 203 | + | if s.alternate != nil { | |
| 204 | + | s.alternate.frame = frame | |
| 205 | + | } else { | |
| 206 | + | s.Frame = frame | |
| 207 | + | } | |
| 208 | + | s.fullRedraw() | |
| 209 | + | return nil | |
| 210 | + | } | |
| @@ -1,6 +1,180 @@ | |||||
| 1 | package vt | 1 | 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 | + | // MarshalVisible encodes the exact visible primary frame, including every | |||
| 103 | + | // blank, row width, and terminal cell attribute. | |||
| 104 | + | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||
| 105 | + | if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 { | |||
| 106 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 107 | + | } | |||
| 108 | + | cells := uint64(frame.Width) * uint64(frame.Height) | |||
| 109 | + | bytes, ok := historyCellByteCount(cells) | |||
| 110 | + | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes { | |||
| 111 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 112 | + | } | |||
| 113 | + | out := make([]byte, 0, 13+bytes) | |||
| 114 | + | out = append(out, visibleMagic...) | |||
| 115 | + | out = append(out, historyVersion) | |||
| 116 | + | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |||
| 117 | + | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | |||
| 118 | + | for y := range frame.Height { | |||
| 119 | + | for _, cell := range frame.Row(y) { | |||
| 120 | + | if !validHistoryCell(cell) { | |||
| 121 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 122 | + | } | |||
| 123 | + | out = appendHistoryCell(out, cell) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | return out, nil | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // PreflightVisibleBlob validates dimensions and cell declarations without | |||
| 130 | + | // allocating a frame. | |||
| 131 | + | func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||
| 132 | + | if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion { | |||
| 133 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 134 | + | } | |||
| 135 | + | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | |||
| 136 | + | if width != 0 && height > math.MaxUint64/width { | |||
| 137 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 138 | + | } | |||
| 139 | + | cells := width * height | |||
| 140 | + | bytes, ok := historyCellByteCount(cells) | |||
| 141 | + | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes { | |||
| 142 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 143 | + | } | |||
| 144 | + | p := historyParser{data: data[13:]} | |||
| 145 | + | for range cells { | |||
| 146 | + | cell, ok := p.cell() | |||
| 147 | + | if !ok || !validHistoryCell(cell) { | |||
| 148 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // UnmarshalVisible decodes a preflighted exact visible frame. | |||
| 155 | + | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | |||
| 156 | + | if _, err := PreflightVisibleBlob(data); err != nil { | |||
| 157 | + | return renderer.Frame{}, err | |||
| 158 | + | } | |||
| 159 | + | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | |||
| 160 | + | frame := renderer.NewFrame(width, height) | |||
| 161 | + | p := historyParser{data: data[13:]} | |||
| 162 | + | for y := range height { | |||
| 163 | + | for x := range width { | |||
| 164 | + | cell, _ := p.cell() | |||
| 165 | + | frame.Set(x, y, cell) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | return frame, nil | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame. | |||
| 172 | + | func (s *Screen) PrimaryVisibleFrame() renderer.Frame { | |||
| 173 | + | if s.alternate != nil { | |||
| 174 | + | return s.alternate.frame.Clone() | |||
| 175 | + | } | |||
| 176 | + | return s.Frame.Clone() | |||
| 177 | + | } | |||
| 4 | 178 | ||||
| 5 | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | 179 | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | ||
| 6 | // When the alternate screen is active, it snapshots the saved primary screen | 180 | // When the alternate screen is active, it snapshots the saved primary screen | ||
| @@ -16,3 +190,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |||||
| 16 | } | 190 | } | ||
| 17 | return rows | 191 | return rows | ||
| 18 | } | 192 | } | ||
| 193 | + | ||||
| 194 | + | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | |||
| 195 | + | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |||
| 196 | + | frame, err := UnmarshalVisible(blob) | |||
| 197 | + | if err != nil { | |||
| 198 | + | return err | |||
| 199 | + | } | |||
| 200 | + | // A persisted primary frame is authoritative: restoring it directly keeps | |||
| 201 | + | // exact row widths and height even when a collapsed layout chose a smaller | |||
| 202 | + | // initial PTY size. A subsequent resize reconciles it with live geometry. | |||
| 203 | + | if s.alternate != nil { | |||
| 204 | + | s.alternate.frame = frame | |||
| 205 | + | } else { | |||
| 206 | + | s.Frame = frame | |||
| 207 | + | } | |||
| 208 | + | s.fullRedraw() | |||
| 209 | + | return nil | |||
| 210 | + | } | |||