Merge pull request #77 from bnema/perf/history-chunks-v3
perf(snapshot): adopt immutable history chunks and v3 persistence
39 files · +4411 -1251
M internal/usecase/copy/mode.go +44 -18
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "unicode" | 7 | "unicode" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/vev/pkg/renderer" | 9 | "github.com/bnema/vev/pkg/renderer" | ||
| 10 | + | "github.com/bnema/vev/pkg/vt" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits | 13 | // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits | ||
| @@ -17,45 +18,49 @@ 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 seals the current VT history tail, then freezes that immutable |
| 30 | - | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | 31 | + | // view and clones the visible screen. Sealing prevents repeated copy entry from |
| 31 | - | var history HistoryView | 32 | + | // cloning every row in a partial tail. |
| 32 | - | if sb != nil { | 33 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { |
| 33 | - | history = sb.View() | 34 | + | var history vt.HistoryView |
| 35 | + | if historySource != nil { | |||
| 36 | + | history = historySource.SealAndView() | |||
| 34 | } | 37 | } | ||
| 35 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | 38 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | ||
| 36 | } | 39 | } | ||
| 37 | 40 | ||||
| 38 | // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows. | 41 | // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows. | ||
| 39 | // It is intended for callers that already have a complete document rather than | 42 | // It is intended for callers that already have a complete document rather than | ||
| 40 | - | // a scrollback and visible frame. | 43 | + | // a scrollback and visible frame. Height is viewport geometry, not a request to |
| 44 | + | // append blank document rows. | |||
| 41 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | 45 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | ||
| 42 | - | owned := make([][]renderer.Cell, len(rows)) | 46 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256}) |
| 43 | - | for i, row := range rows { | 47 | + | for _, row := range rows { |
| 44 | - | owned[i] = append([]renderer.Cell(nil), row...) | 48 | + | history.Append(row) |
| 45 | } | 49 | } | ||
| 46 | - | return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height} | 50 | + | return Snapshot{history: history.SealAndView(), Width: width, Height: height} |
| 47 | } | 51 | } | ||
| 48 | 52 | ||||
| 49 | // Len returns the number of document rows. | 53 | // Len returns the number of document rows. | ||
| 50 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | 54 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | ||
| 51 | 55 | ||||
| 52 | - | // Row returns document row i, or nil when i is out of range. | 56 | + | // Row returns document row i, or nil when i is out of range. History rows |
| 57 | + | // borrow immutable history storage and must be treated as read-only. | |||
| 53 | func (s Snapshot) Row(i int) []renderer.Cell { | 58 | func (s Snapshot) Row(i int) []renderer.Cell { | ||
| 54 | if i < 0 { | 59 | if i < 0 { | ||
| 55 | return nil | 60 | return nil | ||
| 56 | } | 61 | } | ||
| 57 | if i < s.history.Len() { | 62 | if i < s.history.Len() { | ||
| 58 | - | return s.history.Row(i) | 63 | + | return s.history.BorrowedRow(i) |
| 59 | } | 64 | } | ||
| 60 | i -= s.history.Len() | 65 | i -= s.history.Len() | ||
| 61 | if i >= s.screen.Height { | 66 | if i >= s.screen.Height { | ||
| @@ -64,6 +69,27 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||||
| 64 | return s.screen.Row(i) | 69 | return s.screen.Row(i) | ||
| 65 | } | 70 | } | ||
| 66 | 71 | ||||
| 72 | + | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | |||
| 73 | + | rowIndex := 0 | |||
| 74 | + | stopped := false | |||
| 75 | + | s.history.Range(func(row []renderer.Cell) bool { | |||
| 76 | + | if !yield(rowIndex, row) { | |||
| 77 | + | stopped = true | |||
| 78 | + | return false | |||
| 79 | + | } | |||
| 80 | + | rowIndex++ | |||
| 81 | + | return true | |||
| 82 | + | }) | |||
| 83 | + | if stopped { | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | for y := range s.screen.Height { | |||
| 87 | + | if !yield(rowIndex+y, s.screen.Row(y)) { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 67 | // Mode stores per-client scrollback viewport and line-selection state. | 93 | // Mode stores per-client scrollback viewport and line-selection state. | ||
| 68 | type SearchMatch struct { | 94 | type SearchMatch struct { | ||
| 69 | Row int | 95 | Row int | ||
| @@ -102,8 +128,7 @@ func FindMatches(s Snapshot, query string) []SearchMatch { | |||||
| 102 | } | 128 | } | ||
| 103 | needle := lowerRunes(query) | 129 | needle := lowerRunes(query) | ||
| 104 | matches := make([]SearchMatch, 0) | 130 | matches := make([]SearchMatch, 0) | ||
| 105 | - | for row := range s.Len() { | 131 | + | s.rangeRows(func(row int, cells []renderer.Cell) bool { |
| 106 | - | cells := s.Row(row) | |||
| 107 | haystack, cellIndexes := searchableCells(cells) | 132 | haystack, cellIndexes := searchableCells(cells) | ||
| 108 | var text string | 133 | var text string | ||
| 109 | for start := 0; start+len(needle) <= len(haystack); { | 134 | for start := 0; start+len(needle) <= len(haystack); { | ||
| @@ -122,7 +147,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch { | |||||
| 122 | matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text}) | 147 | matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text}) | ||
| 123 | start += len(needle) | 148 | start += len(needle) | ||
| 124 | } | 149 | } | ||
| 125 | - | } | 150 | + | return true |
| 151 | + | }) | |||
| 126 | return matches | 152 | return matches | ||
| 127 | } | 153 | } | ||
| 128 | 154 | ||||
M internal/usecase/copy/mode_test.go +128 -3
| @@ -7,11 +7,30 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/vev/pkg/renderer" | 9 | "github.com/bnema/vev/pkg/renderer" | ||
| 10 | + | "github.com/bnema/vev/pkg/vt" | |||
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | var benchmarkSnapshotSink 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 | + | ||||
| 26 | + | func rowText(cells []renderer.Cell) string { | |||
| 27 | + | runes := make([]rune, len(cells)) | |||
| 28 | + | for i, cell := range cells { | |||
| 29 | + | runes[i] = cell.Rune | |||
| 30 | + | } | |||
| 31 | + | return string(runes) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 15 | func snapshot(lines []string, height int) Snapshot { | 34 | func snapshot(lines []string, height int) Snapshot { | ||
| 16 | rows := make([][]renderer.Cell, len(lines)) | 35 | rows := make([][]renderer.Cell, len(lines)) | ||
| 17 | for i, line := range lines { | 36 | for i, line := range lines { | ||
| @@ -355,6 +374,24 @@ func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T) | |||||
| 355 | require.Equal(t, 2, matches[0].End) | 374 | require.Equal(t, 2, matches[0].End) | ||
| 356 | } | 375 | } | ||
| 357 | 376 | ||||
| 377 | + | func TestFindMatchesDoesNotCopyEveryHistoryRow(t *testing.T) { | |||
| 378 | + | const rows = 100 | |||
| 379 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows, ChunkRows: 10}) | |||
| 380 | + | for range rows { | |||
| 381 | + | history.Append(row(strings.Repeat("x", 120))) | |||
| 382 | + | } | |||
| 383 | + | s := NewSnapshot(history, renderer.NewFrame(120, 1)) | |||
| 384 | + | ||||
| 385 | + | allocs := testing.AllocsPerRun(10, func() { | |||
| 386 | + | if matches := FindMatches(s, "missing"); len(matches) != 0 { | |||
| 387 | + | t.Fatalf("unexpected matches: %d", len(matches)) | |||
| 388 | + | } | |||
| 389 | + | }) | |||
| 390 | + | if allocs >= 250 { | |||
| 391 | + | t.Fatalf("FindMatches allocations = %.0f, want < 250 (no row copy per history row)", allocs) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 358 | func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) { | 395 | func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) { | ||
| 359 | row := []renderer.Cell{ | 396 | row := []renderer.Cell{ | ||
| 360 | {Rune: '界'}, | 397 | {Rune: '界'}, | ||
| @@ -478,6 +515,94 @@ func TestOSC52FromBase64(t *testing.T) { | |||||
| 478 | } | 515 | } | ||
| 479 | } | 516 | } | ||
| 480 | 517 | ||||
| 518 | + | func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) { | |||
| 519 | + | history := newHistory(1) | |||
| 520 | + | history.Append(row("old")) | |||
| 521 | + | screen := renderer.NewFrame(3, 1) | |||
| 522 | + | copy(screen.Row(0), row("one")) | |||
| 523 | + | ||||
| 524 | + | document := NewSnapshot(history, screen) | |||
| 525 | + | history.Append(row("new")) | |||
| 526 | + | screen.Set(0, 0, renderer.Cell{Rune: 'x'}) | |||
| 527 | + | ||||
| 528 | + | require.Equal(t, 2, document.Len()) | |||
| 529 | + | require.Equal(t, "old", rowText(document.Row(0))) | |||
| 530 | + | require.Equal(t, "one", rowText(document.Row(1))) | |||
| 531 | + | require.Nil(t, document.Row(-1)) | |||
| 532 | + | require.Nil(t, document.Row(2)) | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) { | |||
| 536 | + | rows := [][]renderer.Cell{row("one")} | |||
| 537 | + | document := NewSnapshotFromRows(rows, 3, 1) | |||
| 538 | + | rows[0][0].Rune = 'x' | |||
| 539 | + | require.Equal(t, "one", rowText(document.Row(0))) | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | func TestNewSnapshotFromRowsKeepsDocumentAndViewportDimensionsIndependent(t *testing.T) { | |||
| 543 | + | document := NewSnapshotFromRows([][]renderer.Cell{row("one"), row("two")}, 7, 5) | |||
| 544 | + | ||||
| 545 | + | require.Equal(t, 2, document.Len()) | |||
| 546 | + | require.Equal(t, 7, document.Width) | |||
| 547 | + | require.Equal(t, 5, document.Height) | |||
| 548 | + | require.Equal(t, "one", rowText(document.Row(0))) | |||
| 549 | + | require.Equal(t, "two", rowText(document.Row(1))) | |||
| 550 | + | require.Nil(t, document.Row(2)) | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | // This scaling gate deliberately compares equal row counts rather than an | |||
| 554 | + | // absolute budget, which varies between Go releases. A HistoryView shares | |||
| 555 | + | // sealed chunks, so its allocation must not grow with history-row width. | |||
| 556 | + | func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) { | |||
| 557 | + | if testing.Short() { | |||
| 558 | + | t.Skip("skipping benchmark-driven allocation gate in short mode") | |||
| 559 | + | } | |||
| 560 | + | narrow := benchmarkNewSnapshotBytes(16) | |||
| 561 | + | wide := benchmarkNewSnapshotBytes(512) | |||
| 562 | + | const conservativeWidthTolerance = 2 | |||
| 563 | + | require.LessOrEqual(t, wide, narrow*conservativeWidthTolerance, | |||
| 564 | + | "NewSnapshot B/op scaled with row width: narrow=%d wide=%d", narrow, wide) | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | func benchmarkNewSnapshotBytes(width int) int64 { | |||
| 568 | + | const historyRows = 256 | |||
| 569 | + | history := newHistory(historyRows) | |||
| 570 | + | for range historyRows { | |||
| 571 | + | history.Append(make([]renderer.Cell, width)) | |||
| 572 | + | } | |||
| 573 | + | // Keep visible-frame work constant so the comparison isolates history rows. | |||
| 574 | + | screen := renderer.NewFrame(8, 4) | |||
| 575 | + | result := testing.Benchmark(func(b *testing.B) { | |||
| 576 | + | b.ReportAllocs() | |||
| 577 | + | b.ResetTimer() | |||
| 578 | + | for b.Loop() { | |||
| 579 | + | benchmarkSnapshotSink = NewSnapshot(history, screen) | |||
| 580 | + | } | |||
| 581 | + | }) | |||
| 582 | + | return result.AllocedBytesPerOp() | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | // This regression replaces the removed Scrollback ring tests: the VT history | |||
| 586 | + | // remains bounded oldest-first and a captured copy document survives eviction. | |||
| 587 | + | func TestNewSnapshotRetainsBoundedHistoryAcrossLaterEviction(t *testing.T) { | |||
| 588 | + | history := newHistory(2) | |||
| 589 | + | history.Append(row("one")) | |||
| 590 | + | history.Append(row("two")) | |||
| 591 | + | document := NewSnapshot(history, renderer.NewFrame(3, 1)) | |||
| 592 | + | history.Append(row("three")) | |||
| 593 | + | history.Append(row("four")) | |||
| 594 | + | ||||
| 595 | + | // A copy document is history followed by the visible frame. Its total is | |||
| 596 | + | // therefore the bounded retained history plus the one visible row. | |||
| 597 | + | require.Equal(t, 3, document.Len()) | |||
| 598 | + | require.Equal(t, "one", rowText(document.Row(0))) | |||
| 599 | + | require.Equal(t, "two", rowText(document.Row(1))) | |||
| 600 | + | require.Equal(t, " ", rowText(document.Row(2)), "the visible frame remains part of the document") | |||
| 601 | + | view := history.View() | |||
| 602 | + | require.Equal(t, "three", rowText(view.Row(0))) | |||
| 603 | + | require.Equal(t, "four", rowText(view.Row(1))) | |||
| 604 | + | } | |||
| 605 | + | ||||
| 481 | func BenchmarkNewSnapshot10KRows(b *testing.B) { | 606 | func BenchmarkNewSnapshot10KRows(b *testing.B) { | ||
| 482 | const ( | 607 | const ( | ||
| 483 | width = 120 | 608 | width = 120 | ||
| @@ -485,9 +610,9 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) { | |||||
| 485 | historyRows = 10_000 | 610 | historyRows = 10_000 | ||
| 486 | ) | 611 | ) | ||
| 487 | 612 | ||||
| 488 | - | scrollback := NewScrollback(historyRows) | 613 | + | history := newHistory(historyRows) |
| 489 | for range historyRows { | 614 | for range historyRows { | ||
| 490 | - | scrollback.Append(row(strings.Repeat("x", width))) | 615 | + | history.Append(row(strings.Repeat("x", width))) |
| 491 | } | 616 | } | ||
| 492 | screen := renderer.NewFrame(width, height) | 617 | screen := renderer.NewFrame(width, height) | ||
| 493 | for y := range height { | 618 | for y := range height { | ||
| @@ -497,7 +622,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) { | |||||
| 497 | b.ReportAllocs() | 622 | b.ReportAllocs() | ||
| 498 | b.ResetTimer() | 623 | b.ResetTimer() | ||
| 499 | for b.Loop() { | 624 | for b.Loop() { | ||
| 500 | - | benchmarkSnapshotSink = NewSnapshot(scrollback, screen) | 625 | + | benchmarkSnapshotSink = NewSnapshot(history, screen) |
| 501 | } | 626 | } | ||
| 502 | } | 627 | } | ||
| 503 | 628 | ||||
D internal/usecase/copy/scrollback.go +0 -83
| @@ -1,83 +0,0 @@ | |||||
| 1 | - | package copy | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | - | ||||
| 5 | - | // Scrollback stores evicted terminal rows in a fixed-size ring buffer. | |||
| 6 | - | type Scrollback struct { | |||
| 7 | - | rows [][]renderer.Cell | |||
| 8 | - | head int | |||
| 9 | - | len int | |||
| 10 | - | } | |||
| 11 | - | ||||
| 12 | - | // NewScrollback constructs a scrollback ring capped at capacity rows. | |||
| 13 | - | func NewScrollback(capacity int) *Scrollback { | |||
| 14 | - | if capacity < 0 { | |||
| 15 | - | capacity = 0 | |||
| 16 | - | } | |||
| 17 | - | return &Scrollback{rows: make([][]renderer.Cell, capacity)} | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | // Cap returns the maximum number of rows retained. | |||
| 21 | - | func (s *Scrollback) Cap() int { return len(s.rows) } | |||
| 22 | - | ||||
| 23 | - | // Len returns the number of retained rows. | |||
| 24 | - | func (s *Scrollback) Len() int { return s.len } | |||
| 25 | - | ||||
| 26 | - | // Head returns the physical ring slot holding the oldest retained row. | |||
| 27 | - | func (s *Scrollback) Head() int { return s.head } | |||
| 28 | - | ||||
| 29 | - | // Append stores a copy of row, evicting the oldest row when the ring is full. | |||
| 30 | - | func (s *Scrollback) Append(row []renderer.Cell) { | |||
| 31 | - | if len(s.rows) == 0 { | |||
| 32 | - | return | |||
| 33 | - | } | |||
| 34 | - | cp := append([]renderer.Cell(nil), row...) | |||
| 35 | - | if s.len < len(s.rows) { | |||
| 36 | - | s.rows[(s.head+s.len)%len(s.rows)] = cp | |||
| 37 | - | s.len++ | |||
| 38 | - | return | |||
| 39 | - | } | |||
| 40 | - | s.rows[s.head] = cp | |||
| 41 | - | s.head = (s.head + 1) % len(s.rows) | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | // Snapshot returns retained rows oldest-first. | |||
| 45 | - | // | |||
| 46 | - | // The returned outer slice is a copy, but row slices are shared with the | |||
| 47 | - | // scrollback storage. Callers must treat the returned rows as read-only. | |||
| 48 | - | func (s *Scrollback) Snapshot() [][]renderer.Cell { | |||
| 49 | - | if s.len == 0 || len(s.rows) == 0 { | |||
| 50 | - | return nil | |||
| 51 | - | } | |||
| 52 | - | rows := make([][]renderer.Cell, s.len) | |||
| 53 | - | for i := range s.len { | |||
| 54 | - | rows[i] = s.rows[(s.head+i)%len(s.rows)] | |||
| 55 | - | } | |||
| 56 | - | return rows | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | // HistoryView is a frozen oldest-first view of scrollback rows. | |||
| 60 | - | // | |||
| 61 | - | // Its rows share cell slices with the scrollback at the time View is called. | |||
| 62 | - | // Callers must treat rows returned by Row as read-only. | |||
| 63 | - | type HistoryView struct { | |||
| 64 | - | rows [][]renderer.Cell | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | // View returns a frozen scrollback view. Appending to the scrollback cannot | |||
| 68 | - | // alter the view because Append replaces ring slots rather than mutating rows. | |||
| 69 | - | func (s *Scrollback) View() HistoryView { | |||
| 70 | - | return HistoryView{rows: s.Snapshot()} | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | // Len returns the number of rows in the view. | |||
| 74 | - | func (v HistoryView) Len() int { return len(v.rows) } | |||
| 75 | - | ||||
| 76 | - | // Row returns the row at logical index i, oldest first, or nil when i is out | |||
| 77 | - | // of range. Returned rows are shared with the view and must be read-only. | |||
| 78 | - | func (v HistoryView) Row(i int) []renderer.Cell { | |||
| 79 | - | if i < 0 || i >= len(v.rows) { | |||
| 80 | - | return nil | |||
| 81 | - | } | |||
| 82 | - | return v.rows[i] | |||
| 83 | - | } | |||
D internal/usecase/copy/scrollback_test.go +0 -209
| @@ -1,209 +0,0 @@ | |||||
| 1 | - | package copy | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func row(text string) []renderer.Cell { | |||
| 10 | - | runes := []rune(text) | |||
| 11 | - | cells := make([]renderer.Cell, len(runes)) | |||
| 12 | - | for i, r := range runes { | |||
| 13 | - | cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |||
| 14 | - | } | |||
| 15 | - | return cells | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | func rowText(cells []renderer.Cell) string { | |||
| 19 | - | runes := make([]rune, len(cells)) | |||
| 20 | - | for i, c := range cells { | |||
| 21 | - | runes[i] = c.Rune | |||
| 22 | - | } | |||
| 23 | - | return string(runes) | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | func TestScrollbackRing(t *testing.T) { | |||
| 27 | - | tests := []struct { | |||
| 28 | - | name string | |||
| 29 | - | cap int | |||
| 30 | - | append []string | |||
| 31 | - | wantHead int | |||
| 32 | - | wantLen int | |||
| 33 | - | wantRows []string | |||
| 34 | - | }{ | |||
| 35 | - | { | |||
| 36 | - | name: "empty ring drops rows", | |||
| 37 | - | cap: 0, | |||
| 38 | - | append: []string{"aa", "bb"}, | |||
| 39 | - | wantHead: 0, | |||
| 40 | - | wantLen: 0, | |||
| 41 | - | }, | |||
| 42 | - | { | |||
| 43 | - | name: "below capacity keeps head at zero", | |||
| 44 | - | cap: 3, | |||
| 45 | - | append: []string{"aa", "bb"}, | |||
| 46 | - | wantHead: 0, | |||
| 47 | - | wantLen: 2, | |||
| 48 | - | wantRows: []string{"aa", "bb"}, | |||
| 49 | - | }, | |||
| 50 | - | { | |||
| 51 | - | name: "at capacity fills without rotating head", | |||
| 52 | - | cap: 3, | |||
| 53 | - | append: []string{"aa", "bb", "cc"}, | |||
| 54 | - | wantHead: 0, | |||
| 55 | - | wantLen: 3, | |||
| 56 | - | wantRows: []string{"aa", "bb", "cc"}, | |||
| 57 | - | }, | |||
| 58 | - | { | |||
| 59 | - | name: "over capacity evicts oldest and rotates head", | |||
| 60 | - | cap: 3, | |||
| 61 | - | append: []string{"aa", "bb", "cc", "dd", "ee"}, | |||
| 62 | - | wantHead: 2, | |||
| 63 | - | wantLen: 3, | |||
| 64 | - | wantRows: []string{"cc", "dd", "ee"}, | |||
| 65 | - | }, | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | for _, tt := range tests { | |||
| 69 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 70 | - | sb := NewScrollback(tt.cap) | |||
| 71 | - | for _, r := range tt.append { | |||
| 72 | - | sb.Append(row(r)) | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | if got := sb.Cap(); got != tt.cap { | |||
| 76 | - | t.Fatalf("Cap() = %d, want %d", got, tt.cap) | |||
| 77 | - | } | |||
| 78 | - | if got := sb.Head(); got != tt.wantHead { | |||
| 79 | - | t.Fatalf("Head() = %d, want %d", got, tt.wantHead) | |||
| 80 | - | } | |||
| 81 | - | if got := sb.Len(); got != tt.wantLen { | |||
| 82 | - | t.Fatalf("Len() = %d, want %d", got, tt.wantLen) | |||
| 83 | - | } | |||
| 84 | - | for i, want := range tt.wantRows { | |||
| 85 | - | if got := rowText(sb.View().Row(i)); got != want { | |||
| 86 | - | t.Fatalf("Row(%d) = %q, want %q", i, got, want) | |||
| 87 | - | } | |||
| 88 | - | } | |||
| 89 | - | }) | |||
| 90 | - | } | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | func TestScrollbackCopiesRows(t *testing.T) { | |||
| 94 | - | tests := []struct { | |||
| 95 | - | name string | |||
| 96 | - | }{ | |||
| 97 | - | {name: "append copies caller row"}, | |||
| 98 | - | } | |||
| 99 | - | for _, tt := range tests { | |||
| 100 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 101 | - | sb := NewScrollback(1) | |||
| 102 | - | r := row("ab") | |||
| 103 | - | sb.Append(r) | |||
| 104 | - | r[0].Rune = 'z' | |||
| 105 | - | ||||
| 106 | - | if text := rowText(sb.View().Row(0)); text != "ab" { | |||
| 107 | - | t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text) | |||
| 108 | - | } | |||
| 109 | - | }) | |||
| 110 | - | } | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | func TestScrollbackSnapshot(t *testing.T) { | |||
| 114 | - | tests := []struct { | |||
| 115 | - | name string | |||
| 116 | - | cap int | |||
| 117 | - | append []string | |||
| 118 | - | wantRows []string | |||
| 119 | - | }{ | |||
| 120 | - | { | |||
| 121 | - | name: "empty capacity snapshots no rows", | |||
| 122 | - | cap: 0, | |||
| 123 | - | append: []string{"aa", "bb"}, | |||
| 124 | - | wantRows: nil, | |||
| 125 | - | }, | |||
| 126 | - | { | |||
| 127 | - | name: "oldest first after ring wrap", | |||
| 128 | - | cap: 3, | |||
| 129 | - | append: []string{"aa", "bb", "cc", "dd"}, | |||
| 130 | - | wantRows: []string{"bb", "cc", "dd"}, | |||
| 131 | - | }, | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | for _, tt := range tests { | |||
| 135 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 136 | - | sb := NewScrollback(tt.cap) | |||
| 137 | - | for _, r := range tt.append { | |||
| 138 | - | sb.Append(row(r)) | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | got := sb.Snapshot() | |||
| 142 | - | if len(got) != len(tt.wantRows) { | |||
| 143 | - | t.Fatalf("Snapshot() len = %d, want %d", len(got), len(tt.wantRows)) | |||
| 144 | - | } | |||
| 145 | - | for i, want := range tt.wantRows { | |||
| 146 | - | if text := rowText(got[i]); text != want { | |||
| 147 | - | t.Fatalf("Snapshot()[%d] = %q, want %q", i, text, want) | |||
| 148 | - | } | |||
| 149 | - | } | |||
| 150 | - | if len(got) > 0 { | |||
| 151 | - | got[0] = row("zz") | |||
| 152 | - | if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] { | |||
| 153 | - | t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0]) | |||
| 154 | - | } | |||
| 155 | - | } | |||
| 156 | - | }) | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) { | |||
| 161 | - | sb := NewScrollback(2) | |||
| 162 | - | sb.Append(row("aa")) | |||
| 163 | - | sb.Append(row("bb")) | |||
| 164 | - | snapshot := sb.Snapshot() | |||
| 165 | - | ||||
| 166 | - | sb.Append(row("cc")) | |||
| 167 | - | sb.Append(row("dd")) | |||
| 168 | - | ||||
| 169 | - | if text := rowText(snapshot[0]); text != "aa" { | |||
| 170 | - | t.Fatalf("snapshot[0] after append = %q, want original row", text) | |||
| 171 | - | } | |||
| 172 | - | if text := rowText(snapshot[1]); text != "bb" { | |||
| 173 | - | t.Fatalf("snapshot[1] after append = %q, want original row", text) | |||
| 174 | - | } | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | func TestScrollbackViewSurvivesCompleteRingOverwrite(t *testing.T) { | |||
| 178 | - | sb := NewScrollback(2) | |||
| 179 | - | sb.Append(row("aa")) | |||
| 180 | - | sb.Append(row("bb")) | |||
| 181 | - | view := sb.View() | |||
| 182 | - | if &view.Row(0)[0] != &sb.rows[0][0] { | |||
| 183 | - | t.Fatal("View() deep-copied cells, want shared row storage") | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | sb.Append(row("cc")) | |||
| 187 | - | sb.Append(row("dd")) | |||
| 188 | - | ||||
| 189 | - | if got := view.Len(); got != 2 { | |||
| 190 | - | t.Fatalf("View().Len() = %d, want 2", got) | |||
| 191 | - | } | |||
| 192 | - | for i, want := range []string{"aa", "bb"} { | |||
| 193 | - | if got := rowText(view.Row(i)); got != want { | |||
| 194 | - | t.Fatalf("View().Row(%d) = %q, want %q", i, got, want) | |||
| 195 | - | } | |||
| 196 | - | } | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | func TestHistoryViewRowBounds(t *testing.T) { | |||
| 200 | - | sb := NewScrollback(1) | |||
| 201 | - | sb.Append(row("aa")) | |||
| 202 | - | view := sb.View() | |||
| 203 | - | ||||
| 204 | - | for _, i := range []int{-1, 1} { | |||
| 205 | - | if got := view.Row(i); got != nil { | |||
| 206 | - | t.Errorf("View().Row(%d) = %v, want nil", i, got) | |||
| 207 | - | } | |||
| 208 | - | } | |||
| 209 | - | } | |||
D internal/usecase/copy/snapshot_test.go +0 -85
| @@ -1,85 +0,0 @@ | |||||
| 1 | - | package copy | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) { | |||
| 10 | - | sb := NewScrollback(1) | |||
| 11 | - | sb.Append(row("old")) | |||
| 12 | - | screen := renderer.NewFrame(3, 1) | |||
| 13 | - | copy(screen.Row(0), row("one")) | |||
| 14 | - | ||||
| 15 | - | snapshot := NewSnapshot(sb, screen) | |||
| 16 | - | if &snapshot.Row(0)[0] != &sb.View().Row(0)[0] { | |||
| 17 | - | t.Fatal("NewSnapshot() deep-copied immutable history") | |||
| 18 | - | } | |||
| 19 | - | sb.Append(row("new")) | |||
| 20 | - | screen.Set(0, 0, renderer.Cell{Rune: 'x'}) | |||
| 21 | - | ||||
| 22 | - | if got := snapshot.Len(); got != 2 { | |||
| 23 | - | t.Fatalf("Len() = %d, want 2", got) | |||
| 24 | - | } | |||
| 25 | - | for i, want := range []string{"old", "one"} { | |||
| 26 | - | if got := rowText(snapshot.Row(i)); got != want { | |||
| 27 | - | t.Errorf("Row(%d) = %q, want %q", i, got, want) | |||
| 28 | - | } | |||
| 29 | - | } | |||
| 30 | - | for _, i := range []int{-1, 2} { | |||
| 31 | - | if got := snapshot.Row(i); got != nil { | |||
| 32 | - | t.Errorf("Row(%d) = %v, want nil", i, got) | |||
| 33 | - | } | |||
| 34 | - | } | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) { | |||
| 38 | - | rows := [][]renderer.Cell{row("one")} | |||
| 39 | - | snapshot := NewSnapshotFromRows(rows, 3, 1) | |||
| 40 | - | rows[0][0].Rune = 'x' | |||
| 41 | - | if got := rowText(snapshot.Row(0)); got != "one" { | |||
| 42 | - | t.Fatalf("Row(0) = %q, want deep-owned caller row", got) | |||
| 43 | - | } | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | // This scaling gate deliberately compares equal row counts rather than an | |||
| 47 | - | // absolute budget, which varies between Go releases. A full history-row clone | |||
| 48 | - | // grows with row width; the immutable HistoryView allocation does not. | |||
| 49 | - | func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) { | |||
| 50 | - | if testing.Short() { | |||
| 51 | - | t.Skip("skipping benchmark-driven allocation gate in short mode") | |||
| 52 | - | } | |||
| 53 | - | narrow := benchmarkNewSnapshotBytes(16) | |||
| 54 | - | wide := benchmarkNewSnapshotBytes(512) | |||
| 55 | - | assertWidthIndependentAllocations(t, "NewSnapshot", narrow, wide) | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | func benchmarkNewSnapshotBytes(width int) int64 { | |||
| 59 | - | const historyRows = 256 | |||
| 60 | - | ||||
| 61 | - | scrollback := NewScrollback(historyRows) | |||
| 62 | - | for range historyRows { | |||
| 63 | - | scrollback.Append(make([]renderer.Cell, width)) | |||
| 64 | - | } | |||
| 65 | - | // Keep visible-frame work constant so the comparison isolates history rows. | |||
| 66 | - | screen := renderer.NewFrame(8, 4) | |||
| 67 | - | result := testing.Benchmark(func(b *testing.B) { | |||
| 68 | - | b.ReportAllocs() | |||
| 69 | - | b.ResetTimer() | |||
| 70 | - | for b.Loop() { | |||
| 71 | - | benchmarkSnapshotSink = NewSnapshot(scrollback, screen) | |||
| 72 | - | } | |||
| 73 | - | }) | |||
| 74 | - | return result.AllocedBytesPerOp() | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | func assertWidthIndependentAllocations(t *testing.T, operation string, narrow, wide int64) { | |||
| 78 | - | t.Helper() | |||
| 79 | - | // Two times permits allocator noise while decisively rejecting a cell-row | |||
| 80 | - | // copy, whose allocation grows by 32x for the widths above. | |||
| 81 | - | const conservativeWidthTolerance = 2 | |||
| 82 | - | if wide > narrow*conservativeWidthTolerance { | |||
| 83 | - | t.Fatalf("%s B/op scaled with row width: narrow=%d wide=%d (limit=%d)", operation, narrow, wide, narrow*conservativeWidthTolerance) | |||
| 84 | - | } | |||
| 85 | - | } | |||
A internal/usecase/daemon/copy_enter_allocation_budget_race_test.go +8 -0
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | //go:build race | |||
| 2 | + | ||||
| 3 | + | package daemon | |||
| 4 | + | ||||
| 5 | + | // Race instrumentation allocations are not part of the parent benchmark's | |||
| 6 | + | // allocation baseline. TestCopyEnterAllocationBudget still exercises copy | |||
| 7 | + | // entry and validates its result under -race. | |||
| 8 | + | const copyEnterAllocationBudgetEnabled = false | |||
A internal/usecase/daemon/copy_enter_allocation_budget_test.go +7 -0
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | //go:build !race | |||
| 2 | + | ||||
| 3 | + | package daemon | |||
| 4 | + | ||||
| 5 | + | // copyEnterAllocationBudgetEnabled keeps the allocation gate aligned with the | |||
| 6 | + | // non-race parent benchmark, whose allocation baseline defines the budget. | |||
| 7 | + | const copyEnterAllocationBudgetEnabled = true | |||
M internal/usecase/daemon/copymode.go +1 -1
| @@ -66,7 +66,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 66 | // Capture the live pane under pane.mu; all subsequent copy interaction 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 | 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 | tb := sess.activeTab() | 112 | tb := sess.activeTab() | ||
| 113 | pane := tb.focusedPane() | 113 | pane := tb.focusedPane() | ||
| 114 | pane.mu.Lock() | 114 | pane.mu.Lock() | ||
| 115 | - | snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame) | 115 | + | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) |
| 116 | pane.mu.Unlock() | 116 | pane.mu.Unlock() | ||
| 117 | mode := scopy.NewMode(snap) | 117 | mode := scopy.NewMode(snap) | ||
| 118 | bars := barState{status: sess.statusSegments(true)} | 118 | bars := barState{status: sess.statusSegments(true)} | ||
| @@ -147,7 +147,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||||
| 147 | tb := sess.activeTab() | 147 | tb := sess.activeTab() | ||
| 148 | tb.focusedPane().screen = vt.NewScreen(12, 3) | 148 | tb.focusedPane().screen = vt.NewScreen(12, 3) | ||
| 149 | tb.focusedPane().screen.Write([]byte("live")) | 149 | tb.focusedPane().screen.Write([]byte("live")) | ||
| 150 | - | snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame) | 150 | + | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame) |
| 151 | mode := scopy.NewMode(snap) | 151 | mode := scopy.NewMode(snap) | ||
| 152 | 152 | ||||
| 153 | bars := barState{status: sess.statusSegments(true)} | 153 | bars := barState{status: sess.statusSegments(true)} | ||
| @@ -166,7 +166,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |||||
| 166 | writes := make(chan []byte, 1) | 166 | writes := make(chan []byte, 1) | ||
| 167 | p, _ := newBlockingPTYWithWrites(t, writes) | 167 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 168 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 168 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 169 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 169 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 170 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 170 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 171 | 171 | ||||
| 172 | d.handleInput(sess, ac, []byte("\x1b ")) | 172 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| @@ -293,8 +293,8 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||||
| 293 | p, _ := newBlockingPTY(t) | 293 | p, _ := newBlockingPTY(t) | ||
| 294 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 294 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 295 | pane := sess.tabs[0].focusedPane() | 295 | pane := sess.tabs[0].focusedPane() | ||
| 296 | - | pane.scrollback = scopy.NewScrollback(4) | 296 | + | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) |
| 297 | - | pane.scrollback.Append(testRow("old alpha")) | 297 | + | pane.history.Append(testRow("old alpha")) |
| 298 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | 298 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | ||
| 299 | 299 | ||||
| 300 | d.enterCopyMode(sess, ac) | 300 | d.enterCopyMode(sess, ac) | ||
| @@ -322,9 +322,9 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||||
| 322 | writes := make(chan []byte, 1) | 322 | writes := make(chan []byte, 1) | ||
| 323 | p, _ := newBlockingPTYWithWrites(t, writes) | 323 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 324 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 324 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 325 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 325 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 326 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 ")) | 326 | + | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) |
| 327 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 ")) | 327 | + | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) |
| 328 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 328 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 329 | 329 | ||||
| 330 | d.enterCopyMode(sess, ac) | 330 | d.enterCopyMode(sess, ac) | ||
| @@ -445,7 +445,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 445 | } | 445 | } | ||
| 446 | p.mu.Lock() | 446 | p.mu.Lock() | ||
| 447 | defer p.mu.Unlock() | 447 | defer p.mu.Unlock() | ||
| 448 | - | return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 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 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 485 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 485 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 486 | 486 | ||||
| 487 | d.enterCopyMode(sess, ac) | 487 | d.enterCopyMode(sess, ac) | ||
| @@ -515,9 +515,9 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||||
| 515 | t.Run(tc.name, func(t *testing.T) { | 515 | t.Run(tc.name, func(t *testing.T) { | ||
| 516 | p, _ := newBlockingPTY(t) | 516 | p, _ := newBlockingPTY(t) | ||
| 517 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 517 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 518 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 518 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 519 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 ")) | 519 | + | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) |
| 520 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 ")) | 520 | + | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) |
| 521 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 521 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 522 | 522 | ||||
| 523 | d.enterCopyMode(sess, ac) | 523 | d.enterCopyMode(sess, ac) | ||
| @@ -535,7 +535,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||||
| 535 | func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | 535 | func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | ||
| 536 | p, _ := newBlockingPTY(t) | 536 | p, _ := newBlockingPTY(t) | ||
| 537 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 537 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 538 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1) | 538 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1}) |
| 539 | longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1) | 539 | longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1) | ||
| 540 | sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1) | 540 | sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1) | ||
| 541 | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine)) | 541 | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine)) | ||
| @@ -560,7 +560,7 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) { | |||||
| 560 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 560 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 561 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | 561 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | ||
| 562 | d.clock = clk | 562 | d.clock = clk | ||
| 563 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 563 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 564 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 564 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 565 | 565 | ||||
| 566 | d.enterCopyMode(sess, ac) | 566 | d.enterCopyMode(sess, ac) | ||
| @@ -584,7 +584,7 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) { | |||||
| 584 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 584 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 585 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | 585 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | ||
| 586 | d.clock = clk | 586 | d.clock = clk | ||
| 587 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 587 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 588 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 588 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 589 | 589 | ||||
| 590 | d.enterCopyMode(sess, ac) | 590 | d.enterCopyMode(sess, ac) | ||
| @@ -627,7 +627,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||||
| 627 | t.Run(tc.name, func(t *testing.T) { | 627 | t.Run(tc.name, func(t *testing.T) { | ||
| 628 | p, _ := newBlockingPTY(t) | 628 | p, _ := newBlockingPTY(t) | ||
| 629 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 629 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 630 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 630 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 631 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 631 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 632 | 632 | ||||
| 633 | d.enterCopyMode(sess, ac) | 633 | d.enterCopyMode(sess, ac) | ||
| @@ -676,7 +676,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) { | |||||
| 676 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | 676 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | ||
| 677 | p, _ := newBlockingPTY(t) | 677 | p, _ := newBlockingPTY(t) | ||
| 678 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 678 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 679 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 679 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 680 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 680 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 681 | 681 | ||||
| 682 | done := make(chan struct{}) | 682 | done := make(chan struct{}) | ||
| @@ -750,11 +750,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||||
| 750 | var want *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 | ||||
M internal/usecase/daemon/daemon.go +23 -0
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "github.com/bnema/vev/internal/persist" | 16 | "github.com/bnema/vev/internal/persist" | ||
| 17 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 18 | "github.com/bnema/vev/internal/usecase/keys" | 18 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 19 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 19 | ) | 20 | ) | ||
| 20 | 21 | ||||
| 21 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | 22 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | ||
| @@ -101,6 +102,21 @@ type Daemon struct { | |||||
| 101 | persistEnabled bool | 102 | persistEnabled bool | ||
| 102 | snaps ports.SnapshotStore | 103 | snaps ports.SnapshotStore | ||
| 103 | snapsEnabled bool | 104 | snapsEnabled bool | ||
| 105 | + | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |||
| 106 | + | snapshotJobs chan *snapshotCapture | |||
| 107 | + | snapshotWorkerMu sync.Mutex | |||
| 108 | + | snapshotWorkerID uint64 | |||
| 109 | + | snapshotWorkerCtx context.Context | |||
| 110 | + | snapshotWorkerCancel context.CancelFunc | |||
| 111 | + | snapshotWorkerDone chan struct{} | |||
| 112 | + | snapshotWorkerFlush chan struct{} | |||
| 113 | + | snapshotWorkerFinalWake chan struct{} | |||
| 114 | + | // snapshotFinalJobs retains immutable terminal captures rejected by the | |||
| 115 | + | // bounded regular queue. It is only populated during session teardown and | |||
| 116 | + | // drained by the same worker, so persistence remains outside daemon locks. | |||
| 117 | + | snapshotFinalJobs []*snapshotCapture | |||
| 118 | + | snapshotWorkerClosing bool | |||
| 119 | + | snapshotWorkerInFlight *snapshotCapture | |||
| 104 | restoreDone chan struct{} | 120 | restoreDone chan struct{} | ||
| 105 | restoreOnce sync.Once | 121 | restoreOnce sync.Once | ||
| 106 | procCwd func(int) (string, error) | 122 | procCwd func(int) (string, error) | ||
| @@ -285,6 +301,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 285 | done: make(chan struct{}), | 301 | done: make(chan struct{}), | ||
| 286 | restoreDone: make(chan struct{}), | 302 | restoreDone: make(chan struct{}), | ||
| 287 | animWake: make(chan struct{}, 1), | 303 | animWake: make(chan struct{}, 1), | ||
| 304 | + | snapshotMarshal: snapcodec.Marshal, | |||
| 305 | + | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |||
| 288 | resumeParkGrace: defaultResumeParkGrace, | 306 | resumeParkGrace: defaultResumeParkGrace, | ||
| 289 | barScripts: &barScriptState{ | 307 | barScripts: &barScriptState{ | ||
| 290 | cfg: barConfigFromDomain(domain.Defaults().Bar), | 308 | cfg: barConfigFromDomain(domain.Defaults().Bar), | ||
| @@ -355,6 +373,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 355 | }) | 373 | }) | ||
| 356 | } | 374 | } | ||
| 357 | if d.snapsEnabled { | 375 | if d.snapsEnabled { | ||
| 376 | + | d.startSnapshotEncodeWorker() | |||
| 358 | d.sessWg.Go(func() { | 377 | d.sessWg.Go(func() { | ||
| 359 | d.snapshotSaver(d.serveCtx) | 378 | d.snapshotSaver(d.serveCtx) | ||
| 360 | }) | 379 | }) | ||
| @@ -403,7 +422,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 403 | d.waitNotifies() | 422 | d.waitNotifies() | ||
| 404 | d.hardCancel() | 423 | d.hardCancel() | ||
| 405 | d.serveCancel() | 424 | d.serveCancel() | ||
| 425 | + | // Wait for handlers before flushing snapshots: a handler that already | |||
| 426 | + | // entered killSession may still submit its terminal capture after the | |||
| 427 | + | // registry snapshot has been removed. | |||
| 406 | d.connWg.Wait() | 428 | d.connWg.Wait() | ||
| 429 | + | d.stopSnapshotEncodeWorker() | |||
| 407 | d.shutdownAll(ports.ReasonServerShutdown) | 430 | d.shutdownAll(ports.ReasonServerShutdown) | ||
| 408 | d.sessWg.Wait() | 431 | d.sessWg.Wait() | ||
| 409 | d.waitNotifies() | 432 | d.waitNotifies() | ||
A internal/usecase/daemon/history_test_helper_test.go +10 -0
| @@ -0,0 +1,10 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/vt" | |||
| 4 | + | ||||
| 5 | + | func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) } | |||
| 6 | + | ||||
| 7 | + | func installTestHistory(p *pane, config vt.HistoryConfig) { | |||
| 8 | + | p.screen = vt.NewScreenWithHistory(p.screen.Frame.Width, p.screen.Frame.Height, config) | |||
| 9 | + | p.history = p.screen.History() | |||
| 10 | + | } | |||
M internal/usecase/daemon/input.go +3 -3
| @@ -202,8 +202,8 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 202 | mouseMode, mouseSGR := p.screen.MouseMode() | 202 | mouseMode, mouseSGR := p.screen.MouseMode() | ||
| 203 | altScreen := p.screen.AltScreenActive() | 203 | altScreen := p.screen.AltScreenActive() | ||
| 204 | scrollbackRows := 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 +3 -3
| @@ -14,10 +14,10 @@ import ( | |||||
| 14 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 17 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 18 | "github.com/bnema/vev/internal/usecase/keys" | 17 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 19 | "github.com/bnema/vev/internal/usecase/layout" | 18 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 20 | "github.com/bnema/vev/internal/usecase/picker" | 19 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 20 | + | "github.com/bnema/vev/pkg/vt" | |||
| 21 | ) | 21 | ) | ||
| 22 | 22 | ||||
| 23 | // --- test doubles ----------------------------------------------------------- | 23 | // --- test doubles ----------------------------------------------------------- | ||
| @@ -931,7 +931,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||||
| 931 | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | 931 | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | ||
| 932 | p, _ := newBlockingPTY(t) | 932 | p, _ := newBlockingPTY(t) | ||
| 933 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 933 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 934 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50) | 934 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50}) |
| 935 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | 935 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | ||
| 936 | 936 | ||||
| 937 | // Press on a content row while scrollback is empty: the anchor is | 937 | // Press on a content row while scrollback is empty: the anchor is | ||
| @@ -941,7 +941,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||||
| 941 | // Simulate 5 lines evicted into scrollback between the Press and the | 941 | // Simulate 5 lines evicted into scrollback between the Press and the | ||
| 942 | // first Motion (e.g. the child kept producing output). | 942 | // first Motion (e.g. the child kept producing output). | ||
| 943 | for range 5 { | 943 | for range 5 { | ||
| 944 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted")) | 944 | + | sess.tabs[0].focusedPane().history.Append(testRow("evicted")) |
| 945 | } | 945 | } | ||
| 946 | 946 | ||||
| 947 | // Motion lands on wire row 3 (0-based row 2 of the *current* screen). | 947 | // Motion lands on wire row 3 (0-based row 2 of the *current* screen). | ||
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
| @@ -173,7 +173,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing. | |||||
| 173 | func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) { | 173 | func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) { | ||
| 174 | ac := &attachedClient{} | 174 | ac := &attachedClient{} | ||
| 175 | ac.initOverlays() | 175 | ac.initOverlays() | ||
| 176 | - | history := scopy.NewScrollback(1) | 176 | + | history := newTestHistory(1) |
| 177 | history.Append(testRow("before")) | 177 | history.Append(testRow("before")) | ||
| 178 | document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1)) | 178 | document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1)) | ||
| 179 | 179 | ||||
M internal/usecase/daemon/pane.go +17 -20
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 9 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 10 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 11 | "github.com/bnema/vev/pkg/vt" | 10 | "github.com/bnema/vev/pkg/vt" | ||
| 12 | ) | 11 | ) | ||
| @@ -16,15 +15,15 @@ import ( | |||||
| 16 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | 15 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | ||
| 17 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | 16 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | ||
| 18 | type pane struct { | 17 | type pane struct { | ||
| 19 | - | id layout.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,14 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | |||||
| 48 | } | 47 | } | ||
| 49 | 48 | ||||
| 50 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | 49 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | ||
| 51 | - | sb := scopy.NewScrollback(defaultScrollbackRows) | 50 | + | screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows}) |
| 52 | - | screen := vt.NewScreen(sz.Cols, sz.Rows) | |||
| 53 | - | screen.OnLineEvicted = sb.Append | |||
| 54 | return &pane{ | 51 | return &pane{ | ||
| 55 | - | id: id, | 52 | + | id: id, |
| 56 | - | stableID: stableID, | 53 | + | stableID: stableID, |
| 57 | - | pty: pty, | 54 | + | pty: pty, |
| 58 | - | screen: screen, | 55 | + | screen: screen, |
| 59 | - | scrollback: sb, | 56 | + | history: screen.History(), |
| 60 | - | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | 57 | + | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, |
| 61 | - | title: paneTitleState{displayFallback: "sh"}, | 58 | + | title: paneTitleState{displayFallback: "sh"}, |
| 62 | } | 59 | } | ||
| 63 | } | 60 | } | ||
M internal/usecase/daemon/performance_benchmark_test.go +291 -17
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | + | "runtime" | |||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -145,11 +146,29 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |||||
| 145 | for _, tb := range fixture.sess.tabs { | 146 | for _, tb := range fixture.sess.tabs { | ||
| 146 | require.Len(t, tb.panes, 4) | 147 | require.Len(t, tb.panes, 4) | ||
| 147 | for _, p := range tb.panes { | 148 | for _, p := range tb.panes { | ||
| 148 | - | require.Equal(t, 10_000, p.scrollback.Len()) | 149 | + | require.Equal(t, 10_000, p.history.Len()) |
| 149 | } | 150 | } | ||
| 150 | } | 151 | } | ||
| 151 | } | 152 | } | ||
| 152 | 153 | ||||
| 154 | + | func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) { | |||
| 155 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000}) | |||
| 156 | + | ||||
| 157 | + | first, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |||
| 158 | + | require.True(t, ok) | |||
| 159 | + | require.Len(t, first.tabs, 1) | |||
| 160 | + | require.Len(t, first.tabs[0].panes, 2) | |||
| 161 | + | require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len()) | |||
| 162 | + | require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount()) | |||
| 163 | + | ||||
| 164 | + | second, ok := fixture.d.captureSnapshotState(fixture.sess, 2) | |||
| 165 | + | require.True(t, ok) | |||
| 166 | + | secondPane := capturePaneByID(second, first.tabs[0].panes[0].id) | |||
| 167 | + | if secondPane == nil || first.tabs[0].panes[0].history.Chunk(0) != secondPane.history.Chunk(0) { | |||
| 168 | + | t.Fatal("unchanged sealed history chunk was not reused across captures") | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 153 | func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) { | 172 | func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) { | ||
| 154 | for _, topology := range daemonHistoryTopologies { | 173 | for _, topology := range daemonHistoryTopologies { | ||
| 155 | t.Run(topology.name, func(t *testing.T) { | 174 | t.Run(topology.name, func(t *testing.T) { | ||
| @@ -179,12 +198,41 @@ var daemonHistoryTopologies = []daemonHistoryTopology{ | |||||
| 179 | {name: "8tabs-1pane", tabs: 8, panes: 1}, | 198 | {name: "8tabs-1pane", tabs: 8, panes: 1}, | ||
| 180 | } | 199 | } | ||
| 181 | 200 | ||||
| 201 | + | // v3 bounds a decoded snapshot to 256 MiB. One 120-column 10k-row tab is | |||
| 202 | + | // representative and valid; multi-tab fixtures exceed that durable budget, so | |||
| 203 | + | // snapshot capture/encode benchmark only the valid one-tab topologies. | |||
| 204 | + | var daemonSnapshotTopologies = []daemonHistoryTopology{ | |||
| 205 | + | {name: "1tab-1pane-control", tabs: 1, panes: 1}, | |||
| 206 | + | {name: "1tab-4panes", tabs: 1, panes: 4}, | |||
| 207 | + | } | |||
| 208 | + | ||||
| 182 | func BenchmarkDaemonHistoryLivePaint(b *testing.B) { | 209 | func BenchmarkDaemonHistoryLivePaint(b *testing.B) { | ||
| 183 | benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() }) | 210 | benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() }) | ||
| 184 | } | 211 | } | ||
| 212 | + | ||||
| 213 | + | // BenchmarkDaemonHistorySnapshotCapture measures the synchronous immutable | |||
| 214 | + | // capture boundary only. Encoding and the bounded worker queue are deliberately | |||
| 215 | + | // excluded so scheduler latency cannot affect this benchmark. | |||
| 185 | func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) { | 216 | func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) { | ||
| 186 | - | benchmarkDaemonLargeHistory(b, "snapshot-capture", func(f *performanceFixture) { f.captureSnapshot() }) | 217 | + | benchmarkDaemonSnapshotCapture(b) |
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // BenchmarkDaemonHistorySnapshotEncode measures v3 encoding from an already | |||
| 221 | + | // immutable capture, without queueing or persistence. | |||
| 222 | + | func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) { | |||
| 223 | + | benchmarkDaemonSnapshotEncode(b) | |||
| 187 | } | 224 | } | ||
| 225 | + | ||||
| 226 | + | // BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration | |||
| 227 | + | // from a validated v3 pane manifest without spawning PTY or session workers. | |||
| 228 | + | func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) { | |||
| 229 | + | benchmarkDaemonSnapshotRestore(b) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // BenchmarkDaemonHistoryCopyEnter uses the parent benchmark's repeated-entry | |||
| 233 | + | // semantics: each operation replaces the active copy document. Exit cleanup is | |||
| 234 | + | // intentionally outside this benchmark, so its allocations cannot be reported | |||
| 235 | + | // as copy-entry work. | |||
| 188 | func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | 236 | func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | ||
| 189 | benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) }) | 237 | benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) }) | ||
| 190 | } | 238 | } | ||
| @@ -223,8 +271,12 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||||
| 223 | fixture := newPerformanceFixture(b, performanceConfig{ | 271 | fixture := newPerformanceFixture(b, performanceConfig{ | ||
| 224 | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000, | 272 | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000, | ||
| 225 | }) | 273 | }) | ||
| 274 | + | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |||
| 275 | + | b.Fatal("invalid daemon history fixture") | |||
| 276 | + | } | |||
| 226 | if workload == "copy-search" { | 277 | if workload == "copy-search" { | ||
| 227 | - | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | 278 | + | benchmarkDaemonCopySearch(b, fixture, run) |
| 279 | + | return | |||
| 228 | } | 280 | } | ||
| 229 | b.ReportAllocs() | 281 | b.ReportAllocs() | ||
| 230 | fixture.resetMetrics() | 282 | fixture.resetMetrics() | ||
| @@ -238,26 +290,165 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||||
| 238 | b.Fatalf("decode last output: %v", err) | 290 | b.Fatalf("decode last output: %v", err) | ||
| 239 | } | 291 | } | ||
| 240 | } | 292 | } | ||
| 241 | - | if workload == "snapshot-capture" { | |||
| 242 | - | payload := fixture.snaps.lastPayload() | |||
| 243 | - | if payload == nil { | |||
| 244 | - | b.Fatal("snapshot capture retained no snapshot payload") | |||
| 245 | - | } | |||
| 246 | - | if _, err := snapcodec.Unmarshal(payload); err != nil { | |||
| 247 | - | b.Fatalf("decode last snapshot: %v", err) | |||
| 248 | - | } | |||
| 249 | - | } | |||
| 250 | if workload == "live-paint" && metrics.outputFrames != uint64(b.N) { | 293 | if workload == "live-paint" && metrics.outputFrames != uint64(b.N) { | ||
| 251 | b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N) | 294 | b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N) | ||
| 252 | } | 295 | } | ||
| 253 | - | if workload == "snapshot-capture" && metrics.snapshotWrites != uint64(b.N) { | |||
| 254 | - | b.Fatalf("snapshot capture wrote %d snapshots for %d operations", metrics.snapshotWrites, b.N) | |||
| 255 | - | } | |||
| 256 | benchmarkReportMetrics(b, metrics, b.N, 10_000) | 296 | benchmarkReportMetrics(b, metrics, b.N, 10_000) | ||
| 257 | }) | 297 | }) | ||
| 258 | } | 298 | } | ||
| 259 | } | 299 | } | ||
| 260 | 300 | ||||
| 301 | + | func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) { | |||
| 302 | + | b.Helper() | |||
| 303 | + | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |||
| 304 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 305 | + | b.ReportAllocs() | |||
| 306 | + | b.ResetTimer() | |||
| 307 | + | for range b.N { | |||
| 308 | + | run(fixture) | |||
| 309 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 310 | + | } | |||
| 311 | + | b.StopTimer() | |||
| 312 | + | if fixture.searchMatches() == 0 { | |||
| 313 | + | b.Fatal("copy search produced no deterministic matches") | |||
| 314 | + | } | |||
| 315 | + | b.ReportMetric(1, "copyoperations/op") | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||
| 319 | + | for _, topology := range daemonSnapshotTopologies { | |||
| 320 | + | b.Run(topology.name, func(b *testing.B) { | |||
| 321 | + | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000}) | |||
| 322 | + | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |||
| 323 | + | b.Fatal("invalid daemon snapshot capture fixture") | |||
| 324 | + | } | |||
| 325 | + | first, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |||
| 326 | + | if !ok || !validBenchmarkCapture(first) { | |||
| 327 | + | b.Fatal("invalid snapshot capture fixture") | |||
| 328 | + | } | |||
| 329 | + | firstPane := first.tabs[0].panes[0] | |||
| 330 | + | firstChunk := firstPane.history.Chunk(0) | |||
| 331 | + | captures := 0 | |||
| 332 | + | var last *snapshotCapture | |||
| 333 | + | b.ReportAllocs() | |||
| 334 | + | b.ResetTimer() | |||
| 335 | + | for range b.N { | |||
| 336 | + | last, ok = fixture.d.captureSnapshotState(fixture.sess, uint64(captures+2)) | |||
| 337 | + | if !ok { | |||
| 338 | + | b.Fatal("snapshot capture unexpectedly rejected fixture") | |||
| 339 | + | } | |||
| 340 | + | captures++ | |||
| 341 | + | } | |||
| 342 | + | b.StopTimer() | |||
| 343 | + | lastPane := capturePaneByID(last, firstPane.id) | |||
| 344 | + | if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.history.Chunk(0) != firstChunk { | |||
| 345 | + | b.Fatal("snapshot capture did not preserve immutable sealed history") | |||
| 346 | + | } | |||
| 347 | + | b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op") | |||
| 348 | + | }) | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func benchmarkDaemonSnapshotEncode(b *testing.B) { | |||
| 353 | + | for _, topology := range daemonSnapshotTopologies { | |||
| 354 | + | b.Run(topology.name, func(b *testing.B) { | |||
| 355 | + | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000}) | |||
| 356 | + | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |||
| 357 | + | b.Fatal("invalid daemon snapshot encode fixture") | |||
| 358 | + | } | |||
| 359 | + | capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |||
| 360 | + | if !ok || !validBenchmarkCapture(capture) { | |||
| 361 | + | b.Fatal("invalid snapshot encode fixture") | |||
| 362 | + | } | |||
| 363 | + | encoded, err := fixture.d.encodeSnapshotCapture(capture) | |||
| 364 | + | if err != nil { | |||
| 365 | + | b.Fatal(err) | |||
| 366 | + | } | |||
| 367 | + | if _, err := snapcodec.Unmarshal(encoded); err != nil { | |||
| 368 | + | b.Fatalf("invalid v3 snapshot encode fixture: %v", err) | |||
| 369 | + | } | |||
| 370 | + | var last []byte | |||
| 371 | + | b.ReportAllocs() | |||
| 372 | + | b.SetBytes(int64(len(encoded))) | |||
| 373 | + | b.ResetTimer() | |||
| 374 | + | for range b.N { | |||
| 375 | + | last, err = fixture.d.encodeSnapshotCapture(capture) | |||
| 376 | + | if err != nil { | |||
| 377 | + | b.Fatal(err) | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | b.StopTimer() | |||
| 381 | + | if _, err := snapcodec.Unmarshal(last); err != nil { | |||
| 382 | + | b.Fatalf("snapshot encoder produced invalid v3 data: %v", err) | |||
| 383 | + | } | |||
| 384 | + | b.ReportMetric(float64(len(last)), "snapshotbytes/op") | |||
| 385 | + | }) | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func benchmarkDaemonSnapshotRestore(b *testing.B) { | |||
| 390 | + | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | |||
| 391 | + | if !fixture.hasHistoryTopology(1, 1, 10_000) { | |||
| 392 | + | b.Fatal("invalid daemon snapshot restore fixture") | |||
| 393 | + | } | |||
| 394 | + | capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |||
| 395 | + | if !ok || !validBenchmarkCapture(capture) { | |||
| 396 | + | b.Fatal("invalid snapshot restore fixture") | |||
| 397 | + | } | |||
| 398 | + | encoded, err := fixture.d.encodeSnapshotCapture(capture) | |||
| 399 | + | if err != nil { | |||
| 400 | + | b.Fatal(err) | |||
| 401 | + | } | |||
| 402 | + | snapshot, err := snapcodec.Unmarshal(encoded) | |||
| 403 | + | if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 { | |||
| 404 | + | b.Fatalf("invalid v3 snapshot restore fixture: %v", err) | |||
| 405 | + | } | |||
| 406 | + | paneSnapshot := snapshot.Tabs[0].Panes[0] | |||
| 407 | + | b.ReportAllocs() | |||
| 408 | + | b.SetBytes(int64(len(encoded))) | |||
| 409 | + | b.ResetTimer() | |||
| 410 | + | for range b.N { | |||
| 411 | + | if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil { | |||
| 412 | + | b.Fatal(err) | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | b.StopTimer() | |||
| 416 | + | fixture.activePane.mu.Lock() | |||
| 417 | + | restoredRows := fixture.activePane.history.Len() | |||
| 418 | + | fixture.activePane.mu.Unlock() | |||
| 419 | + | if restoredRows != 10_000 { | |||
| 420 | + | b.Fatalf("snapshot restore retained %d history rows, want 10000", restoredRows) | |||
| 421 | + | } | |||
| 422 | + | b.ReportMetric(float64(restoredRows), "historyrows/restore") | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func validBenchmarkCapture(capture *snapshotCapture) bool { | |||
| 426 | + | return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].history.Len() == 10_000 && capture.tabs[0].panes[0].history.ChunkCount() > 0 | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | func capturePaneCount(capture *snapshotCapture) int { | |||
| 430 | + | count := 0 | |||
| 431 | + | for _, tab := range capture.tabs { | |||
| 432 | + | count += len(tab.panes) | |||
| 433 | + | } | |||
| 434 | + | return count | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func capturePaneByID(capture *snapshotCapture, id layout.PaneID) *snapshotCapturePane { | |||
| 438 | + | if capture == nil { | |||
| 439 | + | return nil | |||
| 440 | + | } | |||
| 441 | + | for tabIndex := range capture.tabs { | |||
| 442 | + | for paneIndex := range capture.tabs[tabIndex].panes { | |||
| 443 | + | pane := &capture.tabs[tabIndex].panes[paneIndex] | |||
| 444 | + | if pane.id == id { | |||
| 445 | + | return pane | |||
| 446 | + | } | |||
| 447 | + | } | |||
| 448 | + | } | |||
| 449 | + | return nil | |||
| 450 | + | } | |||
| 451 | + | ||||
| 261 | func TestCoordinatorCoalescingRatioReportsInvalidationsPerWake(t *testing.T) { | 452 | func TestCoordinatorCoalescingRatioReportsInvalidationsPerWake(t *testing.T) { | ||
| 262 | require.Equal(t, float64(3), coordinatorCoalescingRatio(performanceMetrics{ | 453 | require.Equal(t, float64(3), coordinatorCoalescingRatio(performanceMetrics{ | ||
| 263 | coordinatorWakes: 1, | 454 | coordinatorWakes: 1, | ||
| @@ -331,6 +522,42 @@ func TestLivePaintAllocationBudget(t *testing.T) { | |||||
| 331 | } | 522 | } | ||
| 332 | } | 523 | } | ||
| 333 | 524 | ||||
| 525 | + | // TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy | |||
| 526 | + | // rendering borrows sealed VT history rows; allocating a copy per viewport row | |||
| 527 | + | // is a production regression even though the capture itself remains immutable. | |||
| 528 | + | func TestCopyEnterAllocationBudget(t *testing.T) { | |||
| 529 | + | for _, tt := range []struct { | |||
| 530 | + | name string | |||
| 531 | + | tabs, panes, max int | |||
| 532 | + | }{ | |||
| 533 | + | {name: "1tab-1pane", tabs: 1, panes: 1, max: 38}, | |||
| 534 | + | {name: "1tab-4panes", tabs: 1, panes: 4, max: 43}, | |||
| 535 | + | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42}, | |||
| 536 | + | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48}, | |||
| 537 | + | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50}, | |||
| 538 | + | } { | |||
| 539 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 540 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | |||
| 541 | + | run := func() { | |||
| 542 | + | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |||
| 543 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 544 | + | } | |||
| 545 | + | ||||
| 546 | + | // Always exercise and validate copy entry. The race detector adds | |||
| 547 | + | // instrumentation allocations which are not reported by the parent | |||
| 548 | + | // benchmark, so its allocation count cannot represent this budget. | |||
| 549 | + | run() | |||
| 550 | + | require.True(t, fixture.copyModeActive(), "copy entry must install a history-backed mode") | |||
| 551 | + | if !copyEnterAllocationBudgetEnabled { | |||
| 552 | + | return | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | allocs := testing.AllocsPerRun(20, run) | |||
| 556 | + | require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10%% of the parent allocation baseline") | |||
| 557 | + | }) | |||
| 558 | + | } | |||
| 559 | + | } | |||
| 560 | + | ||||
| 334 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | 561 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | ||
| 335 | fixture := newPerformanceFixture(t, performanceConfig{}) | 562 | fixture := newPerformanceFixture(t, performanceConfig{}) | ||
| 336 | 563 | ||||
| @@ -501,13 +728,15 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 501 | sess: sess, | 728 | sess: sess, | ||
| 502 | ac: ac, | 729 | ac: ac, | ||
| 503 | output: output, | 730 | output: output, | ||
| 504 | - | snaps: &countingSnapshotStore{}, | 731 | + | snaps: &countingSnapshotStore{done: make(chan struct{}, 1)}, |
| 505 | pty: pty, | 732 | pty: pty, | ||
| 506 | clock: clock, | 733 | clock: clock, | ||
| 507 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | 734 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | ||
| 508 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | 735 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | ||
| 509 | } | 736 | } | ||
| 510 | WithSnapshotStore(fixture.snaps)(d) | 737 | WithSnapshotStore(fixture.snaps)(d) | ||
| 738 | + | d.startSnapshotEncodeWorker() | |||
| 739 | + | t.Cleanup(d.stopSnapshotEncodeWorker) | |||
| 511 | for tabIndex, tb := range sess.tabs { | 740 | for tabIndex, tb := range sess.tabs { | ||
| 512 | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) | 741 | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) | ||
| 513 | } | 742 | } | ||
| @@ -550,7 +779,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||||
| 550 | for _, p := range tb.panes { | 779 | for _, p := range tb.panes { | ||
| 551 | p.mu.Lock() | 780 | p.mu.Lock() | ||
| 552 | width := p.screen.Frame.Width | 781 | width := p.screen.Frame.Width | ||
| 553 | - | for row := 0; p.scrollback.Len() < historyRows; row++ { | 782 | + | for row := 0; p.history.Len() < historyRows; row++ { |
| 554 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | 783 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | ||
| 555 | } | 784 | } | ||
| 556 | p.mu.Unlock() | 785 | p.mu.Unlock() | ||
| @@ -594,6 +823,17 @@ func (f *performanceFixture) paintLive() { | |||||
| 594 | func (f *performanceFixture) captureSnapshot() { | 823 | func (f *performanceFixture) captureSnapshot() { | ||
| 595 | f.t.Helper() | 824 | f.t.Helper() | ||
| 596 | require.True(f.t, f.d.captureSession(f.sess)) | 825 | require.True(f.t, f.d.captureSession(f.sess)) | ||
| 826 | + | <-f.snaps.done | |||
| 827 | + | for range 4096 { | |||
| 828 | + | f.sess.snapshotMu.Lock() | |||
| 829 | + | pending := f.sess.snapshotPending | |||
| 830 | + | f.sess.snapshotMu.Unlock() | |||
| 831 | + | if !pending { | |||
| 832 | + | return | |||
| 833 | + | } | |||
| 834 | + | runtime.Gosched() | |||
| 835 | + | } | |||
| 836 | + | f.t.Fatal("snapshot worker did not finish performance capture") | |||
| 597 | } | 837 | } | ||
| 598 | 838 | ||||
| 599 | func (f *performanceFixture) enterCopySearch() { | 839 | func (f *performanceFixture) enterCopySearch() { | ||
| @@ -602,6 +842,30 @@ func (f *performanceFixture) enterCopySearch() { | |||||
| 602 | f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) | 842 | f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) | ||
| 603 | } | 843 | } | ||
| 604 | 844 | ||||
| 845 | + | func (f *performanceFixture) hasHistoryTopology(tabs, panes, historyRows int) bool { | |||
| 846 | + | if len(f.sess.tabs) != tabs { | |||
| 847 | + | return false | |||
| 848 | + | } | |||
| 849 | + | for _, tb := range f.sess.tabs { | |||
| 850 | + | tb.mu.Lock() | |||
| 851 | + | if len(tb.panes) != panes { | |||
| 852 | + | tb.mu.Unlock() | |||
| 853 | + | return false | |||
| 854 | + | } | |||
| 855 | + | for _, p := range tb.panes { | |||
| 856 | + | p.mu.Lock() | |||
| 857 | + | rows := p.history.Len() | |||
| 858 | + | p.mu.Unlock() | |||
| 859 | + | if rows != historyRows { | |||
| 860 | + | tb.mu.Unlock() | |||
| 861 | + | return false | |||
| 862 | + | } | |||
| 863 | + | } | |||
| 864 | + | tb.mu.Unlock() | |||
| 865 | + | } | |||
| 866 | + | return true | |||
| 867 | + | } | |||
| 868 | + | ||||
| 605 | func (f *performanceFixture) searchMatches() int { | 869 | func (f *performanceFixture) searchMatches() int { | ||
| 606 | f.ac.overlays.copyMu.Lock() | 870 | f.ac.overlays.copyMu.Lock() | ||
| 607 | defer f.ac.overlays.copyMu.Unlock() | 871 | defer f.ac.overlays.copyMu.Unlock() | ||
| @@ -611,6 +875,12 @@ func (f *performanceFixture) searchMatches() int { | |||||
| 611 | return len(f.ac.overlays.copyMode.Searches) | 875 | return len(f.ac.overlays.copyMode.Searches) | ||
| 612 | } | 876 | } | ||
| 613 | 877 | ||||
| 878 | + | func (f *performanceFixture) copyModeActive() bool { | |||
| 879 | + | f.ac.overlays.copyMu.Lock() | |||
| 880 | + | defer f.ac.overlays.copyMu.Unlock() | |||
| 881 | + | return f.ac.overlays.copyMode != nil && f.ac.overlays.copySnapshot != nil && f.ac.overlays.copySnapshot.Len() >= 10_000 | |||
| 882 | + | } | |||
| 883 | + | ||||
| 614 | func (f *performanceFixture) resize() { | 884 | func (f *performanceFixture) resize() { | ||
| 615 | f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)]) | 885 | f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)]) | ||
| 616 | } | 886 | } | ||
| @@ -754,6 +1024,7 @@ type countingSnapshotStore struct { | |||||
| 754 | mu sync.Mutex | 1024 | mu sync.Mutex | ||
| 755 | countingSnapshotMetrics | 1025 | countingSnapshotMetrics | ||
| 756 | last []byte | 1026 | last []byte | ||
| 1027 | + | done chan struct{} | |||
| 757 | } | 1028 | } | ||
| 758 | 1029 | ||||
| 759 | func (s *countingSnapshotStore) Write(_ string, data []byte) error { | 1030 | func (s *countingSnapshotStore) Write(_ string, data []byte) error { | ||
| @@ -762,6 +1033,9 @@ func (s *countingSnapshotStore) Write(_ string, data []byte) error { | |||||
| 762 | s.bytes += uint64(len(data)) | 1033 | s.bytes += uint64(len(data)) | ||
| 763 | s.last = data | 1034 | s.last = data | ||
| 764 | s.mu.Unlock() | 1035 | s.mu.Unlock() | ||
| 1036 | + | if s.done != nil { | |||
| 1037 | + | s.done <- struct{}{} | |||
| 1038 | + | } | |||
| 765 | return nil | 1039 | return nil | ||
| 766 | } | 1040 | } | ||
| 767 | func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | 1041 | func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | ||
M internal/usecase/daemon/render_test.go +8 -8
| @@ -572,14 +572,14 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||||
| 572 | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4}) | 572 | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4}) | ||
| 573 | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | 573 | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | ||
| 574 | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | 574 | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | ||
| 575 | - | require.Equal(t, 2, win.focusedPane().scrollback.Len()) | 575 | + | require.Equal(t, 2, win.focusedPane().history.Len()) |
| 576 | - | require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0))) | 576 | + | require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0))) |
| 577 | - | require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1))) | 577 | + | require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1))) |
| 578 | 578 | ||||
| 579 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | 579 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | ||
| 580 | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | 580 | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | ||
| 581 | require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1)) | 581 | require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1)) | ||
| 582 | - | require.Equal(t, 2, win.focusedPane().scrollback.Len()) | 582 | + | require.Equal(t, 2, win.focusedPane().history.Len()) |
| 583 | } | 583 | } | ||
| 584 | 584 | ||||
| 585 | func frameRowString(f renderer.Frame, y int) string { | 585 | func frameRowString(f renderer.Frame, y int) string { | ||
| @@ -911,7 +911,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||||
| 911 | open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) }, | 911 | open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) }, | ||
| 912 | close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }, | 912 | close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }, | ||
| 913 | prepareScreen: func(sess *session) { | 913 | prepareScreen: func(sess *session) { | ||
| 914 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 914 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 915 | }, | 915 | }, | ||
| 916 | visible: "[SCROLL]", | 916 | visible: "[SCROLL]", | ||
| 917 | notVisible: "[SCROLL]", | 917 | notVisible: "[SCROLL]", | ||
| @@ -923,10 +923,10 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||||
| 923 | p, release := newBlockingPTY(t) | 923 | p, release := newBlockingPTY(t) | ||
| 924 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 924 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 925 | defer release() | 925 | defer release() | ||
| 926 | - | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |||
| 927 | if tt.prepareScreen != nil { | 926 | if tt.prepareScreen != nil { | ||
| 928 | tt.prepareScreen(sess) | 927 | tt.prepareScreen(sess) | ||
| 929 | } | 928 | } | ||
| 929 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |||
| 930 | if tt.wantRestored == "" { | 930 | if tt.wantRestored == "" { | ||
| 931 | tt.wantRestored = "live" | 931 | tt.wantRestored = "live" | ||
| 932 | } | 932 | } | ||
| @@ -1295,7 +1295,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||||
| 1295 | } | 1295 | } | ||
| 1296 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | 1296 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | ||
| 1297 | p.screen.Write([]byte("ab\r\ncd")) | 1297 | p.screen.Write([]byte("ab\r\ncd")) | ||
| 1298 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 1298 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) |
| 1299 | mode := scopy.NewMode(document) | 1299 | mode := scopy.NewMode(document) | ||
| 1300 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | 1300 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | ||
| 1301 | 1301 | ||||
| @@ -1355,7 +1355,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||||
| 1355 | defer releaseFloating() | 1355 | defer releaseFloating() | ||
| 1356 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | 1356 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | ||
| 1357 | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | 1357 | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | ||
| 1358 | - | fp.scrollback.Append(testRow("flt-old")) | 1358 | + | fp.history.Append(testRow("flt-old")) |
| 1359 | fp.screen.Write([]byte("flt-live")) | 1359 | fp.screen.Write([]byte("flt-live")) | ||
| 1360 | installTestFloating(sess.activeTab(), fp, true) | 1360 | installTestFloating(sess.activeTab(), fp, true) | ||
| 1361 | 1361 | ||||
M internal/usecase/daemon/scrollback_test.go +15 -6
| @@ -14,29 +14,38 @@ func TestNewTabInitializesScrollback(t *testing.T) { | |||||
| 14 | wantRow string | 14 | wantRow string | ||
| 15 | }{ | 15 | }{ | ||
| 16 | { | 16 | { | ||
| 17 | - | name: "tab owns scrollback wired to vt eviction callback", | 17 | + | name: "primary evictions enter screen owned history", |
| 18 | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | 18 | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | ||
| 19 | wantLen: 1, | 19 | wantLen: 1, | ||
| 20 | wantRow: "AAAA", | 20 | wantRow: "AAAA", | ||
| 21 | }, | 21 | }, | ||
| 22 | + | { | |||
| 23 | + | name: "alternate screen evictions do not enter pane history", | |||
| 24 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"), | |||
| 25 | + | wantLen: 1, | |||
| 26 | + | wantRow: "AAAA", | |||
| 27 | + | }, | |||
| 22 | } | 28 | } | ||
| 23 | 29 | ||||
| 24 | for _, tt := range tests { | 30 | for _, tt := range tests { | ||
| 25 | t.Run(tt.name, func(t *testing.T) { | 31 | t.Run(tt.name, func(t *testing.T) { | ||
| 26 | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) | 32 | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) | ||
| 27 | - | if win.focusedPane().scrollback == nil { | 33 | + | if win.focusedPane().history == nil { |
| 28 | t.Fatal("scrollback is nil") | 34 | t.Fatal("scrollback is nil") | ||
| 29 | } | 35 | } | ||
| 30 | - | if win.focusedPane().scrollback.Cap() != defaultScrollbackRows { | 36 | + | if win.focusedPane().history != win.focusedPane().screen.History() { |
| 31 | - | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows) | 37 | + | t.Fatal("pane history is not owned by its screen") |
| 38 | + | } | |||
| 39 | + | if win.focusedPane().history.Cap() != defaultScrollbackRows { | |||
| 40 | + | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows) | |||
| 32 | } | 41 | } | ||
| 33 | 42 | ||||
| 34 | win.focusedPane().screen.Write(tt.write) | 43 | win.focusedPane().screen.Write(tt.write) | ||
| 35 | 44 | ||||
| 36 | - | if got := win.focusedPane().scrollback.Len(); got != tt.wantLen { | 45 | + | if got := win.focusedPane().history.Len(); got != tt.wantLen { |
| 37 | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | 46 | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | ||
| 38 | } | 47 | } | ||
| 39 | - | row := win.focusedPane().scrollback.View().Row(0) | 48 | + | row := win.focusedPane().history.View().Row(0) |
| 40 | runes := make([]rune, len(row)) | 49 | runes := make([]rune, len(row)) | ||
| 41 | for i, c := range row { | 50 | for i, c := range row { | ||
| 42 | runes[i] = c.Rune | 51 | runes[i] = c.Rune | ||
M internal/usecase/daemon/session.go +9 -1
| @@ -44,6 +44,13 @@ type session struct { | |||||
| 44 | mruAt atomic.Uint64 | 44 | mruAt atomic.Uint64 | ||
| 45 | snapDirty atomic.Bool | 45 | snapDirty atomic.Bool | ||
| 46 | snapEligible atomic.Bool | 46 | snapEligible atomic.Bool | ||
| 47 | + | // snapshotMu serializes the dirty generation with worker completion. It is | |||
| 48 | + | // intentionally independent from mu: persistence never holds session state | |||
| 49 | + | // locks while encoding or writing. | |||
| 50 | + | snapshotMu sync.Mutex | |||
| 51 | + | snapshotGeneration uint64 | |||
| 52 | + | snapshotPending bool | |||
| 53 | + | snapshotChanged chan struct{} | |||
| 47 | // syncGen makes synchronized-output watchdog generations unique across all | 54 | // syncGen makes synchronized-output watchdog generations unique across all | ||
| 48 | // panes in this session. | 55 | // panes in this session. | ||
| 49 | syncGen atomic.Uint64 | 56 | syncGen atomic.Uint64 | ||
| @@ -671,7 +678,8 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 671 | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | 678 | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | ||
| 672 | } | 679 | } | ||
| 673 | } else { | 680 | } else { | ||
| 674 | - | d.captureSession(sess) | 681 | + | markSnapshotDirty(sess) |
| 682 | + | d.scheduleFinalSnapshot(sess) | |||
| 675 | } | 683 | } | ||
| 676 | } | 684 | } | ||
| 677 | d.mu.Lock() | 685 | d.mu.Lock() | ||
M internal/usecase/daemon/snapshot.go +415 -78
| @@ -4,17 +4,53 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "math" | 6 | "math" | ||
| 7 | + | "sync" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 13 | "github.com/bnema/vev/pkg/renderer" | 13 | "github.com/bnema/vev/pkg/renderer" | ||
| 14 | "github.com/bnema/vev/pkg/vt" | 14 | "github.com/bnema/vev/pkg/vt" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | - | const snapshotInterval = 30 * time.Second | 17 | + | // snapshotQueueCapacity bounds retained immutable captures. A full queue never |
| 18 | + | // stalls session producers; the session remains dirty for the next interval. | |||
| 19 | + | const snapshotQueueCapacity = 1 | |||
| 20 | + | ||||
| 21 | + | type snapshotCapture struct { | |||
| 22 | + | session *session | |||
| 23 | + | generation uint64 | |||
| 24 | + | name string | |||
| 25 | + | createdAt uint64 | |||
| 26 | + | active uint16 | |||
| 27 | + | tabs []snapshotCaptureTab | |||
| 28 | + | finishOnce sync.Once | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type snapshotCaptureTab struct { | |||
| 32 | + | stableID string | |||
| 33 | + | cols uint16 | |||
| 34 | + | rows uint16 | |||
| 35 | + | nextPaneID uint64 | |||
| 36 | + | focus layout.PaneID | |||
| 37 | + | tree *layout.Tree | |||
| 38 | + | panes []snapshotCapturePane | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type snapshotCapturePane struct { | |||
| 42 | + | id layout.PaneID | |||
| 43 | + | stableID string | |||
| 44 | + | cwd string | |||
| 45 | + | history vt.HistoryView | |||
| 46 | + | visible renderer.Frame | |||
| 47 | + | process *snapcodec.Process | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | const ( | |||
| 51 | + | snapshotInterval = 30 * time.Second | |||
| 52 | + | snapshotFinalFlushTimeout = time.Second | |||
| 53 | + | ) | |||
| 18 | 54 | ||||
| 19 | func (d *Daemon) closeRestoreDone() { | 55 | func (d *Daemon) closeRestoreDone() { | ||
| 20 | d.restoreOnce.Do(func() { close(d.restoreDone) }) | 56 | d.restoreOnce.Do(func() { close(d.restoreDone) }) | ||
| @@ -51,6 +87,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 51 | if err := ctx.Err(); err != nil { | 87 | if err := ctx.Err(); err != nil { | ||
| 52 | return err | 88 | return err | ||
| 53 | } | 89 | } | ||
| 90 | + | if len(snap.Tabs) == 0 { | |||
| 91 | + | if snap.Active != 0 { | |||
| 92 | + | return fmt.Errorf("snapshot: active tab out of range") | |||
| 93 | + | } | |||
| 94 | + | } else if int(snap.Active) >= len(snap.Tabs) { | |||
| 95 | + | return fmt.Errorf("snapshot: active tab out of range") | |||
| 96 | + | } | |||
| 54 | if snap.Name == "" { | 97 | if snap.Name == "" { | ||
| 55 | return fmt.Errorf("snapshot: empty session name") | 98 | return fmt.Errorf("snapshot: empty session name") | ||
| 56 | } | 99 | } | ||
| @@ -149,7 +192,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 149 | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | 192 | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | ||
| 150 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | 193 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | ||
| 151 | p.rect = contentRect | 194 | p.rect = contentRect | ||
| 152 | - | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | 195 | + | if err := restorePaneTerminal(p, paneSnap); err != nil { |
| 196 | + | // p is not in tb.panes yet, so closeOpened cannot reach it. | |||
| 197 | + | p.cancel() | |||
| 198 | + | _ = pty.Close() | |||
| 199 | + | closeOpened() | |||
| 200 | + | return err | |||
| 201 | + | } | |||
| 153 | if restoreCommand != "" { | 202 | if restoreCommand != "" { | ||
| 154 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | 203 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | ||
| 155 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | 204 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | ||
| @@ -164,9 +213,6 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 164 | } | 213 | } | ||
| 165 | createdAt := int64(snap.CreatedAt) | 214 | createdAt := int64(snap.CreatedAt) | ||
| 166 | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt} | 215 | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt} | ||
| 167 | - | if sess.active < 0 || sess.active >= len(sess.tabs) { | |||
| 168 | - | sess.active = 0 | |||
| 169 | - | } | |||
| 170 | sess.snapEligible.Store(true) | 216 | sess.snapEligible.Store(true) | ||
| 171 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | 217 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | ||
| 172 | sess.cwd = snap.Tabs[0].Panes[0].Cwd | 218 | sess.cwd = snap.Tabs[0].Panes[0].Cwd | ||
| @@ -201,36 +247,51 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 201 | } | 247 | } | ||
| 202 | 248 | ||||
| 203 | func markSnapshotDirty(sess *session) { | 249 | func markSnapshotDirty(sess *session) { | ||
| 204 | - | if sess == nil { | 250 | + | if sess == nil || !sess.snapEligible.Load() { |
| 205 | return | 251 | return | ||
| 206 | } | 252 | } | ||
| 207 | - | if sess.snapEligible.Load() { | 253 | + | sess.snapshotMu.Lock() |
| 208 | - | sess.snapDirty.Store(true) | 254 | + | sess.snapshotGeneration++ |
| 255 | + | sess.snapDirty.Store(true) | |||
| 256 | + | sess.signalSnapshotChangedLocked() | |||
| 257 | + | sess.snapshotMu.Unlock() | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // snapshotChangeLocked returns a channel closed by the next snapshot state | |||
| 261 | + | // transition. snapshotMu must be held. | |||
| 262 | + | func (sess *session) snapshotChangeLocked() chan struct{} { | |||
| 263 | + | if sess.snapshotChanged == nil { | |||
| 264 | + | sess.snapshotChanged = make(chan struct{}) | |||
| 209 | } | 265 | } | ||
| 266 | + | return sess.snapshotChanged | |||
| 210 | } | 267 | } | ||
| 211 | 268 | ||||
| 212 | - | func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) { | 269 | + | // signalSnapshotChangedLocked wakes waiters after a snapshot state transition. |
| 270 | + | // snapshotMu must be held. | |||
| 271 | + | func (sess *session) signalSnapshotChangedLocked() { | |||
| 272 | + | close(sess.snapshotChangeLocked()) | |||
| 273 | + | sess.snapshotChanged = make(chan struct{}) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |||
| 277 | + | if len(snap.Tail) == 0 { | |||
| 278 | + | return fmt.Errorf("snapshot history: missing tail blob") | |||
| 279 | + | } | |||
| 280 | + | if len(snap.Visible) == 0 { | |||
| 281 | + | return fmt.Errorf("snapshot visible: missing visible blob") | |||
| 282 | + | } | |||
| 213 | p.mu.Lock() | 283 | p.mu.Lock() | ||
| 214 | defer p.mu.Unlock() | 284 | defer p.mu.Unlock() | ||
| 215 | - | p.scrollback = scopy.NewScrollback(defaultScrollbackRows) | 285 | + | screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail) |
| 216 | - | for _, row := range scrollback { | 286 | + | if err != nil { |
| 217 | - | p.scrollback.Append(row) | 287 | + | return fmt.Errorf("snapshot history: %w", err) |
| 218 | - | } | 288 | + | } |
| 219 | - | if len(visible) > 0 { | 289 | + | if err := screen.RestorePrimaryVisible(snap.Visible); err != nil { |
| 220 | - | p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height) | 290 | + | return fmt.Errorf("snapshot visible: %w", err) |
| 221 | - | for y, row := range visible { | |||
| 222 | - | if y >= p.screen.Frame.Height { | |||
| 223 | - | break | |||
| 224 | - | } | |||
| 225 | - | for x, cell := range row { | |||
| 226 | - | if x >= p.screen.Frame.Width { | |||
| 227 | - | break | |||
| 228 | - | } | |||
| 229 | - | p.screen.Frame.Set(x, y, cell) | |||
| 230 | - | } | |||
| 231 | - | } | |||
| 232 | } | 291 | } | ||
| 233 | - | p.screen.OnLineEvicted = p.scrollback.Append | 292 | + | p.screen = screen |
| 293 | + | p.history = screen.History() | |||
| 294 | + | return nil | |||
| 234 | } | 295 | } | ||
| 235 | 296 | ||||
| 236 | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | 297 | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | ||
| @@ -251,35 +312,112 @@ func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | |||||
| 251 | return domain.Size{Cols: cols, Rows: rows} | 312 | return domain.Size{Cols: cols, Rows: rows} | ||
| 252 | } | 313 | } | ||
| 253 | 314 | ||||
| 254 | - | func (d *Daemon) captureSession(sess *session) bool { | 315 | + | // scheduleSnapshot captures a session only once while an immutable capture is |
| 255 | - | if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil { | 316 | + | // queued or being written. It never waits for the bounded worker queue. |
| 317 | + | func (d *Daemon) scheduleSnapshot(sess *session) bool { | |||
| 318 | + | return d.scheduleSnapshotWithFinalFallback(sess, false) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // scheduleFinalSnapshot retains a terminal capture rejected by the regular | |||
| 322 | + | // bounded queue. A removed session cannot be retried by snapshotSaver, so the | |||
| 323 | + | // worker drains this fallback before it stops. | |||
| 324 | + | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | |||
| 325 | + | return d.scheduleSnapshotWithFinalFallback(sess, true) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | |||
| 329 | + | if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() { | |||
| 256 | return true | 330 | return true | ||
| 257 | } | 331 | } | ||
| 332 | + | sess.snapshotMu.Lock() | |||
| 333 | + | if !sess.snapDirty.Load() || sess.snapshotPending { | |||
| 334 | + | sess.snapshotMu.Unlock() | |||
| 335 | + | return true | |||
| 336 | + | } | |||
| 337 | + | generation := sess.snapshotGeneration | |||
| 338 | + | sess.snapshotPending = true | |||
| 339 | + | sess.signalSnapshotChangedLocked() | |||
| 340 | + | sess.snapshotMu.Unlock() | |||
| 341 | + | ||||
| 342 | + | capture, ok := d.captureSnapshotState(sess, generation) | |||
| 343 | + | if !ok { | |||
| 344 | + | d.finishSnapshotCapture(capture, false) | |||
| 345 | + | return false | |||
| 346 | + | } | |||
| 347 | + | if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) { | |||
| 348 | + | return true | |||
| 349 | + | } | |||
| 350 | + | // Coalesce under saturation or shutdown: the latest state stays dirty and | |||
| 351 | + | // will be captured on a later tick once an active worker has room. | |||
| 352 | + | d.finishSnapshotCapture(capture, false) | |||
| 353 | + | return false | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // enqueueSnapshotCapture serializes worker shutdown with the non-blocking | |||
| 357 | + | // queue send. The queue is deliberately never closed: producers can race | |||
| 358 | + | // shutdown without risking a send-on-closed panic. | |||
| 359 | + | func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool { | |||
| 360 | + | d.snapshotWorkerMu.Lock() | |||
| 361 | + | defer d.snapshotWorkerMu.Unlock() | |||
| 362 | + | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |||
| 363 | + | return false | |||
| 364 | + | } | |||
| 365 | + | select { | |||
| 366 | + | case d.snapshotJobs <- capture: | |||
| 367 | + | return true | |||
| 368 | + | default: | |||
| 369 | + | return false | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // enqueueFinalSnapshotCapture hands terminal captures to the existing worker | |||
| 374 | + | // without making session teardown wait behind its normal bounded queue. | |||
| 375 | + | func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool { | |||
| 376 | + | d.snapshotWorkerMu.Lock() | |||
| 377 | + | defer d.snapshotWorkerMu.Unlock() | |||
| 378 | + | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |||
| 379 | + | return false | |||
| 380 | + | } | |||
| 381 | + | d.snapshotFinalJobs = append(d.snapshotFinalJobs, capture) | |||
| 382 | + | select { | |||
| 383 | + | case d.snapshotWorkerFinalWake <- struct{}{}: | |||
| 384 | + | default: | |||
| 385 | + | } | |||
| 386 | + | return true | |||
| 387 | + | } | |||
| 258 | 388 | ||||
| 389 | + | // captureSession rotates history tails and clones visible frames while holding | |||
| 390 | + | // each pane lock. The returned capture contains only immutable state; encoding | |||
| 391 | + | // and persistence are deliberately deferred to snapshotEncodeWorker. | |||
| 392 | + | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | |||
| 259 | sess.mu.Lock() | 393 | sess.mu.Lock() | ||
| 260 | - | name := sess.name | 394 | + | capture := &snapshotCapture{ |
| 395 | + | session: sess, | |||
| 396 | + | generation: generation, | |||
| 397 | + | name: sess.name, | |||
| 398 | + | createdAt: uint64(sess.createdAt), | |||
| 399 | + | active: uint16(max(sess.active, 0)), | |||
| 400 | + | } | |||
| 261 | ephemeral := sess.ephemeral | 401 | ephemeral := sess.ephemeral | ||
| 262 | - | createdAt := sess.createdAt | |||
| 263 | - | active := sess.active | |||
| 264 | fallbackCwd := sess.cwd | 402 | fallbackCwd := sess.cwd | ||
| 265 | tabs := append([]*tab(nil), sess.tabs...) | 403 | tabs := append([]*tab(nil), sess.tabs...) | ||
| 266 | sess.mu.Unlock() | 404 | sess.mu.Unlock() | ||
| 267 | - | if ephemeral || name == "" { | 405 | + | if ephemeral || capture.name == "" { |
| 268 | - | return true | 406 | + | return capture, false |
| 269 | } | 407 | } | ||
| 270 | 408 | ||||
| 271 | - | snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))} | 409 | + | capture.tabs = make([]snapshotCaptureTab, 0, len(tabs)) |
| 272 | for _, tb := range tabs { | 410 | for _, tb := range tabs { | ||
| 273 | tb.mu.Lock() | 411 | tb.mu.Lock() | ||
| 274 | - | tabSnap := snapcodec.Tab{ | 412 | + | tabCapture := snapshotCaptureTab{ |
| 275 | - | StableID: tb.stableID, | 413 | + | stableID: tb.stableID, |
| 276 | - | Cols: uint16(max(tb.size.Cols, 0)), | 414 | + | cols: uint16(max(tb.size.Cols, 0)), |
| 277 | - | Rows: uint16(max(tb.size.Rows, 0)), | 415 | + | rows: uint16(max(tb.size.Rows, 0)), |
| 278 | - | NextPaneID: uint64(max(tb.nextPaneID, 0)), | 416 | + | nextPaneID: uint64(max(tb.nextPaneID, 0)), |
| 279 | - | Tree: tb.tree.Clone(), | 417 | + | tree: tb.tree.Clone(), |
| 280 | } | 418 | } | ||
| 281 | if tb.tree != nil { | 419 | if tb.tree != nil { | ||
| 282 | - | tabSnap.Focus = tb.tree.Focus | 420 | + | tabCapture.focus = tb.tree.Focus |
| 283 | } | 421 | } | ||
| 284 | panes := make([]*pane, 0, len(tb.panes)) | 422 | panes := make([]*pane, 0, len(tb.panes)) | ||
| 285 | for _, p := range tb.panes { | 423 | for _, p := range tb.panes { | ||
| @@ -287,51 +425,263 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 287 | } | 425 | } | ||
| 288 | tb.mu.Unlock() | 426 | tb.mu.Unlock() | ||
| 289 | 427 | ||||
| 290 | - | paneIDs := make(map[layout.PaneID]struct{}, len(panes)) | 428 | + | tabCapture.panes = make([]snapshotCapturePane, 0, len(panes)) |
| 291 | - | paneSnaps := make([]snapcodec.Pane, 0, len(panes)) | |||
| 292 | for _, p := range panes { | 429 | for _, p := range panes { | ||
| 293 | p.mu.Lock() | 430 | p.mu.Lock() | ||
| 294 | - | pid := 0 | |||
| 295 | pty := p.pty | 431 | pty := p.pty | ||
| 432 | + | pid := 0 | |||
| 296 | if pty != nil { | 433 | if pty != nil { | ||
| 297 | pid = pty.Pid() | 434 | pid = pty.Pid() | ||
| 298 | } | 435 | } | ||
| 299 | - | paneSnap := snapcodec.Pane{ | 436 | + | paneCapture := snapshotCapturePane{ |
| 300 | - | ID: p.id, | 437 | + | id: p.id, |
| 301 | - | StableID: p.stableID, | 438 | + | stableID: p.stableID, |
| 302 | - | Scrollback: cloneRows(p.scrollback.Snapshot()), | 439 | + | history: p.history.SealAndView(), |
| 303 | - | Visible: cloneRows(p.screen.PrimaryVisibleRows()), | 440 | + | visible: p.screen.PrimaryVisibleFrame(), |
| 304 | } | 441 | } | ||
| 305 | p.mu.Unlock() | 442 | p.mu.Unlock() | ||
| 306 | - | paneSnap.Cwd = fallbackCwd | 443 | + | paneCapture.cwd = fallbackCwd |
| 307 | if d.procCwd != nil && pid > 0 { | 444 | if d.procCwd != nil && pid > 0 { | ||
| 308 | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | 445 | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | ||
| 309 | - | paneSnap.Cwd = cwd | 446 | + | paneCapture.cwd = cwd |
| 310 | } | 447 | } | ||
| 311 | } | 448 | } | ||
| 312 | - | paneSnap.Process = d.capturePaneProcess(pty, pid) | 449 | + | paneCapture.process = d.capturePaneProcess(pty, pid) |
| 313 | - | paneIDs[paneSnap.ID] = struct{}{} | 450 | + | tabCapture.panes = append(tabCapture.panes, paneCapture) |
| 314 | - | paneSnaps = append(paneSnaps, paneSnap) | 451 | + | } |
| 452 | + | capture.tabs = append(capture.tabs, tabCapture) | |||
| 453 | + | } | |||
| 454 | + | return capture, true | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | // captureSession remains the synchronous producer-facing trigger for callers | |||
| 458 | + | // such as teardown and benchmarks; the actual encoding and Write stay async. | |||
| 459 | + | func (d *Daemon) captureSession(sess *session) bool { | |||
| 460 | + | markSnapshotDirty(sess) | |||
| 461 | + | return d.scheduleSnapshot(sess) | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) { | |||
| 465 | + | snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))} | |||
| 466 | + | for _, tabCapture := range capture.tabs { | |||
| 467 | + | tabSnap := snapcodec.Tab{ | |||
| 468 | + | StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows, | |||
| 469 | + | NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree, | |||
| 470 | + | Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)), | |||
| 471 | + | } | |||
| 472 | + | paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes)) | |||
| 473 | + | for _, paneCapture := range tabCapture.panes { | |||
| 474 | + | sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history) | |||
| 475 | + | visible, visibleErr := vt.MarshalVisible(paneCapture.visible) | |||
| 476 | + | if historyErr != nil || visibleErr != nil { | |||
| 477 | + | return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, visibleErr) | |||
| 478 | + | } | |||
| 479 | + | tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: paneCapture.process}) | |||
| 480 | + | paneIDs[paneCapture.id] = struct{}{} | |||
| 315 | } | 481 | } | ||
| 316 | if tabSnap.Tree != nil { | 482 | if tabSnap.Tree != nil { | ||
| 317 | pruneTreeToPanes(tabSnap.Tree.Root, paneIDs) | 483 | pruneTreeToPanes(tabSnap.Tree.Root, paneIDs) | ||
| 318 | } | 484 | } | ||
| 319 | - | tabSnap.Panes = paneSnaps | |||
| 320 | snap.Tabs = append(snap.Tabs, tabSnap) | 485 | snap.Tabs = append(snap.Tabs, tabSnap) | ||
| 321 | } | 486 | } | ||
| 487 | + | return d.snapshotMarshal(snap) | |||
| 488 | + | } | |||
| 322 | 489 | ||||
| 323 | - | data, err := snapcodec.Marshal(snap) | 490 | + | func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) { |
| 324 | - | if err != nil { | 491 | + | if capture == nil { |
| 325 | - | d.log.Warn("marshaling session snapshot failed", "err", err, "session", name) | 492 | + | return |
| 326 | - | return false | 493 | + | } |
| 494 | + | capture.finishOnce.Do(func() { | |||
| 495 | + | capture.session.snapshotMu.Lock() | |||
| 496 | + | capture.session.snapshotPending = false | |||
| 497 | + | if succeeded && capture.session.snapshotGeneration == capture.generation { | |||
| 498 | + | capture.session.snapDirty.Store(false) | |||
| 499 | + | } else if !succeeded { | |||
| 500 | + | capture.session.snapDirty.Store(true) | |||
| 501 | + | } | |||
| 502 | + | capture.session.signalSnapshotChangedLocked() | |||
| 503 | + | capture.session.snapshotMu.Unlock() | |||
| 504 | + | }) | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | func (d *Daemon) startSnapshotEncodeWorker() { | |||
| 508 | + | if d == nil { | |||
| 509 | + | return | |||
| 510 | + | } | |||
| 511 | + | d.snapshotWorkerMu.Lock() | |||
| 512 | + | if d.snapshotWorkerCancel != nil { | |||
| 513 | + | d.snapshotWorkerMu.Unlock() | |||
| 514 | + | return | |||
| 515 | + | } | |||
| 516 | + | // Shutdown owns the worker lifetime so it can flush captures after Serve | |||
| 517 | + | // cancels its parent context. The worker is always stopped explicitly. | |||
| 518 | + | workerCtx, cancel := context.WithCancel(context.Background()) | |||
| 519 | + | d.snapshotWorkerID++ | |||
| 520 | + | workerID := d.snapshotWorkerID | |||
| 521 | + | d.snapshotWorkerCtx = workerCtx | |||
| 522 | + | d.snapshotWorkerCancel = cancel | |||
| 523 | + | d.snapshotWorkerDone = make(chan struct{}) | |||
| 524 | + | d.snapshotWorkerFlush = make(chan struct{}) | |||
| 525 | + | d.snapshotWorkerFinalWake = make(chan struct{}, 1) | |||
| 526 | + | d.snapshotFinalJobs = nil | |||
| 527 | + | d.snapshotWorkerClosing = false | |||
| 528 | + | d.snapshotWorkerInFlight = nil | |||
| 529 | + | done := d.snapshotWorkerDone | |||
| 530 | + | flush := d.snapshotWorkerFlush | |||
| 531 | + | finalWake := d.snapshotWorkerFinalWake | |||
| 532 | + | d.snapshotWorkerMu.Unlock() | |||
| 533 | + | go func() { | |||
| 534 | + | defer close(done) | |||
| 535 | + | write := func(capture *snapshotCapture) bool { | |||
| 536 | + | if workerCtx.Err() != nil || !d.setSnapshotWorkerInFlight(workerID, capture) { | |||
| 537 | + | d.finishSnapshotCapture(capture, false) | |||
| 538 | + | return false | |||
| 539 | + | } | |||
| 540 | + | data, err := d.encodeSnapshotCapture(capture) | |||
| 541 | + | if err == nil && workerCtx.Err() == nil { | |||
| 542 | + | err = d.snaps.Write(capture.name, data) | |||
| 543 | + | } | |||
| 544 | + | d.clearSnapshotWorkerInFlight(workerID, capture) | |||
| 545 | + | if err != nil && workerCtx.Err() == nil { | |||
| 546 | + | d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name) | |||
| 547 | + | } | |||
| 548 | + | d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil) | |||
| 549 | + | return workerCtx.Err() == nil | |||
| 550 | + | } | |||
| 551 | + | drainFinal := func() bool { | |||
| 552 | + | for { | |||
| 553 | + | capture := d.takeFinalSnapshotCapture() | |||
| 554 | + | if capture == nil { | |||
| 555 | + | return true | |||
| 556 | + | } | |||
| 557 | + | if !write(capture) { | |||
| 558 | + | return false | |||
| 559 | + | } | |||
| 560 | + | } | |||
| 561 | + | } | |||
| 562 | + | flushQueued := func() { | |||
| 563 | + | for { | |||
| 564 | + | select { | |||
| 565 | + | case capture := <-d.snapshotJobs: | |||
| 566 | + | if !write(capture) { | |||
| 567 | + | return | |||
| 568 | + | } | |||
| 569 | + | default: | |||
| 570 | + | _ = drainFinal() | |||
| 571 | + | return | |||
| 572 | + | } | |||
| 573 | + | } | |||
| 574 | + | } | |||
| 575 | + | for { | |||
| 576 | + | select { | |||
| 577 | + | case <-workerCtx.Done(): | |||
| 578 | + | return | |||
| 579 | + | case <-flush: | |||
| 580 | + | flushQueued() | |||
| 581 | + | return | |||
| 582 | + | case <-finalWake: | |||
| 583 | + | if !drainFinal() { | |||
| 584 | + | return | |||
| 585 | + | } | |||
| 586 | + | case capture := <-d.snapshotJobs: | |||
| 587 | + | if !write(capture) { | |||
| 588 | + | return | |||
| 589 | + | } | |||
| 590 | + | } | |||
| 591 | + | } | |||
| 592 | + | }() | |||
| 593 | + | } | |||
| 594 | + | ||||
| 595 | + | func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture { | |||
| 596 | + | d.snapshotWorkerMu.Lock() | |||
| 597 | + | defer d.snapshotWorkerMu.Unlock() | |||
| 598 | + | if len(d.snapshotFinalJobs) == 0 { | |||
| 599 | + | return nil | |||
| 327 | } | 600 | } | ||
| 328 | - | if err := d.snaps.Write(name, data); err != nil { | 601 | + | capture := d.snapshotFinalJobs[0] |
| 329 | - | d.log.Warn("writing session snapshot failed", "err", err, "session", name) | 602 | + | d.snapshotFinalJobs[0] = nil |
| 603 | + | d.snapshotFinalJobs = d.snapshotFinalJobs[1:] | |||
| 604 | + | return capture | |||
| 605 | + | } | |||
| 606 | + | ||||
| 607 | + | func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool { | |||
| 608 | + | d.snapshotWorkerMu.Lock() | |||
| 609 | + | defer d.snapshotWorkerMu.Unlock() | |||
| 610 | + | if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |||
| 330 | return false | 611 | return false | ||
| 331 | } | 612 | } | ||
| 613 | + | d.snapshotWorkerInFlight = capture | |||
| 332 | return true | 614 | return true | ||
| 333 | } | 615 | } | ||
| 334 | 616 | ||||
| 617 | + | func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) { | |||
| 618 | + | d.snapshotWorkerMu.Lock() | |||
| 619 | + | defer d.snapshotWorkerMu.Unlock() | |||
| 620 | + | if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture { | |||
| 621 | + | d.snapshotWorkerInFlight = nil | |||
| 622 | + | } | |||
| 623 | + | } | |||
| 624 | + | ||||
| 625 | + | func (d *Daemon) stopSnapshotEncodeWorker() { | |||
| 626 | + | if d == nil { | |||
| 627 | + | return | |||
| 628 | + | } | |||
| 629 | + | d.snapshotWorkerMu.Lock() | |||
| 630 | + | cancel := d.snapshotWorkerCancel | |||
| 631 | + | if cancel == nil || d.snapshotWorkerClosing { | |||
| 632 | + | d.snapshotWorkerMu.Unlock() | |||
| 633 | + | return | |||
| 634 | + | } | |||
| 635 | + | // Stop accepting producers, then let the single worker drain its bounded | |||
| 636 | + | // queue. A non-context-aware store may still block indefinitely, so the | |||
| 637 | + | // injected clock bounds how long teardown waits before detaching it. | |||
| 638 | + | d.snapshotWorkerClosing = true | |||
| 639 | + | flush := d.snapshotWorkerFlush | |||
| 640 | + | done := d.snapshotWorkerDone | |||
| 641 | + | close(flush) | |||
| 642 | + | timer := d.clock.NewTimer(snapshotFinalFlushTimeout) | |||
| 643 | + | d.snapshotWorkerMu.Unlock() | |||
| 644 | + | ||||
| 645 | + | select { | |||
| 646 | + | case <-done: | |||
| 647 | + | timer.Stop() | |||
| 648 | + | d.finishStoppedSnapshotWorker(false) | |||
| 649 | + | case <-timer.C(): | |||
| 650 | + | cancel() | |||
| 651 | + | d.finishStoppedSnapshotWorker(true) | |||
| 652 | + | } | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) { | |||
| 656 | + | d.snapshotWorkerMu.Lock() | |||
| 657 | + | inFlight := d.snapshotWorkerInFlight | |||
| 658 | + | d.snapshotWorkerCtx = nil | |||
| 659 | + | d.snapshotWorkerCancel = nil | |||
| 660 | + | d.snapshotWorkerDone = nil | |||
| 661 | + | d.snapshotWorkerFlush = nil | |||
| 662 | + | d.snapshotWorkerFinalWake = nil | |||
| 663 | + | d.snapshotWorkerClosing = false | |||
| 664 | + | d.snapshotWorkerInFlight = nil | |||
| 665 | + | queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs)) | |||
| 666 | + | queued = append(queued, d.snapshotFinalJobs...) | |||
| 667 | + | d.snapshotFinalJobs = nil | |||
| 668 | + | for { | |||
| 669 | + | select { | |||
| 670 | + | case capture := <-d.snapshotJobs: | |||
| 671 | + | queued = append(queued, capture) | |||
| 672 | + | default: | |||
| 673 | + | d.snapshotWorkerMu.Unlock() | |||
| 674 | + | if abandoned { | |||
| 675 | + | d.finishSnapshotCapture(inFlight, false) | |||
| 676 | + | } | |||
| 677 | + | for _, capture := range queued { | |||
| 678 | + | d.finishSnapshotCapture(capture, false) | |||
| 679 | + | } | |||
| 680 | + | return | |||
| 681 | + | } | |||
| 682 | + | } | |||
| 683 | + | } | |||
| 684 | + | ||||
| 335 | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | 685 | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | ||
| 336 | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | 686 | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | ||
| 337 | return nil | 687 | return nil | ||
| @@ -368,27 +718,14 @@ func (d *Daemon) snapshotSaver(ctx context.Context) { | |||||
| 368 | sessions := d.sessionsSnapshotLocked() | 718 | sessions := d.sessionsSnapshotLocked() | ||
| 369 | d.mu.Unlock() | 719 | d.mu.Unlock() | ||
| 370 | for _, sess := range sessions { | 720 | for _, sess := range sessions { | ||
| 371 | - | if sess.snapEligible.Load() && sess.snapDirty.Swap(false) { | 721 | + | if sess.snapEligible.Load() && sess.snapDirty.Load() { |
| 372 | - | if !d.captureSession(sess) { | 722 | + | d.scheduleSnapshot(sess) |
| 373 | - | sess.snapDirty.Store(true) | |||
| 374 | - | } | |||
| 375 | } | 723 | } | ||
| 376 | } | 724 | } | ||
| 377 | t.Reset(snapshotInterval) | 725 | t.Reset(snapshotInterval) | ||
| 378 | } | 726 | } | ||
| 379 | } | 727 | } | ||
| 380 | 728 | ||||
| 381 | - | func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell { | |||
| 382 | - | if len(rows) == 0 { | |||
| 383 | - | return nil | |||
| 384 | - | } | |||
| 385 | - | out := make([][]renderer.Cell, len(rows)) | |||
| 386 | - | for i, row := range rows { | |||
| 387 | - | out[i] = append([]renderer.Cell(nil), row...) | |||
| 388 | - | } | |||
| 389 | - | return out | |||
| 390 | - | } | |||
| 391 | - | ||||
| 392 | func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool { | 729 | func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool { | ||
| 393 | if n == nil { | 730 | if n == nil { | ||
| 394 | return false | 731 | return false | ||
A internal/usecase/daemon/snapshot_acceptance_test.go +249 -0
| @@ -0,0 +1,249 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 10 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 12 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 13 | + | "github.com/bnema/vev/internal/usecase/visualsearch" | |||
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 15 | + | "github.com/bnema/vev/pkg/vt" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) { | |||
| 19 | + | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 20 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 21 | + | WithSnapshotStore(store)(d) | |||
| 22 | + | startSnapshotEncodeWorker(t, d) | |||
| 23 | + | ||||
| 24 | + | sess := newSnapshotTestSession(t, "acceptance", false, "/work") | |||
| 25 | + | first := sess.tabs[0] | |||
| 26 | + | first.size = domain.Size{Cols: 80, Rows: 24} | |||
| 27 | + | first.stableID = "tab-one" | |||
| 28 | + | first.nextPaneID = 3 | |||
| 29 | + | first.panes["pane-1"].stableID = "pane-one" | |||
| 30 | + | secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24}) | |||
| 31 | + | secondPane.history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'w'}, {Rune: 'o'}}) | |||
| 32 | + | secondPane.screen.Write([]byte("two")) | |||
| 33 | + | first.panes["pane-2"] = secondPane | |||
| 34 | + | first.tree = &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 35 | + | layout.NewLeaf("pane-1"), | |||
| 36 | + | layout.NewLeaf("pane-2"), | |||
| 37 | + | }}} | |||
| 38 | + | ||||
| 39 | + | second := newTab(snapshotAcceptancePTY(t), domain.Size{Cols: 90, Rows: 30}) | |||
| 40 | + | second.stableID = "tab-two" | |||
| 41 | + | second.nextPaneID = 2 | |||
| 42 | + | second.panes["pane-1"].stableID = "pane-three" | |||
| 43 | + | second.panes["pane-1"].history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'h'}, {Rune: 'r'}, {Rune: 'e'}, {Rune: 'e'}}) | |||
| 44 | + | second.panes["pane-1"].screen.Write([]byte("three")) | |||
| 45 | + | sess.tabs = []*tab{first, second} | |||
| 46 | + | sess.active = 1 | |||
| 47 | + | ||||
| 48 | + | require.True(t, d.captureSession(sess)) | |||
| 49 | + | encoded := <-store.writes | |||
| 50 | + | captured, err := snapcodec.Unmarshal(encoded) | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | assertCapturedSessionTopology(t, captured) | |||
| 53 | + | ||||
| 54 | + | restoreFactory := &restorePTYFactory{} | |||
| 55 | + | restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{}) | |||
| 56 | + | require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured)) | |||
| 57 | + | restoredDaemon.mu.Lock() | |||
| 58 | + | restored := restoredDaemon.findByNameLocked("acceptance") | |||
| 59 | + | restoredDaemon.mu.Unlock() | |||
| 60 | + | require.NotNil(t, restored) | |||
| 61 | + | require.Equal(t, 1, restored.active) | |||
| 62 | + | require.Len(t, restored.tabs, 2) | |||
| 63 | + | ||||
| 64 | + | for _, tt := range []struct { | |||
| 65 | + | name string | |||
| 66 | + | tab int | |||
| 67 | + | pane layout.PaneID | |||
| 68 | + | wantRow string | |||
| 69 | + | wantFocus layout.PaneID | |||
| 70 | + | }{ | |||
| 71 | + | {name: "first tab focused second pane", tab: 0, pane: "pane-2", wantRow: "two", wantFocus: "pane-2"}, | |||
| 72 | + | {name: "second tab focused only pane", tab: 1, pane: "pane-1", wantRow: "three", wantFocus: "pane-1"}, | |||
| 73 | + | } { | |||
| 74 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 75 | + | tab := restored.tabs[tt.tab] | |||
| 76 | + | tab.mu.Lock() | |||
| 77 | + | defer tab.mu.Unlock() | |||
| 78 | + | require.Equal(t, tt.wantFocus, tab.tree.Focus) | |||
| 79 | + | pane := tab.panes[tt.pane] | |||
| 80 | + | require.NotNil(t, pane) | |||
| 81 | + | pane.mu.Lock() | |||
| 82 | + | defer pane.mu.Unlock() | |||
| 83 | + | require.Equal(t, tt.wantRow, rowText(pane.history.View().Row(0))) | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestSnapshotCaptureRetainsImmutableValuesAfterTailMutationAndEviction(t *testing.T) { | |||
| 89 | + | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 90 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 91 | + | WithSnapshotStore(store)(d) | |||
| 92 | + | startSnapshotEncodeWorker(t, d) | |||
| 93 | + | ||||
| 94 | + | entered := make(chan struct{}) | |||
| 95 | + | release := make(chan struct{}) | |||
| 96 | + | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 97 | + | close(entered) | |||
| 98 | + | <-release | |||
| 99 | + | return snapcodec.Marshal(s) | |||
| 100 | + | } | |||
| 101 | + | sess := newSnapshotTestSession(t, "frozen", false, "/work") | |||
| 102 | + | pane := sess.tabs[0].panes["pane-1"] | |||
| 103 | + | pane.screen = vt.NewScreenWithHistory(8, 3, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 104 | + | pane.history = pane.screen.History() | |||
| 105 | + | pane.history.Append([]renderer.Cell{{Rune: 'o'}, {Rune: 'l'}, {Rune: 'd'}}) | |||
| 106 | + | pane.screen.Write([]byte("before")) | |||
| 107 | + | ||||
| 108 | + | require.True(t, d.captureSession(sess)) | |||
| 109 | + | <-entered | |||
| 110 | + | pane.mu.Lock() | |||
| 111 | + | pane.history.Append([]renderer.Cell{{Rune: 'n'}, {Rune: 'e'}, {Rune: 'w'}}) | |||
| 112 | + | pane.history.Append([]renderer.Cell{{Rune: 'e'}, {Rune: 'v'}, {Rune: 'i'}, {Rune: 'c'}, {Rune: 't'}}) | |||
| 113 | + | pane.screen.Write([]byte("\rafter")) | |||
| 114 | + | pane.mu.Unlock() | |||
| 115 | + | close(release) | |||
| 116 | + | ||||
| 117 | + | captured, err := snapcodec.Unmarshal(<-store.writes) | |||
| 118 | + | require.NoError(t, err) | |||
| 119 | + | restoredHistory, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}, captured.Tabs[0].Panes[0].SealedChunks, captured.Tabs[0].Panes[0].Tail) | |||
| 120 | + | require.NoError(t, err) | |||
| 121 | + | require.Equal(t, "old", rowText(restoredHistory.View().Row(0))) | |||
| 122 | + | frame, err := vt.UnmarshalVisible(captured.Tabs[0].Panes[0].Visible) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | require.Contains(t, rowText(frame.Row(0)), "before") | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) { | |||
| 128 | + | sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work") | |||
| 129 | + | pane := sess.tabs[0].panes["pane-1"] | |||
| 130 | + | pane.screen = vt.NewScreenWithHistory(8, 1, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 131 | + | pane.history = pane.screen.History() | |||
| 132 | + | for _, text := range []string{"first", "second"} { | |||
| 133 | + | pane.history.Append(cells(text)) | |||
| 134 | + | } | |||
| 135 | + | pane.screen.Write([]byte("screen")) | |||
| 136 | + | ||||
| 137 | + | document := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |||
| 138 | + | for _, text := range []string{"third", "fourth"} { | |||
| 139 | + | pane.history.Append(cells(text)) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | for _, tt := range []struct { | |||
| 143 | + | name string | |||
| 144 | + | rows func() [][]renderer.Cell | |||
| 145 | + | want []string | |||
| 146 | + | }{ | |||
| 147 | + | {name: "bounded live history is oldest first after eviction", rows: func() [][]renderer.Cell { | |||
| 148 | + | view := pane.history.View() | |||
| 149 | + | rows := make([][]renderer.Cell, view.Len()) | |||
| 150 | + | for i := range rows { | |||
| 151 | + | rows[i] = view.Row(i) | |||
| 152 | + | } | |||
| 153 | + | return rows | |||
| 154 | + | }, want: []string{"third", "fourth"}}, | |||
| 155 | + | {name: "captured document retains evicted rows", rows: func() [][]renderer.Cell { | |||
| 156 | + | rows := make([][]renderer.Cell, document.Len()-document.Height) | |||
| 157 | + | for i := range rows { | |||
| 158 | + | rows[i] = document.Row(i) | |||
| 159 | + | } | |||
| 160 | + | return rows | |||
| 161 | + | }, want: []string{"first", "second"}}, | |||
| 162 | + | } { | |||
| 163 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 164 | + | rows := tt.rows() | |||
| 165 | + | got := make([]string, len(rows)) | |||
| 166 | + | for i, row := range rows { | |||
| 167 | + | got[i] = rowText(row) | |||
| 168 | + | } | |||
| 169 | + | require.Equal(t, tt.want, got) | |||
| 170 | + | }) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | search := visualsearch.New(document) | |||
| 174 | + | for _, r := range "first" { | |||
| 175 | + | search.Insert(r) | |||
| 176 | + | } | |||
| 177 | + | match, ok := search.Selected() | |||
| 178 | + | require.True(t, ok) | |||
| 179 | + | require.Equal(t, "first", match.Text) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY { | |||
| 183 | + | t.Helper() | |||
| 184 | + | pty := portsmocks.NewMockPTY(t) | |||
| 185 | + | pty.EXPECT().Pid().Return(0).Maybe() | |||
| 186 | + | pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |||
| 187 | + | return pty | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) { | |||
| 191 | + | t.Helper() | |||
| 192 | + | require.Equal(t, "acceptance", got.Name) | |||
| 193 | + | require.Equal(t, uint16(1), got.Active) | |||
| 194 | + | require.Len(t, got.Tabs, 2) | |||
| 195 | + | for _, tt := range []struct { | |||
| 196 | + | name string | |||
| 197 | + | tab int | |||
| 198 | + | stableID string | |||
| 199 | + | focus layout.PaneID | |||
| 200 | + | nextPaneID uint64 | |||
| 201 | + | paneIDs []layout.PaneID | |||
| 202 | + | wantSplit bool | |||
| 203 | + | }{ | |||
| 204 | + | {name: "first split tab", tab: 0, stableID: "tab-one", focus: "pane-2", nextPaneID: 3, paneIDs: []layout.PaneID{"pane-1", "pane-2"}, wantSplit: true}, | |||
| 205 | + | {name: "second leaf tab", tab: 1, stableID: "tab-two", focus: "pane-1", nextPaneID: 2, paneIDs: []layout.PaneID{"pane-1"}}, | |||
| 206 | + | } { | |||
| 207 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 208 | + | tab := got.Tabs[tt.tab] | |||
| 209 | + | require.Equal(t, tt.stableID, tab.StableID) | |||
| 210 | + | require.Equal(t, tt.focus, tab.Focus) | |||
| 211 | + | require.Equal(t, tt.nextPaneID, tab.NextPaneID) | |||
| 212 | + | require.NotNil(t, tab.Tree) | |||
| 213 | + | require.Equal(t, tt.focus, tab.Tree.Focus) | |||
| 214 | + | if tt.wantSplit { | |||
| 215 | + | require.Equal(t, layout.Split, tab.Tree.Root.Kind) | |||
| 216 | + | } | |||
| 217 | + | ids := make(map[layout.PaneID]struct{}, len(tab.Panes)) | |||
| 218 | + | for _, pane := range tab.Panes { | |||
| 219 | + | ids[pane.ID] = struct{}{} | |||
| 220 | + | } | |||
| 221 | + | for _, id := range tt.paneIDs { | |||
| 222 | + | _, ok := ids[id] | |||
| 223 | + | require.True(t, ok, "missing pane %q", id) | |||
| 224 | + | } | |||
| 225 | + | _, focused := ids[tab.Focus] | |||
| 226 | + | require.True(t, focused, "focus %q is dangling", tab.Focus) | |||
| 227 | + | assertTreeReferencesCapturedPanes(t, tab.Tree.Root, ids) | |||
| 228 | + | }) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) { | |||
| 233 | + | t.Helper() | |||
| 234 | + | if node == nil { | |||
| 235 | + | return | |||
| 236 | + | } | |||
| 237 | + | if node.Kind == layout.Leaf { | |||
| 238 | + | _, ok := panes[node.Leaf] | |||
| 239 | + | require.True(t, ok, "tree leaf %q is dangling", node.Leaf) | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | if node.Kind == layout.Stack && node.Expanded != "" { | |||
| 243 | + | _, ok := panes[node.Expanded] | |||
| 244 | + | require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded) | |||
| 245 | + | } | |||
| 246 | + | for _, child := range node.Children { | |||
| 247 | + | assertTreeReferencesCapturedPanes(t, child, panes) | |||
| 248 | + | } | |||
| 249 | + | } | |||
M internal/usecase/daemon/snapshot_restore_test.go +136 -34
| @@ -18,8 +18,74 @@ import ( | |||||
| 18 | "github.com/bnema/vev/internal/usecase/layout" | 18 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 20 | "github.com/bnema/vev/pkg/renderer" | 20 | "github.com/bnema/vev/pkg/renderer" | ||
| 21 | + | "github.com/bnema/vev/pkg/vt" | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 24 | + | func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane { | |||
| 25 | + | t.Helper() | |||
| 26 | + | h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | |||
| 27 | + | for _, row := range historyRows { | |||
| 28 | + | h.Append(row) | |||
| 29 | + | } | |||
| 30 | + | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | frame := renderer.NewFrame(40, 24) | |||
| 33 | + | for y, row := range visibleRows { | |||
| 34 | + | copy(frame.Row(y), row) | |||
| 35 | + | } | |||
| 36 | + | visible, err := vt.MarshalVisible(frame) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) { | |||
| 42 | + | valid := terminalPane(t, "pane", "p", "/work", nil, nil) | |||
| 43 | + | tests := []struct { | |||
| 44 | + | name string | |||
| 45 | + | mutate func(*snapcodec.Pane) | |||
| 46 | + | }{ | |||
| 47 | + | {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }}, | |||
| 48 | + | {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }}, | |||
| 49 | + | {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }}, | |||
| 50 | + | {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }}, | |||
| 51 | + | } | |||
| 52 | + | for _, tt := range tests { | |||
| 53 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 54 | + | snap := valid | |||
| 55 | + | tt.mutate(&snap) | |||
| 56 | + | p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24}) | |||
| 57 | + | require.Error(t, restorePaneTerminal(p, snap)) | |||
| 58 | + | }) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) { | |||
| 63 | + | factory := &restorePTYFactory{} | |||
| 64 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 65 | + | ||||
| 66 | + | err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{ | |||
| 67 | + | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")}, | |||
| 68 | + | }}}) | |||
| 69 | + | ||||
| 70 | + | require.Error(t, err) | |||
| 71 | + | require.Empty(t, factory.opens) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) { | |||
| 75 | + | factory := &restorePTYFactory{} | |||
| 76 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 77 | + | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |||
| 78 | + | pane.Tail = []byte("malformed") | |||
| 79 | + | ||||
| 80 | + | err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad", Tabs: []snapcodec.Tab{{ | |||
| 81 | + | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}, | |||
| 82 | + | }}}) | |||
| 83 | + | ||||
| 84 | + | require.Error(t, err) | |||
| 85 | + | require.Len(t, factory.opens, 1) | |||
| 86 | + | require.Equal(t, 1, factory.opens[0].pty.closeCount()) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 23 | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | 89 | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | ||
| 24 | store := &restoreSnapshotStore{} | 90 | store := &restoreSnapshotStore{} | ||
| 25 | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | 91 | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
| @@ -37,8 +103,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 37 | layout.NewLeaf("pane-2"), | 103 | layout.NewLeaf("pane-2"), | ||
| 38 | }}}, | 104 | }}}, | ||
| 39 | Panes: []snapcodec.Pane{ | 105 | Panes: []snapcodec.Pane{ | ||
| 40 | - | {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, | 106 | + | terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}), |
| 41 | - | {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}}, | 107 | + | terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}), |
| 42 | }, | 108 | }, | ||
| 43 | }}, | 109 | }}, | ||
| 44 | })}} | 110 | })}} | ||
| @@ -77,10 +143,11 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 77 | p := tb.panes["pane-2"] | 143 | p := tb.panes["pane-2"] | ||
| 78 | tb.mu.Unlock() | 144 | tb.mu.Unlock() | ||
| 79 | p.mu.Lock() | 145 | p.mu.Lock() | ||
| 80 | - | require.Equal(t, "old2", rowText(p.scrollback.View().Row(0))) | 146 | + | require.Same(t, p.screen.History(), p.history) |
| 81 | - | require.Equal(t, 1, p.scrollback.Len()) | 147 | + | require.Equal(t, "old2", rowText(p.history.View().Row(0))) |
| 148 | + | require.Equal(t, 1, p.history.Len()) | |||
| 82 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | 149 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | ||
| 83 | - | copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 150 | + | copySnap := scopy.NewSnapshot(p.history, p.screen.Frame) |
| 84 | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | 151 | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | ||
| 85 | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | 152 | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | ||
| 86 | p.mu.Unlock() | 153 | p.mu.Unlock() | ||
| @@ -108,8 +175,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 108 | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | 175 | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | ||
| 109 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | 176 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
| 110 | Name: "work", Active: 0, Tabs: []snapcodec.Tab{ | 177 | Name: "work", Active: 0, Tabs: []snapcodec.Tab{ | ||
| 111 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}}, | 178 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}}, |
| 112 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}}, | 179 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}}, |
| 113 | }, | 180 | }, | ||
| 114 | })}}} | 181 | })}}} | ||
| 115 | factory := &restorePTYFactory{} | 182 | factory := &restorePTYFactory{} | ||
| @@ -138,17 +205,15 @@ func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testi | |||||
| 138 | 205 | ||||
| 139 | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | 206 | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | ||
| 140 | t.Helper() | 207 | t.Helper() | ||
| 208 | + | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |||
| 209 | + | pane.Process = proc | |||
| 141 | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | 210 | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
| 142 | Name: name, | 211 | Name: name, | ||
| 143 | Tabs: []snapcodec.Tab{{ | 212 | Tabs: []snapcodec.Tab{{ | ||
| 144 | - | Cols: 80, | 213 | + | Cols: 80, |
| 145 | - | Rows: 24, | 214 | + | Rows: 24, |
| 146 | - | Tree: layout.NewTree("pane-1"), | 215 | + | Tree: layout.NewTree("pane-1"), |
| 147 | - | Panes: []snapcodec.Pane{{ | 216 | + | Panes: []snapcodec.Pane{pane}, |
| 148 | - | ID: "pane-1", | |||
| 149 | - | Cwd: "/tmp", | |||
| 150 | - | Process: proc, | |||
| 151 | - | }}, | |||
| 152 | }}, | 217 | }}, | ||
| 153 | })}}} | 218 | })}}} | ||
| 154 | } | 219 | } | ||
| @@ -185,20 +250,17 @@ func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) { | |||||
| 185 | Cols: 80, | 250 | Cols: 80, | ||
| 186 | Rows: 24, | 251 | Rows: 24, | ||
| 187 | Tree: layout.NewTree("pane-1"), | 252 | Tree: layout.NewTree("pane-1"), | ||
| 188 | - | Panes: []snapcodec.Pane{{ | 253 | + | Panes: func() []snapcodec.Pane { |
| 189 | - | ID: "pane-1", | 254 | + | pane := emptyTerminalPane(t, "pane-1", "/one") |
| 190 | - | Cwd: "/one", | 255 | + | pane.Process = lessProc |
| 191 | - | Process: lessProc, | 256 | + | return []snapcodec.Pane{pane} |
| 192 | - | }}, | 257 | + | }(), |
| 193 | }, | 258 | }, | ||
| 194 | { | 259 | { | ||
| 195 | - | Cols: 80, | 260 | + | Cols: 80, |
| 196 | - | Rows: 24, | 261 | + | Rows: 24, |
| 197 | - | Tree: layout.NewTree("pane-1"), | 262 | + | Tree: layout.NewTree("pane-1"), |
| 198 | - | Panes: []snapcodec.Pane{{ | 263 | + | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}, |
| 199 | - | ID: "pane-1", | |||
| 200 | - | Cwd: "/two", | |||
| 201 | - | }}, | |||
| 202 | }, | 264 | }, | ||
| 203 | }, | 265 | }, | ||
| 204 | })}}}, | 266 | })}}}, | ||
| @@ -246,7 +308,9 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) { | |||||
| 246 | d := newTestDaemon(t, factory, stubClock{}) | 308 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 247 | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | 309 | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | ||
| 248 | 310 | ||||
| 249 | - | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}})) | 311 | + | pane := emptyTerminalPane(t, "pane-1", "/tmp") |
| 312 | + | pane.Process = proc | |||
| 313 | + | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}})) | |||
| 250 | require.Len(t, factory.opens, 1) | 314 | require.Len(t, factory.opens, 1) | ||
| 251 | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | 315 | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | ||
| 252 | } | 316 | } | ||
| @@ -258,7 +322,7 @@ func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) { | |||||
| 258 | defer cancel() | 322 | defer cancel() | ||
| 259 | 323 | ||||
| 260 | require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{ | 324 | require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{ | ||
| 261 | - | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp"}}, | 325 | + | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")}, |
| 262 | }}})) | 326 | }}})) | ||
| 263 | require.Len(t, factory.opens, 1) | 327 | require.Len(t, factory.opens, 1) | ||
| 264 | require.Same(t, ctx, factory.opens[0].ctx) | 328 | require.Same(t, ctx, factory.opens[0].ctx) | ||
| @@ -273,7 +337,11 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) | |||||
| 273 | Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{ | 337 | Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{ | ||
| 274 | layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"), | 338 | layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"), | ||
| 275 | }}}, | 339 | }}}, | ||
| 276 | - | Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}}, | 340 | + | Panes: []snapcodec.Pane{ |
| 341 | + | emptyTerminalPane(t, "a", "/a"), | |||
| 342 | + | emptyTerminalPane(t, "b", "/b"), | |||
| 343 | + | emptyTerminalPane(t, "c", "/c"), | |||
| 344 | + | }, | |||
| 277 | }}, | 345 | }}, | ||
| 278 | })}}} | 346 | })}}} | ||
| 279 | factory := &restorePTYFactory{} | 347 | factory := &restorePTYFactory{} | ||
| @@ -303,7 +371,16 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) | |||||
| 303 | } | 371 | } | ||
| 304 | 372 | ||||
| 305 | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | 373 | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | ||
| 306 | - | valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}}) | 374 | + | valid := mustSnapshotBytes(t, snapcodec.Session{ |
| 375 | + | Name: "live", | |||
| 376 | + | CreatedAt: 1, | |||
| 377 | + | Tabs: []snapcodec.Tab{{ | |||
| 378 | + | Cols: 80, | |||
| 379 | + | Rows: 24, | |||
| 380 | + | Tree: layout.NewTree("pane-1"), | |||
| 381 | + | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")}, | |||
| 382 | + | }}, | |||
| 383 | + | }) | |||
| 307 | badVersion := append([]byte(nil), valid...) | 384 | badVersion := append([]byte(nil), valid...) | ||
| 308 | binary.BigEndian.PutUint16(badVersion[4:6], 1) | 385 | binary.BigEndian.PutUint16(badVersion[4:6], 1) | ||
| 309 | tests := []struct { | 386 | tests := []struct { | ||
| @@ -340,7 +417,7 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) { | |||||
| 340 | Cols: 80, | 417 | Cols: 80, | ||
| 341 | Rows: 24, | 418 | Rows: 24, | ||
| 342 | Tree: layout.NewTree("pane-1"), | 419 | Tree: layout.NewTree("pane-1"), | ||
| 343 | - | Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}}, | 420 | + | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")}, |
| 344 | }}, | 421 | }}, | ||
| 345 | })}}} | 422 | })}}} | ||
| 346 | factory := &restorePTYFactory{} | 423 | factory := &restorePTYFactory{} | ||
| @@ -366,7 +443,15 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | |||||
| 366 | loadFn: func() ([]ports.SnapshotBlob, error) { | 443 | loadFn: func() ([]ports.SnapshotBlob, error) { | ||
| 367 | close(loaded) | 444 | close(loaded) | ||
| 368 | <-releaseLoad | 445 | <-releaseLoad | ||
| 369 | - | return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/restored"}}}}})}}, nil | 446 | + | return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ |
| 447 | + | Name: "work", | |||
| 448 | + | Tabs: []snapcodec.Tab{{ | |||
| 449 | + | Cols: 80, | |||
| 450 | + | Rows: 24, | |||
| 451 | + | Tree: layout.NewTree("pane-1"), | |||
| 452 | + | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")}, | |||
| 453 | + | }}, | |||
| 454 | + | })}}, nil | |||
| 370 | }, | 455 | }, | ||
| 371 | } | 456 | } | ||
| 372 | factory := &restorePTYFactory{} | 457 | factory := &restorePTYFactory{} | ||
| @@ -415,6 +500,11 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) { | |||||
| 415 | require.Equal(t, ports.ErrServerShutdown, perr.code) | 500 | require.Equal(t, ports.ErrServerShutdown, perr.code) | ||
| 416 | } | 501 | } | ||
| 417 | 502 | ||||
| 503 | + | func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane { | |||
| 504 | + | t.Helper() | |||
| 505 | + | return terminalPane(t, id, "", cwd, nil, nil) | |||
| 506 | + | } | |||
| 507 | + | ||||
| 418 | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | 508 | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | ||
| 419 | t.Helper() | 509 | t.Helper() | ||
| 420 | b, err := snapcodec.Marshal(s) | 510 | b, err := snapcodec.Marshal(s) | ||
| @@ -470,6 +560,7 @@ func (f *restorePTYFactory) Open(ctx context.Context, _ string, _ []string, env | |||||
| 470 | type restorePTY struct { | 560 | type restorePTY struct { | ||
| 471 | mu sync.Mutex | 561 | mu sync.Mutex | ||
| 472 | writes []string | 562 | writes []string | ||
| 563 | + | closes int | |||
| 473 | writeErr error | 564 | writeErr error | ||
| 474 | done chan struct{} | 565 | done chan struct{} | ||
| 475 | once sync.Once | 566 | once sync.Once | ||
| @@ -489,7 +580,18 @@ func (p *restorePTY) Write(b []byte) (int, error) { | |||||
| 489 | } | 580 | } | ||
| 490 | return len(b), nil | 581 | return len(b), nil | ||
| 491 | } | 582 | } | ||
| 492 | - | func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil } | 583 | + | func (p *restorePTY) Close() error { |
| 584 | + | p.mu.Lock() | |||
| 585 | + | p.closes++ | |||
| 586 | + | p.mu.Unlock() | |||
| 587 | + | p.once.Do(func() { close(p.done) }) | |||
| 588 | + | return nil | |||
| 589 | + | } | |||
| 590 | + | func (p *restorePTY) closeCount() int { | |||
| 591 | + | p.mu.Lock() | |||
| 592 | + | defer p.mu.Unlock() | |||
| 593 | + | return p.closes | |||
| 594 | + | } | |||
| 493 | func (p *restorePTY) Resize(domain.Size) error { return nil } | 595 | func (p *restorePTY) Resize(domain.Size) error { return nil } | ||
| 494 | func (p *restorePTY) Pid() int { return 0 } | 596 | func (p *restorePTY) Pid() int { return 0 } | ||
| 495 | func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil } | 597 | func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil } | ||
M internal/usecase/daemon/snapshot_test.go +531 -7
| @@ -18,14 +18,423 @@ import ( | |||||
| 18 | "github.com/bnema/vev/internal/usecase/layout" | 18 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 20 | "github.com/bnema/vev/pkg/renderer" | 20 | "github.com/bnema/vev/pkg/renderer" | ||
| 21 | + | "github.com/bnema/vev/pkg/vt" | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 24 | + | func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) { | |||
| 25 | + | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 26 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 27 | + | WithSnapshotStore(store)(d) | |||
| 28 | + | t.Cleanup(d.stopSnapshotEncodeWorker) | |||
| 29 | + | d.startSnapshotEncodeWorker() | |||
| 30 | + | ||||
| 31 | + | entered := make(chan struct{}) | |||
| 32 | + | release := make(chan struct{}) | |||
| 33 | + | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 34 | + | close(entered) | |||
| 35 | + | <-release | |||
| 36 | + | return snapcodec.Marshal(s) | |||
| 37 | + | } | |||
| 38 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |||
| 39 | + | markSnapshotDirty(sess) | |||
| 40 | + | require.True(t, d.scheduleSnapshot(sess)) | |||
| 41 | + | <-entered | |||
| 42 | + | ||||
| 43 | + | paneLocked := make(chan struct{}) | |||
| 44 | + | releasePane := make(chan struct{}) | |||
| 45 | + | go func() { | |||
| 46 | + | p := sess.tabs[0].panes["pane-1"] | |||
| 47 | + | p.mu.Lock() | |||
| 48 | + | close(paneLocked) | |||
| 49 | + | <-releasePane | |||
| 50 | + | p.mu.Unlock() | |||
| 51 | + | }() | |||
| 52 | + | <-paneLocked | |||
| 53 | + | close(releasePane) | |||
| 54 | + | close(release) | |||
| 55 | + | <-store.writes | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestSnapshotEncodeWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) { | |||
| 59 | + | exerciseBlockedSnapshotWorker(t, true) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestSnapshotStoreWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) { | |||
| 63 | + | exerciseBlockedSnapshotWorker(t, false) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) { | |||
| 67 | + | t.Helper() | |||
| 68 | + | store := newGatedSnapshotStore() | |||
| 69 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 70 | + | WithSnapshotStore(store)(d) | |||
| 71 | + | d.startSnapshotEncodeWorker() | |||
| 72 | + | t.Cleanup(func() { | |||
| 73 | + | store.unblock() | |||
| 74 | + | d.stopSnapshotEncodeWorker() | |||
| 75 | + | }) | |||
| 76 | + | if blockEncode { | |||
| 77 | + | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 78 | + | store.enter() | |||
| 79 | + | <-store.release | |||
| 80 | + | return snapcodec.Marshal(s) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | sess := newSnapshotTestSession(t, "live", false, "/live") | |||
| 85 | + | d.sessions[sess.id] = sess | |||
| 86 | + | sess.tabs[0].size = domain.Size{Cols: 80, Rows: 24} | |||
| 87 | + | p := sess.tabs[0].panes["pane-1"] | |||
| 88 | + | tr, sends, _ := newConn(t, ports.Frame{}) | |||
| 89 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 8, Rows: 3}} | |||
| 90 | + | ac.setSession(sess) | |||
| 91 | + | sess.mu.Lock() | |||
| 92 | + | sess.client = ac | |||
| 93 | + | sess.mu.Unlock() | |||
| 94 | + | rendered := make(chan struct{}, 1) | |||
| 95 | + | rc := newRenderCoordinator(renderCoordinatorOptions{ | |||
| 96 | + | clock: stubClock{}, | |||
| 97 | + | onInvalidate: func(renderInvalidation) { | |||
| 98 | + | d.paint(sess, ac, true) | |||
| 99 | + | rendered <- struct{}{} | |||
| 100 | + | }, | |||
| 101 | + | }) | |||
| 102 | + | sess.installRenderCoordinator(rc) | |||
| 103 | + | rc.attach(ac) | |||
| 104 | + | require.True(t, rc.markAttachmentReady(ac)) | |||
| 105 | + | t.Cleanup(rc.noteSessionTeardown) | |||
| 106 | + | ||||
| 107 | + | markSnapshotDirty(sess) | |||
| 108 | + | require.True(t, d.scheduleSnapshot(sess)) | |||
| 109 | + | <-store.entered | |||
| 110 | + | ||||
| 111 | + | paneLocked := make(chan struct{}) | |||
| 112 | + | releasePane := make(chan struct{}) | |||
| 113 | + | go func() { | |||
| 114 | + | p.mu.Lock() | |||
| 115 | + | close(paneLocked) | |||
| 116 | + | <-releasePane | |||
| 117 | + | p.mu.Unlock() | |||
| 118 | + | }() | |||
| 119 | + | <-paneLocked | |||
| 120 | + | close(releasePane) | |||
| 121 | + | ||||
| 122 | + | reader := portsmocks.NewMockPTY(t) | |||
| 123 | + | var reads atomic.Int32 | |||
| 124 | + | reader.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 125 | + | if reads.Add(1) == 1 { | |||
| 126 | + | return copy(buf, []byte("ok")), nil | |||
| 127 | + | } | |||
| 128 | + | return 0, io.EOF | |||
| 129 | + | }).Twice() | |||
| 130 | + | reader.EXPECT().Write(mock.Anything).Return(0, nil).Maybe() | |||
| 131 | + | p.mu.Lock() | |||
| 132 | + | p.pty = reader | |||
| 133 | + | p.onExit = func() {} | |||
| 134 | + | p.mu.Unlock() | |||
| 135 | + | d.sessWg.Add(1) | |||
| 136 | + | go d.ptyReader(sess, sess.tabs[0], p) | |||
| 137 | + | d.sessWg.Wait() | |||
| 138 | + | <-rendered | |||
| 139 | + | _ = awaitFrame(t, sends, ports.MsgOutput) | |||
| 140 | + | p.mu.Lock() | |||
| 141 | + | require.Contains(t, screenLineText(p.screen, 0), "ok") | |||
| 142 | + | p.mu.Unlock() | |||
| 143 | + | ||||
| 144 | + | store.unblock() | |||
| 145 | + | awaitSnapshotIdle(t, sess) | |||
| 146 | + | require.True(t, sess.snapDirty.Load(), "PTY output published while blocked must remain retryable") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestFinalSnapshotFlushPersistsNewestNamedSessionBeforeWorkerTeardown(t *testing.T) { | |||
| 150 | + | clock := newFinalFlushClock() | |||
| 151 | + | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 152 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |||
| 153 | + | WithSnapshotStore(store)(d) | |||
| 154 | + | d.startSnapshotEncodeWorker() | |||
| 155 | + | ||||
| 156 | + | entered := make(chan struct{}) | |||
| 157 | + | release := make(chan struct{}) | |||
| 158 | + | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 159 | + | close(entered) | |||
| 160 | + | <-release | |||
| 161 | + | return snapcodec.Marshal(s) | |||
| 162 | + | } | |||
| 163 | + | sess := newSnapshotTestSession(t, "final", false, "/work") | |||
| 164 | + | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 165 | + | require.True(t, ok) | |||
| 166 | + | pty.EXPECT().Close().Return(nil).Once() | |||
| 167 | + | sess.tabs[0].panes["pane-1"].screen.Write([]byte("\rnewest")) | |||
| 168 | + | d.sessions[sess.id] = sess | |||
| 169 | + | ||||
| 170 | + | require.NoError(t, d.killSession(sess, 0, false)) | |||
| 171 | + | <-entered | |||
| 172 | + | stopped := make(chan struct{}) | |||
| 173 | + | go func() { | |||
| 174 | + | d.stopSnapshotEncodeWorker() | |||
| 175 | + | close(stopped) | |||
| 176 | + | }() | |||
| 177 | + | close(release) | |||
| 178 | + | <-stopped | |||
| 179 | + | awaitSnapshotIdle(t, sess) | |||
| 180 | + | ||||
| 181 | + | select { | |||
| 182 | + | case data := <-store.writes: | |||
| 183 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 184 | + | require.NoError(t, err) | |||
| 185 | + | frame, err := vt.UnmarshalVisible(snap.Tabs[0].Panes[0].Visible) | |||
| 186 | + | require.NoError(t, err) | |||
| 187 | + | require.Contains(t, rowText(frame.Row(0)), "newest") | |||
| 188 | + | default: | |||
| 189 | + | t.Fatal("final snapshot was dropped during worker teardown") | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) { | |||
| 194 | + | store := newNeverReturningSnapshotStore() | |||
| 195 | + | clock := newFinalFlushClock() | |||
| 196 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |||
| 197 | + | WithSnapshotStore(store)(d) | |||
| 198 | + | d.startSnapshotEncodeWorker() | |||
| 199 | + | ||||
| 200 | + | sess := newSnapshotTestSession(t, "stuck-write", false, "/work") | |||
| 201 | + | require.True(t, d.captureSession(sess)) | |||
| 202 | + | <-store.entered | |||
| 203 | + | ||||
| 204 | + | stopped := make(chan struct{}) | |||
| 205 | + | go func() { | |||
| 206 | + | d.stopSnapshotEncodeWorker() | |||
| 207 | + | close(stopped) | |||
| 208 | + | }() | |||
| 209 | + | timer := <-clock.timers | |||
| 210 | + | timer.ch <- time.Time{} | |||
| 211 | + | <-stopped | |||
| 212 | + | awaitSnapshotIdle(t, sess) | |||
| 213 | + | require.True(t, sess.snapDirty.Load(), "an abandoned write must remain retryable") | |||
| 214 | + | require.NotPanics(t, func() { require.False(t, d.scheduleSnapshot(sess)) }) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestRejectedSnapshotCaptureLeavesSessionRetryable(t *testing.T) { | |||
| 218 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 219 | + | WithSnapshotStore(&channelSnapshotStore{writes: make(chan []byte, 1)})(d) | |||
| 220 | + | sess := newSnapshotTestSession(t, "rejected", false, "/work") | |||
| 221 | + | sess.name = "" | |||
| 222 | + | sess.snapEligible.Store(true) | |||
| 223 | + | ||||
| 224 | + | markSnapshotDirty(sess) | |||
| 225 | + | require.False(t, d.scheduleSnapshot(sess)) | |||
| 226 | + | awaitSnapshotIdle(t, sess) | |||
| 227 | + | require.True(t, sess.snapDirty.Load()) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) { | |||
| 231 | + | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 232 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 233 | + | WithSnapshotStore(store)(d) | |||
| 234 | + | d.startSnapshotEncodeWorker() | |||
| 235 | + | d.stopSnapshotEncodeWorker() | |||
| 236 | + | ||||
| 237 | + | sess := newSnapshotTestSession(t, "stopped", false, "/work") | |||
| 238 | + | markSnapshotDirty(sess) | |||
| 239 | + | require.False(t, d.scheduleSnapshot(sess), "a stopped worker must not accept a capture") | |||
| 240 | + | awaitSnapshotIdle(t, sess) | |||
| 241 | + | require.True(t, sess.snapDirty.Load(), "an unsubmitted capture must remain retryable") | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) { | |||
| 245 | + | store := &channelSnapshotStore{writes: make(chan []byte, 3)} | |||
| 246 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 247 | + | WithSnapshotStore(store)(d) | |||
| 248 | + | startSnapshotEncodeWorker(t, d) | |||
| 249 | + | entered := make(chan struct{}) | |||
| 250 | + | release := make(chan struct{}) | |||
| 251 | + | var calls atomic.Int32 | |||
| 252 | + | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 253 | + | if calls.Add(1) == 1 { | |||
| 254 | + | close(entered) | |||
| 255 | + | <-release | |||
| 256 | + | } | |||
| 257 | + | return snapcodec.Marshal(s) | |||
| 258 | + | } | |||
| 259 | + | first := newSnapshotTestSession(t, "first", false, "/work") | |||
| 260 | + | second := newSnapshotTestSession(t, "second", false, "/work") | |||
| 261 | + | third := newSnapshotTestSession(t, "third", false, "/work") | |||
| 262 | + | markSnapshotDirty(first) | |||
| 263 | + | markSnapshotDirty(second) | |||
| 264 | + | markSnapshotDirty(third) | |||
| 265 | + | require.True(t, d.scheduleSnapshot(first)) | |||
| 266 | + | <-entered | |||
| 267 | + | require.True(t, d.scheduleSnapshot(second), "one capture may wait in the bounded queue") | |||
| 268 | + | require.False(t, d.scheduleSnapshot(third), "full queue must not block the producer") | |||
| 269 | + | require.True(t, third.snapDirty.Load()) | |||
| 270 | + | ||||
| 271 | + | close(release) | |||
| 272 | + | awaitSnapshotClean(t, first) | |||
| 273 | + | awaitSnapshotClean(t, second) | |||
| 274 | + | require.True(t, d.scheduleSnapshot(third)) | |||
| 275 | + | <-store.writes | |||
| 276 | + | awaitSnapshotClean(t, third) | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func TestNamedFinalSnapshotSurvivesSaturatedQueue(t *testing.T) { | |||
| 280 | + | for _, tc := range []struct { | |||
| 281 | + | name string | |||
| 282 | + | stop func(t *testing.T, d *Daemon, sess *session) | |||
| 283 | + | }{ | |||
| 284 | + | { | |||
| 285 | + | name: "kill session", | |||
| 286 | + | stop: func(t *testing.T, d *Daemon, sess *session) { | |||
| 287 | + | t.Helper() | |||
| 288 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |||
| 289 | + | }, | |||
| 290 | + | }, | |||
| 291 | + | { | |||
| 292 | + | name: "shutdown all", | |||
| 293 | + | stop: func(t *testing.T, d *Daemon, _ *session) { | |||
| 294 | + | t.Helper() | |||
| 295 | + | d.shutdownAll(ports.ReasonServerShutdown) | |||
| 296 | + | }, | |||
| 297 | + | }, | |||
| 298 | + | } { | |||
| 299 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 300 | + | store := &channelSnapshotStore{writes: make(chan []byte, 3)} | |||
| 301 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 302 | + | WithSnapshotStore(store)(d) | |||
| 303 | + | startSnapshotEncodeWorker(t, d) | |||
| 304 | + | ||||
| 305 | + | entered := make(chan struct{}) | |||
| 306 | + | release := make(chan struct{}) | |||
| 307 | + | var calls atomic.Int32 | |||
| 308 | + | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 309 | + | if calls.Add(1) == 1 { | |||
| 310 | + | close(entered) | |||
| 311 | + | <-release | |||
| 312 | + | } | |||
| 313 | + | return snapcodec.Marshal(s) | |||
| 314 | + | } | |||
| 315 | + | first := newSnapshotTestSession(t, "first", false, "/work") | |||
| 316 | + | second := newSnapshotTestSession(t, "second", false, "/work") | |||
| 317 | + | final := newSnapshotTestSession(t, "final", false, "/work") | |||
| 318 | + | finalPTY, ok := final.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 319 | + | require.True(t, ok) | |||
| 320 | + | finalPTY.EXPECT().Close().Return(nil).Once() | |||
| 321 | + | markSnapshotDirty(first) | |||
| 322 | + | markSnapshotDirty(second) | |||
| 323 | + | require.True(t, d.scheduleSnapshot(first)) | |||
| 324 | + | <-entered | |||
| 325 | + | require.True(t, d.scheduleSnapshot(second)) | |||
| 326 | + | d.sessions[final.id] = final | |||
| 327 | + | ||||
| 328 | + | tc.stop(t, d, final) | |||
| 329 | + | require.NotContains(t, d.sessions, final.id, "final capture must survive session removal") | |||
| 330 | + | close(release) | |||
| 331 | + | ||||
| 332 | + | written := make(map[string]bool, 3) | |||
| 333 | + | deadline := time.NewTimer(time.Second) | |||
| 334 | + | defer deadline.Stop() | |||
| 335 | + | for len(written) < 3 { | |||
| 336 | + | select { | |||
| 337 | + | case data := <-store.writes: | |||
| 338 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 339 | + | require.NoError(t, err) | |||
| 340 | + | written[snap.Name] = true | |||
| 341 | + | case <-deadline.C: | |||
| 342 | + | t.Fatalf("final snapshot was dropped: wrote %v", written) | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | require.True(t, written[final.name]) | |||
| 346 | + | }) | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | func TestSnapshotCompletionSignalsWaiters(t *testing.T) { | |||
| 351 | + | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 352 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 353 | + | WithSnapshotStore(store)(d) | |||
| 354 | + | startSnapshotEncodeWorker(t, d) | |||
| 355 | + | ||||
| 356 | + | entered := make(chan struct{}) | |||
| 357 | + | release := make(chan struct{}) | |||
| 358 | + | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 359 | + | close(entered) | |||
| 360 | + | <-release | |||
| 361 | + | return snapcodec.Marshal(s) | |||
| 362 | + | } | |||
| 363 | + | sess := newSnapshotTestSession(t, "completion", false, "/work") | |||
| 364 | + | require.True(t, d.captureSession(sess)) | |||
| 365 | + | <-entered | |||
| 366 | + | ||||
| 367 | + | sess.snapshotMu.Lock() | |||
| 368 | + | changed := sess.snapshotChangeLocked() | |||
| 369 | + | sess.snapshotMu.Unlock() | |||
| 370 | + | close(release) | |||
| 371 | + | <-changed | |||
| 372 | + | awaitSnapshotClean(t, sess) | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | func TestSnapshotSuccessDoesNotClearNewerGeneration(t *testing.T) { | |||
| 376 | + | store := &channelSnapshotStore{writes: make(chan []byte, 2)} | |||
| 377 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 378 | + | WithSnapshotStore(store)(d) | |||
| 379 | + | startSnapshotEncodeWorker(t, d) | |||
| 380 | + | entered := make(chan struct{}) | |||
| 381 | + | release := make(chan struct{}) | |||
| 382 | + | var calls atomic.Int32 | |||
| 383 | + | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 384 | + | if calls.Add(1) == 1 { | |||
| 385 | + | close(entered) | |||
| 386 | + | <-release | |||
| 387 | + | } | |||
| 388 | + | return snapcodec.Marshal(s) | |||
| 389 | + | } | |||
| 390 | + | sess := newSnapshotTestSession(t, "generation", false, "/work") | |||
| 391 | + | ||||
| 392 | + | markSnapshotDirty(sess) | |||
| 393 | + | require.True(t, d.scheduleSnapshot(sess)) | |||
| 394 | + | <-entered | |||
| 395 | + | markSnapshotDirty(sess) | |||
| 396 | + | close(release) | |||
| 397 | + | <-store.writes | |||
| 398 | + | awaitSnapshotIdle(t, sess) | |||
| 399 | + | require.True(t, sess.snapDirty.Load(), "a stale successful capture must not clear newer state") | |||
| 400 | + | ||||
| 401 | + | require.True(t, d.scheduleSnapshot(sess)) | |||
| 402 | + | <-store.writes | |||
| 403 | + | awaitSnapshotClean(t, sess) | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func TestSnapshotEncodeFailureLeavesSessionDirtyAndRetries(t *testing.T) { | |||
| 407 | + | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 408 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 409 | + | WithSnapshotStore(store)(d) | |||
| 410 | + | startSnapshotEncodeWorker(t, d) | |||
| 411 | + | sess := newSnapshotTestSession(t, "retry-encode", false, "/work") | |||
| 412 | + | failed := make(chan struct{}, 1) | |||
| 413 | + | var calls atomic.Int32 | |||
| 414 | + | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 415 | + | if calls.Add(1) == 1 { | |||
| 416 | + | failed <- struct{}{} | |||
| 417 | + | return nil, errors.New("encode failed") | |||
| 418 | + | } | |||
| 419 | + | return snapcodec.Marshal(s) | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | require.True(t, d.captureSession(sess)) | |||
| 423 | + | <-failed | |||
| 424 | + | awaitSnapshotIdle(t, sess) | |||
| 425 | + | require.True(t, sess.snapDirty.Load()) | |||
| 426 | + | require.True(t, d.scheduleSnapshot(sess)) | |||
| 427 | + | <-store.writes | |||
| 428 | + | awaitSnapshotClean(t, sess) | |||
| 429 | + | } | |||
| 430 | + | ||||
| 23 | func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | 431 | func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | ||
| 24 | clock := newManualSnapshotClock() | 432 | clock := newManualSnapshotClock() | ||
| 25 | store := portsmocks.NewMockSnapshotStore(t) | 433 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 26 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | 434 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | ||
| 27 | WithSnapshotStore(store)(d) | 435 | WithSnapshotStore(store)(d) | ||
| 28 | d.procCwd = func(int) (string, error) { return "/pane", nil } | 436 | d.procCwd = func(int) (string, error) { return "/pane", nil } | ||
| 437 | + | startSnapshotEncodeWorker(t, d) | |||
| 29 | 438 | ||||
| 30 | dirty := newSnapshotTestSession(t, "dirty", false, "/fallback") | 439 | dirty := newSnapshotTestSession(t, "dirty", false, "/fallback") | ||
| 31 | clean := newSnapshotTestSession(t, "clean", false, "/fallback") | 440 | clean := newSnapshotTestSession(t, "clean", false, "/fallback") | ||
| @@ -67,6 +476,7 @@ func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) { | |||||
| 67 | store := portsmocks.NewMockSnapshotStore(t) | 476 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 68 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 477 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 69 | WithSnapshotStore(store)(d) | 478 | WithSnapshotStore(store)(d) | ||
| 479 | + | startSnapshotEncodeWorker(t, d) | |||
| 70 | sess := newSnapshotTestSession(t, "work", false, "/fallback") | 480 | sess := newSnapshotTestSession(t, "work", false, "/fallback") | ||
| 71 | tb := sess.tabs[0] | 481 | tb := sess.tabs[0] | ||
| 72 | floatingPTY, _ := newBlockingPTY(t) | 482 | floatingPTY, _ := newBlockingPTY(t) | ||
| @@ -85,12 +495,14 @@ func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) { | |||||
| 85 | return nil | 495 | return nil | ||
| 86 | }).Once() | 496 | }).Once() | ||
| 87 | require.True(t, d.captureSession(sess)) | 497 | require.True(t, d.captureSession(sess)) | ||
| 498 | + | awaitSnapshotClean(t, sess) | |||
| 88 | } | 499 | } | ||
| 89 | 500 | ||||
| 90 | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | 501 | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | ||
| 91 | store := portsmocks.NewMockSnapshotStore(t) | 502 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 92 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 503 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 93 | WithSnapshotStore(store)(d) | 504 | WithSnapshotStore(store)(d) | ||
| 505 | + | startSnapshotEncodeWorker(t, d) | |||
| 94 | d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) { | 506 | d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) { | ||
| 95 | require.Equal(t, 4321, pgid) | 507 | require.Equal(t, 4321, pgid) | ||
| 96 | require.Equal(t, 1234, shellPid) | 508 | require.Equal(t, 1234, shellPid) | ||
| @@ -113,12 +525,14 @@ func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | |||||
| 113 | }).Once() | 525 | }).Once() | ||
| 114 | 526 | ||||
| 115 | require.True(t, d.captureSession(sess)) | 527 | require.True(t, d.captureSession(sess)) | ||
| 528 | + | awaitSnapshotClean(t, sess) | |||
| 116 | } | 529 | } | ||
| 117 | 530 | ||||
| 118 | func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) { | 531 | func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) { | ||
| 119 | store := portsmocks.NewMockSnapshotStore(t) | 532 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 120 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 533 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 121 | WithSnapshotStore(store)(d) | 534 | WithSnapshotStore(store)(d) | ||
| 535 | + | startSnapshotEncodeWorker(t, d) | |||
| 122 | d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") } | 536 | d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") } | ||
| 123 | 537 | ||||
| 124 | sess := newSnapshotTestSession(t, "work", false, "/fallback") | 538 | sess := newSnapshotTestSession(t, "work", false, "/fallback") | ||
| @@ -133,12 +547,14 @@ func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) { | |||||
| 133 | }).Once() | 547 | }).Once() | ||
| 134 | 548 | ||||
| 135 | require.True(t, d.captureSession(sess)) | 549 | require.True(t, d.captureSession(sess)) | ||
| 550 | + | awaitSnapshotClean(t, sess) | |||
| 136 | } | 551 | } | ||
| 137 | 552 | ||||
| 138 | func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) { | 553 | func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) { | ||
| 139 | store := portsmocks.NewMockSnapshotStore(t) | 554 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 140 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 555 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 141 | WithSnapshotStore(store)(d) | 556 | WithSnapshotStore(store)(d) | ||
| 557 | + | startSnapshotEncodeWorker(t, d) | |||
| 142 | d.procGroupArgv = func(int, int) ([]string, error) { | 558 | d.procGroupArgv = func(int, int) ([]string, error) { | ||
| 143 | t.Fatal("bare shell should not inspect argv") | 559 | t.Fatal("bare shell should not inspect argv") | ||
| 144 | return nil, nil | 560 | return nil, nil | ||
| @@ -156,35 +572,37 @@ func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) { | |||||
| 156 | }).Once() | 572 | }).Once() | ||
| 157 | 573 | ||||
| 158 | require.True(t, d.captureSession(sess)) | 574 | require.True(t, d.captureSession(sess)) | ||
| 575 | + | awaitSnapshotClean(t, sess) | |||
| 159 | } | 576 | } | ||
| 160 | 577 | ||||
| 161 | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | 578 | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | ||
| 162 | store := portsmocks.NewMockSnapshotStore(t) | 579 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 163 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 580 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 164 | WithSnapshotStore(store)(d) | 581 | WithSnapshotStore(store)(d) | ||
| 582 | + | startSnapshotEncodeWorker(t, d) | |||
| 165 | d.procCwd = func(int) (string, error) { return "/live", nil } | 583 | d.procCwd = func(int) (string, error) { return "/live", nil } | ||
| 166 | 584 | ||||
| 167 | sess := newSnapshotTestSession(t, "work", false, "/fallback") | 585 | sess := newSnapshotTestSession(t, "work", false, "/fallback") | ||
| 168 | d.sessions[sess.id] = sess | 586 | d.sessions[sess.id] = sess | ||
| 169 | 587 | ||||
| 170 | - | var wrote atomic.Bool | 588 | + | var closed atomic.Bool |
| 171 | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | 589 | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | ||
| 172 | snap, err := snapcodec.Unmarshal(data) | 590 | snap, err := snapcodec.Unmarshal(data) | ||
| 173 | require.NoError(t, err) | 591 | require.NoError(t, err) | ||
| 174 | require.Equal(t, "work", snap.Name) | 592 | require.Equal(t, "work", snap.Name) | ||
| 175 | require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd) | 593 | require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd) | ||
| 176 | - | wrote.Store(true) | 594 | + | require.True(t, closed.Load(), "snapshot persistence must not delay pane close") |
| 177 | return nil | 595 | return nil | ||
| 178 | }).Once() | 596 | }).Once() | ||
| 179 | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | 597 | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | ||
| 180 | require.True(t, ok) | 598 | require.True(t, ok) | ||
| 181 | mockPTY.EXPECT().Close().RunAndReturn(func() error { | 599 | mockPTY.EXPECT().Close().RunAndReturn(func() error { | ||
| 182 | - | require.True(t, wrote.Load(), "snapshot Write must happen before pane Close") | 600 | + | closed.Store(true) |
| 183 | return nil | 601 | return nil | ||
| 184 | }).Once() | 602 | }).Once() | ||
| 185 | 603 | ||||
| 186 | require.NoError(t, d.killSession(sess, 0, false)) | 604 | require.NoError(t, d.killSession(sess, 0, false)) | ||
| 187 | - | require.True(t, wrote.Load()) | 605 | + | awaitSnapshotClean(t, sess) |
| 188 | } | 606 | } | ||
| 189 | 607 | ||||
| 190 | func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) { | 608 | func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) { | ||
| @@ -241,12 +659,17 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | |||||
| 241 | store := portsmocks.NewMockSnapshotStore(t) | 659 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 242 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | 660 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | ||
| 243 | WithSnapshotStore(store)(d) | 661 | WithSnapshotStore(store)(d) | ||
| 662 | + | startSnapshotEncodeWorker(t, d) | |||
| 244 | 663 | ||||
| 245 | sess := newSnapshotTestSession(t, "retry", false, "/fallback") | 664 | sess := newSnapshotTestSession(t, "retry", false, "/fallback") | ||
| 246 | sess.snapDirty.Store(true) | 665 | sess.snapDirty.Store(true) | ||
| 247 | d.sessions[sess.id] = sess | 666 | d.sessions[sess.id] = sess | ||
| 248 | 667 | ||||
| 249 | - | store.EXPECT().Write("retry", mock.Anything).Return(errors.New("boom")).Once() | 668 | + | failed := make(chan struct{}, 1) |
| 669 | + | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | |||
| 670 | + | failed <- struct{}{} | |||
| 671 | + | return errors.New("boom") | |||
| 672 | + | }).Once() | |||
| 250 | wrote := make(chan struct{}, 1) | 673 | wrote := make(chan struct{}, 1) | ||
| 251 | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | 674 | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | ||
| 252 | wrote <- struct{}{} | 675 | wrote <- struct{}{} | ||
| @@ -257,7 +680,9 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | |||||
| 257 | defer cancel() | 680 | defer cancel() | ||
| 258 | go d.snapshotSaver(ctx) | 681 | go d.snapshotSaver(ctx) | ||
| 259 | clock.fireNext(t) | 682 | clock.fireNext(t) | ||
| 260 | - | require.Eventually(t, func() bool { return sess.snapDirty.Load() }, time.Second, time.Millisecond) | 683 | + | <-failed |
| 684 | + | awaitSnapshotIdle(t, sess) | |||
| 685 | + | require.True(t, sess.snapDirty.Load()) | |||
| 261 | clock.fireNext(t) | 686 | clock.fireNext(t) | ||
| 262 | select { | 687 | select { | ||
| 263 | case <-wrote: | 688 | case <-wrote: | ||
| @@ -365,6 +790,7 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) { | |||||
| 365 | factory := portsmocks.NewMockPTYFactory(t) | 790 | factory := portsmocks.NewMockPTYFactory(t) | ||
| 366 | d := newTestDaemon(t, factory, clock) | 791 | d := newTestDaemon(t, factory, clock) | ||
| 367 | WithSnapshotStore(store)(d) | 792 | WithSnapshotStore(store)(d) | ||
| 793 | + | startSnapshotEncodeWorker(t, d) | |||
| 368 | 794 | ||||
| 369 | sess := newSnapshotTestSession(t, "work", false, "/work") | 795 | sess := newSnapshotTestSession(t, "work", false, "/work") | ||
| 370 | tctx, tcancel := context.WithCancel(context.Background()) | 796 | tctx, tcancel := context.WithCancel(context.Background()) | ||
| @@ -434,7 +860,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||||
| 434 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | 860 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | ||
| 435 | p := tb.panes["pane-1"] | 861 | p := tb.panes["pane-1"] | ||
| 436 | p.screen.Write([]byte("hello")) | 862 | p.screen.Write([]byte("hello")) | ||
| 437 | - | p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | 863 | + | p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) |
| 438 | sess := &session{ | 864 | sess := &session{ | ||
| 439 | id: domain.SessionID("sess-" + name), | 865 | id: domain.SessionID("sess-" + name), | ||
| 440 | name: name, | 866 | name: name, | ||
| @@ -450,6 +876,91 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||||
| 450 | return sess | 876 | return sess | ||
| 451 | } | 877 | } | ||
| 452 | 878 | ||||
| 879 | + | func awaitSnapshotIdle(t *testing.T, sess *session) { | |||
| 880 | + | t.Helper() | |||
| 881 | + | for { | |||
| 882 | + | sess.snapshotMu.Lock() | |||
| 883 | + | pending := sess.snapshotPending | |||
| 884 | + | changed := sess.snapshotChangeLocked() | |||
| 885 | + | sess.snapshotMu.Unlock() | |||
| 886 | + | if !pending { | |||
| 887 | + | return | |||
| 888 | + | } | |||
| 889 | + | <-changed | |||
| 890 | + | } | |||
| 891 | + | } | |||
| 892 | + | ||||
| 893 | + | func awaitSnapshotClean(t *testing.T, sess *session) { | |||
| 894 | + | t.Helper() | |||
| 895 | + | for { | |||
| 896 | + | sess.snapshotMu.Lock() | |||
| 897 | + | clean := !sess.snapDirty.Load() | |||
| 898 | + | changed := sess.snapshotChangeLocked() | |||
| 899 | + | sess.snapshotMu.Unlock() | |||
| 900 | + | if clean { | |||
| 901 | + | return | |||
| 902 | + | } | |||
| 903 | + | <-changed | |||
| 904 | + | } | |||
| 905 | + | } | |||
| 906 | + | ||||
| 907 | + | func startSnapshotEncodeWorker(t *testing.T, d *Daemon) { | |||
| 908 | + | t.Helper() | |||
| 909 | + | d.startSnapshotEncodeWorker() | |||
| 910 | + | t.Cleanup(d.stopSnapshotEncodeWorker) | |||
| 911 | + | } | |||
| 912 | + | ||||
| 913 | + | type gatedSnapshotStore struct { | |||
| 914 | + | entered chan struct{} | |||
| 915 | + | release chan struct{} | |||
| 916 | + | once sync.Once | |||
| 917 | + | releaseOnce sync.Once | |||
| 918 | + | } | |||
| 919 | + | ||||
| 920 | + | func newGatedSnapshotStore() *gatedSnapshotStore { | |||
| 921 | + | return &gatedSnapshotStore{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 922 | + | } | |||
| 923 | + | ||||
| 924 | + | func (s *gatedSnapshotStore) enter() { s.once.Do(func() { close(s.entered) }) } | |||
| 925 | + | func (s *gatedSnapshotStore) unblock() { s.releaseOnce.Do(func() { close(s.release) }) } | |||
| 926 | + | ||||
| 927 | + | func (s *gatedSnapshotStore) Write(_ string, _ []byte) error { | |||
| 928 | + | s.enter() | |||
| 929 | + | <-s.release | |||
| 930 | + | return nil | |||
| 931 | + | } | |||
| 932 | + | func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |||
| 933 | + | func (*gatedSnapshotStore) Delete(string) error { return nil } | |||
| 934 | + | ||||
| 935 | + | type neverReturningSnapshotStore struct { | |||
| 936 | + | entered chan struct{} | |||
| 937 | + | once sync.Once | |||
| 938 | + | block chan struct{} | |||
| 939 | + | } | |||
| 940 | + | ||||
| 941 | + | func newNeverReturningSnapshotStore() *neverReturningSnapshotStore { | |||
| 942 | + | return &neverReturningSnapshotStore{entered: make(chan struct{}), block: make(chan struct{})} | |||
| 943 | + | } | |||
| 944 | + | ||||
| 945 | + | func (s *neverReturningSnapshotStore) Write(string, []byte) error { | |||
| 946 | + | s.once.Do(func() { close(s.entered) }) | |||
| 947 | + | <-s.block | |||
| 948 | + | return nil | |||
| 949 | + | } | |||
| 950 | + | func (*neverReturningSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |||
| 951 | + | func (*neverReturningSnapshotStore) Delete(string) error { return nil } | |||
| 952 | + | ||||
| 953 | + | type channelSnapshotStore struct { | |||
| 954 | + | writes chan []byte | |||
| 955 | + | } | |||
| 956 | + | ||||
| 957 | + | func (s *channelSnapshotStore) Write(_ string, data []byte) error { | |||
| 958 | + | s.writes <- append([]byte(nil), data...) | |||
| 959 | + | return nil | |||
| 960 | + | } | |||
| 961 | + | func (*channelSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |||
| 962 | + | func (*channelSnapshotStore) Delete(string) error { return nil } | |||
| 963 | + | ||||
| 453 | type manualSnapshotClock struct { | 964 | type manualSnapshotClock struct { | ||
| 454 | mu sync.Mutex | 965 | mu sync.Mutex | ||
| 455 | timers []*manualSnapshotTimer | 966 | timers []*manualSnapshotTimer | ||
| @@ -487,6 +998,19 @@ func (c *manualSnapshotClock) fireNext(t *testing.T) { | |||||
| 487 | } | 998 | } | ||
| 488 | } | 999 | } | ||
| 489 | 1000 | ||||
| 1001 | + | type finalFlushClock struct{ timers chan *manualSnapshotTimer } | |||
| 1002 | + | ||||
| 1003 | + | func newFinalFlushClock() *finalFlushClock { | |||
| 1004 | + | return &finalFlushClock{timers: make(chan *manualSnapshotTimer, 1)} | |||
| 1005 | + | } | |||
| 1006 | + | ||||
| 1007 | + | func (c *finalFlushClock) Now() time.Time { return time.Unix(0, 0) } | |||
| 1008 | + | func (c *finalFlushClock) NewTimer(time.Duration) ports.Timer { | |||
| 1009 | + | t := &manualSnapshotTimer{ch: make(chan time.Time, 1)} | |||
| 1010 | + | c.timers <- t | |||
| 1011 | + | return t | |||
| 1012 | + | } | |||
| 1013 | + | ||||
| 490 | type manualSnapshotTimer struct{ ch chan time.Time } | 1014 | type manualSnapshotTimer struct{ ch chan time.Time } | ||
| 491 | 1015 | ||||
| 492 | func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch } | 1016 | func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch } | ||
A internal/usecase/snapshot/benchmark_test.go +122 -0
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/bnema/vev/pkg/vt" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | var ( | |||
| 12 | + | benchmarkV3BytesSink []byte | |||
| 13 | + | benchmarkV3SessionSink Session | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func BenchmarkSnapshotV3Marshal10KRows(b *testing.B) { | |||
| 17 | + | session, encoded := benchmarkV3Session(b) | |||
| 18 | + | b.ReportAllocs() | |||
| 19 | + | b.SetBytes(int64(len(encoded))) | |||
| 20 | + | b.ResetTimer() | |||
| 21 | + | for b.Loop() { | |||
| 22 | + | var err error | |||
| 23 | + | benchmarkV3BytesSink, err = Marshal(session) | |||
| 24 | + | if err != nil { | |||
| 25 | + | b.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | b.StopTimer() | |||
| 29 | + | if _, err := Unmarshal(benchmarkV3BytesSink); err != nil { | |||
| 30 | + | b.Fatalf("marshal produced an invalid v3 snapshot: %v", err) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func BenchmarkSnapshotV3Unmarshal10KRows(b *testing.B) { | |||
| 35 | + | _, encoded := benchmarkV3Session(b) | |||
| 36 | + | b.ReportAllocs() | |||
| 37 | + | b.SetBytes(int64(len(encoded))) | |||
| 38 | + | b.ResetTimer() | |||
| 39 | + | for b.Loop() { | |||
| 40 | + | var err error | |||
| 41 | + | benchmarkV3SessionSink, err = Unmarshal(encoded) | |||
| 42 | + | if err != nil { | |||
| 43 | + | b.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | b.StopTimer() | |||
| 47 | + | if len(benchmarkV3SessionSink.Tabs) != 1 || len(benchmarkV3SessionSink.Tabs[0].Panes) != 1 || len(benchmarkV3SessionSink.Tabs[0].Panes[0].SealedChunks) != 40 { | |||
| 48 | + | b.Fatal("unmarshal lost canonical 10k-row snapshot data") | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func benchmarkV3Session(t testing.TB) (Session, []byte) { | |||
| 53 | + | t.Helper() | |||
| 54 | + | const ( | |||
| 55 | + | cols = 120 | |||
| 56 | + | rows = 40 | |||
| 57 | + | historyRows = 10_000 | |||
| 58 | + | chunkRows = 256 | |||
| 59 | + | ) | |||
| 60 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: historyRows, ChunkRows: chunkRows}) | |||
| 61 | + | for row := range historyRows { | |||
| 62 | + | cells := make([]renderer.Cell, cols) | |||
| 63 | + | for col := range cells { | |||
| 64 | + | cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)} | |||
| 65 | + | } | |||
| 66 | + | history.Append(cells) | |||
| 67 | + | } | |||
| 68 | + | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | |||
| 69 | + | if err != nil { | |||
| 70 | + | t.Fatal(err) | |||
| 71 | + | } | |||
| 72 | + | visibleFrame := renderer.NewFrame(cols, rows) | |||
| 73 | + | for y := range rows { | |||
| 74 | + | for x := range cols { | |||
| 75 | + | visibleFrame.Set(x, y, renderer.Cell{Rune: rune('A' + (x+y)%26)}) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | visible, err := vt.MarshalVisible(visibleFrame) | |||
| 79 | + | if err != nil { | |||
| 80 | + | t.Fatal(err) | |||
| 81 | + | } | |||
| 82 | + | session := Session{ | |||
| 83 | + | Name: "benchmark", | |||
| 84 | + | CreatedAt: 1, | |||
| 85 | + | Tabs: []Tab{{ | |||
| 86 | + | StableID: "tab-1", | |||
| 87 | + | Cols: cols, | |||
| 88 | + | Rows: rows, | |||
| 89 | + | NextPaneID: 2, | |||
| 90 | + | Focus: "pane-1", | |||
| 91 | + | Tree: layout.NewTree("pane-1"), | |||
| 92 | + | Panes: []Pane{{ | |||
| 93 | + | ID: "pane-1", | |||
| 94 | + | StableID: "pane-1", | |||
| 95 | + | Cwd: "/benchmark", | |||
| 96 | + | SealedChunks: sealed, | |||
| 97 | + | Tail: tail, | |||
| 98 | + | Visible: visible, | |||
| 99 | + | }}, | |||
| 100 | + | }}, | |||
| 101 | + | } | |||
| 102 | + | encoded, err := Marshal(session) | |||
| 103 | + | if err != nil { | |||
| 104 | + | t.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | decoded, err := Unmarshal(encoded) | |||
| 107 | + | if err != nil { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | paneCount := 0 | |||
| 111 | + | decodedChunks := 0 | |||
| 112 | + | if len(decoded.Tabs) == 1 { | |||
| 113 | + | paneCount = len(decoded.Tabs[0].Panes) | |||
| 114 | + | if paneCount == 1 { | |||
| 115 | + | decodedChunks = len(decoded.Tabs[0].Panes[0].SealedChunks) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | if len(sealed) != (historyRows+chunkRows-1)/chunkRows || paneCount != 1 || decodedChunks != len(sealed) { | |||
| 119 | + | t.Fatalf("invalid v3 benchmark fixture: chunks=%d tabs=%d panes=%d decoded chunks=%d", len(sealed), len(decoded.Tabs), paneCount, decodedChunks) | |||
| 120 | + | } | |||
| 121 | + | return session, encoded | |||
| 122 | + | } | |||
M internal/usecase/snapshot/codec.go +84 -313
| @@ -1,17 +1,13 @@ | |||||
| 1 | package 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" | |||
| 15 | ) | 11 | ) | ||
| 16 | 12 | ||||
| 17 | var ( | 13 | var ( | ||
| @@ -20,60 +16,44 @@ var ( | |||||
| 20 | ErrBadCRC = errors.New("snapshot: bad crc") | 16 | ErrBadCRC = errors.New("snapshot: bad crc") | ||
| 21 | ErrShortPayload = errors.New("snapshot: payload too short") | 17 | ErrShortPayload = errors.New("snapshot: payload too short") | ||
| 22 | ErrTrailingBytes = errors.New("snapshot: trailing bytes") | 18 | ErrTrailingBytes = errors.New("snapshot: trailing bytes") | ||
| 23 | - | ErrStyleOOB = errors.New("snapshot: style index out of range") | |||
| 24 | ErrUnknownPane = errors.New("snapshot: tree references unknown pane") | 19 | ErrUnknownPane = errors.New("snapshot: tree references unknown pane") | ||
| 25 | ErrInvalidData = errors.New("snapshot: invalid data") | 20 | ErrInvalidData = errors.New("snapshot: invalid data") | ||
| 26 | ) | 21 | ) | ||
| 27 | 22 | ||||
| 28 | const ( | 23 | const ( | ||
| 29 | - | magic = "VEVS" | 24 | + | magic = "VEVS" |
| 30 | - | version = uint16(2) | 25 | + | version = uint16(3) |
| 31 | - | flagFlate = uint16(1 << 0) | 26 | + | maxDecodedBodySize = 256 << 20 |
| 32 | - | flateCutoff = 4 << 10 | 27 | + | maxSnapshotBlobs = 1 << 16 |
| 33 | - | maxDecodedBodySize = 256 << 20 | 28 | + | maxSnapshotBytes = 256 << 20 |
| 34 | - | maxDecodedRows = 1 << 20 | 29 | + | maxSnapshotRows = 1 << 20 |
| 35 | - | maxDecodedRuns = 16 << 20 | 30 | + | maxSnapshotCells = 16 << 20 |
| 36 | - | maxDecodedCells = 16 << 20 | 31 | + | maxSnapshotTreeNodes = 1 << 15 |
| 32 | + | // 256 MiB accommodates a full 10k-row history at practical terminal widths | |||
| 33 | + | // while bounding hostile manifests before decoded slices are allocated. | |||
| 34 | + | maxSnapshotDecodedAllocation = 256 << 20 | |||
| 37 | ) | 35 | ) | ||
| 38 | 36 | ||||
| 39 | - | // Marshal encodes a v2 durable snapshot. | 37 | + | // Marshal encodes the strict v3, flags-zero durable envelope. |
| 40 | func Marshal(s Session) ([]byte, error) { | 38 | func Marshal(s Session) ([]byte, error) { | ||
| 41 | var w payloadWriter | 39 | var w payloadWriter | ||
| 42 | if err := writeSession(&w, s); err != nil { | 40 | if err := writeSession(&w, s); err != nil { | ||
| 43 | return nil, err | 41 | return nil, err | ||
| 44 | } | 42 | } | ||
| 45 | - | body := w.b | 43 | + | if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 { |
| 46 | - | flags := uint16(0) | |||
| 47 | - | if len(body) > flateCutoff { | |||
| 48 | - | var compressed bytes.Buffer | |||
| 49 | - | fw, err := flate.NewWriter(&compressed, flate.DefaultCompression) | |||
| 50 | - | if err != nil { | |||
| 51 | - | return nil, err | |||
| 52 | - | } | |||
| 53 | - | if _, err := fw.Write(body); err != nil { | |||
| 54 | - | _ = fw.Close() | |||
| 55 | - | return nil, err | |||
| 56 | - | } | |||
| 57 | - | if err := fw.Close(); err != nil { | |||
| 58 | - | return nil, err | |||
| 59 | - | } | |||
| 60 | - | body = compressed.Bytes() | |||
| 61 | - | flags |= flagFlate | |||
| 62 | - | } | |||
| 63 | - | if len(body) > math.MaxUint32 { | |||
| 64 | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | 44 | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | ||
| 65 | } | 45 | } | ||
| 66 | - | out := make([]byte, 16+len(body)) | 46 | + | out := make([]byte, 16+len(w.b)) |
| 67 | copy(out[:4], magic) | 47 | copy(out[:4], magic) | ||
| 68 | binary.BigEndian.PutUint16(out[4:6], version) | 48 | binary.BigEndian.PutUint16(out[4:6], version) | ||
| 69 | - | binary.BigEndian.PutUint16(out[6:8], flags) | 49 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b))) |
| 70 | - | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | 50 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b)) |
| 71 | - | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | 51 | + | copy(out[16:], w.b) |
| 72 | - | copy(out[16:], body) | |||
| 73 | return out, nil | 52 | return out, nil | ||
| 74 | } | 53 | } | ||
| 75 | 54 | ||||
| 76 | - | // Unmarshal decodes a strict v2 durable snapshot. | 55 | + | // Unmarshal validates the complete manifest and all VT blobs without |
| 56 | + | // allocations first, then decodes the already preflighted data. | |||
| 77 | func Unmarshal(b []byte) (Session, error) { | 57 | func Unmarshal(b []byte) (Session, error) { | ||
| 78 | if len(b) < 16 { | 58 | if len(b) < 16 { | ||
| 79 | return Session{}, ErrShortPayload | 59 | return Session{}, ErrShortPayload | ||
| @@ -84,38 +64,25 @@ func Unmarshal(b []byte) (Session, error) { | |||||
| 84 | if binary.BigEndian.Uint16(b[4:6]) != version { | 64 | if binary.BigEndian.Uint16(b[4:6]) != version { | ||
| 85 | return Session{}, ErrBadVersion | 65 | return Session{}, ErrBadVersion | ||
| 86 | } | 66 | } | ||
| 87 | - | flags := binary.BigEndian.Uint16(b[6:8]) | 67 | + | if binary.BigEndian.Uint16(b[6:8]) != 0 { |
| 88 | - | if flags&^flagFlate != 0 { | 68 | + | return Session{}, fmt.Errorf("%w: flags", ErrInvalidData) |
| 89 | - | return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData) | |||
| 90 | } | 69 | } | ||
| 91 | - | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | 70 | + | bodyLen := binary.BigEndian.Uint32(b[8:12]) |
| 92 | if bodyLen > maxDecodedBodySize { | 71 | if bodyLen > maxDecodedBodySize { | ||
| 93 | return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData) | 72 | return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData) | ||
| 94 | } | 73 | } | ||
| 95 | - | if len(b) < 16+bodyLen { | 74 | + | if uint64(len(b)) < 16+uint64(bodyLen) { |
| 96 | return Session{}, ErrShortPayload | 75 | return Session{}, ErrShortPayload | ||
| 97 | } | 76 | } | ||
| 98 | - | if len(b) != 16+bodyLen { | 77 | + | if uint64(len(b)) != 16+uint64(bodyLen) { |
| 99 | return Session{}, ErrTrailingBytes | 78 | return Session{}, ErrTrailingBytes | ||
| 100 | } | 79 | } | ||
| 101 | body := b[16:] | 80 | body := b[16:] | ||
| 102 | if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) { | 81 | if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) { | ||
| 103 | return Session{}, ErrBadCRC | 82 | return Session{}, ErrBadCRC | ||
| 104 | } | 83 | } | ||
| 105 | - | if flags&flagFlate != 0 { | 84 | + | if err := preflightSession(body); err != nil { |
| 106 | - | fr := flate.NewReader(bytes.NewReader(body)) | 85 | + | return Session{}, err |
| 107 | - | decompressed, err := io.ReadAll(io.LimitReader(fr, maxDecodedBodySize+1)) | |||
| 108 | - | cerr := fr.Close() | |||
| 109 | - | if err != nil { | |||
| 110 | - | return Session{}, err | |||
| 111 | - | } | |||
| 112 | - | if cerr != nil { | |||
| 113 | - | return Session{}, cerr | |||
| 114 | - | } | |||
| 115 | - | if len(decompressed) > maxDecodedBodySize { | |||
| 116 | - | return Session{}, fmt.Errorf("%w: decompressed body too large", ErrInvalidData) | |||
| 117 | - | } | |||
| 118 | - | body = decompressed | |||
| 119 | } | 86 | } | ||
| 120 | r := payloadReader{b: body} | 87 | r := payloadReader{b: body} | ||
| 121 | s, err := readSession(&r) | 88 | s, err := readSession(&r) | ||
| @@ -240,6 +207,18 @@ func (r *payloadReader) getStrings() ([]string, error) { | |||||
| 240 | } | 207 | } | ||
| 241 | return out, nil | 208 | return out, nil | ||
| 242 | } | 209 | } | ||
| 210 | + | func (r *payloadReader) getBlob() ([]byte, error) { | |||
| 211 | + | n, err := r.getUint32() | |||
| 212 | + | if err != nil { | |||
| 213 | + | return nil, err | |||
| 214 | + | } | |||
| 215 | + | if uint64(n) > uint64(len(r.b)) { | |||
| 216 | + | return nil, ErrShortPayload | |||
| 217 | + | } | |||
| 218 | + | blob := append([]byte(nil), r.b[:n]...) | |||
| 219 | + | r.b = r.b[n:] | |||
| 220 | + | return blob, nil | |||
| 221 | + | } | |||
| 243 | func (r *payloadReader) done() error { | 222 | func (r *payloadReader) done() error { | ||
| 244 | if len(r.b) != 0 { | 223 | if len(r.b) != 0 { | ||
| 245 | return ErrTrailingBytes | 224 | return ErrTrailingBytes | ||
| @@ -248,14 +227,17 @@ func (r *payloadReader) done() error { | |||||
| 248 | } | 227 | } | ||
| 249 | 228 | ||||
| 250 | func writeSession(w *payloadWriter, s Session) error { | 229 | func writeSession(w *payloadWriter, s Session) error { | ||
| 230 | + | if len(s.Tabs) > math.MaxUint16 { | |||
| 231 | + | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |||
| 232 | + | } | |||
| 233 | + | if err := validateActive(s.Active, len(s.Tabs)); err != nil { | |||
| 234 | + | return err | |||
| 235 | + | } | |||
| 251 | if err := w.putString(s.Name); err != nil { | 236 | if err := w.putString(s.Name); err != nil { | ||
| 252 | return err | 237 | return err | ||
| 253 | } | 238 | } | ||
| 254 | w.putUint64(s.CreatedAt) | 239 | w.putUint64(s.CreatedAt) | ||
| 255 | w.putUint16(s.Active) | 240 | w.putUint16(s.Active) | ||
| 256 | - | if len(s.Tabs) > math.MaxUint16 { | |||
| 257 | - | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |||
| 258 | - | } | |||
| 259 | w.putUint16(uint16(len(s.Tabs))) | 241 | w.putUint16(uint16(len(s.Tabs))) | ||
| 260 | for _, t := range s.Tabs { | 242 | for _, t := range s.Tabs { | ||
| 261 | if err := writeTab(w, t); err != nil { | 243 | if err := writeTab(w, t); err != nil { | ||
| @@ -282,6 +264,9 @@ func readSession(r *payloadReader) (Session, error) { | |||||
| 282 | if err != nil { | 264 | if err != nil { | ||
| 283 | return Session{}, err | 265 | return Session{}, err | ||
| 284 | } | 266 | } | ||
| 267 | + | if err := validateActive(active, int(nt)); err != nil { | |||
| 268 | + | return Session{}, err | |||
| 269 | + | } | |||
| 285 | s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)} | 270 | s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)} | ||
| 286 | for i := range s.Tabs { | 271 | for i := range s.Tabs { | ||
| 287 | t, err := readTab(r) | 272 | t, err := readTab(r) | ||
| @@ -464,24 +449,27 @@ func writePane(w *payloadWriter, p Pane) error { | |||||
| 464 | if err := w.putString(p.Cwd); err != nil { | 449 | if err := w.putString(p.Cwd); err != nil { | ||
| 465 | return err | 450 | return err | ||
| 466 | } | 451 | } | ||
| 467 | - | styles := styleTable(p) | 452 | + | if len(p.SealedChunks) > maxSnapshotBlobs { |
| 468 | - | if len(styles) > math.MaxUint16 { | 453 | + | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) |
| 469 | - | return fmt.Errorf("%w: too many styles", ErrInvalidData) | |||
| 470 | - | } | |||
| 471 | - | w.putUint16(uint16(len(styles))) | |||
| 472 | - | for _, s := range styles { | |||
| 473 | - | writeStyle(w, s) | |||
| 474 | } | 454 | } | ||
| 475 | - | idx := make(map[renderer.Style]uint16, len(styles)) | 455 | + | w.putUint32(uint32(len(p.SealedChunks))) |
| 476 | - | for i, s := range styles { | 456 | + | for _, blob := range p.SealedChunks { |
| 477 | - | idx[s] = uint16(i) | 457 | + | if len(blob) == 0 || len(blob) > math.MaxUint32 { |
| 458 | + | return fmt.Errorf("%w: sealed chunk length", ErrInvalidData) | |||
| 459 | + | } | |||
| 460 | + | w.putUint32(uint32(len(blob))) | |||
| 461 | + | w.b = append(w.b, blob...) | |||
| 478 | } | 462 | } | ||
| 479 | - | if err := writeRows(w, p.Scrollback, idx, false); err != nil { | 463 | + | if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 { |
| 480 | - | return err | 464 | + | return fmt.Errorf("%w: tail length", ErrInvalidData) |
| 481 | } | 465 | } | ||
| 482 | - | if err := writeRows(w, p.Visible, idx, true); err != nil { | 466 | + | w.putUint32(uint32(len(p.Tail))) |
| 483 | - | return err | 467 | + | w.b = append(w.b, p.Tail...) |
| 468 | + | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | |||
| 469 | + | return fmt.Errorf("%w: visible length", ErrInvalidData) | |||
| 484 | } | 470 | } | ||
| 471 | + | w.putUint32(uint32(len(p.Visible))) | |||
| 472 | + | w.b = append(w.b, p.Visible...) | |||
| 485 | return writeProcess(w, p.Process) | 473 | return writeProcess(w, p.Process) | ||
| 486 | } | 474 | } | ||
| 487 | 475 | ||||
| @@ -498,24 +486,24 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 498 | if err != nil { | 486 | if err != nil { | ||
| 499 | return Pane{}, err | 487 | return Pane{}, err | ||
| 500 | } | 488 | } | ||
| 501 | - | ns, err := r.getUint16() | 489 | + | n, err := r.getUint32() |
| 502 | if err != nil { | 490 | if err != nil { | ||
| 503 | return Pane{}, err | 491 | return Pane{}, err | ||
| 504 | } | 492 | } | ||
| 505 | - | styles := make([]renderer.Style, ns) | 493 | + | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { |
| 506 | - | for i := range styles { | 494 | + | return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData) |
| 507 | - | s, err := readStyle(r) | 495 | + | } |
| 508 | - | if err != nil { | 496 | + | sealed := make([][]byte, n) |
| 497 | + | for i := range sealed { | |||
| 498 | + | if sealed[i], err = r.getBlob(); err != nil { | |||
| 509 | return Pane{}, err | 499 | return Pane{}, err | ||
| 510 | } | 500 | } | ||
| 511 | - | styles[i] = s | |||
| 512 | } | 501 | } | ||
| 513 | - | budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns} | 502 | + | tail, err := r.getBlob() |
| 514 | - | sb, err := readRows(r, styles, &budget) | |||
| 515 | if err != nil { | 503 | if err != nil { | ||
| 516 | return Pane{}, err | 504 | return Pane{}, err | ||
| 517 | } | 505 | } | ||
| 518 | - | vis, err := readRows(r, styles, &budget) | 506 | + | visible, err := r.getBlob() |
| 519 | if err != nil { | 507 | if err != nil { | ||
| 520 | return Pane{}, err | 508 | return Pane{}, err | ||
| 521 | } | 509 | } | ||
| @@ -523,7 +511,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 523 | if err != nil { | 511 | if err != nil { | ||
| 524 | return Pane{}, err | 512 | return Pane{}, err | ||
| 525 | } | 513 | } | ||
| 526 | - | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil | 514 | + | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil |
| 527 | } | 515 | } | ||
| 528 | 516 | ||||
| 529 | func writeProcess(w *payloadWriter, p *Process) error { | 517 | func writeProcess(w *payloadWriter, p *Process) error { | ||
| @@ -573,238 +561,21 @@ func readProcess(r *payloadReader) (*Process, error) { | |||||
| 573 | return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil | 561 | return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil | ||
| 574 | } | 562 | } | ||
| 575 | 563 | ||||
| 576 | - | func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error { | 564 | + | func validateActive(active uint16, tabCount int) error { |
| 577 | - | clean := trimRows(rows, trimBlankRows) | 565 | + | if tabCount == 0 { |
| 578 | - | if len(clean) > math.MaxUint32 { | 566 | + | if active == 0 { |
| 579 | - | return fmt.Errorf("%w: too many rows", ErrInvalidData) | 567 | + | return nil |
| 580 | - | } | |||
| 581 | - | w.putUint32(uint32(len(clean))) | |||
| 582 | - | for _, row := range clean { | |||
| 583 | - | runs := makeRuns(row) | |||
| 584 | - | if len(runs) > math.MaxUint16 { | |||
| 585 | - | return fmt.Errorf("%w: too many runs", ErrInvalidData) | |||
| 586 | } | 568 | } | ||
| 587 | - | w.putUint16(uint16(len(runs))) | 569 | + | return fmt.Errorf("%w: active tab", ErrInvalidData) |
| 588 | - | for _, run := range runs { | |||
| 589 | - | w.putUint16(uint16(run.len)) | |||
| 590 | - | w.putUint32(uint32(run.cell.Rune)) | |||
| 591 | - | w.putUint16(idx[run.cell.Style]) | |||
| 592 | - | var flags uint8 | |||
| 593 | - | if run.cell.Continuation { | |||
| 594 | - | flags |= 1 | |||
| 595 | - | } | |||
| 596 | - | w.putUint8(flags) | |||
| 597 | - | } | |||
| 598 | - | } | |||
| 599 | - | return nil | |||
| 600 | - | } | |||
| 601 | - | ||||
| 602 | - | type rowBudget struct { | |||
| 603 | - | cells int | |||
| 604 | - | runs int | |||
| 605 | - | } | |||
| 606 | - | ||||
| 607 | - | func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) { | |||
| 608 | - | n, err := r.getUint32() | |||
| 609 | - | if err != nil { | |||
| 610 | - | return nil, err | |||
| 611 | } | 570 | } | ||
| 612 | - | if n > maxDecodedRows { | 571 | + | if int(active) >= tabCount { |
| 613 | - | return nil, fmt.Errorf("%w: too many rows", ErrInvalidData) | 572 | + | return fmt.Errorf("%w: active tab", ErrInvalidData) |
| 614 | } | 573 | } | ||
| 615 | - | if uint64(n) > uint64(len(r.b))/2 { | 574 | + | return nil |
| 616 | - | return nil, ErrShortPayload | |||
| 617 | - | } | |||
| 618 | - | rows := make([][]renderer.Cell, int(n)) | |||
| 619 | - | for i := range rows { | |||
| 620 | - | nr, err := r.getUint16() | |||
| 621 | - | if err != nil { | |||
| 622 | - | return nil, err | |||
| 623 | - | } | |||
| 624 | - | if budget != nil { | |||
| 625 | - | if int(nr) > budget.runs { | |||
| 626 | - | return nil, fmt.Errorf("%w: too many runs", ErrInvalidData) | |||
| 627 | - | } | |||
| 628 | - | budget.runs -= int(nr) | |||
| 629 | - | } | |||
| 630 | - | if uint64(nr) > uint64(len(r.b))/9 { | |||
| 631 | - | return nil, ErrShortPayload | |||
| 632 | - | } | |||
| 633 | - | rowCells := 0 | |||
| 634 | - | for j := 0; j < int(nr); j++ { | |||
| 635 | - | ln, err := r.getUint16() | |||
| 636 | - | if err != nil { | |||
| 637 | - | return nil, err | |||
| 638 | - | } | |||
| 639 | - | rn, err := r.getUint32() | |||
| 640 | - | if err != nil { | |||
| 641 | - | return nil, err | |||
| 642 | - | } | |||
| 643 | - | si, err := r.getUint16() | |||
| 644 | - | if err != nil { | |||
| 645 | - | return nil, err | |||
| 646 | - | } | |||
| 647 | - | flags, err := r.getUint8() | |||
| 648 | - | if err != nil { | |||
| 649 | - | return nil, err | |||
| 650 | - | } | |||
| 651 | - | if int(si) >= len(styles) { | |||
| 652 | - | return nil, ErrStyleOOB | |||
| 653 | - | } | |||
| 654 | - | if flags&^1 != 0 { | |||
| 655 | - | return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData) | |||
| 656 | - | } | |||
| 657 | - | if budget != nil { | |||
| 658 | - | if int(ln) > budget.cells { | |||
| 659 | - | return nil, fmt.Errorf("%w: too many cells", ErrInvalidData) | |||
| 660 | - | } | |||
| 661 | - | budget.cells -= int(ln) | |||
| 662 | - | } | |||
| 663 | - | cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0} | |||
| 664 | - | rowCells += int(ln) | |||
| 665 | - | rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...) | |||
| 666 | - | for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ { | |||
| 667 | - | rows[i][k] = cell | |||
| 668 | - | } | |||
| 669 | - | } | |||
| 670 | - | if rowCells == 0 { | |||
| 671 | - | rows[i] = nil | |||
| 672 | - | } | |||
| 673 | - | } | |||
| 674 | - | return rows, nil | |||
| 675 | - | } | |||
| 676 | - | ||||
| 677 | - | type cellRun struct { | |||
| 678 | - | len int | |||
| 679 | - | cell renderer.Cell | |||
| 680 | - | } | |||
| 681 | - | ||||
| 682 | - | func makeRuns(row []renderer.Cell) []cellRun { | |||
| 683 | - | if len(row) == 0 { | |||
| 684 | - | return nil | |||
| 685 | - | } | |||
| 686 | - | runs := []cellRun{{len: 1, cell: row[0]}} | |||
| 687 | - | for _, c := range row[1:] { | |||
| 688 | - | last := &runs[len(runs)-1] | |||
| 689 | - | if last.cell.Equal(c) && last.len < math.MaxUint16 { | |||
| 690 | - | last.len++ | |||
| 691 | - | } else { | |||
| 692 | - | runs = append(runs, cellRun{len: 1, cell: c}) | |||
| 693 | - | } | |||
| 694 | - | } | |||
| 695 | - | return runs | |||
| 696 | - | } | |||
| 697 | - | ||||
| 698 | - | func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell { | |||
| 699 | - | out := make([][]renderer.Cell, len(rows)) | |||
| 700 | - | for i, row := range rows { | |||
| 701 | - | out[i] = trimTrailingBlankCells(row) | |||
| 702 | - | } | |||
| 703 | - | if trimBlankRows { | |||
| 704 | - | for len(out) > 0 && len(out[len(out)-1]) == 0 { | |||
| 705 | - | out = out[:len(out)-1] | |||
| 706 | - | } | |||
| 707 | - | } | |||
| 708 | - | return out | |||
| 709 | - | } | |||
| 710 | - | ||||
| 711 | - | func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell { | |||
| 712 | - | end := len(row) | |||
| 713 | - | for end > 0 && isBlank(row[end-1]) { | |||
| 714 | - | end-- | |||
| 715 | - | } | |||
| 716 | - | return append([]renderer.Cell(nil), row[:end]...) | |||
| 717 | - | } | |||
| 718 | - | func isBlank(c renderer.Cell) bool { | |||
| 719 | - | return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle()) | |||
| 720 | - | } | |||
| 721 | - | ||||
| 722 | - | func styleTable(p Pane) []renderer.Style { | |||
| 723 | - | seen := map[renderer.Style]bool{} | |||
| 724 | - | var styles []renderer.Style | |||
| 725 | - | addRows := func(rows [][]renderer.Cell, trimBlankRows bool) { | |||
| 726 | - | for _, row := range trimRows(rows, trimBlankRows) { | |||
| 727 | - | for _, c := range row { | |||
| 728 | - | if !seen[c.Style] { | |||
| 729 | - | seen[c.Style] = true | |||
| 730 | - | styles = append(styles, c.Style) | |||
| 731 | - | } | |||
| 732 | - | } | |||
| 733 | - | } | |||
| 734 | - | } | |||
| 735 | - | addRows(p.Scrollback, false) | |||
| 736 | - | addRows(p.Visible, true) | |||
| 737 | - | return styles | |||
| 738 | - | } | |||
| 739 | - | ||||
| 740 | - | func writeStyle(w *payloadWriter, s renderer.Style) { | |||
| 741 | - | var flags uint8 | |||
| 742 | - | if s.Bold { | |||
| 743 | - | flags |= 1 | |||
| 744 | - | } | |||
| 745 | - | if s.Inverse { | |||
| 746 | - | flags |= 2 | |||
| 747 | - | } | |||
| 748 | - | if s.HasForegroundRGB { | |||
| 749 | - | flags |= 4 | |||
| 750 | - | } | |||
| 751 | - | if s.HasBackgroundRGB { | |||
| 752 | - | flags |= 8 | |||
| 753 | - | } | |||
| 754 | - | if s.Italic { | |||
| 755 | - | flags |= 16 | |||
| 756 | - | } | |||
| 757 | - | w.putUint8(flags) | |||
| 758 | - | w.putUint32(uint32(int32(s.Foreground))) | |||
| 759 | - | w.putUint32(uint32(int32(s.Background))) | |||
| 760 | - | w.putUint8(s.ForegroundRGB.R) | |||
| 761 | - | w.putUint8(s.ForegroundRGB.G) | |||
| 762 | - | w.putUint8(s.ForegroundRGB.B) | |||
| 763 | - | w.putUint8(s.BackgroundRGB.R) | |||
| 764 | - | w.putUint8(s.BackgroundRGB.G) | |||
| 765 | - | w.putUint8(s.BackgroundRGB.B) | |||
| 766 | - | } | |||
| 767 | - | ||||
| 768 | - | func readStyle(r *payloadReader) (renderer.Style, error) { | |||
| 769 | - | flags, err := r.getUint8() | |||
| 770 | - | if err != nil { | |||
| 771 | - | return renderer.Style{}, err | |||
| 772 | - | } | |||
| 773 | - | fg, err := r.getUint32() | |||
| 774 | - | if err != nil { | |||
| 775 | - | return renderer.Style{}, err | |||
| 776 | - | } | |||
| 777 | - | bg, err := r.getUint32() | |||
| 778 | - | if err != nil { | |||
| 779 | - | return renderer.Style{}, err | |||
| 780 | - | } | |||
| 781 | - | fr, err := r.getUint8() | |||
| 782 | - | if err != nil { | |||
| 783 | - | return renderer.Style{}, err | |||
| 784 | - | } | |||
| 785 | - | fgc, err := r.getUint8() | |||
| 786 | - | if err != nil { | |||
| 787 | - | return renderer.Style{}, err | |||
| 788 | - | } | |||
| 789 | - | fb, err := r.getUint8() | |||
| 790 | - | if err != nil { | |||
| 791 | - | return renderer.Style{}, err | |||
| 792 | - | } | |||
| 793 | - | br, err := r.getUint8() | |||
| 794 | - | if err != nil { | |||
| 795 | - | return renderer.Style{}, err | |||
| 796 | - | } | |||
| 797 | - | bgc, err := r.getUint8() | |||
| 798 | - | if err != nil { | |||
| 799 | - | return renderer.Style{}, err | |||
| 800 | - | } | |||
| 801 | - | bb, err := r.getUint8() | |||
| 802 | - | if err != nil { | |||
| 803 | - | return renderer.Style{}, err | |||
| 804 | - | } | |||
| 805 | - | return renderer.Style{Bold: flags&1 != 0, Italic: flags&16 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil | |||
| 806 | } | 575 | } | ||
| 807 | 576 | ||||
| 577 | + | // validateTree checks that every pane reference in the layout tree resolves | |||
| 578 | + | // to a pane declared by the enclosing tab. | |||
| 808 | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | 579 | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | ||
| 809 | if t == nil || t.Root == nil { | 580 | if t == nil || t.Root == nil { | ||
| 810 | return nil | 581 | return nil | ||
M internal/usecase/snapshot/codec_test.go +390 -312
| @@ -6,154 +6,292 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "hash/crc32" | 7 | "hash/crc32" | ||
| 8 | "math" | 8 | "math" | ||
| 9 | + | "strconv" | |||
| 9 | "testing" | 10 | "testing" | ||
| 10 | 11 | ||||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 12 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | "github.com/bnema/vev/pkg/renderer" | 13 | "github.com/bnema/vev/pkg/renderer" | ||
| 14 | + | "github.com/bnema/vev/pkg/vt" | |||
| 13 | ) | 15 | ) | ||
| 14 | 16 | ||||
| 15 | - | func TestMarshalMinimalGolden(t *testing.T) { | 17 | + | func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { |
| 16 | - | got, err := Marshal(Session{Name: "s", CreatedAt: 7}) | 18 | + | indexed := renderer.DefaultStyle() |
| 19 | + | indexed.Bold, indexed.Inverse = true, true | |||
| 20 | + | indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough | |||
| 21 | + | indexed.Foreground, indexed.Background = 196, 17 | |||
| 22 | + | indexed.UnderlineStyle, indexed.HasUnderlineColor, indexed.UnderlineColor = renderer.UnderlineCurly, true, 203 | |||
| 23 | + | rgb := renderer.DefaultStyle() | |||
| 24 | + | rgb.Italic, rgb.HasForegroundRGB, rgb.ForegroundRGB = true, true, renderer.RGB{R: 1, G: 2, B: 3} | |||
| 25 | + | rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6} | |||
| 26 | + | rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9} | |||
| 27 | + | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}}) | |||
| 28 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}}) | |||
| 29 | + | sealed2, tail2 := historyBlobs(t, [][]renderer.Cell{{{Rune: 'S', Style: rgb}}, {{Rune: '2', Style: indexed}}}) | |||
| 30 | + | visible2 := visibleBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}}) | |||
| 31 | + | want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{ | |||
| 32 | + | {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, | |||
| 33 | + | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2}, | |||
| 34 | + | }}}} | |||
| 35 | + | encoded, err := Marshal(want) | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | requireV3Envelope(t, encoded) | |||
| 40 | + | got, err := Unmarshal(encoded) | |||
| 17 | if err != nil { | 41 | if err != nil { | ||
| 18 | - | t.Fatalf("Marshal() error = %v", err) | 42 | + | t.Fatal(err) |
| 43 | + | } | |||
| 44 | + | if len(got.Tabs) != 1 || len(got.Tabs[0].Panes) != 2 || got.Tabs[0].Panes[0].ID != "p" || got.Tabs[0].Panes[1].ID != "p2" { | |||
| 45 | + | t.Fatalf("manifest round trip = %#v", got) | |||
| 46 | + | } | |||
| 47 | + | for i, wantPane := range want.Tabs[0].Panes { | |||
| 48 | + | gotPane := got.Tabs[0].Panes[i] | |||
| 49 | + | if len(gotPane.SealedChunks) != len(wantPane.SealedChunks) { | |||
| 50 | + | t.Fatalf("pane %q sealed chunks = %d, want %d", wantPane.ID, len(gotPane.SealedChunks), len(wantPane.SealedChunks)) | |||
| 51 | + | } | |||
| 52 | + | for j := range wantPane.SealedChunks { | |||
| 53 | + | if !bytes.Equal(gotPane.SealedChunks[j], wantPane.SealedChunks[j]) { | |||
| 54 | + | t.Fatalf("pane %q sealed chunk %d changed during round trip", wantPane.ID, j) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | if !bytes.Equal(gotPane.Tail, wantPane.Tail) { | |||
| 58 | + | t.Fatalf("pane %q tail changed during round trip", wantPane.ID) | |||
| 59 | + | } | |||
| 60 | + | if !bytes.Equal(gotPane.Visible, wantPane.Visible) { | |||
| 61 | + | t.Fatalf("pane %q visible blob changed during round trip", wantPane.ID) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil { | |||
| 65 | + | t.Fatalf("history decode: %v", err) | |||
| 19 | } | 66 | } | ||
| 20 | - | want := []byte{ | 67 | + | frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible) |
| 21 | - | 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb, | 68 | + | if err != nil { |
| 22 | - | 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, | 69 | + | t.Fatal(err) |
| 23 | } | 70 | } | ||
| 24 | - | if !bytes.Equal(got, want) { | 71 | + | if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation { |
| 25 | - | t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want) | 72 | + | t.Fatalf("visible terminal data lost: %#v", frame.Row(0)) |
| 26 | } | 73 | } | ||
| 27 | } | 74 | } | ||
| 28 | 75 | ||||
| 29 | - | func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | 76 | + | func TestActiveTabReferenceMustBeExact(t *testing.T) { |
| 30 | - | cases := []struct { | 77 | + | validTab := Tab{} |
| 31 | - | name string | 78 | + | for _, tc := range []struct { |
| 32 | - | tab Tab | 79 | + | name string |
| 80 | + | s Session | |||
| 81 | + | valid bool | |||
| 33 | }{ | 82 | }{ | ||
| 34 | - | {name: "nil tree", tab: Tab{Tree: nil}}, | 83 | + | {name: "zero tabs active zero", s: Session{Active: 0}, valid: true}, |
| 35 | - | {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}}, | 84 | + | {name: "zero tabs active nonzero", s: Session{Active: 1}}, |
| 36 | - | } | 85 | + | {name: "nonempty tabs active in range", s: Session{Active: 0, Tabs: []Tab{validTab}}, valid: true}, |
| 37 | - | for _, tc := range cases { | 86 | + | {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}}, |
| 38 | - | t.Run(tc.name, func(t *testing.T) { | 87 | + | } { |
| 39 | - | b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | 88 | + | t.Run("marshal "+tc.name, func(t *testing.T) { |
| 40 | - | if err != nil { | 89 | + | _, err := Marshal(tc.s) |
| 41 | - | t.Fatalf("Marshal() error = %v", err) | 90 | + | if tc.valid { |
| 91 | + | requireNoError(t, err) | |||
| 92 | + | return | |||
| 42 | } | 93 | } | ||
| 43 | - | got, err := Unmarshal(b) | 94 | + | if !errors.Is(err, ErrInvalidData) { |
| 44 | - | if err != nil { | 95 | + | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) |
| 45 | - | t.Fatalf("Unmarshal() error = %v", err) | |||
| 46 | - | } | |||
| 47 | - | if len(got.Tabs) != 1 { | |||
| 48 | - | t.Fatalf("tabs len = %d, want 1", len(got.Tabs)) | |||
| 49 | - | } | |||
| 50 | - | if got.Tabs[0].Tree == nil { | |||
| 51 | - | t.Fatalf("Tree = nil, want tree with nil root") | |||
| 52 | - | } | |||
| 53 | - | if got.Tabs[0].Tree.Root != nil { | |||
| 54 | - | t.Fatalf("Root = %#v, want nil", got.Tabs[0].Tree.Root) | |||
| 55 | } | 96 | } | ||
| 56 | }) | 97 | }) | ||
| 57 | } | 98 | } | ||
| 99 | + | ||||
| 100 | + | for _, tc := range []struct { | |||
| 101 | + | name string | |||
| 102 | + | data []byte | |||
| 103 | + | }{ | |||
| 104 | + | {name: "zero tabs active nonzero", data: v3ActiveManifest(1, 0)}, | |||
| 105 | + | {name: "nonempty tabs active past end", data: v3ActiveManifest(1, 1)}, | |||
| 106 | + | } { | |||
| 107 | + | t.Run("unmarshal "+tc.name, func(t *testing.T) { | |||
| 108 | + | reject(t, v3Envelope(version, 0, tc.data), ErrInvalidData) | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 58 | } | 111 | } | ||
| 59 | 112 | ||||
| 60 | - | func TestRoundTripSessions(t *testing.T) { | 113 | + | func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) { |
| 61 | - | boldRGB := renderer.DefaultStyle() | 114 | + | for _, version := range []uint16{1, 2} { |
| 62 | - | boldRGB.Bold = true | 115 | + | if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) { |
| 63 | - | boldRGB.HasForegroundRGB = true | 116 | + | t.Fatalf("version %d: %v", version, err) |
| 64 | - | boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | 117 | + | } |
| 65 | - | indexed := renderer.DefaultStyle() | 118 | + | } |
| 66 | - | indexed.Foreground = 2 | 119 | + | } |
| 67 | - | indexed.Background = 4 | 120 | + | |
| 68 | - | indexed.Inverse = true | 121 | + | func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) { |
| 69 | - | indexed.Italic = true | 122 | + | body := v3Manifest(0, nil, nil, 0, nil, 0, nil) |
| 70 | - | deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | 123 | + | valid := v3Envelope(version, 0, body) |
| 71 | - | layout.NewLeaf("1"), | 124 | + | badCRC := append([]byte(nil), valid...) |
| 72 | - | &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}}, | 125 | + | badCRC[15] ^= 1 |
| 73 | - | }}} | 126 | + | for _, tc := range []struct { |
| 74 | - | cjkVisible := [][]renderer.Cell{{ | |||
| 75 | - | {Rune: '好', Style: boldRGB}, | |||
| 76 | - | {Continuation: true, Style: boldRGB}, | |||
| 77 | - | {Rune: 'x', Style: indexed}, | |||
| 78 | - | }} | |||
| 79 | - | multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | |||
| 80 | - | {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}}, | |||
| 81 | - | {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}}, | |||
| 82 | - | }} | |||
| 83 | - | empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}} | |||
| 84 | - | large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}} | |||
| 85 | - | cases := []struct { | |||
| 86 | name string | 127 | name string | ||
| 87 | - | s Session | 128 | + | data []byte |
| 129 | + | want error | |||
| 88 | }{ | 130 | }{ | ||
| 89 | - | {name: "multi tab deep tree", s: multi}, | 131 | + | {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic}, |
| 90 | - | {name: "empty and all blank", s: empty}, | 132 | + | {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData}, |
| 91 | - | {name: "large scrollback", s: large}, | 133 | + | {"crc", badCRC, ErrBadCRC}, {"body", v3EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData}, |
| 134 | + | } { | |||
| 135 | + | t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) }) | |||
| 92 | } | 136 | } | ||
| 93 | - | for _, tc := range cases { | 137 | + | } |
| 138 | + | ||||
| 139 | + | func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testing.T) { | |||
| 140 | + | for _, tc := range []struct { | |||
| 141 | + | name string | |||
| 142 | + | body []byte | |||
| 143 | + | }{ | |||
| 144 | + | {"count", v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)}, | |||
| 145 | + | {"sealed", v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)}, | |||
| 146 | + | {"tail", v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)}, | |||
| 147 | + | {"visible", v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)}, | |||
| 148 | + | } { | |||
| 149 | + | t.Run(tc.name, func(t *testing.T) { reject(t, v3Envelope(version, 0, tc.body), ErrInvalidData) }) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | |||
| 154 | + | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | |||
| 155 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |||
| 156 | + | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |||
| 157 | + | if err != nil { | |||
| 158 | + | t.Fatal(err) | |||
| 159 | + | } | |||
| 160 | + | for n := range len(encoded) { | |||
| 161 | + | reject(t, encoded[:n], nil) | |||
| 162 | + | } | |||
| 163 | + | reject(t, append(encoded, 0), ErrTrailingBytes) | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||
| 167 | + | sealed, tail := historyBlobs(t, nil) | |||
| 168 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |||
| 169 | + | for _, tc := range []struct { | |||
| 170 | + | name string | |||
| 171 | + | panes []Pane | |||
| 172 | + | }{ | |||
| 173 | + | {name: "empty", panes: []Pane{{ID: "", Tail: tail, Visible: visible}}}, | |||
| 174 | + | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}}, | |||
| 175 | + | } { | |||
| 94 | t.Run(tc.name, func(t *testing.T) { | 176 | t.Run(tc.name, func(t *testing.T) { | ||
| 95 | - | b, err := Marshal(tc.s) | 177 | + | data, err := Marshal(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}}) |
| 96 | - | if err != nil { | 178 | + | requireNoError(t, err) |
| 97 | - | t.Fatalf("Marshal() error = %v", err) | 179 | + | reject(t, data, ErrInvalidData) |
| 98 | - | } | |||
| 99 | - | got, err := Unmarshal(b) | |||
| 100 | - | if err != nil { | |||
| 101 | - | t.Fatalf("Unmarshal() error = %v", err) | |||
| 102 | - | } | |||
| 103 | - | if !equalSession(got, trimSession(tc.s)) { | |||
| 104 | - | t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s)) | |||
| 105 | - | } | |||
| 106 | }) | 180 | }) | ||
| 107 | } | 181 | } | ||
| 108 | } | 182 | } | ||
| 109 | 183 | ||||
| 110 | - | func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) { | 184 | + | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { |
| 111 | - | good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}}) | 185 | + | sealed, tail := historyBlobs(t, nil) |
| 186 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |||
| 187 | + | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |||
| 112 | if err != nil { | 188 | if err != nil { | ||
| 113 | t.Fatal(err) | 189 | t.Fatal(err) | ||
| 114 | } | 190 | } | ||
| 115 | - | cases := []struct { | 191 | + | if _, err := Unmarshal(encoded); !errors.Is(err, ErrUnknownPane) { |
| 192 | + | t.Fatalf("error = %v", err) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) { | |||
| 197 | + | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) | |||
| 198 | + | if len(sealed) == 0 { | |||
| 199 | + | t.Fatal("expected a sealed chunk") | |||
| 200 | + | } | |||
| 201 | + | multiChunk := append([]byte(nil), sealed[0][:5]...) | |||
| 202 | + | multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2) | |||
| 203 | + | multiChunk = append(multiChunk, sealed[0][9:]...) | |||
| 204 | + | multiChunk = append(multiChunk, sealed[0][9:]...) | |||
| 205 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |||
| 206 | + | emptyVisible := visibleBlob(t, nil) | |||
| 207 | + | for _, tc := range []struct { | |||
| 116 | name string | 208 | name string | ||
| 117 | - | data []byte | 209 | + | tab Tab |
| 118 | }{ | 210 | }{ | ||
| 119 | - | {"bad magic", append([]byte("NOPE"), good[4:]...)}, | 211 | + | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: tail, Visible: visible}}}}, |
| 120 | - | {"bad version", replaceU16(good, 4, 1)}, | 212 | + | {name: "nonempty tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: sealed[0], Visible: visible}}}}, |
| 121 | - | {"bad crc", append([]byte(nil), good[:len(good)-1]...)}, | 213 | + | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: tail, Visible: emptyVisible}}}}, |
| 122 | - | {"trailing", append(append([]byte(nil), good...), 0)}, | 214 | + | } { |
| 123 | - | {"body len overrun", replaceU32(good, 8, 999999)}, | |||
| 124 | - | {"prefix", good[:len(good)/2]}, | |||
| 125 | - | {"style oob", styleOOBSnapshot(t)}, | |||
| 126 | - | {"unknown pane in tree", unknownPaneSnapshot(t)}, | |||
| 127 | - | } | |||
| 128 | - | for _, tc := range cases { | |||
| 129 | t.Run(tc.name, func(t *testing.T) { | 215 | t.Run(tc.name, func(t *testing.T) { | ||
| 130 | - | defer func() { | 216 | + | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) |
| 131 | - | if r := recover(); r != nil { | 217 | + | requireNoError(t, err) |
| 132 | - | t.Fatalf("Unmarshal panicked: %v", r) | 218 | + | reject(t, data, ErrInvalidData) |
| 133 | - | } | 219 | + | }) |
| 134 | - | }() | 220 | + | } |
| 135 | - | if _, err := Unmarshal(tc.data); err == nil { | 221 | + | } |
| 136 | - | t.Fatalf("Unmarshal() error = nil") | 222 | + | |
| 223 | + | func TestUnmarshalRejectsMalformedTerminalBlobWithoutPanic(t *testing.T) { | |||
| 224 | + | tail := mustHistoryTail(t) | |||
| 225 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |||
| 226 | + | // The visible cell's underline-style byte is outside the renderer enum. | |||
| 227 | + | visible[13+29] = 0xff | |||
| 228 | + | body := v3Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(visible)), visible) | |||
| 229 | + | reject(t, v3Envelope(version, 0, body), ErrInvalidData) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | |||
| 233 | + | for _, tc := range []struct { | |||
| 234 | + | name string | |||
| 235 | + | tab Tab | |||
| 236 | + | }{ | |||
| 237 | + | {name: "nil tree", tab: Tab{Tree: nil}}, | |||
| 238 | + | {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}}, | |||
| 239 | + | } { | |||
| 240 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 241 | + | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |||
| 242 | + | requireNoError(t, err) | |||
| 243 | + | got, err := Unmarshal(data) | |||
| 244 | + | requireNoError(t, err) | |||
| 245 | + | if got.Tabs[0].Tree == nil || got.Tabs[0].Tree.Root != nil { | |||
| 246 | + | t.Fatalf("tree = %#v, want nonnil tree with nil root", got.Tabs[0].Tree) | |||
| 137 | } | 247 | } | ||
| 138 | }) | 248 | }) | ||
| 139 | } | 249 | } | ||
| 140 | - | for i := 0; i < len(good); i++ { | 250 | + | } |
| 141 | - | if _, err := Unmarshal(good[:i]); err == nil { | 251 | + | |
| 142 | - | t.Fatalf("prefix length %d unexpectedly succeeded", i) | 252 | + | func TestRoundTripSessionMetadataAndProcess(t *testing.T) { |
| 143 | - | } | 253 | + | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}, {{Rune: 'i'}}}) |
| 254 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |||
| 255 | + | want := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | |||
| 256 | + | { | |||
| 257 | + | StableID: "t1", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", | |||
| 258 | + | Tree: &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 259 | + | layout.NewLeaf("1"), layout.NewLeaf("2"), | |||
| 260 | + | }}}, | |||
| 261 | + | Panes: []Pane{ | |||
| 262 | + | {ID: "1", StableID: "p1", Cwd: "/one", SealedChunks: sealed, Tail: tail, Visible: visible, Process: &Process{Argv: []string{"pi", "--resume"}, Strategy: "pi", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, | |||
| 263 | + | {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Visible: visible}, | |||
| 264 | + | }, | |||
| 265 | + | }, | |||
| 266 | + | {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}}, | |||
| 267 | + | }} | |||
| 268 | + | data, err := Marshal(want) | |||
| 269 | + | requireNoError(t, err) | |||
| 270 | + | got, err := Unmarshal(data) | |||
| 271 | + | requireNoError(t, err) | |||
| 272 | + | if got.Name != want.Name || got.CreatedAt != want.CreatedAt || got.Active != want.Active || len(got.Tabs) != 2 { | |||
| 273 | + | t.Fatalf("session metadata round trip = %#v", got) | |||
| 274 | + | } | |||
| 275 | + | pane := got.Tabs[0].Panes[0] | |||
| 276 | + | if pane.ID != "1" || pane.StableID != "p1" || pane.Cwd != "/one" || pane.Process == nil || pane.Process.Opts.AgentSessionID != "agent-123" || got.Tabs[0].Tree.Root.Kind != layout.Split { | |||
| 277 | + | t.Fatalf("pane metadata round trip = %#v", pane) | |||
| 144 | } | 278 | } | ||
| 145 | } | 279 | } | ||
| 146 | 280 | ||||
| 147 | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | 281 | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | ||
| 148 | - | _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}}) | 282 | + | _, err := Marshal(Session{Name: "s", Tabs: []Tab{ |
| 283 | + | {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}, | |||
| 284 | + | }}) | |||
| 149 | if !errors.Is(err, ErrInvalidData) { | 285 | if !errors.Is(err, ErrInvalidData) { | ||
| 150 | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | 286 | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | ||
| 151 | } | 287 | } | ||
| 152 | } | 288 | } | ||
| 153 | 289 | ||||
| 154 | func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | 290 | func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | ||
| 291 | + | tail := mustHistoryTail(t) | |||
| 292 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |||
| 155 | var w payloadWriter | 293 | var w payloadWriter | ||
| 156 | - | _ = w.putString("x") | 294 | + | _ = w.putString("s") |
| 157 | w.putUint64(0) | 295 | w.putUint64(0) | ||
| 158 | w.putUint16(0) | 296 | w.putUint16(0) | ||
| 159 | w.putUint16(1) | 297 | w.putUint16(1) | ||
| @@ -165,269 +303,209 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |||||
| 165 | _ = writeNode(&w, layout.NewLeaf("p")) | 303 | _ = writeNode(&w, layout.NewLeaf("p")) | ||
| 166 | w.putUint16(1) | 304 | w.putUint16(1) | ||
| 167 | _ = w.putString("p") | 305 | _ = w.putString("p") | ||
| 168 | - | _ = w.putString("p_stable") | |||
| 169 | _ = w.putString("") | 306 | _ = w.putString("") | ||
| 170 | - | w.putUint16(0) | 307 | + | _ = w.putString("") |
| 171 | - | w.putUint32(0) | |||
| 172 | w.putUint32(0) | 308 | w.putUint32(0) | ||
| 309 | + | w.putUint32(uint32(len(tail))) | |||
| 310 | + | w.b = append(w.b, tail...) | |||
| 311 | + | w.putUint32(uint32(len(visible))) | |||
| 312 | + | w.b = append(w.b, visible...) | |||
| 173 | w.putUint8(1) | 313 | w.putUint8(1) | ||
| 174 | - | w.putUint16(0) | 314 | + | w.putUint16(0) // argv count |
| 175 | _ = w.putString("generic") | 315 | _ = w.putString("generic") | ||
| 176 | _ = w.putString("") | 316 | _ = w.putString("") | ||
| 177 | - | _, err := Unmarshal(reheader(w.b, 0)) | 317 | + | if _, err := Unmarshal(v3Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) { |
| 178 | - | if !errors.Is(err, ErrInvalidData) { | |||
| 179 | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | 318 | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | ||
| 180 | } | 319 | } | ||
| 181 | } | 320 | } | ||
| 182 | 321 | ||||
| 183 | - | func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) { | 322 | + | func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { |
| 184 | - | body := []byte{0} | 323 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256}) |
| 185 | - | oversizedRaw := reheader(body, 0) | 324 | + | for range 256 { |
| 186 | - | binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1)) | 325 | + | history.Append([]renderer.Cell{{Rune: 'x'}}) |
| 187 | - | oversizedFlate := reheader(body, flagFlate) | 326 | + | } |
| 188 | - | binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1)) | 327 | + | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) |
| 189 | - | cases := []struct { | 328 | + | requireNoError(t, err) |
| 190 | - | name string | 329 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 191 | - | data []byte | |||
| 192 | - | }{ | |||
| 193 | - | {name: "raw", data: oversizedRaw}, | |||
| 194 | - | {name: "flate", data: oversizedFlate}, | |||
| 195 | - | } | |||
| 196 | - | for _, tc := range cases { | |||
| 197 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 198 | - | _, err := Unmarshal(tc.data) | |||
| 199 | - | if !errors.Is(err, ErrInvalidData) { | |||
| 200 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | |||
| 201 | - | } | |||
| 202 | - | }) | |||
| 203 | - | } | |||
| 204 | - | } | |||
| 205 | 330 | ||||
| 206 | - | func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) { | 331 | + | const paneCount = maxSnapshotRows/256 + 1 |
| 207 | - | cases := []struct { | 332 | + | tabs := make([]Tab, paneCount) |
| 208 | - | name string | 333 | + | for i := range tabs { |
| 209 | - | data []byte | 334 | + | tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}} |
| 210 | - | want error | |||
| 211 | - | }{ | |||
| 212 | - | {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData}, | |||
| 213 | - | {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload}, | |||
| 214 | } | 335 | } | ||
| 215 | - | for _, tc := range cases { | 336 | + | data, err := Marshal(Session{Name: "over-budget", Tabs: tabs}) |
| 216 | - | t.Run(tc.name, func(t *testing.T) { | 337 | + | requireNoError(t, err) |
| 217 | - | defer func() { | 338 | + | |
| 218 | - | if r := recover(); r != nil { | 339 | + | allocs := testing.AllocsPerRun(3, func() { |
| 219 | - | t.Fatalf("Unmarshal panicked: %v", r) | 340 | + | _, err := Unmarshal(data) |
| 220 | - | } | 341 | + | if !errors.Is(err, ErrInvalidData) { |
| 221 | - | }() | 342 | + | panic(err) |
| 222 | - | _, err := Unmarshal(tc.data) | 343 | + | } |
| 223 | - | if !errors.Is(err, tc.want) { | 344 | + | }) |
| 224 | - | t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want) | 345 | + | // The error and testing harness may allocate a small fixed amount. Rejecting |
| 225 | - | } | 346 | + | // before semantic maps/slices are built must not scale with paneCount. |
| 226 | - | }) | 347 | + | if allocs > 8 { |
| 348 | + | t.Fatalf("Unmarshal() allocations = %f, want fixed overhead only", allocs) | |||
| 227 | } | 349 | } | ||
| 228 | } | 350 | } | ||
| 229 | 351 | ||||
| 230 | - | func TestFlateOnOff(t *testing.T) { | 352 | + | func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) { |
| 231 | - | small, err := Marshal(Session{Name: "small"}) | 353 | + | budget := preflightBudget{alloc: maxSnapshotDecodedAllocation - 1} |
| 232 | - | if err != nil { | 354 | + | if budget.addProduct(1, 2) { |
| 233 | - | t.Fatal(err) | 355 | + | t.Fatal("allocation budget accepted an over-limit aggregate") |
| 234 | - | } | |||
| 235 | - | if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 { | |||
| 236 | - | t.Fatalf("small flags = %#x, want 0", flags) | |||
| 237 | - | } | |||
| 238 | - | large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}}) | |||
| 239 | - | if err != nil { | |||
| 240 | - | t.Fatal(err) | |||
| 241 | - | } | |||
| 242 | - | if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 { | |||
| 243 | - | t.Fatalf("large flags = %#x, want flate bit", flags) | |||
| 244 | } | 356 | } | ||
| 245 | } | 357 | } | ||
| 246 | 358 | ||||
| 247 | - | func BenchmarkMarshal10KRows(b *testing.B) { | 359 | + | func TestPreflightRejectsBroadTreeBeforeSecondPassAllocation(t *testing.T) { |
| 248 | - | s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}} | 360 | + | body := v3WideTreeManifest(32_769) |
| 249 | - | b.ReportAllocs() | 361 | + | if err := preflightSession(body); !errors.Is(err, ErrInvalidData) { |
| 250 | - | for i := 0; i < b.N; i++ { | 362 | + | t.Fatalf("preflightSession() error = %v, want %v", err, ErrInvalidData) |
| 251 | - | if _, err := Marshal(s); err != nil { | |||
| 252 | - | b.Fatal(err) | |||
| 253 | - | } | |||
| 254 | } | 363 | } | ||
| 255 | } | 364 | } | ||
| 256 | 365 | ||||
| 257 | - | func rows(lines ...string) [][]renderer.Cell { | 366 | + | func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) { |
| 258 | - | out := make([][]renderer.Cell, len(lines)) | 367 | + | body := v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil) |
| 259 | - | for i, line := range lines { | 368 | + | reject(t, v3Envelope(version, 0, body), ErrInvalidData) |
| 260 | - | for _, r := range line { | |||
| 261 | - | out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 262 | - | } | |||
| 263 | - | } | |||
| 264 | - | return out | |||
| 265 | } | 369 | } | ||
| 266 | 370 | ||||
| 267 | - | func manyRows(n int) [][]renderer.Cell { | 371 | + | func mustHistoryTail(t *testing.T) []byte { |
| 268 | - | out := make([][]renderer.Cell, n) | 372 | + | t.Helper() |
| 269 | - | for i := range out { | 373 | + | _, tail := historyBlobs(t, nil) |
| 270 | - | out[i] = rows("row data row data row data")[0] | 374 | + | return tail |
| 271 | - | } | |||
| 272 | - | return out | |||
| 273 | } | 375 | } | ||
| 274 | 376 | ||||
| 275 | - | func equalSession(a, b Session) bool { | 377 | + | func requireNoError(t *testing.T, err error) { |
| 276 | - | if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) { | 378 | + | t.Helper() |
| 277 | - | return false | 379 | + | if err != nil { |
| 278 | - | } | 380 | + | t.Fatal(err) |
| 279 | - | for i := range a.Tabs { | |||
| 280 | - | if !equalTab(a.Tabs[i], b.Tabs[i]) { | |||
| 281 | - | return false | |||
| 282 | - | } | |||
| 283 | } | 381 | } | ||
| 284 | - | return true | |||
| 285 | } | 382 | } | ||
| 286 | 383 | ||||
| 287 | - | func equalTab(a, b Tab) bool { | 384 | + | func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) { |
| 288 | - | if a.StableID != b.StableID || a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) { | 385 | + | t.Helper() |
| 289 | - | return false | 386 | + | h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2}) |
| 387 | + | for _, row := range rows { | |||
| 388 | + | h.Append(row) | |||
| 290 | } | 389 | } | ||
| 291 | - | for i := range a.Panes { | 390 | + | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) |
| 292 | - | if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].StableID != b.Panes[i].StableID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalProcess(a.Panes[i].Process, b.Panes[i].Process) || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) { | 391 | + | if err != nil { |
| 293 | - | return false | 392 | + | t.Fatal(err) |
| 294 | - | } | |||
| 295 | } | 393 | } | ||
| 296 | - | return true | 394 | + | return sealed, tail |
| 297 | } | 395 | } | ||
| 298 | - | 396 | + | func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte { | |
| 299 | - | func equalProcess(a, b *Process) bool { | 397 | + | t.Helper() |
| 300 | - | if a == nil || b == nil { | 398 | + | width := 0 |
| 301 | - | return a == b | 399 | + | for _, row := range rows { |
| 400 | + | width = max(width, len(row)) | |||
| 302 | } | 401 | } | ||
| 303 | - | if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) { | 402 | + | f := renderer.NewFrame(width, len(rows)) |
| 304 | - | return false | 403 | + | for y, row := range rows { |
| 404 | + | copy(f.Row(y), row) | |||
| 305 | } | 405 | } | ||
| 306 | - | for i := range a.Argv { | 406 | + | b, err := vt.MarshalVisible(f) |
| 307 | - | if a.Argv[i] != b.Argv[i] { | 407 | + | if err != nil { |
| 308 | - | return false | 408 | + | t.Fatal(err) |
| 309 | - | } | |||
| 310 | } | 409 | } | ||
| 311 | - | return true | 410 | + | return b |
| 312 | } | 411 | } | ||
| 313 | - | 412 | + | func requireV3Envelope(t *testing.T, data []byte) { | |
| 314 | - | func treeRoot(t *layout.Tree) *layout.Node { | 413 | + | t.Helper() |
| 315 | - | if t == nil { | 414 | + | if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 { |
| 316 | - | return nil | 415 | + | t.Fatalf("not v3 envelope: % x", data) |
| 317 | } | 416 | } | ||
| 318 | - | return t.Root | |||
| 319 | } | 417 | } | ||
| 320 | - | 418 | + | func reject(t *testing.T, data []byte, want error) { | |
| 321 | - | func equalNode(a, b *layout.Node) bool { | 419 | + | t.Helper() |
| 322 | - | if a == nil || b == nil { | 420 | + | defer func() { |
| 323 | - | return a == b | 421 | + | if r := recover(); r != nil { |
| 324 | - | } | 422 | + | t.Fatalf("panic: %v", r) |
| 325 | - | if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) { | |||
| 326 | - | return false | |||
| 327 | - | } | |||
| 328 | - | for i := range a.Children { | |||
| 329 | - | if !equalNode(a.Children[i], b.Children[i]) { | |||
| 330 | - | return false | |||
| 331 | } | 423 | } | ||
| 424 | + | }() | |||
| 425 | + | _, err := Unmarshal(data) | |||
| 426 | + | if err == nil { | |||
| 427 | + | t.Fatal("accepted malformed input") | |||
| 332 | } | 428 | } | ||
| 333 | - | return true | 429 | + | if want != nil && !errors.Is(err, want) { |
| 334 | - | } | 430 | + | t.Fatalf("error = %v, want %v", err, want) |
| 335 | - | ||||
| 336 | - | func equalRows(a, b [][]renderer.Cell) bool { | |||
| 337 | - | if len(a) != len(b) { | |||
| 338 | - | return false | |||
| 339 | } | 431 | } | ||
| 340 | - | for i := range a { | |||
| 341 | - | if len(a[i]) != len(b[i]) { | |||
| 342 | - | return false | |||
| 343 | - | } | |||
| 344 | - | for j := range a[i] { | |||
| 345 | - | if !a[i][j].Equal(b[i][j]) { | |||
| 346 | - | return false | |||
| 347 | - | } | |||
| 348 | - | } | |||
| 349 | - | } | |||
| 350 | - | return true | |||
| 351 | } | 432 | } | ||
| 352 | - | 433 | + | func v3Envelope(v, flags uint16, body []byte) []byte { | |
| 353 | - | func trimSession(s Session) Session { | 434 | + | return v3EnvelopeWithLength(v, flags, body, uint32(len(body))) |
| 354 | - | for ti := range s.Tabs { | |||
| 355 | - | for pi := range s.Tabs[ti].Panes { | |||
| 356 | - | s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false) | |||
| 357 | - | s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true) | |||
| 358 | - | } | |||
| 359 | - | } | |||
| 360 | - | return s | |||
| 361 | } | 435 | } | ||
| 362 | - | 436 | + | func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte { | |
| 363 | - | func reheader(body []byte, flags uint16) []byte { | |||
| 364 | out := make([]byte, 16+len(body)) | 437 | out := make([]byte, 16+len(body)) | ||
| 365 | - | copy(out[:4], []byte("VEVS")) | 438 | + | copy(out, magic) |
| 366 | - | binary.BigEndian.PutUint16(out[4:6], version) | 439 | + | binary.BigEndian.PutUint16(out[4:6], v) |
| 367 | binary.BigEndian.PutUint16(out[6:8], flags) | 440 | binary.BigEndian.PutUint16(out[6:8], flags) | ||
| 368 | - | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | 441 | + | binary.BigEndian.PutUint32(out[8:12], length) |
| 369 | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | 442 | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | ||
| 370 | copy(out[16:], body) | 443 | copy(out[16:], body) | ||
| 371 | return out | 444 | return out | ||
| 372 | } | 445 | } | ||
| 373 | 446 | ||||
| 374 | - | func replaceU16(b []byte, off int, v uint16) []byte { | 447 | + | func v3ActiveManifest(active, tabs uint16) []byte { |
| 375 | - | out := append([]byte(nil), b...) | 448 | + | var b []byte |
| 376 | - | binary.BigEndian.PutUint16(out[off:], v) | 449 | + | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } |
| 377 | - | return out | 450 | + | putS("s") |
| 378 | - | } | 451 | + | b = append(b, make([]byte, 8)...) |
| 379 | - | func replaceU32(b []byte, off int, v uint32) []byte { | 452 | + | b = binary.BigEndian.AppendUint16(b, active) |
| 380 | - | out := append([]byte(nil), b...) | 453 | + | b = binary.BigEndian.AppendUint16(b, tabs) |
| 381 | - | binary.BigEndian.PutUint32(out[off:], v) | 454 | + | for range tabs { |
| 382 | - | return out | 455 | + | putS("") |
| 383 | - | } | 456 | + | b = append(b, make([]byte, 2+2+8)...) |
| 384 | - | 457 | + | putS("") | |
| 385 | - | func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte { | 458 | + | b = append(b, 3) // nil root |
| 386 | - | var w payloadWriter | 459 | + | b = binary.BigEndian.AppendUint16(b, 0) |
| 387 | - | _ = w.putString("x") | |||
| 388 | - | w.putUint64(0) | |||
| 389 | - | w.putUint16(0) | |||
| 390 | - | w.putUint16(1) | |||
| 391 | - | _ = w.putString("") | |||
| 392 | - | w.putUint16(0) | |||
| 393 | - | w.putUint16(0) | |||
| 394 | - | w.putUint64(0) | |||
| 395 | - | _ = w.putString("p") | |||
| 396 | - | _ = writeNode(&w, layout.NewLeaf("p")) | |||
| 397 | - | w.putUint16(1) | |||
| 398 | - | _ = w.putString("p") | |||
| 399 | - | _ = w.putString("") | |||
| 400 | - | _ = w.putString("") | |||
| 401 | - | w.putUint16(0) | |||
| 402 | - | w.putUint32(rowCount) | |||
| 403 | - | if rowCount > 0 { | |||
| 404 | - | w.putUint16(runCount) | |||
| 405 | } | 460 | } | ||
| 406 | - | return reheader(w.b, 0) | 461 | + | return b |
| 407 | } | 462 | } | ||
| 408 | 463 | ||||
| 409 | - | func styleOOBSnapshot(t *testing.T) []byte { | 464 | + | // v3Manifest creates just enough session/tab/pane framing for hostile declarations. |
| 410 | - | b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}}) | 465 | + | // v3WideTreeManifest declares a broad but compact tree without allocating a |
| 411 | - | if err != nil { | 466 | + | // matching layout.Node graph. It exercises preflight's manifest budget. |
| 412 | - | t.Fatal(err) | 467 | + | func v3WideTreeManifest(children uint16) []byte { |
| 413 | - | } | 468 | + | var b []byte |
| 414 | - | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | 469 | + | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } |
| 415 | - | body := append([]byte(nil), b[16:16+bodyLen]...) | 470 | + | putS("s") |
| 416 | - | for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0 | 471 | + | b = append(b, make([]byte, 8+2)...) |
| 417 | - | if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' { | 472 | + | b = binary.BigEndian.AppendUint16(b, 1) |
| 418 | - | body[i+6] = 0 | 473 | + | putS("") |
| 419 | - | body[i+7] = 2 | 474 | + | b = append(b, make([]byte, 2+2+8)...) |
| 420 | - | return reheader(body, 0) | 475 | + | putS("") |
| 421 | - | } | 476 | + | b = append(b, 1, byte(layout.Horizontal)) // split node |
| 422 | - | } | 477 | + | b = binary.BigEndian.AppendUint16(b, children) |
| 423 | - | t.Fatal("run not found") | 478 | + | for range children { |
| 424 | - | return nil | 479 | + | b = append(b, 0) // leaf |
| 480 | + | putS("") | |||
| 481 | + | } | |||
| 482 | + | b = binary.BigEndian.AppendUint16(b, 0) // panes | |||
| 483 | + | return b | |||
| 425 | } | 484 | } | ||
| 426 | 485 | ||||
| 427 | - | func unknownPaneSnapshot(t *testing.T) []byte { | 486 | + | func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte { |
| 428 | - | b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}}) | 487 | + | var b []byte |
| 429 | - | if err != nil { | 488 | + | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } |
| 430 | - | t.Fatal(err) | 489 | + | putS("s") |
| 431 | - | } | 490 | + | b = append(b, make([]byte, 8+2)...) |
| 491 | + | b = binary.BigEndian.AppendUint16(b, 1) | |||
| 492 | + | putS("") | |||
| 493 | + | b = append(b, make([]byte, 2+2+8)...) | |||
| 494 | + | putS("p") | |||
| 495 | + | b = append(b, 0) | |||
| 496 | + | putS("p") | |||
| 497 | + | b = binary.BigEndian.AppendUint16(b, 1) | |||
| 498 | + | putS("p") | |||
| 499 | + | putS("") | |||
| 500 | + | putS("") | |||
| 501 | + | b = binary.BigEndian.AppendUint32(b, sealed) | |||
| 502 | + | for _, l := range sealedLens { | |||
| 503 | + | b = binary.BigEndian.AppendUint32(b, l) | |||
| 504 | + | } | |||
| 505 | + | b = append(b, sealedData...) | |||
| 506 | + | b = binary.BigEndian.AppendUint32(b, tailLen) | |||
| 507 | + | b = append(b, tailData...) | |||
| 508 | + | b = binary.BigEndian.AppendUint32(b, visibleLen) | |||
| 509 | + | b = append(b, visibleData...) | |||
| 432 | return b | 510 | return b | ||
| 433 | } | 511 | } | ||
A internal/usecase/snapshot/preflight.go +449 -0
| @@ -0,0 +1,449 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "math" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 8 | + | "github.com/bnema/vev/pkg/vt" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func preflightSession(body []byte) error { | |||
| 12 | + | if err := preflightSessionStructure(body); err != nil { | |||
| 13 | + | return err | |||
| 14 | + | } | |||
| 15 | + | return preflightSessionReferences(body) | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func preflightSessionStructure(body []byte) error { | |||
| 19 | + | r := payloadReader{b: body} | |||
| 20 | + | if err := skipString(&r); err != nil { | |||
| 21 | + | return err | |||
| 22 | + | } | |||
| 23 | + | if _, err := r.getUint64(); err != nil { | |||
| 24 | + | return err | |||
| 25 | + | } | |||
| 26 | + | active, err := r.getUint16() | |||
| 27 | + | if err != nil { | |||
| 28 | + | return err | |||
| 29 | + | } | |||
| 30 | + | tabs, err := r.getUint16() | |||
| 31 | + | if err != nil { | |||
| 32 | + | return err | |||
| 33 | + | } | |||
| 34 | + | if err := validateActive(active, int(tabs)); err != nil { | |||
| 35 | + | return err | |||
| 36 | + | } | |||
| 37 | + | if uint64(tabs) > uint64(len(r.b))/2 { | |||
| 38 | + | return ErrShortPayload | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | var totals vt.DecodeStats | |||
| 42 | + | blobs := uint64(0) | |||
| 43 | + | budget := preflightBudget{} | |||
| 44 | + | for range tabs { | |||
| 45 | + | if err := preflightTabStructure(&r, &totals, &blobs, &budget); err != nil { | |||
| 46 | + | return fmt.Errorf("preflight tab: %w", err) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | if err := r.done(); err != nil { | |||
| 50 | + | return err | |||
| 51 | + | } | |||
| 52 | + | if totals.Rows > maxSnapshotRows || totals.Cells > maxSnapshotCells || totals.Bytes > maxSnapshotBytes { | |||
| 53 | + | return fmt.Errorf("%w: global VT budget", ErrInvalidData) | |||
| 54 | + | } | |||
| 55 | + | return nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func skipString(r *payloadReader) error { | |||
| 59 | + | n, err := r.getUint16() | |||
| 60 | + | if err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | if uint64(n) > uint64(len(r.b)) { | |||
| 64 | + | return ErrShortPayload | |||
| 65 | + | } | |||
| 66 | + | r.b = r.b[n:] | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | type preflightBudget struct { | |||
| 71 | + | nodes uint64 | |||
| 72 | + | alloc uint64 | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (b *preflightBudget) addAllocation(amount uint64) bool { | |||
| 76 | + | if amount > maxSnapshotDecodedAllocation-b.alloc { | |||
| 77 | + | return false | |||
| 78 | + | } | |||
| 79 | + | b.alloc += amount | |||
| 80 | + | return true | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (b *preflightBudget) addProduct(count, size uint64) bool { | |||
| 84 | + | if count != 0 && size > math.MaxUint64/count { | |||
| 85 | + | return false | |||
| 86 | + | } | |||
| 87 | + | return b.addAllocation(count * size) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (b *preflightBudget) addNode() bool { | |||
| 91 | + | if b.nodes >= maxSnapshotTreeNodes || !b.addAllocation(128) { | |||
| 92 | + | return false | |||
| 93 | + | } | |||
| 94 | + | b.nodes++ | |||
| 95 | + | return true | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func preflightTabStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) error { | |||
| 99 | + | if !budget.addAllocation(256) { | |||
| 100 | + | return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData) | |||
| 101 | + | } | |||
| 102 | + | if err := skipString(r); err != nil { | |||
| 103 | + | return err | |||
| 104 | + | } | |||
| 105 | + | if _, err := r.getUint16(); err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | if _, err := r.getUint16(); err != nil { | |||
| 109 | + | return err | |||
| 110 | + | } | |||
| 111 | + | if _, err := r.getUint64(); err != nil { | |||
| 112 | + | return err | |||
| 113 | + | } | |||
| 114 | + | if err := skipString(r); err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | if err := preflightNodeStructure(r, 0, true, budget); err != nil { | |||
| 118 | + | return err | |||
| 119 | + | } | |||
| 120 | + | panes, err := r.getUint16() | |||
| 121 | + | if err != nil { | |||
| 122 | + | return err | |||
| 123 | + | } | |||
| 124 | + | if uint64(panes) > uint64(len(r.b))/2 { | |||
| 125 | + | return ErrShortPayload | |||
| 126 | + | } | |||
| 127 | + | for range panes { | |||
| 128 | + | if err := preflightPaneStructure(r, totals, blobs, budget); err != nil { | |||
| 129 | + | return err | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func preflightNodeStructure(r *payloadReader, depth int, root bool, budget *preflightBudget) error { | |||
| 136 | + | if depth > 64 { | |||
| 137 | + | return fmt.Errorf("%w: tree depth", ErrInvalidData) | |||
| 138 | + | } | |||
| 139 | + | if !budget.addNode() { | |||
| 140 | + | return fmt.Errorf("%w: tree node or decoded allocation budget", ErrInvalidData) | |||
| 141 | + | } | |||
| 142 | + | kind, err := r.getUint8() | |||
| 143 | + | if err != nil { | |||
| 144 | + | return err | |||
| 145 | + | } | |||
| 146 | + | switch kind { | |||
| 147 | + | case 0: | |||
| 148 | + | return skipString(r) | |||
| 149 | + | case 1: | |||
| 150 | + | dir, err := r.getUint8() | |||
| 151 | + | if err != nil { | |||
| 152 | + | return err | |||
| 153 | + | } | |||
| 154 | + | if layout.SplitDir(dir) != layout.Horizontal && layout.SplitDir(dir) != layout.Vertical { | |||
| 155 | + | return fmt.Errorf("%w: split dir", ErrInvalidData) | |||
| 156 | + | } | |||
| 157 | + | case 2: | |||
| 158 | + | if err := skipString(r); err != nil { | |||
| 159 | + | return err | |||
| 160 | + | } | |||
| 161 | + | case 3: | |||
| 162 | + | if !root { | |||
| 163 | + | return fmt.Errorf("%w: nil child node", ErrInvalidData) | |||
| 164 | + | } | |||
| 165 | + | return nil | |||
| 166 | + | default: | |||
| 167 | + | return fmt.Errorf("%w: node kind", ErrInvalidData) | |||
| 168 | + | } | |||
| 169 | + | children, err := r.getUint16() | |||
| 170 | + | if err != nil { | |||
| 171 | + | return err | |||
| 172 | + | } | |||
| 173 | + | if uint64(children) > uint64(len(r.b)) { | |||
| 174 | + | return ErrShortPayload | |||
| 175 | + | } | |||
| 176 | + | for range children { | |||
| 177 | + | if err := preflightNodeStructure(r, depth+1, false, budget); err != nil { | |||
| 178 | + | return err | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) error { | |||
| 185 | + | if !budget.addAllocation(256) { | |||
| 186 | + | return fmt.Errorf("%w: decoded allocation budget", ErrInvalidData) | |||
| 187 | + | } | |||
| 188 | + | if err := skipString(r); err != nil { | |||
| 189 | + | return err | |||
| 190 | + | } | |||
| 191 | + | if err := skipString(r); err != nil { | |||
| 192 | + | return err | |||
| 193 | + | } | |||
| 194 | + | if err := skipString(r); err != nil { | |||
| 195 | + | return err | |||
| 196 | + | } | |||
| 197 | + | n, err := r.getUint32() | |||
| 198 | + | if err != nil { | |||
| 199 | + | return err | |||
| 200 | + | } | |||
| 201 | + | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { | |||
| 202 | + | return fmt.Errorf("%w: sealed chunks", ErrInvalidData) | |||
| 203 | + | } | |||
| 204 | + | for range n { | |||
| 205 | + | stats, err := preflightBlob(r, totals, blobs, true, budget) | |||
| 206 | + | if err != nil { | |||
| 207 | + | return err | |||
| 208 | + | } | |||
| 209 | + | if stats.Chunks != 1 || stats.Rows == 0 { | |||
| 210 | + | return fmt.Errorf("%w: sealed blob role", ErrInvalidData) | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | tail, err := preflightBlob(r, totals, blobs, true, budget) | |||
| 214 | + | if err != nil { | |||
| 215 | + | return err | |||
| 216 | + | } | |||
| 217 | + | if tail.Chunks != 0 || tail.Rows != 0 || tail.Cells != 0 { | |||
| 218 | + | return fmt.Errorf("%w: tail blob role", ErrInvalidData) | |||
| 219 | + | } | |||
| 220 | + | visible, err := preflightBlob(r, totals, blobs, false, budget) | |||
| 221 | + | if err != nil { | |||
| 222 | + | return err | |||
| 223 | + | } | |||
| 224 | + | if visible.Rows == 0 || visible.Cells == 0 { | |||
| 225 | + | return fmt.Errorf("%w: visible geometry", ErrInvalidData) | |||
| 226 | + | } | |||
| 227 | + | return preflightProcess(r) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, history bool, budget *preflightBudget) (vt.DecodeStats, error) { | |||
| 231 | + | n, err := r.getUint32() | |||
| 232 | + | if err != nil { | |||
| 233 | + | return vt.DecodeStats{}, err | |||
| 234 | + | } | |||
| 235 | + | if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes { | |||
| 236 | + | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData) | |||
| 237 | + | } | |||
| 238 | + | if *blobs >= maxSnapshotBlobs { | |||
| 239 | + | return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData) | |||
| 240 | + | } | |||
| 241 | + | blob := r.b[:n] | |||
| 242 | + | r.b = r.b[n:] | |||
| 243 | + | var stats vt.DecodeStats | |||
| 244 | + | if history { | |||
| 245 | + | stats, err = vt.PreflightHistoryBlob(blob) | |||
| 246 | + | } else { | |||
| 247 | + | stats, err = vt.PreflightVisibleBlob(blob) | |||
| 248 | + | } | |||
| 249 | + | if err != nil { | |||
| 250 | + | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData) | |||
| 251 | + | } | |||
| 252 | + | if !totals.Add(stats) { | |||
| 253 | + | return vt.DecodeStats{}, fmt.Errorf("%w: VT budget overflow", ErrInvalidData) | |||
| 254 | + | } | |||
| 255 | + | if !budget.addAllocation(uint64(n)) || !budget.addProduct(stats.Chunks, 64) || !budget.addProduct(stats.Rows, 32) || !budget.addProduct(stats.Cells, 64) { | |||
| 256 | + | return vt.DecodeStats{}, fmt.Errorf("%w: decoded allocation budget", ErrInvalidData) | |||
| 257 | + | } | |||
| 258 | + | *blobs++ | |||
| 259 | + | return stats, nil | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func preflightProcess(r *payloadReader) error { | |||
| 263 | + | present, err := r.getUint8() | |||
| 264 | + | if err != nil { | |||
| 265 | + | return err | |||
| 266 | + | } | |||
| 267 | + | if present == 0 { | |||
| 268 | + | return nil | |||
| 269 | + | } | |||
| 270 | + | if present != 1 { | |||
| 271 | + | return fmt.Errorf("%w: process presence", ErrInvalidData) | |||
| 272 | + | } | |||
| 273 | + | n, err := r.getUint16() | |||
| 274 | + | if err != nil { | |||
| 275 | + | return err | |||
| 276 | + | } | |||
| 277 | + | if n == 0 { | |||
| 278 | + | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |||
| 279 | + | } | |||
| 280 | + | for range n { | |||
| 281 | + | if err := skipString(r); err != nil { | |||
| 282 | + | return err | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | if err := skipString(r); err != nil { | |||
| 286 | + | return err | |||
| 287 | + | } | |||
| 288 | + | return skipString(r) | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // preflightSessionReferences validates duplicate, empty, and dangling pane | |||
| 292 | + | // references only after the structural pass has admitted the complete payload. | |||
| 293 | + | func preflightSessionReferences(body []byte) error { | |||
| 294 | + | r := payloadReader{b: body} | |||
| 295 | + | if err := skipString(&r); err != nil { | |||
| 296 | + | return err | |||
| 297 | + | } | |||
| 298 | + | if _, err := r.getUint64(); err != nil { | |||
| 299 | + | return err | |||
| 300 | + | } | |||
| 301 | + | if _, err := r.getUint16(); err != nil { | |||
| 302 | + | return err | |||
| 303 | + | } | |||
| 304 | + | tabs, err := r.getUint16() | |||
| 305 | + | if err != nil { | |||
| 306 | + | return err | |||
| 307 | + | } | |||
| 308 | + | for range tabs { | |||
| 309 | + | if err := preflightTabReferences(&r); err != nil { | |||
| 310 | + | return fmt.Errorf("preflight tab references: %w", err) | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | return r.done() | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | func preflightTabReferences(r *payloadReader) error { | |||
| 317 | + | if err := skipString(r); err != nil { | |||
| 318 | + | return err | |||
| 319 | + | } | |||
| 320 | + | if _, err := r.getUint16(); err != nil { | |||
| 321 | + | return err | |||
| 322 | + | } | |||
| 323 | + | if _, err := r.getUint16(); err != nil { | |||
| 324 | + | return err | |||
| 325 | + | } | |||
| 326 | + | if _, err := r.getUint64(); err != nil { | |||
| 327 | + | return err | |||
| 328 | + | } | |||
| 329 | + | focus, err := r.getString() | |||
| 330 | + | if err != nil { | |||
| 331 | + | return err | |||
| 332 | + | } | |||
| 333 | + | references := preflightTreeReferences{} | |||
| 334 | + | if err := preflightNodeReferences(r, &references); err != nil { | |||
| 335 | + | return err | |||
| 336 | + | } | |||
| 337 | + | panes, err := r.getUint16() | |||
| 338 | + | if err != nil { | |||
| 339 | + | return err | |||
| 340 | + | } | |||
| 341 | + | ids := make(map[layout.PaneID]struct{}, panes) | |||
| 342 | + | for range panes { | |||
| 343 | + | id, err := r.getString() | |||
| 344 | + | if err != nil { | |||
| 345 | + | return err | |||
| 346 | + | } | |||
| 347 | + | if id == "" { | |||
| 348 | + | return fmt.Errorf("%w: empty pane ID", ErrInvalidData) | |||
| 349 | + | } | |||
| 350 | + | paneID := layout.PaneID(id) | |||
| 351 | + | if _, exists := ids[paneID]; exists { | |||
| 352 | + | return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData) | |||
| 353 | + | } | |||
| 354 | + | ids[paneID] = struct{}{} | |||
| 355 | + | if err := skipString(r); err != nil { | |||
| 356 | + | return err | |||
| 357 | + | } | |||
| 358 | + | if err := skipString(r); err != nil { | |||
| 359 | + | return err | |||
| 360 | + | } | |||
| 361 | + | n, err := r.getUint32() | |||
| 362 | + | if err != nil { | |||
| 363 | + | return err | |||
| 364 | + | } | |||
| 365 | + | for range n { | |||
| 366 | + | if err := skipBlob(r); err != nil { | |||
| 367 | + | return err | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | if err := skipBlob(r); err != nil { | |||
| 371 | + | return err | |||
| 372 | + | } | |||
| 373 | + | if err := skipBlob(r); err != nil { | |||
| 374 | + | return err | |||
| 375 | + | } | |||
| 376 | + | if err := preflightProcess(r); err != nil { | |||
| 377 | + | return err | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | if focus != "" { | |||
| 381 | + | references.ids = append(references.ids, layout.PaneID(focus)) | |||
| 382 | + | } | |||
| 383 | + | for _, id := range references.ids { | |||
| 384 | + | if _, ok := ids[id]; !ok { | |||
| 385 | + | return ErrUnknownPane | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | return nil | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func skipBlob(r *payloadReader) error { | |||
| 392 | + | n, err := r.getUint32() | |||
| 393 | + | if err != nil { | |||
| 394 | + | return err | |||
| 395 | + | } | |||
| 396 | + | if uint64(n) > uint64(len(r.b)) { | |||
| 397 | + | return ErrShortPayload | |||
| 398 | + | } | |||
| 399 | + | r.b = r.b[n:] | |||
| 400 | + | return nil | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | type preflightTreeReferences struct{ ids []layout.PaneID } | |||
| 404 | + | ||||
| 405 | + | func (r *preflightTreeReferences) add(id layout.PaneID) { | |||
| 406 | + | r.ids = append(r.ids, id) | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func preflightNodeReferences(r *payloadReader, references *preflightTreeReferences) error { | |||
| 410 | + | kind, err := r.getUint8() | |||
| 411 | + | if err != nil { | |||
| 412 | + | return err | |||
| 413 | + | } | |||
| 414 | + | switch kind { | |||
| 415 | + | case 0: | |||
| 416 | + | leaf, err := r.getString() | |||
| 417 | + | if err != nil { | |||
| 418 | + | return err | |||
| 419 | + | } | |||
| 420 | + | references.add(layout.PaneID(leaf)) | |||
| 421 | + | return nil | |||
| 422 | + | case 1: | |||
| 423 | + | if _, err := r.getUint8(); err != nil { | |||
| 424 | + | return err | |||
| 425 | + | } | |||
| 426 | + | case 2: | |||
| 427 | + | expanded, err := r.getString() | |||
| 428 | + | if err != nil { | |||
| 429 | + | return err | |||
| 430 | + | } | |||
| 431 | + | if expanded != "" { | |||
| 432 | + | references.add(layout.PaneID(expanded)) | |||
| 433 | + | } | |||
| 434 | + | case 3: | |||
| 435 | + | return nil | |||
| 436 | + | default: | |||
| 437 | + | return fmt.Errorf("%w: node kind", ErrInvalidData) | |||
| 438 | + | } | |||
| 439 | + | children, err := r.getUint16() | |||
| 440 | + | if err != nil { | |||
| 441 | + | return err | |||
| 442 | + | } | |||
| 443 | + | for range children { | |||
| 444 | + | if err := preflightNodeReferences(r, references); err != nil { | |||
| 445 | + | return err | |||
| 446 | + | } | |||
| 447 | + | } | |||
| 448 | + | return nil | |||
| 449 | + | } | |||
A internal/usecase/snapshot/preflight_acceptance_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) { | |||
| 12 | + | sealed, tail := historyBlobs(t, nil) | |||
| 13 | + | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |||
| 14 | + | for _, tt := range []struct { | |||
| 15 | + | name string | |||
| 16 | + | tab Tab | |||
| 17 | + | }{ | |||
| 18 | + | { | |||
| 19 | + | name: "tree leaf", | |||
| 20 | + | tab: Tab{ | |||
| 21 | + | Focus: "pane", | |||
| 22 | + | Tree: layout.NewTree("missing"), | |||
| 23 | + | Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}}, | |||
| 24 | + | }, | |||
| 25 | + | }, | |||
| 26 | + | { | |||
| 27 | + | name: "tab focus", | |||
| 28 | + | tab: Tab{ | |||
| 29 | + | Focus: "missing", | |||
| 30 | + | Tree: layout.NewTree("pane"), | |||
| 31 | + | Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}}, | |||
| 32 | + | }, | |||
| 33 | + | }, | |||
| 34 | + | } { | |||
| 35 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 36 | + | encoded, err := Marshal(Session{Name: "hostile", Tabs: []Tab{tt.tab}}) | |||
| 37 | + | if err != nil { | |||
| 38 | + | t.Fatalf("Marshal: %v", err) | |||
| 39 | + | } | |||
| 40 | + | if err := preflightSession(encoded[16:]); !errors.Is(err, ErrUnknownPane) { | |||
| 41 | + | t.Fatalf("preflightSession() error = %v, want %v", err, ErrUnknownPane) | |||
| 42 | + | } | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | } | |||
M internal/usecase/snapshot/snapshot.go +10 -12
| @@ -1,9 +1,6 @@ | |||||
| 1 | package 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 | require.Equal(t, "alpha", matches[0].Text) | 61 | require.Equal(t, "alpha", matches[0].Text) | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | - | func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) { | 64 | + | func TestVisualSearchCloneRetainsDocumentAndKeepsUIStateIndependent(t *testing.T) { |
| 65 | m := New(testSnapshot("alpha", "beta alpha")) | 65 | m := New(testSnapshot("alpha", "beta alpha")) | ||
| 66 | for _, r := range "alpha" { | 66 | for _, r := range "alpha" { | ||
| 67 | m.Insert(r) | 67 | m.Insert(r) | ||
| 68 | } | 68 | } | ||
| 69 | clone := m.Clone() | 69 | clone := m.Clone() | ||
| 70 | 70 | ||||
| 71 | - | require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0]) | 71 | + | require.Equal(t, m.snapshot.Row(0), clone.snapshot.Row(0)) |
| 72 | clone.Down() | 72 | clone.Down() | ||
| 73 | clone.Insert('z') | 73 | clone.Insert('z') | ||
| 74 | 74 | ||||
M pkg/vt/benchmark_test.go +158 -1
| @@ -1,6 +1,163 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | var ( | |||
| 10 | + | benchmarkHistoryViewSink HistoryView | |||
| 11 | + | benchmarkHistoryBlobSink []byte | |||
| 12 | + | benchmarkHistoryBlobsSink [][]byte | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // TestHistoryViewPartialTailAllocationBounded guards the capture property used | |||
| 16 | + | // by persistence: retained sealed chunks are shared, while only the small | |||
| 17 | + | // mutable tail is copied into a view. | |||
| 18 | + | func TestHistoryViewPartialTailAllocationBounded(t *testing.T) { | |||
| 19 | + | const ( | |||
| 20 | + | width = 120 | |||
| 21 | + | chunkRows = 64 | |||
| 22 | + | tailRows = 7 | |||
| 23 | + | ) | |||
| 24 | + | sealed := benchmarkHistory(t, chunkRows*4, width, chunkRows) | |||
| 25 | + | sealed.SealAndView() | |||
| 26 | + | partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows) | |||
| 27 | + | partialBefore := partial.View() | |||
| 28 | + | ||||
| 29 | + | sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() }) | |||
| 30 | + | partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() }) | |||
| 31 | + | if partialAllocs > sealedAllocs+tailRows+3 { | |||
| 32 | + | t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows) | |||
| 33 | + | } | |||
| 34 | + | if partial.View().Chunk(0) != partialBefore.Chunk(0) { | |||
| 35 | + | t.Fatal("unchanged sealed chunks must retain identity across partial-tail views") | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func BenchmarkHistoryView10KRowsSealedReuse(b *testing.B) { | |||
| 40 | + | history := benchmarkHistory(b, 10_000, 120, 256) | |||
| 41 | + | sealed := history.SealAndView() | |||
| 42 | + | if sealed.Len() != 10_000 || sealed.ChunkCount() != 40 || sealed.Chunk(0) == nil { | |||
| 43 | + | b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", sealed.Len(), sealed.ChunkCount()) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | b.ReportAllocs() | |||
| 47 | + | b.ResetTimer() | |||
| 48 | + | for b.Loop() { | |||
| 49 | + | benchmarkHistoryViewSink = history.View() | |||
| 50 | + | } | |||
| 51 | + | b.StopTimer() | |||
| 52 | + | if got := benchmarkHistoryViewSink; got.Len() != sealed.Len() || got.Chunk(0) != sealed.Chunk(0) { | |||
| 53 | + | b.Fatal("unchanged sealed history chunks were not reused") | |||
| 54 | + | } | |||
| 55 | + | b.ReportMetric(float64(sealed.ChunkCount()), "sealedchunks/view") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) { | |||
| 59 | + | history := benchmarkHistory(b, 10_000, 120, 256) | |||
| 60 | + | before := history.View() | |||
| 61 | + | if before.Len() != 10_000 || before.ChunkCount() != 40 || before.Chunk(0) == nil { | |||
| 62 | + | b.Fatalf("invalid partial-tail history fixture: rows=%d chunks=%d", before.Len(), before.ChunkCount()) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | b.ReportAllocs() | |||
| 66 | + | b.ResetTimer() | |||
| 67 | + | for b.Loop() { | |||
| 68 | + | benchmarkHistoryViewSink = history.View() | |||
| 69 | + | } | |||
| 70 | + | b.StopTimer() | |||
| 71 | + | if got := benchmarkHistoryViewSink; got.Len() != before.Len() || got.Chunk(0) != before.Chunk(0) { | |||
| 72 | + | b.Fatal("partial-tail capture did not reuse its sealed chunks") | |||
| 73 | + | } | |||
| 74 | + | b.ReportMetric(float64(10_000%256), "tailrows/view") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func BenchmarkMarshalHistoryChunk256x120(b *testing.B) { | |||
| 78 | + | history := benchmarkHistory(b, 256, 120, 256) | |||
| 79 | + | chunk := history.SealAndView().Chunk(0) | |||
| 80 | + | if chunk == nil { | |||
| 81 | + | b.Fatal("invalid history chunk fixture") | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | b.ReportAllocs() | |||
| 85 | + | b.ResetTimer() | |||
| 86 | + | for b.Loop() { | |||
| 87 | + | var err error | |||
| 88 | + | benchmarkHistoryBlobSink, err = MarshalHistoryChunk(chunk) | |||
| 89 | + | if err != nil { | |||
| 90 | + | b.Fatal(err) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | b.StopTimer() | |||
| 94 | + | if _, err := PreflightHistoryBlob(benchmarkHistoryBlobSink); err != nil { | |||
| 95 | + | b.Fatalf("encoded history chunk is invalid: %v", err) | |||
| 96 | + | } | |||
| 97 | + | b.SetBytes(int64(len(benchmarkHistoryBlobSink))) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func BenchmarkUnmarshalHistoryChunk256x120(b *testing.B) { | |||
| 101 | + | history := benchmarkHistory(b, 256, 120, 256) | |||
| 102 | + | blob, err := MarshalHistoryChunk(history.SealAndView().Chunk(0)) | |||
| 103 | + | if err != nil { | |||
| 104 | + | b.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | if stats, err := PreflightHistoryBlob(blob); err != nil || stats.Rows != 256 || stats.Cells != 256*120 { | |||
| 107 | + | b.Fatalf("invalid encoded history chunk fixture: stats=%+v err=%v", stats, err) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | b.ReportAllocs() | |||
| 111 | + | b.SetBytes(int64(len(blob))) | |||
| 112 | + | b.ResetTimer() | |||
| 113 | + | for b.Loop() { | |||
| 114 | + | var err error | |||
| 115 | + | benchmarkHistoryViewSink, err = UnmarshalHistory(blob) | |||
| 116 | + | if err != nil { | |||
| 117 | + | b.Fatal(err) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | b.StopTimer() | |||
| 121 | + | if benchmarkHistoryViewSink.Len() != 256 || benchmarkHistoryViewSink.ChunkCount() != 1 { | |||
| 122 | + | b.Fatal("decoded history chunk lost rows") | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func BenchmarkMarshalSealedHistory10KRows(b *testing.B) { | |||
| 127 | + | history := benchmarkHistory(b, 10_000, 120, 256) | |||
| 128 | + | view := history.SealAndView() | |||
| 129 | + | if view.Len() != 10_000 || view.ChunkCount() != 40 { | |||
| 130 | + | b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", view.Len(), view.ChunkCount()) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | b.ReportAllocs() | |||
| 134 | + | b.ResetTimer() | |||
| 135 | + | for b.Loop() { | |||
| 136 | + | var err error | |||
| 137 | + | benchmarkHistoryBlobsSink, benchmarkHistoryBlobSink, err = MarshalSealedHistory(view) | |||
| 138 | + | if err != nil { | |||
| 139 | + | b.Fatal(err) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | b.StopTimer() | |||
| 143 | + | if len(benchmarkHistoryBlobsSink) != view.ChunkCount() || len(benchmarkHistoryBlobSink) == 0 { | |||
| 144 | + | b.Fatal("sealed-history encoder produced an incomplete capture") | |||
| 145 | + | } | |||
| 146 | + | b.ReportMetric(float64(len(benchmarkHistoryBlobsSink)), "sealedchunks/op") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History { | |||
| 150 | + | t.Helper() | |||
| 151 | + | history := NewHistory(HistoryConfig{MaxRows: rows, ChunkRows: chunkRows}) | |||
| 152 | + | for row := range rows { | |||
| 153 | + | cells := make([]renderer.Cell, width) | |||
| 154 | + | for col := range cells { | |||
| 155 | + | cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)} | |||
| 156 | + | } | |||
| 157 | + | history.Append(cells) | |||
| 158 | + | } | |||
| 159 | + | return history | |||
| 160 | + | } | |||
| 4 | 161 | ||||
| 5 | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | 162 | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | ||
| 6 | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | 163 | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | ||
A pkg/vt/history.go +163 -0
| @@ -0,0 +1,163 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | // HistoryConfig controls the bounded terminal history retained by a Screen. | |||
| 6 | + | type HistoryConfig struct { | |||
| 7 | + | MaxRows int | |||
| 8 | + | ChunkRows int | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | // History stores terminal rows in immutable chunks. It is intended to be | |||
| 12 | + | // mutated by the owner of a Screen; views are safe to retain after later | |||
| 13 | + | // appends. | |||
| 14 | + | type History struct { | |||
| 15 | + | maxRows int | |||
| 16 | + | chunkRows int | |||
| 17 | + | chunks []*HistoryChunk | |||
| 18 | + | tail [][]renderer.Cell | |||
| 19 | + | rows int | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // HistoryChunk is an immutable group of history rows. Its identity is stable | |||
| 23 | + | // and can be used by consumers to avoid copying unchanged sealed chunks. | |||
| 24 | + | type HistoryChunk struct { | |||
| 25 | + | rows [][]renderer.Cell | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // HistoryView is an immutable snapshot of history. Row returns a copy so the | |||
| 29 | + | // storage behind a sealed chunk remains owned by VT. | |||
| 30 | + | type HistoryView struct { | |||
| 31 | + | chunks []*HistoryChunk | |||
| 32 | + | rows int | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func NewHistory(config HistoryConfig) *History { | |||
| 36 | + | if config.MaxRows <= 0 { | |||
| 37 | + | return &History{} | |||
| 38 | + | } | |||
| 39 | + | chunkRows := config.ChunkRows | |||
| 40 | + | if chunkRows <= 0 { | |||
| 41 | + | chunkRows = 256 | |||
| 42 | + | } | |||
| 43 | + | chunkRows = min(chunkRows, 256) | |||
| 44 | + | chunkRows = min(chunkRows, config.MaxRows) | |||
| 45 | + | return &History{maxRows: config.MaxRows, chunkRows: chunkRows} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Append records a copy of row. Once a chunk is full it is sealed forever. | |||
| 49 | + | func (h *History) Append(row []renderer.Cell) { | |||
| 50 | + | if h == nil || h.maxRows == 0 { | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |||
| 54 | + | h.rows++ | |||
| 55 | + | if len(h.tail) == h.chunkRows { | |||
| 56 | + | h.sealTail() | |||
| 57 | + | } | |||
| 58 | + | h.evict() | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (h *History) sealTail() { | |||
| 62 | + | if len(h.tail) == 0 { | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail}) | |||
| 66 | + | h.tail = nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (h *History) evict() { | |||
| 70 | + | for h.rows > h.maxRows && len(h.chunks) > 0 { | |||
| 71 | + | evicted := h.chunks[0] | |||
| 72 | + | h.rows -= len(evicted.rows) | |||
| 73 | + | copy(h.chunks, h.chunks[1:]) | |||
| 74 | + | h.chunks[len(h.chunks)-1] = nil | |||
| 75 | + | h.chunks = h.chunks[:len(h.chunks)-1] | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // SealAndView rotates the mutable tail into an immutable chunk, then captures | |||
| 80 | + | // the sealed chunks by identity. Callers must synchronize access to History. | |||
| 81 | + | func (h *History) SealAndView() HistoryView { | |||
| 82 | + | if h != nil { | |||
| 83 | + | h.sealTail() | |||
| 84 | + | } | |||
| 85 | + | return h.View() | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // View captures the current history. Sealed chunks are shared by identity; a | |||
| 89 | + | // partially-filled tail is copied into a new immutable chunk for this view. | |||
| 90 | + | func (h *History) View() HistoryView { | |||
| 91 | + | if h == nil || h.rows == 0 { | |||
| 92 | + | return HistoryView{} | |||
| 93 | + | } | |||
| 94 | + | chunks := append([]*HistoryChunk(nil), h.chunks...) | |||
| 95 | + | if len(h.tail) > 0 { | |||
| 96 | + | rows := make([][]renderer.Cell, len(h.tail)) | |||
| 97 | + | for i, row := range h.tail { | |||
| 98 | + | rows[i] = append([]renderer.Cell(nil), row...) | |||
| 99 | + | } | |||
| 100 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |||
| 101 | + | } | |||
| 102 | + | return HistoryView{chunks: chunks, rows: h.rows} | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Len returns the currently retained history row count. | |||
| 106 | + | func (h *History) Len() int { | |||
| 107 | + | if h == nil { | |||
| 108 | + | return 0 | |||
| 109 | + | } | |||
| 110 | + | return h.rows | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // Cap returns the configured bounded row capacity. | |||
| 114 | + | func (h *History) Cap() int { | |||
| 115 | + | if h == nil { | |||
| 116 | + | return 0 | |||
| 117 | + | } | |||
| 118 | + | return h.maxRows | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (v HistoryView) Len() int { return v.rows } | |||
| 122 | + | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | |||
| 123 | + | ||||
| 124 | + | // Chunk returns the immutable chunk at i, or nil when i is out of range. | |||
| 125 | + | func (v HistoryView) Chunk(i int) *HistoryChunk { | |||
| 126 | + | if i < 0 || i >= len(v.chunks) { | |||
| 127 | + | return nil | |||
| 128 | + | } | |||
| 129 | + | return v.chunks[i] | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // Range calls yield for each row in oldest-first order. The row is borrowed | |||
| 133 | + | // immutable storage: yield must not mutate or retain it after returning. | |||
| 134 | + | func (v HistoryView) Range(yield func([]renderer.Cell) bool) { | |||
| 135 | + | for _, chunk := range v.chunks { | |||
| 136 | + | for _, row := range chunk.rows { | |||
| 137 | + | if !yield(row) { | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Row returns a copy of the row at i, or nil when i is out of range. | |||
| 145 | + | func (v HistoryView) Row(i int) []renderer.Cell { | |||
| 146 | + | return append([]renderer.Cell(nil), v.BorrowedRow(i)...) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // BorrowedRow returns immutable storage for the row at i, or nil when i is out | |||
| 150 | + | // of range. The caller must not mutate or retain the result after it no longer | |||
| 151 | + | // retains v. Consumers that need ownership should use Row instead. | |||
| 152 | + | func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||
| 153 | + | if i < 0 { | |||
| 154 | + | return nil | |||
| 155 | + | } | |||
| 156 | + | for _, chunk := range v.chunks { | |||
| 157 | + | if i < len(chunk.rows) { | |||
| 158 | + | return chunk.rows[i] | |||
| 159 | + | } | |||
| 160 | + | i -= len(chunk.rows) | |||
| 161 | + | } | |||
| 162 | + | return nil | |||
| 163 | + | } | |||
A pkg/vt/history_acceptance_test.go +108 -0
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *testing.T) { | |||
| 10 | + | indexed := renderer.Style{ | |||
| 11 | + | Bold: true, | |||
| 12 | + | Italic: true, | |||
| 13 | + | Inverse: true, | |||
| 14 | + | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough, | |||
| 15 | + | Foreground: 196, | |||
| 16 | + | Background: 17, | |||
| 17 | + | HasUnderlineColor: true, | |||
| 18 | + | UnderlineColor: 203, | |||
| 19 | + | } | |||
| 20 | + | rgb := renderer.Style{ | |||
| 21 | + | Bold: true, | |||
| 22 | + | Italic: true, | |||
| 23 | + | Inverse: true, | |||
| 24 | + | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough, | |||
| 25 | + | HasForegroundRGB: true, | |||
| 26 | + | ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 27 | + | HasBackgroundRGB: true, | |||
| 28 | + | BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 29 | + | HasUnderlineColorRGB: true, | |||
| 30 | + | UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9}, | |||
| 31 | + | } | |||
| 32 | + | underlines := []renderer.UnderlineStyle{ | |||
| 33 | + | renderer.UnderlineNone, | |||
| 34 | + | renderer.UnderlineSingle, | |||
| 35 | + | renderer.UnderlineDouble, | |||
| 36 | + | renderer.UnderlineCurly, | |||
| 37 | + | renderer.UnderlineDotted, | |||
| 38 | + | renderer.UnderlineDashed, | |||
| 39 | + | } | |||
| 40 | + | makeRow := func(r rune, style renderer.Style) []renderer.Cell { | |||
| 41 | + | style.UnderlineStyle = underlines[int(r)%len(underlines)] | |||
| 42 | + | return []renderer.Cell{ | |||
| 43 | + | {Rune: r, Style: style}, | |||
| 44 | + | {Rune: ' ', Style: renderer.DefaultStyle()}, | |||
| 45 | + | {Rune: '界', Style: style}, | |||
| 46 | + | {Continuation: true, Style: style}, | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | tests := []struct { | |||
| 51 | + | name string | |||
| 52 | + | run func(*testing.T) | |||
| 53 | + | }{ | |||
| 54 | + | { | |||
| 55 | + | name: "history view retains sealed chunks and mutable tail", | |||
| 56 | + | run: func(t *testing.T) { | |||
| 57 | + | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 58 | + | // A through F exercise every underline style; G remains in the | |||
| 59 | + | // mutable tail so both history ownership paths are encoded. | |||
| 60 | + | want := [][]renderer.Cell{ | |||
| 61 | + | makeRow('A', indexed), makeRow('B', rgb), makeRow('C', indexed), | |||
| 62 | + | makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed), | |||
| 63 | + | } | |||
| 64 | + | for _, row := range want { | |||
| 65 | + | history.Append(row) | |||
| 66 | + | } | |||
| 67 | + | if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks { | |||
| 68 | + | t.Fatalf("chunk count = %d, want three sealed chunks plus copied tail %d", got, wantChunks) | |||
| 69 | + | } | |||
| 70 | + | encoded, err := MarshalHistory(history.View()) | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatalf("MarshalHistory: %v", err) | |||
| 73 | + | } | |||
| 74 | + | got, err := UnmarshalHistory(encoded) | |||
| 75 | + | if err != nil { | |||
| 76 | + | t.Fatalf("UnmarshalHistory: %v", err) | |||
| 77 | + | } | |||
| 78 | + | assertHistoryRowsEqual(t, got, want) | |||
| 79 | + | }, | |||
| 80 | + | }, | |||
| 81 | + | { | |||
| 82 | + | name: "visible frame retains blanks wide heads continuations and styles", | |||
| 83 | + | run: func(t *testing.T) { | |||
| 84 | + | want := renderer.NewFrame(4, 2) | |||
| 85 | + | copy(want.Row(0), makeRow('D', rgb)) | |||
| 86 | + | copy(want.Row(1), makeRow('E', indexed)) | |||
| 87 | + | encoded, err := MarshalVisible(want) | |||
| 88 | + | if err != nil { | |||
| 89 | + | t.Fatalf("MarshalVisible: %v", err) | |||
| 90 | + | } | |||
| 91 | + | got, err := UnmarshalVisible(encoded) | |||
| 92 | + | if err != nil { | |||
| 93 | + | t.Fatalf("UnmarshalVisible: %v", err) | |||
| 94 | + | } | |||
| 95 | + | for y := range want.Height { | |||
| 96 | + | for x, cell := range want.Row(y) { | |||
| 97 | + | if !got.At(x, y).Equal(cell) { | |||
| 98 | + | t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, got.At(x, y), cell) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | }, | |||
| 103 | + | }, | |||
| 104 | + | } | |||
| 105 | + | for _, tt := range tests { | |||
| 106 | + | t.Run(tt.name, tt.run) | |||
| 107 | + | } | |||
| 108 | + | } | |||
A pkg/vt/history_codec.go +304 -0
| @@ -0,0 +1,304 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "math" | |||
| 8 | + | "unicode/utf8" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | historyMagic = "VTH1" | |||
| 15 | + | historyVersion = 1 | |||
| 16 | + | historyCellBytes = 41 | |||
| 17 | + | maxHistoryChunkRows = 256 | |||
| 18 | + | ||||
| 19 | + | // The daemon retains 10,000 history rows. Support a 20% row margin and | |||
| 20 | + | // 160-cell rows, which leaves headroom above the representative 10k×120 | |||
| 21 | + | // terminal while bounding snapshot allocation. | |||
| 22 | + | maxHistoryChunks = 12_000 | |||
| 23 | + | maxHistoryRows = 12_000 | |||
| 24 | + | maxHistoryRowCells = 160 | |||
| 25 | + | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | |||
| 26 | + | ||||
| 27 | + | maxHistoryDecodeStyles = maxHistoryCells | |||
| 28 | + | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | var errInvalidHistory = errors.New("invalid history payload") | |||
| 32 | + | ||||
| 33 | + | // MarshalHistory encodes a HistoryView in a deterministic, self-contained | |||
| 34 | + | // format. It preserves chunk boundaries as well as every Cell and Style field. | |||
| 35 | + | func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 36 | + | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks { | |||
| 37 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 38 | + | } | |||
| 39 | + | stats := historyDecodeStats{} | |||
| 40 | + | for _, chunk := range view.chunks { | |||
| 41 | + | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows || | |||
| 42 | + | !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) { | |||
| 43 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 44 | + | } | |||
| 45 | + | for _, row := range chunk.rows { | |||
| 46 | + | cellCount := uint64(len(row)) | |||
| 47 | + | cellBytes, ok := historyCellByteCount(cellCount) | |||
| 48 | + | if cellCount > maxHistoryRowCells || !ok || | |||
| 49 | + | !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) || | |||
| 50 | + | !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) || | |||
| 51 | + | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |||
| 52 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | out := make([]byte, 0, 9) | |||
| 58 | + | out = append(out, historyMagic...) | |||
| 59 | + | out = append(out, historyVersion) | |||
| 60 | + | out = appendUint32(out, uint32(len(view.chunks))) | |||
| 61 | + | for _, chunk := range view.chunks { | |||
| 62 | + | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows { | |||
| 63 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 64 | + | } | |||
| 65 | + | out = appendUint32(out, uint32(len(chunk.rows))) | |||
| 66 | + | for _, row := range chunk.rows { | |||
| 67 | + | if uint64(len(row)) > math.MaxUint32 { | |||
| 68 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 69 | + | } | |||
| 70 | + | out = appendUint32(out, uint32(len(row))) | |||
| 71 | + | for _, cell := range row { | |||
| 72 | + | if !validHistoryCell(cell) { | |||
| 73 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 74 | + | } | |||
| 75 | + | out = appendHistoryCell(out, cell) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | return out, nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func historyViewRowCount(view HistoryView) int { | |||
| 83 | + | n := 0 | |||
| 84 | + | for _, chunk := range view.chunks { | |||
| 85 | + | if chunk != nil && len(chunk.rows) <= math.MaxInt-n { | |||
| 86 | + | n += len(chunk.rows) | |||
| 87 | + | continue | |||
| 88 | + | } | |||
| 89 | + | return -1 | |||
| 90 | + | } | |||
| 91 | + | return n | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func appendUint32(dst []byte, v uint32) []byte { | |||
| 95 | + | return binary.BigEndian.AppendUint32(dst, v) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |||
| 99 | + | style := cell.Style | |||
| 100 | + | dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune)) | |||
| 101 | + | flags := byte(0) | |||
| 102 | + | if cell.Continuation { | |||
| 103 | + | flags |= 1 << 0 | |||
| 104 | + | } | |||
| 105 | + | if style.Bold { | |||
| 106 | + | flags |= 1 << 1 | |||
| 107 | + | } | |||
| 108 | + | if style.Italic { | |||
| 109 | + | flags |= 1 << 2 | |||
| 110 | + | } | |||
| 111 | + | if style.Inverse { | |||
| 112 | + | flags |= 1 << 3 | |||
| 113 | + | } | |||
| 114 | + | if style.HasForegroundRGB { | |||
| 115 | + | flags |= 1 << 4 | |||
| 116 | + | } | |||
| 117 | + | if style.HasBackgroundRGB { | |||
| 118 | + | flags |= 1 << 5 | |||
| 119 | + | } | |||
| 120 | + | if style.HasUnderlineColor { | |||
| 121 | + | flags |= 1 << 6 | |||
| 122 | + | } | |||
| 123 | + | if style.HasUnderlineColorRGB { | |||
| 124 | + | flags |= 1 << 7 | |||
| 125 | + | } | |||
| 126 | + | dst = append(dst, flags) | |||
| 127 | + | dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs)) | |||
| 128 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground))) | |||
| 129 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background))) | |||
| 130 | + | dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B) | |||
| 131 | + | dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B) | |||
| 132 | + | dst = append(dst, byte(style.UnderlineStyle)) | |||
| 133 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor))) | |||
| 134 | + | return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | |||
| 138 | + | // malformed declarations, truncated data, and trailing bytes. | |||
| 139 | + | func UnmarshalHistory(data []byte) (HistoryView, error) { | |||
| 140 | + | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |||
| 141 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 142 | + | } | |||
| 143 | + | if _, ok := preflightHistory(data[5:]); !ok { | |||
| 144 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | p := historyParser{data: data[5:]} | |||
| 148 | + | chunkCount, ok := p.uint32() | |||
| 149 | + | if !ok || uint64(chunkCount) > uint64(len(p.data))/4 { | |||
| 150 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 151 | + | } | |||
| 152 | + | chunks := make([]*HistoryChunk, 0, chunkCount) | |||
| 153 | + | rowsTotal := 0 | |||
| 154 | + | for range chunkCount { | |||
| 155 | + | rowCount, ok := p.uint32() | |||
| 156 | + | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 { | |||
| 157 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 158 | + | } | |||
| 159 | + | if rowsTotal > math.MaxInt-int(rowCount) { | |||
| 160 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 161 | + | } | |||
| 162 | + | rows := make([][]renderer.Cell, 0, rowCount) | |||
| 163 | + | for range rowCount { | |||
| 164 | + | cellCount, ok := p.uint32() | |||
| 165 | + | if !ok || uint64(cellCount) > maxHistoryRowCells || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) { | |||
| 166 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 167 | + | } | |||
| 168 | + | row := make([]renderer.Cell, cellCount) | |||
| 169 | + | for i := range row { | |||
| 170 | + | cell, ok := p.cell() | |||
| 171 | + | if !ok || !validHistoryCell(cell) { | |||
| 172 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 173 | + | } | |||
| 174 | + | row[i] = cell | |||
| 175 | + | } | |||
| 176 | + | rows = append(rows, row) | |||
| 177 | + | } | |||
| 178 | + | rowsTotal += int(rowCount) | |||
| 179 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |||
| 180 | + | } | |||
| 181 | + | if len(p.data) != 0 { | |||
| 182 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 183 | + | } | |||
| 184 | + | return HistoryView{chunks: chunks, rows: rowsTotal}, nil | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | type historyDecodeStats struct { | |||
| 188 | + | chunks uint64 | |||
| 189 | + | rows uint64 | |||
| 190 | + | cells uint64 | |||
| 191 | + | styles uint64 | |||
| 192 | + | bytes uint64 | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // preflightHistory validates declarations and aggregate budgets without | |||
| 196 | + | // allocating decoded history. Its totals can be composed by snapshot decoders. | |||
| 197 | + | func preflightHistory(data []byte) (historyDecodeStats, bool) { | |||
| 198 | + | p := historyParser{data: data} | |||
| 199 | + | chunkCount, ok := p.uint32() | |||
| 200 | + | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | |||
| 201 | + | return historyDecodeStats{}, false | |||
| 202 | + | } | |||
| 203 | + | stats := historyDecodeStats{chunks: uint64(chunkCount)} | |||
| 204 | + | for range chunkCount { | |||
| 205 | + | rowCount, ok := p.uint32() | |||
| 206 | + | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 || !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |||
| 207 | + | return historyDecodeStats{}, false | |||
| 208 | + | } | |||
| 209 | + | for range rowCount { | |||
| 210 | + | cellCount, ok := p.uint32() | |||
| 211 | + | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | |||
| 212 | + | if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes || uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | |||
| 213 | + | return historyDecodeStats{}, false | |||
| 214 | + | } | |||
| 215 | + | if !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | |||
| 216 | + | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | |||
| 217 | + | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |||
| 218 | + | return historyDecodeStats{}, false | |||
| 219 | + | } | |||
| 220 | + | p.data = p.data[int(cellBytes):] | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | return stats, len(p.data) == 0 | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func historyCellByteCount(cellCount uint64) (uint64, bool) { | |||
| 227 | + | if cellCount > math.MaxUint64/historyCellBytes { | |||
| 228 | + | return 0, false | |||
| 229 | + | } | |||
| 230 | + | return cellCount * historyCellBytes, true | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool { | |||
| 234 | + | if *total > limit || amount > limit-*total { | |||
| 235 | + | return false | |||
| 236 | + | } | |||
| 237 | + | *total += amount | |||
| 238 | + | return true | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | type historyParser struct{ data []byte } | |||
| 242 | + | ||||
| 243 | + | func (p *historyParser) uint32() (uint32, bool) { | |||
| 244 | + | if len(p.data) < 4 { | |||
| 245 | + | return 0, false | |||
| 246 | + | } | |||
| 247 | + | v := binary.BigEndian.Uint32(p.data) | |||
| 248 | + | p.data = p.data[4:] | |||
| 249 | + | return v, true | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func (p *historyParser) cell() (renderer.Cell, bool) { | |||
| 253 | + | if len(p.data) < historyCellBytes { | |||
| 254 | + | return renderer.Cell{}, false | |||
| 255 | + | } | |||
| 256 | + | b := p.data[:historyCellBytes] | |||
| 257 | + | p.data = p.data[historyCellBytes:] | |||
| 258 | + | foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15])) | |||
| 259 | + | if !ok { | |||
| 260 | + | return renderer.Cell{}, false | |||
| 261 | + | } | |||
| 262 | + | background, ok := historyInt(binary.BigEndian.Uint64(b[15:23])) | |||
| 263 | + | if !ok { | |||
| 264 | + | return renderer.Cell{}, false | |||
| 265 | + | } | |||
| 266 | + | underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38])) | |||
| 267 | + | if !ok { | |||
| 268 | + | return renderer.Cell{}, false | |||
| 269 | + | } | |||
| 270 | + | flags := b[4] | |||
| 271 | + | return renderer.Cell{ | |||
| 272 | + | Rune: rune(binary.BigEndian.Uint32(b[0:4])), | |||
| 273 | + | Continuation: flags&1 != 0, | |||
| 274 | + | Style: renderer.Style{ | |||
| 275 | + | Bold: flags&(1<<1) != 0, | |||
| 276 | + | Italic: flags&(1<<2) != 0, | |||
| 277 | + | Inverse: flags&(1<<3) != 0, | |||
| 278 | + | Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])), | |||
| 279 | + | Foreground: foreground, | |||
| 280 | + | Background: background, | |||
| 281 | + | HasForegroundRGB: flags&(1<<4) != 0, | |||
| 282 | + | ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]}, | |||
| 283 | + | HasBackgroundRGB: flags&(1<<5) != 0, | |||
| 284 | + | BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]}, | |||
| 285 | + | UnderlineStyle: renderer.UnderlineStyle(b[29]), | |||
| 286 | + | HasUnderlineColor: flags&(1<<6) != 0, | |||
| 287 | + | UnderlineColor: underlineColor, | |||
| 288 | + | HasUnderlineColorRGB: flags&(1<<7) != 0, | |||
| 289 | + | UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]}, | |||
| 290 | + | }, | |||
| 291 | + | }, true | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func historyInt(raw uint64) (int, bool) { | |||
| 295 | + | v := int64(raw) | |||
| 296 | + | if int64(int(v)) != v { | |||
| 297 | + | return 0, false | |||
| 298 | + | } | |||
| 299 | + | return int(v), true | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | func validHistoryCell(cell renderer.Cell) bool { | |||
| 303 | + | return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed | |||
| 304 | + | } | |||
A pkg/vt/history_codec_test.go +246 -0
| @@ -0,0 +1,246 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |||
| 11 | + | indexed := renderer.Style{ | |||
| 12 | + | Bold: true, | |||
| 13 | + | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrStrikethrough, | |||
| 14 | + | Foreground: 123, | |||
| 15 | + | Background: 45, | |||
| 16 | + | UnderlineStyle: renderer.UnderlineDashed, | |||
| 17 | + | HasUnderlineColor: true, | |||
| 18 | + | UnderlineColor: 201, | |||
| 19 | + | } | |||
| 20 | + | rgb := renderer.Style{ | |||
| 21 | + | Italic: true, | |||
| 22 | + | Inverse: true, | |||
| 23 | + | Attrs: renderer.AttrBlink, | |||
| 24 | + | HasForegroundRGB: true, | |||
| 25 | + | ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 26 | + | HasBackgroundRGB: true, | |||
| 27 | + | BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 28 | + | UnderlineStyle: renderer.UnderlineCurly, | |||
| 29 | + | HasUnderlineColorRGB: true, | |||
| 30 | + | UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9}, | |||
| 31 | + | } | |||
| 32 | + | tests := []struct { | |||
| 33 | + | name string | |||
| 34 | + | rows [][]renderer.Cell | |||
| 35 | + | }{ | |||
| 36 | + | { | |||
| 37 | + | name: "indexed RGB blank and wide continuation cells are exact", | |||
| 38 | + | rows: [][]renderer.Cell{ | |||
| 39 | + | { | |||
| 40 | + | {Rune: 'I', Style: indexed}, | |||
| 41 | + | {Rune: 'R', Style: rgb}, | |||
| 42 | + | {Rune: ' ', Style: renderer.DefaultStyle()}, | |||
| 43 | + | {Rune: '界', Style: rgb}, | |||
| 44 | + | {Continuation: true, Style: rgb}, | |||
| 45 | + | }, | |||
| 46 | + | { | |||
| 47 | + | {Rune: ' ', Style: renderer.DefaultStyle()}, | |||
| 48 | + | {Rune: 'X', Style: indexed}, | |||
| 49 | + | }, | |||
| 50 | + | }, | |||
| 51 | + | }, | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | for _, tt := range tests { | |||
| 55 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 56 | + | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 57 | + | for _, row := range tt.rows { | |||
| 58 | + | history.Append(row) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | encoded, err := MarshalHistory(history.View()) | |||
| 62 | + | if err != nil { | |||
| 63 | + | t.Fatalf("marshal history: %v", err) | |||
| 64 | + | } | |||
| 65 | + | decoded, err := UnmarshalHistory(encoded) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatalf("unmarshal history: %v", err) | |||
| 68 | + | } | |||
| 69 | + | assertHistoryRowsEqual(t, decoded, tt.rows) | |||
| 70 | + | }) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |||
| 75 | + | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2}) | |||
| 76 | + | history.Append(historyRow("AAAA")) | |||
| 77 | + | history.Append(historyRow("BBBB")) | |||
| 78 | + | encoded, err := MarshalHistory(history.View()) | |||
| 79 | + | if err != nil { | |||
| 80 | + | t.Fatalf("marshal history: %v", err) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | tests := []struct { | |||
| 84 | + | name string | |||
| 85 | + | data []byte | |||
| 86 | + | }{ | |||
| 87 | + | {name: "empty", data: nil}, | |||
| 88 | + | {name: "truncated prefix", data: encoded[:len(encoded)-1]}, | |||
| 89 | + | {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)}, | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | for _, tt := range tests { | |||
| 93 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 94 | + | if _, err := UnmarshalHistory(tt.data); err == nil { | |||
| 95 | + | t.Fatal("unmarshal accepted malformed history payload") | |||
| 96 | + | } | |||
| 97 | + | }) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) { | |||
| 102 | + | const ( | |||
| 103 | + | rows = 10_000 | |||
| 104 | + | width = 120 | |||
| 105 | + | ) | |||
| 106 | + | ||||
| 107 | + | encoded, err := MarshalHistory(historyViewWithDimensions(rows, width)) | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatalf("marshal representative history: %v", err) | |||
| 110 | + | } | |||
| 111 | + | decoded, err := UnmarshalHistory(encoded) | |||
| 112 | + | if err != nil { | |||
| 113 | + | t.Fatalf("unmarshal representative history: %v", err) | |||
| 114 | + | } | |||
| 115 | + | if got := decoded.Len(); got != rows { | |||
| 116 | + | t.Fatalf("decoded rows = %d, want %d", got, rows) | |||
| 117 | + | } | |||
| 118 | + | for _, rowIndex := range []int{0, rows - 1} { | |||
| 119 | + | if got := len(decoded.Row(rowIndex)); got != width { | |||
| 120 | + | t.Fatalf("decoded row %d width = %d, want %d", rowIndex, got, width) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) { | |||
| 126 | + | const ( | |||
| 127 | + | supportedRows = 12_000 | |||
| 128 | + | supportedWidth = 160 | |||
| 129 | + | ) | |||
| 130 | + | tests := []struct { | |||
| 131 | + | name string | |||
| 132 | + | view HistoryView | |||
| 133 | + | data []byte | |||
| 134 | + | }{ | |||
| 135 | + | { | |||
| 136 | + | name: "too many rows", | |||
| 137 | + | view: historyViewWithDimensions(supportedRows+1, 0), | |||
| 138 | + | data: historyPayloadWithDimensions(supportedRows+1, 0), | |||
| 139 | + | }, | |||
| 140 | + | { | |||
| 141 | + | name: "too many cells in a row", | |||
| 142 | + | view: historyViewWithDimensions(1, supportedWidth+1), | |||
| 143 | + | data: historyPayloadWithDimensions(1, supportedWidth+1), | |||
| 144 | + | }, | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | for _, tt := range tests { | |||
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | + | if _, err := MarshalHistory(tt.view); err == nil { | |||
| 150 | + | t.Fatal("marshal accepted dimensions beyond the supported limits") | |||
| 151 | + | } | |||
| 152 | + | if _, err := UnmarshalHistory(tt.data); err == nil { | |||
| 153 | + | t.Fatal("unmarshal accepted dimensions beyond the supported limits") | |||
| 154 | + | } | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) { | |||
| 160 | + | tests := []struct { | |||
| 161 | + | name string | |||
| 162 | + | chunkCount int | |||
| 163 | + | rowCount int | |||
| 164 | + | }{ | |||
| 165 | + | {name: "more than twelve thousand chunks", chunkCount: 12_001, rowCount: 1}, | |||
| 166 | + | {name: "more than twelve thousand rows", chunkCount: 47, rowCount: maxHistoryChunkRows}, | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | for _, tt := range tests { | |||
| 170 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 171 | + | if _, err := UnmarshalHistory(hostileHistoryDeclarations(tt.chunkCount, tt.rowCount)); err == nil { | |||
| 172 | + | t.Fatal("unmarshal accepted resource-exhausting history declarations") | |||
| 173 | + | } | |||
| 174 | + | }) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func historyViewWithDimensions(rowCount, width int) HistoryView { | |||
| 179 | + | totalRows := rowCount | |||
| 180 | + | row := make([]renderer.Cell, width) | |||
| 181 | + | for i := range row { | |||
| 182 | + | row[i].Rune = 'x' | |||
| 183 | + | } | |||
| 184 | + | chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows) | |||
| 185 | + | for rowCount > 0 { | |||
| 186 | + | chunkRows := min(rowCount, maxHistoryChunkRows) | |||
| 187 | + | rows := make([][]renderer.Cell, chunkRows) | |||
| 188 | + | for i := range rows { | |||
| 189 | + | rows[i] = row | |||
| 190 | + | } | |||
| 191 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |||
| 192 | + | rowCount -= chunkRows | |||
| 193 | + | } | |||
| 194 | + | return HistoryView{chunks: chunks, rows: totalRows} | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func historyPayloadWithDimensions(rowCount, width int) []byte { | |||
| 198 | + | data := make([]byte, 9) | |||
| 199 | + | copy(data, historyMagic) | |||
| 200 | + | data[4] = historyVersion | |||
| 201 | + | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | |||
| 202 | + | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |||
| 203 | + | for rowCount > 0 { | |||
| 204 | + | chunkRows := min(rowCount, maxHistoryChunkRows) | |||
| 205 | + | data = binary.BigEndian.AppendUint32(data, uint32(chunkRows)) | |||
| 206 | + | for range chunkRows { | |||
| 207 | + | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |||
| 208 | + | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | |||
| 209 | + | data = append(data, make([]byte, width*historyCellBytes)...) | |||
| 210 | + | } | |||
| 211 | + | rowCount -= chunkRows | |||
| 212 | + | } | |||
| 213 | + | return data | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | |||
| 217 | + | data := make([]byte, 9, 9+chunkCount*(4+rowCount*4)) | |||
| 218 | + | copy(data, historyMagic) | |||
| 219 | + | data[4] = historyVersion | |||
| 220 | + | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |||
| 221 | + | for range chunkCount { | |||
| 222 | + | data = binary.BigEndian.AppendUint32(data, uint32(rowCount)) | |||
| 223 | + | for range rowCount { | |||
| 224 | + | data = binary.BigEndian.AppendUint32(data, 0) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | return data | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) { | |||
| 231 | + | t.Helper() | |||
| 232 | + | if got := view.Len(); got != len(want) { | |||
| 233 | + | t.Fatalf("row count = %d, want %d", got, len(want)) | |||
| 234 | + | } | |||
| 235 | + | for y, wantRow := range want { | |||
| 236 | + | gotRow := view.Row(y) | |||
| 237 | + | if len(gotRow) != len(wantRow) { | |||
| 238 | + | t.Fatalf("row %d width = %d, want %d", y, len(gotRow), len(wantRow)) | |||
| 239 | + | } | |||
| 240 | + | for x, wantCell := range wantRow { | |||
| 241 | + | if !gotRow[x].Equal(wantCell) { | |||
| 242 | + | t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, gotRow[x], wantCell) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | } | |||
A pkg/vt/history_test.go +173 -0
| @@ -0,0 +1,173 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | maxRows int | |||
| 13 | + | chunkRows int | |||
| 14 | + | rows []string | |||
| 15 | + | want []string | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "oldest chunks are evicted before newer chunks", | |||
| 19 | + | maxRows: 4, | |||
| 20 | + | chunkRows: 2, | |||
| 21 | + | rows: []string{"aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"}, | |||
| 22 | + | want: []string{"cccc", "dddd", "eeee", "ffff"}, | |||
| 23 | + | }, | |||
| 24 | + | { | |||
| 25 | + | name: "nonmultiple capacity evicts a whole oldest immutable chunk", | |||
| 26 | + | maxRows: 5, | |||
| 27 | + | chunkRows: 2, | |||
| 28 | + | rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"}, | |||
| 29 | + | want: []string{"2222", "3333", "4444", "5555"}, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "rows preserve append order across chunk boundaries", | |||
| 33 | + | maxRows: 4, | |||
| 34 | + | chunkRows: 2, | |||
| 35 | + | rows: []string{"0000", "1111", "2222", "3333"}, | |||
| 36 | + | want: []string{"0000", "1111", "2222", "3333"}, | |||
| 37 | + | }, | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | for _, tt := range tests { | |||
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 42 | + | history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows}) | |||
| 43 | + | for _, text := range tt.rows { | |||
| 44 | + | history.Append(historyRow(text)) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | view := history.View() | |||
| 48 | + | if got := view.Len(); got > tt.maxRows { | |||
| 49 | + | t.Fatalf("retained row count = %d, exceeds capacity %d", got, tt.maxRows) | |||
| 50 | + | } | |||
| 51 | + | if got := historyViewTexts(view); !equalStrings(got, tt.want) { | |||
| 52 | + | t.Fatalf("view rows = %#v, want %#v", got, tt.want) | |||
| 53 | + | } | |||
| 54 | + | if got, want := view.ChunkCount(), (len(tt.want)+1)/2; got != want { | |||
| 55 | + | t.Fatalf("chunk count = %d, want %d", got, want) | |||
| 56 | + | } | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestHistoryTailRotationAndStableViews(t *testing.T) { | |||
| 62 | + | tests := []struct { | |||
| 63 | + | name string | |||
| 64 | + | }{ | |||
| 65 | + | {name: "a full tail seals and a later append cannot change an existing view"}, | |||
| 66 | + | {name: "rows passed to append are copied before the screen recycles them"}, | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | for _, tt := range tests { | |||
| 70 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 71 | + | history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2}) | |||
| 72 | + | first := historyRow("AAAA") | |||
| 73 | + | history.Append(first) | |||
| 74 | + | history.Append(historyRow("BBBB")) | |||
| 75 | + | view := history.View() | |||
| 76 | + | if got, want := view.ChunkCount(), 1; got != want { | |||
| 77 | + | t.Fatalf("sealed chunk count = %d, want %d", got, want) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | first[0].Rune = 'X' | |||
| 81 | + | history.Append(historyRow("CCCC")) | |||
| 82 | + | ||||
| 83 | + | if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) { | |||
| 84 | + | t.Fatalf("stable view rows = %#v, want %#v", got, want) | |||
| 85 | + | } | |||
| 86 | + | if got, want := historyViewTexts(history.View()), []string{"AAAA", "BBBB", "CCCC"}; !equalStrings(got, want) { | |||
| 87 | + | t.Fatalf("current view rows = %#v, want %#v", got, want) | |||
| 88 | + | } | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) { | |||
| 94 | + | tests := []struct { | |||
| 95 | + | name string | |||
| 96 | + | }{ | |||
| 97 | + | {name: "unchanged sealed chunks are shared by successive views"}, | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | for _, tt := range tests { | |||
| 101 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 102 | + | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 103 | + | history.Append(historyRow("AAAA")) | |||
| 104 | + | history.Append(historyRow("BBBB")) | |||
| 105 | + | before := history.View() | |||
| 106 | + | ||||
| 107 | + | history.Append(historyRow("CCCC")) | |||
| 108 | + | after := history.View() | |||
| 109 | + | ||||
| 110 | + | if before.ChunkCount() == 0 || after.ChunkCount() == 0 { | |||
| 111 | + | t.Fatal("sealed chunk missing from view") | |||
| 112 | + | } | |||
| 113 | + | if before.Chunk(0) != after.Chunk(0) { | |||
| 114 | + | t.Fatal("unchanged sealed chunk was copied instead of reused") | |||
| 115 | + | } | |||
| 116 | + | }) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) { | |||
| 121 | + | tests := []struct { | |||
| 122 | + | name string | |||
| 123 | + | write []byte | |||
| 124 | + | want []string | |||
| 125 | + | }{ | |||
| 126 | + | { | |||
| 127 | + | name: "primary screen evictions enter terminal history", | |||
| 128 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |||
| 129 | + | want: []string{"AAAA"}, | |||
| 130 | + | }, | |||
| 131 | + | { | |||
| 132 | + | name: "alternate screen scrolling never enters terminal history", | |||
| 133 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"), | |||
| 134 | + | want: []string{"AAAA"}, | |||
| 135 | + | }, | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | for _, tt := range tests { | |||
| 139 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 140 | + | screen := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 141 | + | screen.Write(tt.write) | |||
| 142 | + | ||||
| 143 | + | if got := historyViewTexts(screen.History().View()); !equalStrings(got, tt.want) { | |||
| 144 | + | t.Fatalf("terminal history rows = %#v, want %#v", got, tt.want) | |||
| 145 | + | } | |||
| 146 | + | }) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func historyRow(text string) []renderer.Cell { | |||
| 151 | + | row := make([]renderer.Cell, len(text)) | |||
| 152 | + | for i, r := range text { | |||
| 153 | + | row[i] = renderer.BlankCell() | |||
| 154 | + | row[i].Rune = r | |||
| 155 | + | } | |||
| 156 | + | return row | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func historyViewTexts(view HistoryView) []string { | |||
| 160 | + | rows := make([]string, view.Len()) | |||
| 161 | + | for i := range rows { | |||
| 162 | + | rows[i] = rowText(view.Row(i)) | |||
| 163 | + | } | |||
| 164 | + | return rows | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func rowText(row []renderer.Cell) string { | |||
| 168 | + | runes := make([]rune, len(row)) | |||
| 169 | + | for i, cell := range row { | |||
| 170 | + | runes[i] = cell.Rune | |||
| 171 | + | } | |||
| 172 | + | return string(runes) | |||
| 173 | + | } | |||
M pkg/vt/screen.go +26 -7
| @@ -85,6 +85,8 @@ type Screen struct { | |||||
| 85 | // never invoke it. Nil disables it. | 85 | // never invoke it. Nil disables it. | ||
| 86 | OnClipboard func(b64 string) | 86 | OnClipboard func(b64 string) | ||
| 87 | 87 | ||||
| 88 | + | history *History | |||
| 89 | + | ||||
| 88 | defaultFG renderer.RGB | 90 | defaultFG renderer.RGB | ||
| 89 | defaultBG renderer.RGB | 91 | defaultBG renderer.RGB | ||
| 90 | defaultColorsKnown bool | 92 | defaultColorsKnown bool | ||
| @@ -128,15 +130,23 @@ func NewScreen(width, height int) *Screen { | |||||
| 128 | return s | 130 | return s | ||
| 129 | } | 131 | } | ||
| 130 | 132 | ||||
| 133 | + | // NewScreenWithHistory creates a screen that records rows evicted from its | |||
| 134 | + | // primary screen into bounded immutable terminal history. | |||
| 135 | + | func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen { | |||
| 136 | + | s := NewScreen(width, height) | |||
| 137 | + | s.history = NewHistory(config) | |||
| 138 | + | return s | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // History returns this screen's terminal history, or nil when history was not | |||
| 142 | + | // configured with NewScreenWithHistory. | |||
| 143 | + | func (s *Screen) History() *History { return s.history } | |||
| 144 | + | ||||
| 131 | func (s *Screen) Resize(width, height int) { | 145 | func (s *Screen) Resize(width, height int) { | ||
| 132 | if width == s.Frame.Width && height == s.Frame.Height { | 146 | if width == s.Frame.Width && height == s.Frame.Height { | ||
| 133 | return | 147 | return | ||
| 134 | } | 148 | } | ||
| 135 | - | evict := func(row []renderer.Cell) { | 149 | + | evict := s.recordEvicted |
| 136 | - | if s.OnLineEvicted != nil { | |||
| 137 | - | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |||
| 138 | - | } | |||
| 139 | - | } | |||
| 140 | 150 | ||||
| 141 | if s.alternate != nil { | 151 | if s.alternate != nil { | ||
| 142 | var shift int | 152 | var shift int | ||
| @@ -580,11 +590,20 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||||
| 580 | } | 590 | } | ||
| 581 | 591 | ||||
| 582 | func (s *Screen) emitLineEvicted(top, n int) { | 592 | func (s *Screen) emitLineEvicted(top, n int) { | ||
| 583 | - | if s.OnLineEvicted == nil || s.alternate != nil { | 593 | + | if s.alternate != nil { |
| 584 | return | 594 | return | ||
| 585 | } | 595 | } | ||
| 586 | for y := top; y < top+n; y++ { | 596 | for y := top; y < top+n; y++ { | ||
| 587 | - | s.OnLineEvicted(append([]renderer.Cell(nil), s.Frame.Row(y)...)) | 597 | + | s.recordEvicted(s.Frame.Row(y)) |
| 598 | + | } | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | func (s *Screen) recordEvicted(row []renderer.Cell) { | |||
| 602 | + | if s.history != nil { | |||
| 603 | + | s.history.Append(row) | |||
| 604 | + | } | |||
| 605 | + | if s.OnLineEvicted != nil { | |||
| 606 | + | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |||
| 588 | } | 607 | } | ||
| 589 | } | 608 | } | ||
| 590 | 609 | ||||
M pkg/vt/snapshot.go +206 -1
| @@ -1,6 +1,193 @@ | |||||
| 1 | package 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 | + | // NewScreenWithRestoredHistory constructs a screen that owns history restored | |||
| 103 | + | // directly from canonical blobs. Call RestorePrimaryVisible to install a | |||
| 104 | + | // persisted primary frame before making the screen live. | |||
| 105 | + | func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, sealed [][]byte, tail []byte) (*Screen, error) { | |||
| 106 | + | history, err := HistoryFromBlobs(config, sealed, tail) | |||
| 107 | + | if err != nil { | |||
| 108 | + | return nil, err | |||
| 109 | + | } | |||
| 110 | + | screen := NewScreenWithHistory(width, height, config) | |||
| 111 | + | screen.history = history | |||
| 112 | + | return screen, nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // MarshalVisible encodes the exact visible primary frame, including every | |||
| 116 | + | // blank, row width, and terminal cell attribute. | |||
| 117 | + | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||
| 118 | + | if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 { | |||
| 119 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 120 | + | } | |||
| 121 | + | cells := uint64(frame.Width) * uint64(frame.Height) | |||
| 122 | + | bytes, ok := historyCellByteCount(cells) | |||
| 123 | + | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes { | |||
| 124 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 125 | + | } | |||
| 126 | + | out := make([]byte, 0, 13+bytes) | |||
| 127 | + | out = append(out, visibleMagic...) | |||
| 128 | + | out = append(out, historyVersion) | |||
| 129 | + | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |||
| 130 | + | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | |||
| 131 | + | for y := range frame.Height { | |||
| 132 | + | for _, cell := range frame.Row(y) { | |||
| 133 | + | if !validHistoryCell(cell) { | |||
| 134 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 135 | + | } | |||
| 136 | + | out = appendHistoryCell(out, cell) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | return out, nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // PreflightVisibleBlob validates dimensions and cell declarations without | |||
| 143 | + | // allocating a frame. | |||
| 144 | + | func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||
| 145 | + | if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion { | |||
| 146 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 147 | + | } | |||
| 148 | + | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | |||
| 149 | + | if width != 0 && height > math.MaxUint64/width { | |||
| 150 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 151 | + | } | |||
| 152 | + | cells := width * height | |||
| 153 | + | bytes, ok := historyCellByteCount(cells) | |||
| 154 | + | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes { | |||
| 155 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 156 | + | } | |||
| 157 | + | p := historyParser{data: data[13:]} | |||
| 158 | + | for range cells { | |||
| 159 | + | cell, ok := p.cell() | |||
| 160 | + | if !ok || !validHistoryCell(cell) { | |||
| 161 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // UnmarshalVisible decodes a preflighted exact visible frame. | |||
| 168 | + | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | |||
| 169 | + | if _, err := PreflightVisibleBlob(data); err != nil { | |||
| 170 | + | return renderer.Frame{}, err | |||
| 171 | + | } | |||
| 172 | + | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | |||
| 173 | + | frame := renderer.NewFrame(width, height) | |||
| 174 | + | p := historyParser{data: data[13:]} | |||
| 175 | + | for y := range height { | |||
| 176 | + | for x := range width { | |||
| 177 | + | cell, _ := p.cell() | |||
| 178 | + | frame.Set(x, y, cell) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | return frame, nil | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame. | |||
| 185 | + | func (s *Screen) PrimaryVisibleFrame() renderer.Frame { | |||
| 186 | + | if s.alternate != nil { | |||
| 187 | + | return s.alternate.frame.Clone() | |||
| 188 | + | } | |||
| 189 | + | return s.Frame.Clone() | |||
| 190 | + | } | |||
| 4 | 191 | ||||
| 5 | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | 192 | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | ||
| 6 | // When the alternate screen is active, it snapshots the saved primary screen | 193 | // When the alternate screen is active, it snapshots the saved primary screen | ||
| @@ -16,3 +203,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |||||
| 16 | } | 203 | } | ||
| 17 | return rows | 204 | return rows | ||
| 18 | } | 205 | } | ||
| 206 | + | ||||
| 207 | + | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | |||
| 208 | + | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |||
| 209 | + | frame, err := UnmarshalVisible(blob) | |||
| 210 | + | if err != nil { | |||
| 211 | + | return err | |||
| 212 | + | } | |||
| 213 | + | // A persisted primary frame is authoritative: restoring it directly keeps | |||
| 214 | + | // exact row widths and height even when a collapsed layout chose a smaller | |||
| 215 | + | // initial PTY size. A subsequent resize reconciles it with live geometry. | |||
| 216 | + | if s.alternate != nil { | |||
| 217 | + | s.alternate.frame = frame | |||
| 218 | + | } else { | |||
| 219 | + | s.Frame = frame | |||
| 220 | + | } | |||
| 221 | + | s.fullRedraw() | |||
| 222 | + | return nil | |||
| 223 | + | } | |||