detached 8151d78

refactor(daemon): encode snapshots asynchronously

commit
8151d785d3ee3593aa73512066cc1ee2fcd4569b
author
brice · · 2026-07-12 16:23 UTC
parent
89d8fa0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +792 -60
M internal/usecase/copy/mode_test.go +85 -0
@@ -23,6 +23,14 @@ func row(text string) []renderer.Cell {
2323
2424 func newHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
2525
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+
2634 func snapshot(lines []string, height int) Snapshot {
2735 rows := make([][]renderer.Cell, len(lines))
2836 for i, line := range lines {
@@ -489,6 +497,83 @@ func TestOSC52FromBase64(t *testing.T) {
489497 }
490498 }
491499
500+func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {
501+ history := newHistory(1)
502+ history.Append(row("old"))
503+ screen := renderer.NewFrame(3, 1)
504+ copy(screen.Row(0), row("one"))
505+
506+ document := NewSnapshot(history, screen)
507+ history.Append(row("new"))
508+ screen.Set(0, 0, renderer.Cell{Rune: 'x'})
509+
510+ require.Equal(t, 2, document.Len())
511+ require.Equal(t, "old", rowText(document.Row(0)))
512+ require.Equal(t, "one", rowText(document.Row(1)))
513+ require.Nil(t, document.Row(-1))
514+ require.Nil(t, document.Row(2))
515+}
516+
517+func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
518+ rows := [][]renderer.Cell{row("one")}
519+ document := NewSnapshotFromRows(rows, 3, 1)
520+ rows[0][0].Rune = 'x'
521+ require.Equal(t, "one", rowText(document.Row(0)))
522+}
523+
524+// This scaling gate deliberately compares equal row counts rather than an
525+// absolute budget, which varies between Go releases. A HistoryView shares
526+// sealed chunks, so its allocation must not grow with history-row width.
527+func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
528+ if testing.Short() {
529+ t.Skip("skipping benchmark-driven allocation gate in short mode")
530+ }
531+ narrow := benchmarkNewSnapshotBytes(16)
532+ wide := benchmarkNewSnapshotBytes(512)
533+ const conservativeWidthTolerance = 2
534+ require.LessOrEqual(t, wide, narrow*conservativeWidthTolerance,
535+ "NewSnapshot B/op scaled with row width: narrow=%d wide=%d", narrow, wide)
536+}
537+
538+func benchmarkNewSnapshotBytes(width int) int64 {
539+ const historyRows = 256
540+ history := newHistory(historyRows)
541+ for range historyRows {
542+ history.Append(make([]renderer.Cell, width))
543+ }
544+ // Keep visible-frame work constant so the comparison isolates history rows.
545+ screen := renderer.NewFrame(8, 4)
546+ result := testing.Benchmark(func(b *testing.B) {
547+ b.ReportAllocs()
548+ b.ResetTimer()
549+ for b.Loop() {
550+ benchmarkSnapshotSink = NewSnapshot(history, screen)
551+ }
552+ })
553+ return result.AllocedBytesPerOp()
554+}
555+
556+// This regression replaces the removed Scrollback ring tests: the VT history
557+// remains bounded oldest-first and a captured copy document survives eviction.
558+func TestNewSnapshotRetainsBoundedHistoryAcrossLaterEviction(t *testing.T) {
559+ history := newHistory(2)
560+ history.Append(row("one"))
561+ history.Append(row("two"))
562+ document := NewSnapshot(history, renderer.NewFrame(3, 1))
563+ history.Append(row("three"))
564+ history.Append(row("four"))
565+
566+ // A copy document is history followed by the visible frame. Its total is
567+ // therefore the bounded retained history plus the one visible row.
568+ require.Equal(t, 3, document.Len())
569+ require.Equal(t, "one", rowText(document.Row(0)))
570+ require.Equal(t, "two", rowText(document.Row(1)))
571+ require.Equal(t, " ", rowText(document.Row(2)), "the visible frame remains part of the document")
572+ view := history.View()
573+ require.Equal(t, "three", rowText(view.Row(0)))
574+ require.Equal(t, "four", rowText(view.Row(1)))
575+}
576+
492577 func BenchmarkNewSnapshot10KRows(b *testing.B) {
493578 const (
494579 width = 120
@@ -23,6 +23,14 @@ func row(text string) []renderer.Cell {
23 23
24 func newHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }24 func newHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
25 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+
26 func snapshot(lines []string, height int) Snapshot {34 func snapshot(lines []string, height int) Snapshot {
27 rows := make([][]renderer.Cell, len(lines))35 rows := make([][]renderer.Cell, len(lines))
28 for i, line := range lines {36 for i, line := range lines {
@@ -489,6 +497,83 @@ func TestOSC52FromBase64(t *testing.T) {
489 }497 }
490 }498 }
491 499
500+func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {
501+ history := newHistory(1)
502+ history.Append(row("old"))
503+ screen := renderer.NewFrame(3, 1)
504+ copy(screen.Row(0), row("one"))
505+
506+ document := NewSnapshot(history, screen)
507+ history.Append(row("new"))
508+ screen.Set(0, 0, renderer.Cell{Rune: 'x'})
509+
510+ require.Equal(t, 2, document.Len())
511+ require.Equal(t, "old", rowText(document.Row(0)))
512+ require.Equal(t, "one", rowText(document.Row(1)))
513+ require.Nil(t, document.Row(-1))
514+ require.Nil(t, document.Row(2))
515+}
516+
517+func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
518+ rows := [][]renderer.Cell{row("one")}
519+ document := NewSnapshotFromRows(rows, 3, 1)
520+ rows[0][0].Rune = 'x'
521+ require.Equal(t, "one", rowText(document.Row(0)))
522+}
523+
524+// This scaling gate deliberately compares equal row counts rather than an
525+// absolute budget, which varies between Go releases. A HistoryView shares
526+// sealed chunks, so its allocation must not grow with history-row width.
527+func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
528+ if testing.Short() {
529+ t.Skip("skipping benchmark-driven allocation gate in short mode")
530+ }
531+ narrow := benchmarkNewSnapshotBytes(16)
532+ wide := benchmarkNewSnapshotBytes(512)
533+ const conservativeWidthTolerance = 2
534+ require.LessOrEqual(t, wide, narrow*conservativeWidthTolerance,
535+ "NewSnapshot B/op scaled with row width: narrow=%d wide=%d", narrow, wide)
536+}
537+
538+func benchmarkNewSnapshotBytes(width int) int64 {
539+ const historyRows = 256
540+ history := newHistory(historyRows)
541+ for range historyRows {
542+ history.Append(make([]renderer.Cell, width))
543+ }
544+ // Keep visible-frame work constant so the comparison isolates history rows.
545+ screen := renderer.NewFrame(8, 4)
546+ result := testing.Benchmark(func(b *testing.B) {
547+ b.ReportAllocs()
548+ b.ResetTimer()
549+ for b.Loop() {
550+ benchmarkSnapshotSink = NewSnapshot(history, screen)
551+ }
552+ })
553+ return result.AllocedBytesPerOp()
554+}
555+
556+// This regression replaces the removed Scrollback ring tests: the VT history
557+// remains bounded oldest-first and a captured copy document survives eviction.
558+func TestNewSnapshotRetainsBoundedHistoryAcrossLaterEviction(t *testing.T) {
559+ history := newHistory(2)
560+ history.Append(row("one"))
561+ history.Append(row("two"))
562+ document := NewSnapshot(history, renderer.NewFrame(3, 1))
563+ history.Append(row("three"))
564+ history.Append(row("four"))
565+
566+ // A copy document is history followed by the visible frame. Its total is
567+ // therefore the bounded retained history plus the one visible row.
568+ require.Equal(t, 3, document.Len())
569+ require.Equal(t, "one", rowText(document.Row(0)))
570+ require.Equal(t, "two", rowText(document.Row(1)))
571+ require.Equal(t, " ", rowText(document.Row(2)), "the visible frame remains part of the document")
572+ view := history.View()
573+ require.Equal(t, "three", rowText(view.Row(0)))
574+ require.Equal(t, "four", rowText(view.Row(1)))
575+}
576+
492 func BenchmarkNewSnapshot10KRows(b *testing.B) {577 func BenchmarkNewSnapshot10KRows(b *testing.B) {
493 const (578 const (
494 width = 120579 width = 120
M internal/usecase/daemon/daemon.go +11 -0
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/vev/internal/persist"
1717 "github.com/bnema/vev/internal/ports"
1818 "github.com/bnema/vev/internal/usecase/keys"
19+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1920 )
2021
2122 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -101,6 +102,12 @@ type Daemon struct {
101102 persistEnabled bool
102103 snaps ports.SnapshotStore
103104 snapsEnabled bool
105+ snapshotMarshal func(snapcodec.Session) ([]byte, error)
106+ snapshotJobs chan snapshotCapture
107+ snapshotWorkerMu sync.Mutex
108+ snapshotWorkerCtx context.Context
109+ snapshotWorkerCancel context.CancelFunc
110+ snapshotWorkerDone chan struct{}
104111 restoreDone chan struct{}
105112 restoreOnce sync.Once
106113 procCwd func(int) (string, error)
@@ -285,6 +292,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
285292 done: make(chan struct{}),
286293 restoreDone: make(chan struct{}),
287294 animWake: make(chan struct{}, 1),
295+ snapshotMarshal: snapcodec.Marshal,
296+ snapshotJobs: make(chan snapshotCapture, snapshotQueueCapacity),
288297 resumeParkGrace: defaultResumeParkGrace,
289298 barScripts: &barScriptState{
290299 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -355,6 +364,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
355364 })
356365 }
357366 if d.snapsEnabled {
367+ d.startSnapshotEncodeWorker(d.serveCtx)
358368 d.sessWg.Go(func() {
359369 d.snapshotSaver(d.serveCtx)
360370 })
@@ -403,6 +413,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
403413 d.waitNotifies()
404414 d.hardCancel()
405415 d.serveCancel()
416+ d.stopSnapshotEncodeWorker()
406417 d.connWg.Wait()
407418 d.shutdownAll(ports.ReasonServerShutdown)
408419 d.sessWg.Wait()
@@ -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,12 @@ type Daemon struct {
101 persistEnabled bool102 persistEnabled bool
102 snaps ports.SnapshotStore103 snaps ports.SnapshotStore
103 snapsEnabled bool104 snapsEnabled bool
105+ snapshotMarshal func(snapcodec.Session) ([]byte, error)
106+ snapshotJobs chan snapshotCapture
107+ snapshotWorkerMu sync.Mutex
108+ snapshotWorkerCtx context.Context
109+ snapshotWorkerCancel context.CancelFunc
110+ snapshotWorkerDone chan struct{}
104 restoreDone chan struct{}111 restoreDone chan struct{}
105 restoreOnce sync.Once112 restoreOnce sync.Once
106 procCwd func(int) (string, error)113 procCwd func(int) (string, error)
@@ -285,6 +292,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
285 done: make(chan struct{}),292 done: make(chan struct{}),
286 restoreDone: make(chan struct{}),293 restoreDone: make(chan struct{}),
287 animWake: make(chan struct{}, 1),294 animWake: make(chan struct{}, 1),
295+ snapshotMarshal: snapcodec.Marshal,
296+ snapshotJobs: make(chan snapshotCapture, snapshotQueueCapacity),
288 resumeParkGrace: defaultResumeParkGrace,297 resumeParkGrace: defaultResumeParkGrace,
289 barScripts: &barScriptState{298 barScripts: &barScriptState{
290 cfg: barConfigFromDomain(domain.Defaults().Bar),299 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -355,6 +364,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
355 })364 })
356 }365 }
357 if d.snapsEnabled {366 if d.snapsEnabled {
367+ d.startSnapshotEncodeWorker(d.serveCtx)
358 d.sessWg.Go(func() {368 d.sessWg.Go(func() {
359 d.snapshotSaver(d.serveCtx)369 d.snapshotSaver(d.serveCtx)
360 })370 })
@@ -403,6 +413,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
403 d.waitNotifies()413 d.waitNotifies()
404 d.hardCancel()414 d.hardCancel()
405 d.serveCancel()415 d.serveCancel()
416+ d.stopSnapshotEncodeWorker()
406 d.connWg.Wait()417 d.connWg.Wait()
407 d.shutdownAll(ports.ReasonServerShutdown)418 d.shutdownAll(ports.ReasonServerShutdown)
408 d.sessWg.Wait()419 d.sessWg.Wait()
M internal/usecase/daemon/performance_benchmark_test.go +24 -1
@@ -1,8 +1,10 @@
11 package daemon
22
33 import (
4+ "context"
45 "fmt"
56 "io"
7+ "runtime"
68 "sync"
79 "testing"
810 "time"
@@ -501,13 +503,19 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
501503 sess: sess,
502504 ac: ac,
503505 output: output,
504- snaps: &countingSnapshotStore{},
506+ snaps: &countingSnapshotStore{done: make(chan struct{}, 1)},
505507 pty: pty,
506508 clock: clock,
507509 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
508510 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
509511 }
510512 WithSnapshotStore(fixture.snaps)(d)
513+ snapshotCtx, cancelSnapshotWorker := context.WithCancel(context.Background())
514+ d.startSnapshotEncodeWorker(snapshotCtx)
515+ t.Cleanup(func() {
516+ cancelSnapshotWorker()
517+ d.stopSnapshotEncodeWorker()
518+ })
511519 for tabIndex, tb := range sess.tabs {
512520 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
513521 }
@@ -594,6 +602,17 @@ func (f *performanceFixture) paintLive() {
594602 func (f *performanceFixture) captureSnapshot() {
595603 f.t.Helper()
596604 require.True(f.t, f.d.captureSession(f.sess))
605+ <-f.snaps.done
606+ for range 4096 {
607+ f.sess.snapshotMu.Lock()
608+ pending := f.sess.snapshotPending
609+ f.sess.snapshotMu.Unlock()
610+ if !pending {
611+ return
612+ }
613+ runtime.Gosched()
614+ }
615+ f.t.Fatal("snapshot worker did not finish performance capture")
597616 }
598617
599618 func (f *performanceFixture) enterCopySearch() {
@@ -754,6 +773,7 @@ type countingSnapshotStore struct {
754773 mu sync.Mutex
755774 countingSnapshotMetrics
756775 last []byte
776+ done chan struct{}
757777 }
758778
759779 func (s *countingSnapshotStore) Write(_ string, data []byte) error {
@@ -762,6 +782,9 @@ func (s *countingSnapshotStore) Write(_ string, data []byte) error {
762782 s.bytes += uint64(len(data))
763783 s.last = data
764784 s.mu.Unlock()
785+ if s.done != nil {
786+ s.done <- struct{}{}
787+ }
765788 return nil
766789 }
767790 func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
@@ -1,8 +1,10 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "io"6 "io"
7+ "runtime"
6 "sync"8 "sync"
7 "testing"9 "testing"
8 "time"10 "time"
@@ -501,13 +503,19 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
501 sess: sess,503 sess: sess,
502 ac: ac,504 ac: ac,
503 output: output,505 output: output,
504- snaps: &countingSnapshotStore{},506+ snaps: &countingSnapshotStore{done: make(chan struct{}, 1)},
505 pty: pty,507 pty: pty,
506 clock: clock,508 clock: clock,
507 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},509 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},510 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
509 }511 }
510 WithSnapshotStore(fixture.snaps)(d)512 WithSnapshotStore(fixture.snaps)(d)
513+ snapshotCtx, cancelSnapshotWorker := context.WithCancel(context.Background())
514+ d.startSnapshotEncodeWorker(snapshotCtx)
515+ t.Cleanup(func() {
516+ cancelSnapshotWorker()
517+ d.stopSnapshotEncodeWorker()
518+ })
511 for tabIndex, tb := range sess.tabs {519 for tabIndex, tb := range sess.tabs {
512 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))520 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
513 }521 }
@@ -594,6 +602,17 @@ func (f *performanceFixture) paintLive() {
594 func (f *performanceFixture) captureSnapshot() {602 func (f *performanceFixture) captureSnapshot() {
595 f.t.Helper()603 f.t.Helper()
596 require.True(f.t, f.d.captureSession(f.sess))604 require.True(f.t, f.d.captureSession(f.sess))
605+ <-f.snaps.done
606+ for range 4096 {
607+ f.sess.snapshotMu.Lock()
608+ pending := f.sess.snapshotPending
609+ f.sess.snapshotMu.Unlock()
610+ if !pending {
611+ return
612+ }
613+ runtime.Gosched()
614+ }
615+ f.t.Fatal("snapshot worker did not finish performance capture")
597 }616 }
598 617
599 func (f *performanceFixture) enterCopySearch() {618 func (f *performanceFixture) enterCopySearch() {
@@ -754,6 +773,7 @@ type countingSnapshotStore struct {
754 mu sync.Mutex773 mu sync.Mutex
755 countingSnapshotMetrics774 countingSnapshotMetrics
756 last []byte775 last []byte
776+ done chan struct{}
757 }777 }
758 778
759 func (s *countingSnapshotStore) Write(_ string, data []byte) error {779 func (s *countingSnapshotStore) Write(_ string, data []byte) error {
@@ -762,6 +782,9 @@ func (s *countingSnapshotStore) Write(_ string, data []byte) error {
762 s.bytes += uint64(len(data))782 s.bytes += uint64(len(data))
763 s.last = data783 s.last = data
764 s.mu.Unlock()784 s.mu.Unlock()
785+ if s.done != nil {
786+ s.done <- struct{}{}
787+ }
765 return nil788 return nil
766 }789 }
767 func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }790 func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
M internal/usecase/daemon/session.go +8 -1
@@ -44,6 +44,12 @@ type session struct {
4444 mruAt atomic.Uint64
4545 snapDirty atomic.Bool
4646 snapEligible atomic.Bool
47+ // snapshotMu serializes the dirty generation with worker completion. It is
48+ // intentionally independent from mu: persistence never holds session state
49+ // locks while encoding or writing.
50+ snapshotMu sync.Mutex
51+ snapshotGeneration uint64
52+ snapshotPending bool
4753 // syncGen makes synchronized-output watchdog generations unique across all
4854 // panes in this session.
4955 syncGen atomic.Uint64
@@ -671,7 +677,8 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
671677 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
672678 }
673679 } else {
674- d.captureSession(sess)
680+ markSnapshotDirty(sess)
681+ d.scheduleSnapshot(sess)
675682 }
676683 }
677684 d.mu.Lock()
@@ -44,6 +44,12 @@ type session struct {
44 mruAt atomic.Uint6444 mruAt atomic.Uint64
45 snapDirty atomic.Bool45 snapDirty atomic.Bool
46 snapEligible atomic.Bool46 snapEligible atomic.Bool
47+ // snapshotMu serializes the dirty generation with worker completion. It is
48+ // intentionally independent from mu: persistence never holds session state
49+ // locks while encoding or writing.
50+ snapshotMu sync.Mutex
51+ snapshotGeneration uint64
52+ snapshotPending bool
47 // syncGen makes synchronized-output watchdog generations unique across all53 // syncGen makes synchronized-output watchdog generations unique across all
48 // panes in this session.54 // panes in this session.
49 syncGen atomic.Uint6455 syncGen atomic.Uint64
@@ -671,7 +677,8 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
671 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)677 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
672 }678 }
673 } else {679 } else {
674- d.captureSession(sess)680+ markSnapshotDirty(sess)
681+ d.scheduleSnapshot(sess)
675 }682 }
676 }683 }
677 d.mu.Lock()684 d.mu.Lock()
M internal/usecase/daemon/snapshot.go +222 -52
@@ -9,9 +9,42 @@ import (
99 "github.com/bnema/vev/internal/domain"
1010 "github.com/bnema/vev/internal/usecase/layout"
1111 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
12+ "github.com/bnema/vev/pkg/renderer"
1213 "github.com/bnema/vev/pkg/vt"
1314 )
1415
16+// snapshotQueueCapacity bounds retained immutable captures. A full queue never
17+// stalls session producers; the session remains dirty for the next interval.
18+const snapshotQueueCapacity = 1
19+
20+type snapshotCapture struct {
21+ session *session
22+ generation uint64
23+ name string
24+ createdAt uint64
25+ active uint16
26+ tabs []snapshotCaptureTab
27+}
28+
29+type snapshotCaptureTab struct {
30+ stableID string
31+ cols uint16
32+ rows uint16
33+ nextPaneID uint64
34+ focus layout.PaneID
35+ tree *layout.Tree
36+ panes []snapshotCapturePane
37+}
38+
39+type snapshotCapturePane struct {
40+ id layout.PaneID
41+ stableID string
42+ cwd string
43+ history vt.HistoryView
44+ visible renderer.Frame
45+ process *snapcodec.Process
46+}
47+
1548 const snapshotInterval = 30 * time.Second
1649
1750 func (d *Daemon) closeRestoreDone() {
@@ -202,12 +235,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
202235 }
203236
204237 func markSnapshotDirty(sess *session) {
205- if sess == nil {
238+ if sess == nil || !sess.snapEligible.Load() {
206239 return
207240 }
208- if sess.snapEligible.Load() {
209- sess.snapDirty.Store(true)
210- }
241+ sess.snapshotMu.Lock()
242+ sess.snapshotGeneration++
243+ sess.snapDirty.Store(true)
244+ sess.snapshotMu.Unlock()
211245 }
212246
213247 func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
@@ -254,35 +288,84 @@ func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
254288 return domain.Size{Cols: cols, Rows: rows}
255289 }
256290
257-func (d *Daemon) captureSession(sess *session) bool {
258- if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil {
291+// scheduleSnapshot captures a session only once while an immutable capture is
292+// queued or being written. It never waits for the bounded worker queue.
293+func (d *Daemon) scheduleSnapshot(sess *session) bool {
294+ if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() {
259295 return true
260296 }
297+ sess.snapshotMu.Lock()
298+ if !sess.snapDirty.Load() || sess.snapshotPending {
299+ sess.snapshotMu.Unlock()
300+ return true
301+ }
302+ generation := sess.snapshotGeneration
303+ sess.snapshotPending = true
304+ sess.snapshotMu.Unlock()
261305
306+ capture, ok := d.captureSnapshotState(sess, generation)
307+ if !ok {
308+ d.finishSnapshotCapture(sess, generation, false)
309+ return false
310+ }
311+ if d.enqueueSnapshotCapture(capture) {
312+ return true
313+ }
314+ // Coalesce under saturation or shutdown: the latest state stays dirty and
315+ // will be captured on a later tick once an active worker has room.
316+ d.finishSnapshotCapture(sess, generation, false)
317+ return false
318+}
319+
320+// enqueueSnapshotCapture serializes worker shutdown with the non-blocking
321+// queue send. The queue is deliberately never closed: producers can race
322+// shutdown without risking a send-on-closed panic.
323+func (d *Daemon) enqueueSnapshotCapture(capture snapshotCapture) bool {
324+ d.snapshotWorkerMu.Lock()
325+ defer d.snapshotWorkerMu.Unlock()
326+ if d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
327+ return false
328+ }
329+ select {
330+ case d.snapshotJobs <- capture:
331+ return true
332+ default:
333+ return false
334+ }
335+}
336+
337+// captureSession rotates history tails and clones visible frames while holding
338+// each pane lock. The returned capture contains only immutable state; encoding
339+// and persistence are deliberately deferred to snapshotEncodeWorker.
340+func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (snapshotCapture, bool) {
262341 sess.mu.Lock()
263- name := sess.name
342+ capture := snapshotCapture{
343+ session: sess,
344+ generation: generation,
345+ name: sess.name,
346+ createdAt: uint64(sess.createdAt),
347+ active: uint16(max(sess.active, 0)),
348+ }
264349 ephemeral := sess.ephemeral
265- createdAt := sess.createdAt
266- active := sess.active
267350 fallbackCwd := sess.cwd
268351 tabs := append([]*tab(nil), sess.tabs...)
269352 sess.mu.Unlock()
270- if ephemeral || name == "" {
271- return true
353+ if ephemeral || capture.name == "" {
354+ return snapshotCapture{}, false
272355 }
273356
274- snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))}
357+ capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
275358 for _, tb := range tabs {
276359 tb.mu.Lock()
277- tabSnap := snapcodec.Tab{
278- StableID: tb.stableID,
279- Cols: uint16(max(tb.size.Cols, 0)),
280- Rows: uint16(max(tb.size.Rows, 0)),
281- NextPaneID: uint64(max(tb.nextPaneID, 0)),
282- Tree: tb.tree.Clone(),
360+ tabCapture := snapshotCaptureTab{
361+ stableID: tb.stableID,
362+ cols: uint16(max(tb.size.Cols, 0)),
363+ rows: uint16(max(tb.size.Rows, 0)),
364+ nextPaneID: uint64(max(tb.nextPaneID, 0)),
365+ tree: tb.tree.Clone(),
283366 }
284367 if tb.tree != nil {
285- tabSnap.Focus = tb.tree.Focus
368+ tabCapture.focus = tb.tree.Focus
286369 }
287370 panes := make([]*pane, 0, len(tb.panes))
288371 for _, p := range tb.panes {
@@ -290,55 +373,144 @@ func (d *Daemon) captureSession(sess *session) bool {
290373 }
291374 tb.mu.Unlock()
292375
293- paneIDs := make(map[layout.PaneID]struct{}, len(panes))
294- paneSnaps := make([]snapcodec.Pane, 0, len(panes))
376+ tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
295377 for _, p := range panes {
296378 p.mu.Lock()
297- pid := 0
298379 pty := p.pty
380+ pid := 0
299381 if pty != nil {
300382 pid = pty.Pid()
301383 }
302- historyView := p.history.SealAndView()
303- visibleFrame := p.screen.PrimaryVisibleFrame()
304- paneID, paneStableID := p.id, p.stableID
305- p.mu.Unlock()
306- // Sealed VT chunks are immutable and visibleFrame is the one required
307- // screen clone, so both encoders run after pane.mu is released.
308- sealed, tail, historyErr := vt.MarshalSealedHistory(historyView)
309- visible, visibleErr := vt.MarshalVisible(visibleFrame)
310- if historyErr != nil || visibleErr != nil {
311- d.log.Warn("capturing terminal snapshot failed", "session", name, "pane", paneID, "history_err", historyErr, "visible_err", visibleErr)
312- return false
384+ paneCapture := snapshotCapturePane{
385+ id: p.id,
386+ stableID: p.stableID,
387+ history: p.history.SealAndView(),
388+ visible: p.screen.PrimaryVisibleFrame(),
313389 }
314- paneSnap := snapcodec.Pane{ID: paneID, StableID: paneStableID, SealedChunks: sealed, Tail: tail, Visible: visible}
315- paneSnap.Cwd = fallbackCwd
390+ p.mu.Unlock()
391+ paneCapture.cwd = fallbackCwd
316392 if d.procCwd != nil && pid > 0 {
317393 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
318- paneSnap.Cwd = cwd
394+ paneCapture.cwd = cwd
319395 }
320396 }
321- paneSnap.Process = d.capturePaneProcess(pty, pid)
322- paneIDs[paneSnap.ID] = struct{}{}
323- paneSnaps = append(paneSnaps, paneSnap)
397+ paneCapture.process = d.capturePaneProcess(pty, pid)
398+ tabCapture.panes = append(tabCapture.panes, paneCapture)
399+ }
400+ capture.tabs = append(capture.tabs, tabCapture)
401+ }
402+ return capture, true
403+}
404+
405+// captureSession remains the synchronous producer-facing trigger for callers
406+// such as teardown and benchmarks; the actual encoding and Write stay async.
407+func (d *Daemon) captureSession(sess *session) bool {
408+ markSnapshotDirty(sess)
409+ return d.scheduleSnapshot(sess)
410+}
411+
412+func (d *Daemon) encodeSnapshotCapture(capture snapshotCapture) ([]byte, error) {
413+ snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))}
414+ for _, tabCapture := range capture.tabs {
415+ tabSnap := snapcodec.Tab{
416+ StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows,
417+ NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree,
418+ Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)),
419+ }
420+ paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes))
421+ for _, paneCapture := range tabCapture.panes {
422+ sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history)
423+ visible, visibleErr := vt.MarshalVisible(paneCapture.visible)
424+ if historyErr != nil || visibleErr != nil {
425+ return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, visibleErr)
426+ }
427+ 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})
428+ paneIDs[paneCapture.id] = struct{}{}
324429 }
325430 if tabSnap.Tree != nil {
326431 pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
327432 }
328- tabSnap.Panes = paneSnaps
329433 snap.Tabs = append(snap.Tabs, tabSnap)
330434 }
435+ return d.snapshotMarshal(snap)
436+}
331437
332- data, err := snapcodec.Marshal(snap)
333- if err != nil {
334- d.log.Warn("marshaling session snapshot failed", "err", err, "session", name)
335- return false
438+func (d *Daemon) finishSnapshotCapture(sess *session, generation uint64, succeeded bool) {
439+ sess.snapshotMu.Lock()
440+ sess.snapshotPending = false
441+ if succeeded && sess.snapshotGeneration == generation {
442+ sess.snapDirty.Store(false)
443+ } else if !succeeded {
444+ sess.snapDirty.Store(true)
336445 }
337- if err := d.snaps.Write(name, data); err != nil {
338- d.log.Warn("writing session snapshot failed", "err", err, "session", name)
339- return false
446+ sess.snapshotMu.Unlock()
447+}
448+
449+func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
450+ if d == nil || ctx.Err() != nil {
451+ return
452+ }
453+ d.snapshotWorkerMu.Lock()
454+ if d.snapshotWorkerCancel != nil {
455+ d.snapshotWorkerMu.Unlock()
456+ return
457+ }
458+ workerCtx, cancel := context.WithCancel(ctx)
459+ d.snapshotWorkerCtx = workerCtx
460+ d.snapshotWorkerCancel = cancel
461+ d.snapshotWorkerDone = make(chan struct{})
462+ done := d.snapshotWorkerDone
463+ d.snapshotWorkerMu.Unlock()
464+ go func() {
465+ defer close(done)
466+ for {
467+ select {
468+ case <-workerCtx.Done():
469+ return
470+ case capture := <-d.snapshotJobs:
471+ // A queued capture must not begin persistence after shutdown.
472+ if workerCtx.Err() != nil {
473+ d.finishSnapshotCapture(capture.session, capture.generation, false)
474+ return
475+ }
476+ data, err := d.encodeSnapshotCapture(capture)
477+ if err == nil {
478+ err = d.snaps.Write(capture.name, data)
479+ }
480+ if err != nil {
481+ d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name)
482+ }
483+ d.finishSnapshotCapture(capture.session, capture.generation, err == nil)
484+ }
485+ }
486+ }()
487+}
488+
489+func (d *Daemon) stopSnapshotEncodeWorker() {
490+ if d == nil {
491+ return
492+ }
493+ d.snapshotWorkerMu.Lock()
494+ defer d.snapshotWorkerMu.Unlock()
495+ cancel, done := d.snapshotWorkerCancel, d.snapshotWorkerDone
496+ if cancel == nil {
497+ return
498+ }
499+ // Keep workerMu held until the old worker has exited and its queue has been
500+ // drained, so a concurrent restart cannot consume a stale capture.
501+ d.snapshotWorkerCtx = nil
502+ d.snapshotWorkerCancel = nil
503+ d.snapshotWorkerDone = nil
504+ cancel()
505+ <-done
506+ for {
507+ select {
508+ case capture := <-d.snapshotJobs:
509+ d.finishSnapshotCapture(capture.session, capture.generation, false)
510+ default:
511+ return
512+ }
340513 }
341- return true
342514 }
343515
344516 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
@@ -377,10 +549,8 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
377549 sessions := d.sessionsSnapshotLocked()
378550 d.mu.Unlock()
379551 for _, sess := range sessions {
380- if sess.snapEligible.Load() && sess.snapDirty.Swap(false) {
381- if !d.captureSession(sess) {
382- sess.snapDirty.Store(true)
383- }
552+ if sess.snapEligible.Load() && sess.snapDirty.Load() {
553+ d.scheduleSnapshot(sess)
384554 }
385555 }
386556 t.Reset(snapshotInterval)
@@ -9,9 +9,42 @@ import (
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
11 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"11 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
12+ "github.com/bnema/vev/pkg/renderer"
12 "github.com/bnema/vev/pkg/vt"13 "github.com/bnema/vev/pkg/vt"
13 )14 )
14 15
16+// snapshotQueueCapacity bounds retained immutable captures. A full queue never
17+// stalls session producers; the session remains dirty for the next interval.
18+const snapshotQueueCapacity = 1
19+
20+type snapshotCapture struct {
21+ session *session
22+ generation uint64
23+ name string
24+ createdAt uint64
25+ active uint16
26+ tabs []snapshotCaptureTab
27+}
28+
29+type snapshotCaptureTab struct {
30+ stableID string
31+ cols uint16
32+ rows uint16
33+ nextPaneID uint64
34+ focus layout.PaneID
35+ tree *layout.Tree
36+ panes []snapshotCapturePane
37+}
38+
39+type snapshotCapturePane struct {
40+ id layout.PaneID
41+ stableID string
42+ cwd string
43+ history vt.HistoryView
44+ visible renderer.Frame
45+ process *snapcodec.Process
46+}
47+
15 const snapshotInterval = 30 * time.Second48 const snapshotInterval = 30 * time.Second
16 49
17 func (d *Daemon) closeRestoreDone() {50 func (d *Daemon) closeRestoreDone() {
@@ -202,12 +235,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
202 }235 }
203 236
204 func markSnapshotDirty(sess *session) {237 func markSnapshotDirty(sess *session) {
205- if sess == nil {238+ if sess == nil || !sess.snapEligible.Load() {
206 return239 return
207 }240 }
208- if sess.snapEligible.Load() {241+ sess.snapshotMu.Lock()
209- sess.snapDirty.Store(true)242+ sess.snapshotGeneration++
210- }243+ sess.snapDirty.Store(true)
244+ sess.snapshotMu.Unlock()
211 }245 }
212 246
213 func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {247 func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
@@ -254,35 +288,84 @@ func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
254 return domain.Size{Cols: cols, Rows: rows}288 return domain.Size{Cols: cols, Rows: rows}
255 }289 }
256 290
257-func (d *Daemon) captureSession(sess *session) bool {291+// scheduleSnapshot captures a session only once while an immutable capture is
258- if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil {292+// queued or being written. It never waits for the bounded worker queue.
293+func (d *Daemon) scheduleSnapshot(sess *session) bool {
294+ if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() {
259 return true295 return true
260 }296 }
297+ sess.snapshotMu.Lock()
298+ if !sess.snapDirty.Load() || sess.snapshotPending {
299+ sess.snapshotMu.Unlock()
300+ return true
301+ }
302+ generation := sess.snapshotGeneration
303+ sess.snapshotPending = true
304+ sess.snapshotMu.Unlock()
261 305
306+ capture, ok := d.captureSnapshotState(sess, generation)
307+ if !ok {
308+ d.finishSnapshotCapture(sess, generation, false)
309+ return false
310+ }
311+ if d.enqueueSnapshotCapture(capture) {
312+ return true
313+ }
314+ // Coalesce under saturation or shutdown: the latest state stays dirty and
315+ // will be captured on a later tick once an active worker has room.
316+ d.finishSnapshotCapture(sess, generation, false)
317+ return false
318+}
319+
320+// enqueueSnapshotCapture serializes worker shutdown with the non-blocking
321+// queue send. The queue is deliberately never closed: producers can race
322+// shutdown without risking a send-on-closed panic.
323+func (d *Daemon) enqueueSnapshotCapture(capture snapshotCapture) bool {
324+ d.snapshotWorkerMu.Lock()
325+ defer d.snapshotWorkerMu.Unlock()
326+ if d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
327+ return false
328+ }
329+ select {
330+ case d.snapshotJobs <- capture:
331+ return true
332+ default:
333+ return false
334+ }
335+}
336+
337+// captureSession rotates history tails and clones visible frames while holding
338+// each pane lock. The returned capture contains only immutable state; encoding
339+// and persistence are deliberately deferred to snapshotEncodeWorker.
340+func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (snapshotCapture, bool) {
262 sess.mu.Lock()341 sess.mu.Lock()
263- name := sess.name342+ capture := snapshotCapture{
343+ session: sess,
344+ generation: generation,
345+ name: sess.name,
346+ createdAt: uint64(sess.createdAt),
347+ active: uint16(max(sess.active, 0)),
348+ }
264 ephemeral := sess.ephemeral349 ephemeral := sess.ephemeral
265- createdAt := sess.createdAt
266- active := sess.active
267 fallbackCwd := sess.cwd350 fallbackCwd := sess.cwd
268 tabs := append([]*tab(nil), sess.tabs...)351 tabs := append([]*tab(nil), sess.tabs...)
269 sess.mu.Unlock()352 sess.mu.Unlock()
270- if ephemeral || name == "" {353+ if ephemeral || capture.name == "" {
271- return true354+ return snapshotCapture{}, false
272 }355 }
273 356
274- snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))}357+ capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
275 for _, tb := range tabs {358 for _, tb := range tabs {
276 tb.mu.Lock()359 tb.mu.Lock()
277- tabSnap := snapcodec.Tab{360+ tabCapture := snapshotCaptureTab{
278- StableID: tb.stableID,361+ stableID: tb.stableID,
279- Cols: uint16(max(tb.size.Cols, 0)),362+ cols: uint16(max(tb.size.Cols, 0)),
280- Rows: uint16(max(tb.size.Rows, 0)),363+ rows: uint16(max(tb.size.Rows, 0)),
281- NextPaneID: uint64(max(tb.nextPaneID, 0)),364+ nextPaneID: uint64(max(tb.nextPaneID, 0)),
282- Tree: tb.tree.Clone(),365+ tree: tb.tree.Clone(),
283 }366 }
284 if tb.tree != nil {367 if tb.tree != nil {
285- tabSnap.Focus = tb.tree.Focus368+ tabCapture.focus = tb.tree.Focus
286 }369 }
287 panes := make([]*pane, 0, len(tb.panes))370 panes := make([]*pane, 0, len(tb.panes))
288 for _, p := range tb.panes {371 for _, p := range tb.panes {
@@ -290,55 +373,144 @@ func (d *Daemon) captureSession(sess *session) bool {
290 }373 }
291 tb.mu.Unlock()374 tb.mu.Unlock()
292 375
293- paneIDs := make(map[layout.PaneID]struct{}, len(panes))376+ tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
294- paneSnaps := make([]snapcodec.Pane, 0, len(panes))
295 for _, p := range panes {377 for _, p := range panes {
296 p.mu.Lock()378 p.mu.Lock()
297- pid := 0
298 pty := p.pty379 pty := p.pty
380+ pid := 0
299 if pty != nil {381 if pty != nil {
300 pid = pty.Pid()382 pid = pty.Pid()
301 }383 }
302- historyView := p.history.SealAndView()384+ paneCapture := snapshotCapturePane{
303- visibleFrame := p.screen.PrimaryVisibleFrame()385+ id: p.id,
304- paneID, paneStableID := p.id, p.stableID386+ stableID: p.stableID,
305- p.mu.Unlock()387+ history: p.history.SealAndView(),
306- // Sealed VT chunks are immutable and visibleFrame is the one required388+ visible: p.screen.PrimaryVisibleFrame(),
307- // screen clone, so both encoders run after pane.mu is released.
308- sealed, tail, historyErr := vt.MarshalSealedHistory(historyView)
309- visible, visibleErr := vt.MarshalVisible(visibleFrame)
310- if historyErr != nil || visibleErr != nil {
311- d.log.Warn("capturing terminal snapshot failed", "session", name, "pane", paneID, "history_err", historyErr, "visible_err", visibleErr)
312- return false
313 }389 }
314- paneSnap := snapcodec.Pane{ID: paneID, StableID: paneStableID, SealedChunks: sealed, Tail: tail, Visible: visible}390+ p.mu.Unlock()
315- paneSnap.Cwd = fallbackCwd391+ paneCapture.cwd = fallbackCwd
316 if d.procCwd != nil && pid > 0 {392 if d.procCwd != nil && pid > 0 {
317 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {393 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
318- paneSnap.Cwd = cwd394+ paneCapture.cwd = cwd
319 }395 }
320 }396 }
321- paneSnap.Process = d.capturePaneProcess(pty, pid)397+ paneCapture.process = d.capturePaneProcess(pty, pid)
322- paneIDs[paneSnap.ID] = struct{}{}398+ tabCapture.panes = append(tabCapture.panes, paneCapture)
323- paneSnaps = append(paneSnaps, paneSnap)399+ }
400+ capture.tabs = append(capture.tabs, tabCapture)
401+ }
402+ return capture, true
403+}
404+
405+// captureSession remains the synchronous producer-facing trigger for callers
406+// such as teardown and benchmarks; the actual encoding and Write stay async.
407+func (d *Daemon) captureSession(sess *session) bool {
408+ markSnapshotDirty(sess)
409+ return d.scheduleSnapshot(sess)
410+}
411+
412+func (d *Daemon) encodeSnapshotCapture(capture snapshotCapture) ([]byte, error) {
413+ snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))}
414+ for _, tabCapture := range capture.tabs {
415+ tabSnap := snapcodec.Tab{
416+ StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows,
417+ NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree,
418+ Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)),
419+ }
420+ paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes))
421+ for _, paneCapture := range tabCapture.panes {
422+ sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history)
423+ visible, visibleErr := vt.MarshalVisible(paneCapture.visible)
424+ if historyErr != nil || visibleErr != nil {
425+ return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, visibleErr)
426+ }
427+ 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})
428+ paneIDs[paneCapture.id] = struct{}{}
324 }429 }
325 if tabSnap.Tree != nil {430 if tabSnap.Tree != nil {
326 pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)431 pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
327 }432 }
328- tabSnap.Panes = paneSnaps
329 snap.Tabs = append(snap.Tabs, tabSnap)433 snap.Tabs = append(snap.Tabs, tabSnap)
330 }434 }
435+ return d.snapshotMarshal(snap)
436+}
331 437
332- data, err := snapcodec.Marshal(snap)438+func (d *Daemon) finishSnapshotCapture(sess *session, generation uint64, succeeded bool) {
333- if err != nil {439+ sess.snapshotMu.Lock()
334- d.log.Warn("marshaling session snapshot failed", "err", err, "session", name)440+ sess.snapshotPending = false
335- return false441+ if succeeded && sess.snapshotGeneration == generation {
442+ sess.snapDirty.Store(false)
443+ } else if !succeeded {
444+ sess.snapDirty.Store(true)
336 }445 }
337- if err := d.snaps.Write(name, data); err != nil {446+ sess.snapshotMu.Unlock()
338- d.log.Warn("writing session snapshot failed", "err", err, "session", name)447+}
339- return false448+
449+func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
450+ if d == nil || ctx.Err() != nil {
451+ return
452+ }
453+ d.snapshotWorkerMu.Lock()
454+ if d.snapshotWorkerCancel != nil {
455+ d.snapshotWorkerMu.Unlock()
456+ return
457+ }
458+ workerCtx, cancel := context.WithCancel(ctx)
459+ d.snapshotWorkerCtx = workerCtx
460+ d.snapshotWorkerCancel = cancel
461+ d.snapshotWorkerDone = make(chan struct{})
462+ done := d.snapshotWorkerDone
463+ d.snapshotWorkerMu.Unlock()
464+ go func() {
465+ defer close(done)
466+ for {
467+ select {
468+ case <-workerCtx.Done():
469+ return
470+ case capture := <-d.snapshotJobs:
471+ // A queued capture must not begin persistence after shutdown.
472+ if workerCtx.Err() != nil {
473+ d.finishSnapshotCapture(capture.session, capture.generation, false)
474+ return
475+ }
476+ data, err := d.encodeSnapshotCapture(capture)
477+ if err == nil {
478+ err = d.snaps.Write(capture.name, data)
479+ }
480+ if err != nil {
481+ d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name)
482+ }
483+ d.finishSnapshotCapture(capture.session, capture.generation, err == nil)
484+ }
485+ }
486+ }()
487+}
488+
489+func (d *Daemon) stopSnapshotEncodeWorker() {
490+ if d == nil {
491+ return
492+ }
493+ d.snapshotWorkerMu.Lock()
494+ defer d.snapshotWorkerMu.Unlock()
495+ cancel, done := d.snapshotWorkerCancel, d.snapshotWorkerDone
496+ if cancel == nil {
497+ return
498+ }
499+ // Keep workerMu held until the old worker has exited and its queue has been
500+ // drained, so a concurrent restart cannot consume a stale capture.
501+ d.snapshotWorkerCtx = nil
502+ d.snapshotWorkerCancel = nil
503+ d.snapshotWorkerDone = nil
504+ cancel()
505+ <-done
506+ for {
507+ select {
508+ case capture := <-d.snapshotJobs:
509+ d.finishSnapshotCapture(capture.session, capture.generation, false)
510+ default:
511+ return
512+ }
340 }513 }
341- return true
342 }514 }
343 515
344 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {516 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
@@ -377,10 +549,8 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
377 sessions := d.sessionsSnapshotLocked()549 sessions := d.sessionsSnapshotLocked()
378 d.mu.Unlock()550 d.mu.Unlock()
379 for _, sess := range sessions {551 for _, sess := range sessions {
380- if sess.snapEligible.Load() && sess.snapDirty.Swap(false) {552+ if sess.snapEligible.Load() && sess.snapDirty.Load() {
381- if !d.captureSession(sess) {553+ d.scheduleSnapshot(sess)
382- sess.snapDirty.Store(true)
383- }
384 }554 }
385 }555 }
386 t.Reset(snapshotInterval)556 t.Reset(snapshotInterval)
M internal/usecase/daemon/snapshot_test.go +325 -6
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "io"
7+ "runtime"
78 "sync"
89 "sync/atomic"
910 "testing"
@@ -20,12 +21,245 @@ import (
2021 "github.com/bnema/vev/pkg/renderer"
2122 )
2223
24+func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) {
25+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
26+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
27+ WithSnapshotStore(store)(d)
28+ ctx, cancel := context.WithCancel(t.Context())
29+ t.Cleanup(func() {
30+ cancel()
31+ d.stopSnapshotEncodeWorker()
32+ })
33+ d.startSnapshotEncodeWorker(ctx)
34+
35+ entered := make(chan struct{})
36+ release := make(chan struct{})
37+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
38+ close(entered)
39+ <-release
40+ return snapcodec.Marshal(s)
41+ }
42+ sess := newSnapshotTestSession(t, "work", false, "/work")
43+ markSnapshotDirty(sess)
44+ require.True(t, d.scheduleSnapshot(sess))
45+ <-entered
46+
47+ paneUnlocked := make(chan struct{})
48+ go func() {
49+ p := sess.tabs[0].panes["pane-1"]
50+ p.mu.Lock()
51+ p.mu.Unlock()
52+ close(paneUnlocked)
53+ }()
54+ <-paneUnlocked
55+ close(release)
56+ <-store.writes
57+}
58+
59+func TestSnapshotEncodeWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) {
60+ exerciseBlockedSnapshotWorker(t, true)
61+}
62+
63+func TestSnapshotStoreWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) {
64+ exerciseBlockedSnapshotWorker(t, false)
65+}
66+
67+func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
68+ t.Helper()
69+ store := newGatedSnapshotStore()
70+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
71+ WithSnapshotStore(store)(d)
72+ ctx, cancel := context.WithCancel(t.Context())
73+ d.startSnapshotEncodeWorker(ctx)
74+ t.Cleanup(func() {
75+ store.unblock()
76+ cancel()
77+ d.stopSnapshotEncodeWorker()
78+ })
79+ if blockEncode {
80+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
81+ store.enter()
82+ <-store.release
83+ return snapcodec.Marshal(s)
84+ }
85+ }
86+
87+ sess := newSnapshotTestSession(t, "live", false, "/live")
88+ d.sessions[sess.id] = sess
89+ sess.tabs[0].size = domain.Size{Cols: 80, Rows: 24}
90+ p := sess.tabs[0].panes["pane-1"]
91+ tr, sends, _ := newConn(t, ports.Frame{})
92+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 8, Rows: 3}}
93+ ac.setSession(sess)
94+ sess.mu.Lock()
95+ sess.client = ac
96+ sess.mu.Unlock()
97+ rendered := make(chan struct{}, 1)
98+ rc := newRenderCoordinator(renderCoordinatorOptions{
99+ clock: stubClock{},
100+ onInvalidate: func(renderInvalidation) {
101+ d.paint(sess, ac, true)
102+ rendered <- struct{}{}
103+ },
104+ })
105+ sess.installRenderCoordinator(rc)
106+ rc.attach(ac)
107+ require.True(t, rc.markAttachmentReady(ac))
108+ t.Cleanup(rc.noteSessionTeardown)
109+
110+ markSnapshotDirty(sess)
111+ require.True(t, d.scheduleSnapshot(sess))
112+ <-store.entered
113+
114+ paneUnlocked := make(chan struct{})
115+ go func() {
116+ p.mu.Lock()
117+ p.mu.Unlock()
118+ close(paneUnlocked)
119+ }()
120+ <-paneUnlocked
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 TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) {
150+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
151+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
152+ WithSnapshotStore(store)(d)
153+ ctx, cancel := context.WithCancel(t.Context())
154+ d.startSnapshotEncodeWorker(ctx)
155+ d.stopSnapshotEncodeWorker()
156+ t.Cleanup(cancel)
157+
158+ sess := newSnapshotTestSession(t, "stopped", false, "/work")
159+ markSnapshotDirty(sess)
160+ require.False(t, d.scheduleSnapshot(sess), "a stopped worker must not accept a capture")
161+ awaitSnapshotIdle(t, sess)
162+ require.True(t, sess.snapDirty.Load(), "an unsubmitted capture must remain retryable")
163+}
164+
165+func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) {
166+ store := &channelSnapshotStore{writes: make(chan []byte, 3)}
167+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
168+ WithSnapshotStore(store)(d)
169+ startSnapshotEncodeWorker(t, d)
170+ entered := make(chan struct{})
171+ release := make(chan struct{})
172+ var calls atomic.Int32
173+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
174+ if calls.Add(1) == 1 {
175+ close(entered)
176+ <-release
177+ }
178+ return snapcodec.Marshal(s)
179+ }
180+ first := newSnapshotTestSession(t, "first", false, "/work")
181+ second := newSnapshotTestSession(t, "second", false, "/work")
182+ third := newSnapshotTestSession(t, "third", false, "/work")
183+ markSnapshotDirty(first)
184+ markSnapshotDirty(second)
185+ markSnapshotDirty(third)
186+ require.True(t, d.scheduleSnapshot(first))
187+ <-entered
188+ require.True(t, d.scheduleSnapshot(second), "one capture may wait in the bounded queue")
189+ require.False(t, d.scheduleSnapshot(third), "full queue must not block the producer")
190+ require.True(t, third.snapDirty.Load())
191+
192+ close(release)
193+ awaitSnapshotClean(t, first)
194+ awaitSnapshotClean(t, second)
195+ require.True(t, d.scheduleSnapshot(third))
196+ <-store.writes
197+ awaitSnapshotClean(t, third)
198+}
199+
200+func TestSnapshotSuccessDoesNotClearNewerGeneration(t *testing.T) {
201+ store := &channelSnapshotStore{writes: make(chan []byte, 2)}
202+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
203+ WithSnapshotStore(store)(d)
204+ startSnapshotEncodeWorker(t, d)
205+ entered := make(chan struct{})
206+ release := make(chan struct{})
207+ var calls atomic.Int32
208+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
209+ if calls.Add(1) == 1 {
210+ close(entered)
211+ <-release
212+ }
213+ return snapcodec.Marshal(s)
214+ }
215+ sess := newSnapshotTestSession(t, "generation", false, "/work")
216+
217+ markSnapshotDirty(sess)
218+ require.True(t, d.scheduleSnapshot(sess))
219+ <-entered
220+ markSnapshotDirty(sess)
221+ close(release)
222+ <-store.writes
223+ awaitSnapshotIdle(t, sess)
224+ require.True(t, sess.snapDirty.Load(), "a stale successful capture must not clear newer state")
225+
226+ require.True(t, d.scheduleSnapshot(sess))
227+ <-store.writes
228+ awaitSnapshotClean(t, sess)
229+}
230+
231+func TestSnapshotEncodeFailureLeavesSessionDirtyAndRetries(t *testing.T) {
232+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
233+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
234+ WithSnapshotStore(store)(d)
235+ startSnapshotEncodeWorker(t, d)
236+ sess := newSnapshotTestSession(t, "retry-encode", false, "/work")
237+ failed := make(chan struct{}, 1)
238+ var calls atomic.Int32
239+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
240+ if calls.Add(1) == 1 {
241+ failed <- struct{}{}
242+ return nil, errors.New("encode failed")
243+ }
244+ return snapcodec.Marshal(s)
245+ }
246+
247+ require.True(t, d.captureSession(sess))
248+ <-failed
249+ awaitSnapshotIdle(t, sess)
250+ require.True(t, sess.snapDirty.Load())
251+ require.True(t, d.scheduleSnapshot(sess))
252+ <-store.writes
253+ awaitSnapshotClean(t, sess)
254+}
255+
23256 func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
24257 clock := newManualSnapshotClock()
25258 store := portsmocks.NewMockSnapshotStore(t)
26259 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
27260 WithSnapshotStore(store)(d)
28261 d.procCwd = func(int) (string, error) { return "/pane", nil }
262+ startSnapshotEncodeWorker(t, d)
29263
30264 dirty := newSnapshotTestSession(t, "dirty", false, "/fallback")
31265 clean := newSnapshotTestSession(t, "clean", false, "/fallback")
@@ -67,6 +301,7 @@ func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
67301 store := portsmocks.NewMockSnapshotStore(t)
68302 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69303 WithSnapshotStore(store)(d)
304+ startSnapshotEncodeWorker(t, d)
70305 sess := newSnapshotTestSession(t, "work", false, "/fallback")
71306 tb := sess.tabs[0]
72307 floatingPTY, _ := newBlockingPTY(t)
@@ -85,12 +320,14 @@ func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
85320 return nil
86321 }).Once()
87322 require.True(t, d.captureSession(sess))
323+ awaitSnapshotClean(t, sess)
88324 }
89325
90326 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
91327 store := portsmocks.NewMockSnapshotStore(t)
92328 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
93329 WithSnapshotStore(store)(d)
330+ startSnapshotEncodeWorker(t, d)
94331 d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) {
95332 require.Equal(t, 4321, pgid)
96333 require.Equal(t, 1234, shellPid)
@@ -113,12 +350,14 @@ func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
113350 }).Once()
114351
115352 require.True(t, d.captureSession(sess))
353+ awaitSnapshotClean(t, sess)
116354 }
117355
118356 func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
119357 store := portsmocks.NewMockSnapshotStore(t)
120358 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
121359 WithSnapshotStore(store)(d)
360+ startSnapshotEncodeWorker(t, d)
122361 d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") }
123362
124363 sess := newSnapshotTestSession(t, "work", false, "/fallback")
@@ -133,12 +372,14 @@ func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
133372 }).Once()
134373
135374 require.True(t, d.captureSession(sess))
375+ awaitSnapshotClean(t, sess)
136376 }
137377
138378 func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
139379 store := portsmocks.NewMockSnapshotStore(t)
140380 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
141381 WithSnapshotStore(store)(d)
382+ startSnapshotEncodeWorker(t, d)
142383 d.procGroupArgv = func(int, int) ([]string, error) {
143384 t.Fatal("bare shell should not inspect argv")
144385 return nil, nil
@@ -156,35 +397,37 @@ func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
156397 }).Once()
157398
158399 require.True(t, d.captureSession(sess))
400+ awaitSnapshotClean(t, sess)
159401 }
160402
161403 func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {
162404 store := portsmocks.NewMockSnapshotStore(t)
163405 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
164406 WithSnapshotStore(store)(d)
407+ startSnapshotEncodeWorker(t, d)
165408 d.procCwd = func(int) (string, error) { return "/live", nil }
166409
167410 sess := newSnapshotTestSession(t, "work", false, "/fallback")
168411 d.sessions[sess.id] = sess
169412
170- var wrote atomic.Bool
413+ var closed atomic.Bool
171414 store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
172415 snap, err := snapcodec.Unmarshal(data)
173416 require.NoError(t, err)
174417 require.Equal(t, "work", snap.Name)
175418 require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd)
176- wrote.Store(true)
419+ require.True(t, closed.Load(), "snapshot persistence must not delay pane close")
177420 return nil
178421 }).Once()
179422 mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
180423 require.True(t, ok)
181424 mockPTY.EXPECT().Close().RunAndReturn(func() error {
182- require.True(t, wrote.Load(), "snapshot Write must happen before pane Close")
425+ closed.Store(true)
183426 return nil
184427 }).Once()
185428
186429 require.NoError(t, d.killSession(sess, 0, false))
187- require.True(t, wrote.Load())
430+ awaitSnapshotClean(t, sess)
188431 }
189432
190433 func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
@@ -241,12 +484,17 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
241484 store := portsmocks.NewMockSnapshotStore(t)
242485 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
243486 WithSnapshotStore(store)(d)
487+ startSnapshotEncodeWorker(t, d)
244488
245489 sess := newSnapshotTestSession(t, "retry", false, "/fallback")
246490 sess.snapDirty.Store(true)
247491 d.sessions[sess.id] = sess
248492
249- store.EXPECT().Write("retry", mock.Anything).Return(errors.New("boom")).Once()
493+ failed := make(chan struct{}, 1)
494+ store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
495+ failed <- struct{}{}
496+ return errors.New("boom")
497+ }).Once()
250498 wrote := make(chan struct{}, 1)
251499 store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
252500 wrote <- struct{}{}
@@ -257,7 +505,9 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
257505 defer cancel()
258506 go d.snapshotSaver(ctx)
259507 clock.fireNext(t)
260- require.Eventually(t, func() bool { return sess.snapDirty.Load() }, time.Second, time.Millisecond)
508+ <-failed
509+ awaitSnapshotIdle(t, sess)
510+ require.True(t, sess.snapDirty.Load())
261511 clock.fireNext(t)
262512 select {
263513 case <-wrote:
@@ -365,6 +615,7 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
365615 factory := portsmocks.NewMockPTYFactory(t)
366616 d := newTestDaemon(t, factory, clock)
367617 WithSnapshotStore(store)(d)
618+ startSnapshotEncodeWorker(t, d)
368619
369620 sess := newSnapshotTestSession(t, "work", false, "/work")
370621 tctx, tcancel := context.WithCancel(context.Background())
@@ -450,6 +701,74 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
450701 return sess
451702 }
452703
704+func awaitSnapshotIdle(t *testing.T, sess *session) {
705+ t.Helper()
706+ for range 4096 {
707+ sess.snapshotMu.Lock()
708+ pending := sess.snapshotPending
709+ sess.snapshotMu.Unlock()
710+ if !pending {
711+ return
712+ }
713+ runtime.Gosched()
714+ }
715+ t.Fatal("snapshot worker did not become idle")
716+}
717+
718+func awaitSnapshotClean(t *testing.T, sess *session) {
719+ t.Helper()
720+ for range 4096 {
721+ if !sess.snapDirty.Load() {
722+ return
723+ }
724+ runtime.Gosched()
725+ }
726+ t.Fatal("snapshot worker did not finish")
727+}
728+
729+func startSnapshotEncodeWorker(t *testing.T, d *Daemon) {
730+ t.Helper()
731+ ctx, cancel := context.WithCancel(t.Context())
732+ d.startSnapshotEncodeWorker(ctx)
733+ t.Cleanup(func() {
734+ cancel()
735+ d.stopSnapshotEncodeWorker()
736+ })
737+}
738+
739+type gatedSnapshotStore struct {
740+ entered chan struct{}
741+ release chan struct{}
742+ once sync.Once
743+ releaseOnce sync.Once
744+}
745+
746+func newGatedSnapshotStore() *gatedSnapshotStore {
747+ return &gatedSnapshotStore{entered: make(chan struct{}), release: make(chan struct{})}
748+}
749+
750+func (s *gatedSnapshotStore) enter() { s.once.Do(func() { close(s.entered) }) }
751+func (s *gatedSnapshotStore) unblock() { s.releaseOnce.Do(func() { close(s.release) }) }
752+
753+func (s *gatedSnapshotStore) Write(_ string, _ []byte) error {
754+ s.enter()
755+ <-s.release
756+ return nil
757+}
758+func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
759+func (*gatedSnapshotStore) Delete(string) error { return nil }
760+
761+type channelSnapshotStore struct {
762+ writes chan []byte
763+}
764+
765+func (s *channelSnapshotStore) Write(_ string, data []byte) error {
766+ s.writes <- append([]byte(nil), data...)
767+ return nil
768+}
769+func (*channelSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
770+func (*channelSnapshotStore) Delete(string) error { return nil }
771+
453772 type manualSnapshotClock struct {
454773 mu sync.Mutex
455774 timers []*manualSnapshotTimer
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "io"6 "io"
7+ "runtime"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
9 "testing"10 "testing"
@@ -20,12 +21,245 @@ import (
20 "github.com/bnema/vev/pkg/renderer"21 "github.com/bnema/vev/pkg/renderer"
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+ ctx, cancel := context.WithCancel(t.Context())
29+ t.Cleanup(func() {
30+ cancel()
31+ d.stopSnapshotEncodeWorker()
32+ })
33+ d.startSnapshotEncodeWorker(ctx)
34+
35+ entered := make(chan struct{})
36+ release := make(chan struct{})
37+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
38+ close(entered)
39+ <-release
40+ return snapcodec.Marshal(s)
41+ }
42+ sess := newSnapshotTestSession(t, "work", false, "/work")
43+ markSnapshotDirty(sess)
44+ require.True(t, d.scheduleSnapshot(sess))
45+ <-entered
46+
47+ paneUnlocked := make(chan struct{})
48+ go func() {
49+ p := sess.tabs[0].panes["pane-1"]
50+ p.mu.Lock()
51+ p.mu.Unlock()
52+ close(paneUnlocked)
53+ }()
54+ <-paneUnlocked
55+ close(release)
56+ <-store.writes
57+}
58+
59+func TestSnapshotEncodeWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) {
60+ exerciseBlockedSnapshotWorker(t, true)
61+}
62+
63+func TestSnapshotStoreWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) {
64+ exerciseBlockedSnapshotWorker(t, false)
65+}
66+
67+func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
68+ t.Helper()
69+ store := newGatedSnapshotStore()
70+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
71+ WithSnapshotStore(store)(d)
72+ ctx, cancel := context.WithCancel(t.Context())
73+ d.startSnapshotEncodeWorker(ctx)
74+ t.Cleanup(func() {
75+ store.unblock()
76+ cancel()
77+ d.stopSnapshotEncodeWorker()
78+ })
79+ if blockEncode {
80+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
81+ store.enter()
82+ <-store.release
83+ return snapcodec.Marshal(s)
84+ }
85+ }
86+
87+ sess := newSnapshotTestSession(t, "live", false, "/live")
88+ d.sessions[sess.id] = sess
89+ sess.tabs[0].size = domain.Size{Cols: 80, Rows: 24}
90+ p := sess.tabs[0].panes["pane-1"]
91+ tr, sends, _ := newConn(t, ports.Frame{})
92+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 8, Rows: 3}}
93+ ac.setSession(sess)
94+ sess.mu.Lock()
95+ sess.client = ac
96+ sess.mu.Unlock()
97+ rendered := make(chan struct{}, 1)
98+ rc := newRenderCoordinator(renderCoordinatorOptions{
99+ clock: stubClock{},
100+ onInvalidate: func(renderInvalidation) {
101+ d.paint(sess, ac, true)
102+ rendered <- struct{}{}
103+ },
104+ })
105+ sess.installRenderCoordinator(rc)
106+ rc.attach(ac)
107+ require.True(t, rc.markAttachmentReady(ac))
108+ t.Cleanup(rc.noteSessionTeardown)
109+
110+ markSnapshotDirty(sess)
111+ require.True(t, d.scheduleSnapshot(sess))
112+ <-store.entered
113+
114+ paneUnlocked := make(chan struct{})
115+ go func() {
116+ p.mu.Lock()
117+ p.mu.Unlock()
118+ close(paneUnlocked)
119+ }()
120+ <-paneUnlocked
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 TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) {
150+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
151+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
152+ WithSnapshotStore(store)(d)
153+ ctx, cancel := context.WithCancel(t.Context())
154+ d.startSnapshotEncodeWorker(ctx)
155+ d.stopSnapshotEncodeWorker()
156+ t.Cleanup(cancel)
157+
158+ sess := newSnapshotTestSession(t, "stopped", false, "/work")
159+ markSnapshotDirty(sess)
160+ require.False(t, d.scheduleSnapshot(sess), "a stopped worker must not accept a capture")
161+ awaitSnapshotIdle(t, sess)
162+ require.True(t, sess.snapDirty.Load(), "an unsubmitted capture must remain retryable")
163+}
164+
165+func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) {
166+ store := &channelSnapshotStore{writes: make(chan []byte, 3)}
167+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
168+ WithSnapshotStore(store)(d)
169+ startSnapshotEncodeWorker(t, d)
170+ entered := make(chan struct{})
171+ release := make(chan struct{})
172+ var calls atomic.Int32
173+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
174+ if calls.Add(1) == 1 {
175+ close(entered)
176+ <-release
177+ }
178+ return snapcodec.Marshal(s)
179+ }
180+ first := newSnapshotTestSession(t, "first", false, "/work")
181+ second := newSnapshotTestSession(t, "second", false, "/work")
182+ third := newSnapshotTestSession(t, "third", false, "/work")
183+ markSnapshotDirty(first)
184+ markSnapshotDirty(second)
185+ markSnapshotDirty(third)
186+ require.True(t, d.scheduleSnapshot(first))
187+ <-entered
188+ require.True(t, d.scheduleSnapshot(second), "one capture may wait in the bounded queue")
189+ require.False(t, d.scheduleSnapshot(third), "full queue must not block the producer")
190+ require.True(t, third.snapDirty.Load())
191+
192+ close(release)
193+ awaitSnapshotClean(t, first)
194+ awaitSnapshotClean(t, second)
195+ require.True(t, d.scheduleSnapshot(third))
196+ <-store.writes
197+ awaitSnapshotClean(t, third)
198+}
199+
200+func TestSnapshotSuccessDoesNotClearNewerGeneration(t *testing.T) {
201+ store := &channelSnapshotStore{writes: make(chan []byte, 2)}
202+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
203+ WithSnapshotStore(store)(d)
204+ startSnapshotEncodeWorker(t, d)
205+ entered := make(chan struct{})
206+ release := make(chan struct{})
207+ var calls atomic.Int32
208+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
209+ if calls.Add(1) == 1 {
210+ close(entered)
211+ <-release
212+ }
213+ return snapcodec.Marshal(s)
214+ }
215+ sess := newSnapshotTestSession(t, "generation", false, "/work")
216+
217+ markSnapshotDirty(sess)
218+ require.True(t, d.scheduleSnapshot(sess))
219+ <-entered
220+ markSnapshotDirty(sess)
221+ close(release)
222+ <-store.writes
223+ awaitSnapshotIdle(t, sess)
224+ require.True(t, sess.snapDirty.Load(), "a stale successful capture must not clear newer state")
225+
226+ require.True(t, d.scheduleSnapshot(sess))
227+ <-store.writes
228+ awaitSnapshotClean(t, sess)
229+}
230+
231+func TestSnapshotEncodeFailureLeavesSessionDirtyAndRetries(t *testing.T) {
232+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
233+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
234+ WithSnapshotStore(store)(d)
235+ startSnapshotEncodeWorker(t, d)
236+ sess := newSnapshotTestSession(t, "retry-encode", false, "/work")
237+ failed := make(chan struct{}, 1)
238+ var calls atomic.Int32
239+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
240+ if calls.Add(1) == 1 {
241+ failed <- struct{}{}
242+ return nil, errors.New("encode failed")
243+ }
244+ return snapcodec.Marshal(s)
245+ }
246+
247+ require.True(t, d.captureSession(sess))
248+ <-failed
249+ awaitSnapshotIdle(t, sess)
250+ require.True(t, sess.snapDirty.Load())
251+ require.True(t, d.scheduleSnapshot(sess))
252+ <-store.writes
253+ awaitSnapshotClean(t, sess)
254+}
255+
23 func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {256 func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
24 clock := newManualSnapshotClock()257 clock := newManualSnapshotClock()
25 store := portsmocks.NewMockSnapshotStore(t)258 store := portsmocks.NewMockSnapshotStore(t)
26 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)259 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
27 WithSnapshotStore(store)(d)260 WithSnapshotStore(store)(d)
28 d.procCwd = func(int) (string, error) { return "/pane", nil }261 d.procCwd = func(int) (string, error) { return "/pane", nil }
262+ startSnapshotEncodeWorker(t, d)
29 263
30 dirty := newSnapshotTestSession(t, "dirty", false, "/fallback")264 dirty := newSnapshotTestSession(t, "dirty", false, "/fallback")
31 clean := newSnapshotTestSession(t, "clean", false, "/fallback")265 clean := newSnapshotTestSession(t, "clean", false, "/fallback")
@@ -67,6 +301,7 @@ func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
67 store := portsmocks.NewMockSnapshotStore(t)301 store := portsmocks.NewMockSnapshotStore(t)
68 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})302 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69 WithSnapshotStore(store)(d)303 WithSnapshotStore(store)(d)
304+ startSnapshotEncodeWorker(t, d)
70 sess := newSnapshotTestSession(t, "work", false, "/fallback")305 sess := newSnapshotTestSession(t, "work", false, "/fallback")
71 tb := sess.tabs[0]306 tb := sess.tabs[0]
72 floatingPTY, _ := newBlockingPTY(t)307 floatingPTY, _ := newBlockingPTY(t)
@@ -85,12 +320,14 @@ func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
85 return nil320 return nil
86 }).Once()321 }).Once()
87 require.True(t, d.captureSession(sess))322 require.True(t, d.captureSession(sess))
323+ awaitSnapshotClean(t, sess)
88 }324 }
89 325
90 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {326 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
91 store := portsmocks.NewMockSnapshotStore(t)327 store := portsmocks.NewMockSnapshotStore(t)
92 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})328 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
93 WithSnapshotStore(store)(d)329 WithSnapshotStore(store)(d)
330+ startSnapshotEncodeWorker(t, d)
94 d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) {331 d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) {
95 require.Equal(t, 4321, pgid)332 require.Equal(t, 4321, pgid)
96 require.Equal(t, 1234, shellPid)333 require.Equal(t, 1234, shellPid)
@@ -113,12 +350,14 @@ func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
113 }).Once()350 }).Once()
114 351
115 require.True(t, d.captureSession(sess))352 require.True(t, d.captureSession(sess))
353+ awaitSnapshotClean(t, sess)
116 }354 }
117 355
118 func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {356 func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
119 store := portsmocks.NewMockSnapshotStore(t)357 store := portsmocks.NewMockSnapshotStore(t)
120 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})358 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
121 WithSnapshotStore(store)(d)359 WithSnapshotStore(store)(d)
360+ startSnapshotEncodeWorker(t, d)
122 d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") }361 d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") }
123 362
124 sess := newSnapshotTestSession(t, "work", false, "/fallback")363 sess := newSnapshotTestSession(t, "work", false, "/fallback")
@@ -133,12 +372,14 @@ func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
133 }).Once()372 }).Once()
134 373
135 require.True(t, d.captureSession(sess))374 require.True(t, d.captureSession(sess))
375+ awaitSnapshotClean(t, sess)
136 }376 }
137 377
138 func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {378 func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
139 store := portsmocks.NewMockSnapshotStore(t)379 store := portsmocks.NewMockSnapshotStore(t)
140 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})380 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
141 WithSnapshotStore(store)(d)381 WithSnapshotStore(store)(d)
382+ startSnapshotEncodeWorker(t, d)
142 d.procGroupArgv = func(int, int) ([]string, error) {383 d.procGroupArgv = func(int, int) ([]string, error) {
143 t.Fatal("bare shell should not inspect argv")384 t.Fatal("bare shell should not inspect argv")
144 return nil, nil385 return nil, nil
@@ -156,35 +397,37 @@ func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
156 }).Once()397 }).Once()
157 398
158 require.True(t, d.captureSession(sess))399 require.True(t, d.captureSession(sess))
400+ awaitSnapshotClean(t, sess)
159 }401 }
160 402
161 func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {403 func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {
162 store := portsmocks.NewMockSnapshotStore(t)404 store := portsmocks.NewMockSnapshotStore(t)
163 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})405 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
164 WithSnapshotStore(store)(d)406 WithSnapshotStore(store)(d)
407+ startSnapshotEncodeWorker(t, d)
165 d.procCwd = func(int) (string, error) { return "/live", nil }408 d.procCwd = func(int) (string, error) { return "/live", nil }
166 409
167 sess := newSnapshotTestSession(t, "work", false, "/fallback")410 sess := newSnapshotTestSession(t, "work", false, "/fallback")
168 d.sessions[sess.id] = sess411 d.sessions[sess.id] = sess
169 412
170- var wrote atomic.Bool413+ var closed atomic.Bool
171 store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {414 store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
172 snap, err := snapcodec.Unmarshal(data)415 snap, err := snapcodec.Unmarshal(data)
173 require.NoError(t, err)416 require.NoError(t, err)
174 require.Equal(t, "work", snap.Name)417 require.Equal(t, "work", snap.Name)
175 require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd)418 require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd)
176- wrote.Store(true)419+ require.True(t, closed.Load(), "snapshot persistence must not delay pane close")
177 return nil420 return nil
178 }).Once()421 }).Once()
179 mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)422 mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
180 require.True(t, ok)423 require.True(t, ok)
181 mockPTY.EXPECT().Close().RunAndReturn(func() error {424 mockPTY.EXPECT().Close().RunAndReturn(func() error {
182- require.True(t, wrote.Load(), "snapshot Write must happen before pane Close")425+ closed.Store(true)
183 return nil426 return nil
184 }).Once()427 }).Once()
185 428
186 require.NoError(t, d.killSession(sess, 0, false))429 require.NoError(t, d.killSession(sess, 0, false))
187- require.True(t, wrote.Load())430+ awaitSnapshotClean(t, sess)
188 }431 }
189 432
190 func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {433 func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
@@ -241,12 +484,17 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
241 store := portsmocks.NewMockSnapshotStore(t)484 store := portsmocks.NewMockSnapshotStore(t)
242 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)485 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
243 WithSnapshotStore(store)(d)486 WithSnapshotStore(store)(d)
487+ startSnapshotEncodeWorker(t, d)
244 488
245 sess := newSnapshotTestSession(t, "retry", false, "/fallback")489 sess := newSnapshotTestSession(t, "retry", false, "/fallback")
246 sess.snapDirty.Store(true)490 sess.snapDirty.Store(true)
247 d.sessions[sess.id] = sess491 d.sessions[sess.id] = sess
248 492
249- store.EXPECT().Write("retry", mock.Anything).Return(errors.New("boom")).Once()493+ failed := make(chan struct{}, 1)
494+ store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
495+ failed <- struct{}{}
496+ return errors.New("boom")
497+ }).Once()
250 wrote := make(chan struct{}, 1)498 wrote := make(chan struct{}, 1)
251 store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {499 store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
252 wrote <- struct{}{}500 wrote <- struct{}{}
@@ -257,7 +505,9 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
257 defer cancel()505 defer cancel()
258 go d.snapshotSaver(ctx)506 go d.snapshotSaver(ctx)
259 clock.fireNext(t)507 clock.fireNext(t)
260- require.Eventually(t, func() bool { return sess.snapDirty.Load() }, time.Second, time.Millisecond)508+ <-failed
509+ awaitSnapshotIdle(t, sess)
510+ require.True(t, sess.snapDirty.Load())
261 clock.fireNext(t)511 clock.fireNext(t)
262 select {512 select {
263 case <-wrote:513 case <-wrote:
@@ -365,6 +615,7 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
365 factory := portsmocks.NewMockPTYFactory(t)615 factory := portsmocks.NewMockPTYFactory(t)
366 d := newTestDaemon(t, factory, clock)616 d := newTestDaemon(t, factory, clock)
367 WithSnapshotStore(store)(d)617 WithSnapshotStore(store)(d)
618+ startSnapshotEncodeWorker(t, d)
368 619
369 sess := newSnapshotTestSession(t, "work", false, "/work")620 sess := newSnapshotTestSession(t, "work", false, "/work")
370 tctx, tcancel := context.WithCancel(context.Background())621 tctx, tcancel := context.WithCancel(context.Background())
@@ -450,6 +701,74 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
450 return sess701 return sess
451 }702 }
452 703
704+func awaitSnapshotIdle(t *testing.T, sess *session) {
705+ t.Helper()
706+ for range 4096 {
707+ sess.snapshotMu.Lock()
708+ pending := sess.snapshotPending
709+ sess.snapshotMu.Unlock()
710+ if !pending {
711+ return
712+ }
713+ runtime.Gosched()
714+ }
715+ t.Fatal("snapshot worker did not become idle")
716+}
717+
718+func awaitSnapshotClean(t *testing.T, sess *session) {
719+ t.Helper()
720+ for range 4096 {
721+ if !sess.snapDirty.Load() {
722+ return
723+ }
724+ runtime.Gosched()
725+ }
726+ t.Fatal("snapshot worker did not finish")
727+}
728+
729+func startSnapshotEncodeWorker(t *testing.T, d *Daemon) {
730+ t.Helper()
731+ ctx, cancel := context.WithCancel(t.Context())
732+ d.startSnapshotEncodeWorker(ctx)
733+ t.Cleanup(func() {
734+ cancel()
735+ d.stopSnapshotEncodeWorker()
736+ })
737+}
738+
739+type gatedSnapshotStore struct {
740+ entered chan struct{}
741+ release chan struct{}
742+ once sync.Once
743+ releaseOnce sync.Once
744+}
745+
746+func newGatedSnapshotStore() *gatedSnapshotStore {
747+ return &gatedSnapshotStore{entered: make(chan struct{}), release: make(chan struct{})}
748+}
749+
750+func (s *gatedSnapshotStore) enter() { s.once.Do(func() { close(s.entered) }) }
751+func (s *gatedSnapshotStore) unblock() { s.releaseOnce.Do(func() { close(s.release) }) }
752+
753+func (s *gatedSnapshotStore) Write(_ string, _ []byte) error {
754+ s.enter()
755+ <-s.release
756+ return nil
757+}
758+func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
759+func (*gatedSnapshotStore) Delete(string) error { return nil }
760+
761+type channelSnapshotStore struct {
762+ writes chan []byte
763+}
764+
765+func (s *channelSnapshotStore) Write(_ string, data []byte) error {
766+ s.writes <- append([]byte(nil), data...)
767+ return nil
768+}
769+func (*channelSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
770+func (*channelSnapshotStore) Delete(string) error { return nil }
771+
453 type manualSnapshotClock struct {772 type manualSnapshotClock struct {
454 mu sync.Mutex773 mu sync.Mutex
455 timers []*manualSnapshotTimer774 timers []*manualSnapshotTimer
M internal/usecase/snapshot/codec_test.go +117 -0
@@ -114,6 +114,123 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
114114 }
115115 }
116116
117+func TestUnmarshalRejectsMalformedTerminalBlobWithoutPanic(t *testing.T) {
118+ tail := mustHistoryTail(t)
119+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
120+ // The visible cell's underline-style byte is outside the renderer enum.
121+ visible[13+29] = 0xff
122+ body := v3Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(visible)), visible)
123+ reject(t, v3Envelope(version, 0, body), ErrInvalidData)
124+}
125+
126+func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
127+ for _, tc := range []struct {
128+ name string
129+ tab Tab
130+ }{
131+ {name: "nil tree", tab: Tab{Tree: nil}},
132+ {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
133+ } {
134+ t.Run(tc.name, func(t *testing.T) {
135+ data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
136+ requireNoError(t, err)
137+ got, err := Unmarshal(data)
138+ requireNoError(t, err)
139+ if got.Tabs[0].Tree == nil || got.Tabs[0].Tree.Root != nil {
140+ t.Fatalf("tree = %#v, want nonnil tree with nil root", got.Tabs[0].Tree)
141+ }
142+ })
143+ }
144+}
145+
146+func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
147+ sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}, {{Rune: 'i'}}})
148+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
149+ want := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
150+ {
151+ StableID: "t1", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2",
152+ Tree: &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
153+ layout.NewLeaf("1"), layout.NewLeaf("2"),
154+ }}},
155+ Panes: []Pane{
156+ {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"}}},
157+ {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Visible: visible},
158+ },
159+ },
160+ {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}}},
161+ }}
162+ data, err := Marshal(want)
163+ requireNoError(t, err)
164+ got, err := Unmarshal(data)
165+ requireNoError(t, err)
166+ if got.Name != want.Name || got.CreatedAt != want.CreatedAt || got.Active != want.Active || len(got.Tabs) != 2 {
167+ t.Fatalf("session metadata round trip = %#v", got)
168+ }
169+ pane := got.Tabs[0].Panes[0]
170+ 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 {
171+ t.Fatalf("pane metadata round trip = %#v", pane)
172+ }
173+}
174+
175+func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
176+ _, err := Marshal(Session{Name: "s", Tabs: []Tab{
177+ {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}},
178+ }})
179+ if !errors.Is(err, ErrInvalidData) {
180+ t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
181+ }
182+}
183+
184+func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
185+ tail := mustHistoryTail(t)
186+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
187+ var w payloadWriter
188+ _ = w.putString("s")
189+ w.putUint64(0)
190+ w.putUint16(0)
191+ w.putUint16(1)
192+ _ = w.putString("t")
193+ w.putUint16(80)
194+ w.putUint16(24)
195+ w.putUint64(1)
196+ _ = w.putString("p")
197+ _ = writeNode(&w, layout.NewLeaf("p"))
198+ w.putUint16(1)
199+ _ = w.putString("p")
200+ _ = w.putString("")
201+ _ = w.putString("")
202+ w.putUint32(0)
203+ w.putUint32(uint32(len(tail)))
204+ w.b = append(w.b, tail...)
205+ w.putUint32(uint32(len(visible)))
206+ w.b = append(w.b, visible...)
207+ w.putUint8(1)
208+ w.putUint16(0) // argv count
209+ _ = w.putString("generic")
210+ _ = w.putString("")
211+ if _, err := Unmarshal(v3Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) {
212+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
213+ }
214+}
215+
216+func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) {
217+ body := v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)
218+ reject(t, v3Envelope(version, 0, body), ErrInvalidData)
219+}
220+
221+func mustHistoryTail(t *testing.T) []byte {
222+ t.Helper()
223+ _, tail := historyBlobs(t, nil)
224+ return tail
225+}
226+
227+func requireNoError(t *testing.T, err error) {
228+ t.Helper()
229+ if err != nil {
230+ t.Fatal(err)
231+ }
232+}
233+
117234 func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
118235 t.Helper()
119236 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
@@ -114,6 +114,123 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
114 }114 }
115 }115 }
116 116
117+func TestUnmarshalRejectsMalformedTerminalBlobWithoutPanic(t *testing.T) {
118+ tail := mustHistoryTail(t)
119+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
120+ // The visible cell's underline-style byte is outside the renderer enum.
121+ visible[13+29] = 0xff
122+ body := v3Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(visible)), visible)
123+ reject(t, v3Envelope(version, 0, body), ErrInvalidData)
124+}
125+
126+func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
127+ for _, tc := range []struct {
128+ name string
129+ tab Tab
130+ }{
131+ {name: "nil tree", tab: Tab{Tree: nil}},
132+ {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
133+ } {
134+ t.Run(tc.name, func(t *testing.T) {
135+ data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
136+ requireNoError(t, err)
137+ got, err := Unmarshal(data)
138+ requireNoError(t, err)
139+ if got.Tabs[0].Tree == nil || got.Tabs[0].Tree.Root != nil {
140+ t.Fatalf("tree = %#v, want nonnil tree with nil root", got.Tabs[0].Tree)
141+ }
142+ })
143+ }
144+}
145+
146+func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
147+ sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}, {{Rune: 'i'}}})
148+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
149+ want := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
150+ {
151+ StableID: "t1", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2",
152+ Tree: &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
153+ layout.NewLeaf("1"), layout.NewLeaf("2"),
154+ }}},
155+ Panes: []Pane{
156+ {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"}}},
157+ {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Visible: visible},
158+ },
159+ },
160+ {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}}},
161+ }}
162+ data, err := Marshal(want)
163+ requireNoError(t, err)
164+ got, err := Unmarshal(data)
165+ requireNoError(t, err)
166+ if got.Name != want.Name || got.CreatedAt != want.CreatedAt || got.Active != want.Active || len(got.Tabs) != 2 {
167+ t.Fatalf("session metadata round trip = %#v", got)
168+ }
169+ pane := got.Tabs[0].Panes[0]
170+ 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 {
171+ t.Fatalf("pane metadata round trip = %#v", pane)
172+ }
173+}
174+
175+func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
176+ _, err := Marshal(Session{Name: "s", Tabs: []Tab{
177+ {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}},
178+ }})
179+ if !errors.Is(err, ErrInvalidData) {
180+ t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
181+ }
182+}
183+
184+func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
185+ tail := mustHistoryTail(t)
186+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
187+ var w payloadWriter
188+ _ = w.putString("s")
189+ w.putUint64(0)
190+ w.putUint16(0)
191+ w.putUint16(1)
192+ _ = w.putString("t")
193+ w.putUint16(80)
194+ w.putUint16(24)
195+ w.putUint64(1)
196+ _ = w.putString("p")
197+ _ = writeNode(&w, layout.NewLeaf("p"))
198+ w.putUint16(1)
199+ _ = w.putString("p")
200+ _ = w.putString("")
201+ _ = w.putString("")
202+ w.putUint32(0)
203+ w.putUint32(uint32(len(tail)))
204+ w.b = append(w.b, tail...)
205+ w.putUint32(uint32(len(visible)))
206+ w.b = append(w.b, visible...)
207+ w.putUint8(1)
208+ w.putUint16(0) // argv count
209+ _ = w.putString("generic")
210+ _ = w.putString("")
211+ if _, err := Unmarshal(v3Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) {
212+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
213+ }
214+}
215+
216+func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) {
217+ body := v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)
218+ reject(t, v3Envelope(version, 0, body), ErrInvalidData)
219+}
220+
221+func mustHistoryTail(t *testing.T) []byte {
222+ t.Helper()
223+ _, tail := historyBlobs(t, nil)
224+ return tail
225+}
226+
227+func requireNoError(t *testing.T, err error) {
228+ t.Helper()
229+ if err != nil {
230+ t.Fatal(err)
231+ }
232+}
233+
117 func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {234 func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
118 t.Helper()235 t.Helper()
119 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})236 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close