1package daemon
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 vt "github.com/bnema/vev-vt"
11 renderer "github.com/bnema/vev-vt/ansi"
12 "github.com/bnema/vev/internal/domain"
13 snapcodec "github.com/bnema/vev/internal/snapshotcodec"
14)
15
16func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {
17 for _, tt := range []struct {
18 name string
19 fill bool
20 }{
21 {name: "empty tail"},
22 {name: "non-empty tail", fill: true},
23 } {
24 t.Run(tt.name, func(t *testing.T) {
25 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2})
26 if tt.fill {
27 appendHistoryRow(t, history, []renderer.Cell{{Rune: 'x'}})
28 }
29 tail := history.SnapshotView().Tail()
30
31 got, err := marshalSnapshotTail(tail)
32 require.NoError(t, err)
33 if tt.fill {
34 want, err := vt.MarshalHistory(tail)
35 require.NoError(t, err)
36 require.Equal(t, want, got)
37 return
38 }
39 want, err := vt.MarshalEmptyHistoryTail()
40 require.NoError(t, err)
41 require.Equal(t, want, got)
42 })
43 }
44}
45
46func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) {
47 d := New(nil, nil, nil)
48 sess := newSnapshotTestSession(t, "work", false, "/work")
49 capture, ok := d.captureSnapshotState(sess, 1)
50 if !ok {
51 t.Fatal("capture rejected named session")
52 }
53
54 pane := sess.tabs[0].panes["pane-1"]
55 pane.mu.Lock()
56 done := make(chan error, 1)
57 go func() {
58 _, err := d.incrementalPublication(capture)
59 done <- err
60 }()
61 select {
62 case err := <-done:
63 if err != nil {
64 t.Fatalf("encode capture: %v", err)
65 }
66 case <-time.After(testWaitTimeout):
67 pane.mu.Unlock()
68 t.Fatal("recovery transcript encoding waited for pane lock")
69 }
70 pane.mu.Unlock()
71}
72
73func TestSnapshotCaptureOwnsRecoveryTranscriptAcrossLiveMutations(t *testing.T) {
74 d := New(nil, nil, nil)
75 sess := newSnapshotTestSession(t, "work", false, "/work")
76 capture, ok := d.captureSnapshotState(sess, 1)
77 require.True(t, ok)
78
79 pane := sess.tabs[0].panes["pane-1"]
80 pane.mu.Lock()
81 pane.screen.Write([]byte("\x1b[1;1Hchanged"))
82 pane.mu.Unlock()
83
84 publication, err := d.incrementalPublication(capture)
85 require.NoError(t, err)
86 restored, err := sessionFromGeneration(snapshotGeneration(publication))
87 require.NoError(t, err)
88 view, err := vt.UnmarshalHistory(restored.Tabs[0].Panes[0].Transcript)
89 require.NoError(t, err)
90 require.Equal(t, "hello", strings.TrimRight(cellsString(view.Row(0)), " "))
91}
92
93func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
94 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})
95 for i := range 6 {
96 appendHistoryRow(t, history, []renderer.Cell{{Rune: rune('a' + i)}})
97 }
98 view := history.SnapshotView()
99 require.Equal(t, 6, view.ChunkCount())
100
101 firstPayload, err := vt.MarshalHistoryChunk(view.Chunk(0))
102 require.NoError(t, err)
103 firstObject, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, firstPayload)
104 require.NoError(t, err)
105 cache := newSnapshotChunkCache(2 * len(firstObject.Data))
106 encodes := make(map[*vt.HistoryChunk]int)
107 cache.marshalChunk = func(chunk *vt.HistoryChunk) ([]byte, error) {
108 encodes[chunk]++
109 return vt.MarshalHistoryChunk(chunk)
110 }
111
112 for pass := range 3 {
113 for i := range view.ChunkCount() {
114 _, _, err := cache.objectLocked(view.Chunk(i))
115 require.NoError(t, err)
116 }
117 require.LessOrEqual(t, cache.used, cache.limit)
118 if pass == 0 {
119 require.Len(t, cache.byPtr, 2)
120 }
121 }
122
123 // The cache admits the first bounded working set then rejects later scan
124 // misses. Consequently, each warmed retained chunk is encoded only once
125 // even though the history is repeatedly scanned oldest-to-newest.
126 require.Equal(t, 1, encodes[view.Chunk(0)])
127 require.Equal(t, 1, encodes[view.Chunk(1)])
128 require.Equal(t, 3, encodes[view.Chunk(2)])
129 require.Equal(t, 3, encodes[view.Chunk(5)])
130
131 isolated := newSnapshotChunkCache(cache.limit)
132 isolatedEncodes := 0
133 isolated.marshalChunk = func(chunk *vt.HistoryChunk) ([]byte, error) {
134 isolatedEncodes++
135 return vt.MarshalHistoryChunk(chunk)
136 }
137 _, _, err = isolated.objectLocked(view.Chunk(0))
138 require.NoError(t, err)
139 require.Equal(t, 1, isolatedEncodes, "a session cache must not share encoded history with another session")
140}
141
142func TestMarkSnapshotCaptureObjectsPublishedReleasesCachedEncoding(t *testing.T) {
143 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
144 appendHistoryRow(t, history, []renderer.Cell{{Rune: 'x'}})
145 view := history.SnapshotView()
146 chunk := view.Chunk(0)
147 transcript := vt.NewScreen(1, 1).RecoveryTranscriptSnapshot()
148 d := New(nil, nil, nil)
149 sess := newSnapshotTestSession(t, "work", false, "/work")
150 capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), transcript: transcript}}}}}
151
152 _, err := d.incrementalPublication(capture)
153 require.NoError(t, err)
154 entry, ok := sess.snapshotChunkCache.byPtr[chunk]
155 require.True(t, ok)
156 usedBefore := sess.snapshotChunkCache.used
157 ref := capture.sealedRefs[chunk]
158
159 markSnapshotCaptureObjectsPublished(capture)
160
161 require.NotContains(t, sess.snapshotChunkCache.byPtr, chunk)
162 require.Equal(t, usedBefore-len(entry.object.Data), sess.snapshotChunkCache.used)
163 require.Equal(t, ref, sess.snapshotChunkCache.persisted[chunk])
164 gotRef, object, err := sess.snapshotChunkCache.objectLocked(chunk)
165 require.NoError(t, err)
166 require.Equal(t, ref, gotRef)
167 require.Nil(t, object)
168
169 // Repeated acknowledgement must not release the same bytes twice.
170 markSnapshotCaptureObjectsPublished(capture)
171 require.Equal(t, usedBefore-len(entry.object.Data), sess.snapshotChunkCache.used)
172 require.Equal(t, ref, sess.snapshotChunkCache.persisted[chunk])
173}
174
175func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
176 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
177 appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()})
178 view := history.SnapshotView()
179 transcript := vt.NewScreen(1, 1).RecoveryTranscriptSnapshot()
180 d := New(nil, nil, nil)
181 sess := newSnapshotTestSession(t, "work", false, "/work")
182 capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), transcript: transcript}}}}}
183 first, err := d.incrementalPublication(capture)
184 if err != nil {
185 t.Fatal(err)
186 }
187 firstRef := domain.CheckpointRef{Generation: first.Generation, ManifestDigest: snapcodec.ManifestDigest(first.Manifest)}
188 capture.checkpoint = firstRef
189 markSnapshotCaptureObjectsPublished(capture)
190 capture.generation = 2
191 capture.parentCheckpoint = &firstRef
192 second, err := d.incrementalPublication(capture)
193 if err != nil {
194 t.Fatal(err)
195 }
196 if len(first.Objects) == 0 || len(second.Objects) == 0 {
197 t.Fatal("publication omitted required objects")
198 }
199 require.Len(t, second.Objects, 2, "a generation must supply its tail and transcript once without resupplying persisted sealed chunks")
200 kinds := make(map[snapcodec.ObjectKind]int)
201 for _, object := range second.Objects {
202 kind, err := snapcodec.PreflightObject(object.Data)
203 require.NoError(t, err)
204 kinds[kind]++
205 }
206 require.Equal(t, map[snapcodec.ObjectKind]int{snapcodec.HistoryTail: 1, snapcodec.RecoveryTranscript: 1}, kinds)
207 require.Equal(t, &firstRef, second.ParentCheckpoint)
208 manifest, err := snapcodec.UnmarshalManifest(second.Manifest)
209 require.NoError(t, err)
210 require.Equal(t, &firstRef, manifest.ParentCheckpoint)
211 if got, want := sess.snapshotChunkCache.used, sess.snapshotChunkCache.limit; got > want {
212 t.Fatalf("cache bytes = %d, limit = %d", got, want)
213 }
214}