1package daemon
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/require"
11
12 renderer "github.com/bnema/vev-vt"
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/usecase/picker"
16)
17
18type remotePreviewTestClock struct {
19 mu sync.Mutex
20 now time.Time
21}
22
23func (c *remotePreviewTestClock) Now() time.Time {
24 c.mu.Lock()
25 defer c.mu.Unlock()
26 return c.now
27}
28
29func (c *remotePreviewTestClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
30
31func (c *remotePreviewTestClock) Advance(delta time.Duration) {
32 c.mu.Lock()
33 c.now = c.now.Add(delta)
34 c.mu.Unlock()
35}
36
37type remotePreviewTestClient struct {
38 mu sync.Mutex
39 calls int
40 result ports.RemotePreview
41 err error
42 started chan struct{}
43 finished chan struct{}
44 release chan struct{}
45 lastWidth uint16
46 lastHeight uint16
47 lastTarget domain.RemoteSessionTarget
48 ignoreCancel bool
49 startedSignal bool
50 finishedSignal bool
51}
52
53func (c *remotePreviewTestClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) {
54 c.mu.Lock()
55 c.calls++
56 c.lastTarget = target
57 c.lastWidth, c.lastHeight = width, height
58 result, err := c.result, c.err
59 started, release, finished := c.started, c.release, c.finished
60 if started != nil && !c.startedSignal {
61 c.startedSignal = true
62 close(started)
63 }
64 c.mu.Unlock()
65 if release != nil {
66 if c.ignoreCancel {
67 <-release
68 } else {
69 select {
70 case <-release:
71 case <-ctx.Done():
72 return ports.RemotePreview{}, ctx.Err()
73 }
74 }
75 }
76 if finished != nil {
77 c.mu.Lock()
78 if !c.finishedSignal {
79 c.finishedSignal = true
80 close(finished)
81 }
82 c.mu.Unlock()
83 }
84 return cloneRemotePreview(result), err
85}
86
87func (c *remotePreviewTestClient) Calls() int {
88 c.mu.Lock()
89 defer c.mu.Unlock()
90 return c.calls
91}
92
93func (c *remotePreviewTestClient) LastSize() (uint16, uint16) {
94 c.mu.Lock()
95 defer c.mu.Unlock()
96 return c.lastWidth, c.lastHeight
97}
98
99func (c *remotePreviewTestClient) LastTarget() domain.RemoteSessionTarget {
100 c.mu.Lock()
101 defer c.mu.Unlock()
102 return c.lastTarget
103}
104
105func remotePreviewCacheTarget() domain.RemoteSessionTarget {
106 var lifecycle domain.SessionLifecycleID
107 lifecycle[0] = 0x42
108 return domain.RemoteSessionTarget{
109 Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
110 SessionName: "work", LiveTabID: "tab-1",
111 }
112}
113
114func remotePreviewCacheResult(target domain.RemoteSessionTarget, revision uint64) ports.RemotePreview {
115 return remotePreviewCacheResultRune(target, revision, 'x')
116}
117
118func remotePreviewCacheResultRune(target domain.RemoteSessionTarget, revision uint64, value rune) ports.RemotePreview {
119 return ports.RemotePreview{
120 Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewOK,
121 LifecycleID: target.LifecycleID, TabID: target.LiveTabID,
122 Revision: revision, Width: 1, Height: 1,
123 Cells: []renderer.Cell{{Rune: value, Style: renderer.DefaultStyle()}},
124 }
125}
126
127func TestFetchRemotePreviewSingleFlightAndCopiesCache(t *testing.T) {
128 clock := &remotePreviewTestClock{now: time.Unix(100, 0)}
129 target := remotePreviewCacheTarget()
130 client := &remotePreviewTestClient{
131 result: remotePreviewCacheResult(target, 1),
132 started: make(chan struct{}),
133 release: make(chan struct{}),
134 }
135 d := newTestDaemon(t, nil, clock)
136 d.remotePreviewClient = client
137
138 firstDone := make(chan struct{})
139 var first ports.RemotePreview
140 var firstErr error
141 go func() {
142 first, firstErr = d.fetchRemotePreview(context.Background(), target, 1, 1)
143 close(firstDone)
144 }()
145 <-client.started
146
147 secondDone := make(chan struct{})
148 var second ports.RemotePreview
149 var secondErr error
150 go func() {
151 second, secondErr = d.fetchRemotePreview(context.Background(), target, 1, 1)
152 close(secondDone)
153 }()
154 close(client.release)
155 <-firstDone
156 <-secondDone
157 require.NoError(t, firstErr)
158 require.NoError(t, secondErr)
159 require.Equal(t, first, second)
160 require.Equal(t, 1, client.Calls(), "same key must use one remote request")
161
162 first.Cells[0].Rune = 'm'
163 cached, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
164 require.NoError(t, err)
165 require.Equal(t, rune('x'), cached.Cells[0].Rune, "callers must not mutate the memory-only cache")
166 require.Equal(t, 1, client.Calls())
167}
168
169func TestFetchRemotePreviewAdapterTimeoutAppliesCooldown(t *testing.T) {
170 clock := &remotePreviewTestClock{now: time.Unix(250, 0)}
171 target := remotePreviewCacheTarget()
172 client := &remotePreviewTestClient{err: ports.ErrRemotePreviewTimeout}
173 d := newTestDaemon(t, nil, clock)
174 d.remotePreviewClient = client
175
176 _, firstErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
177 require.ErrorIs(t, firstErr, ports.ErrRemotePreviewTimeout)
178 _, secondErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
179 require.ErrorIs(t, secondErr, errRemotePreviewCooldown)
180 require.Equal(t, 1, client.Calls())
181}
182
183func TestFetchRemotePreviewRejectsMalformedResponseAndAppliesCooldown(t *testing.T) {
184 clock := &remotePreviewTestClock{now: time.Unix(200, 0)}
185 target := remotePreviewCacheTarget()
186 client := &remotePreviewTestClient{err: errors.New("remote unavailable")}
187 d := newTestDaemon(t, nil, clock)
188 d.remotePreviewClient = client
189
190 _, firstErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
191 require.Error(t, firstErr)
192 _, secondErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
193 require.Error(t, secondErr)
194 require.Equal(t, 1, client.Calls(), "a failed target must be cooled down")
195
196 clock.Advance(remotePreviewCooldown)
197 _, thirdErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
198 require.Error(t, thirdErr)
199 require.Equal(t, 2, client.Calls(), "cooldown expiry must permit a retry")
200}
201
202func TestFetchRemotePreviewServesStaleWhileRefreshing(t *testing.T) {
203 clock := &remotePreviewTestClock{now: time.Unix(300, 0)}
204 target := remotePreviewCacheTarget()
205 client := &remotePreviewTestClient{result: remotePreviewCacheResult(target, 1)}
206 d := newTestDaemon(t, nil, clock)
207 d.remotePreviewClient = client
208
209 first, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
210 require.NoError(t, err)
211 require.Equal(t, uint64(1), first.Revision)
212
213 clock.Advance(remotePreviewCacheTTL + time.Second)
214 client.mu.Lock()
215 client.result = remotePreviewCacheResult(target, 2)
216 client.started = make(chan struct{})
217 client.release = make(chan struct{})
218 client.startedSignal = false
219 client.finishedSignal = false
220 client.mu.Unlock()
221
222 stale, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
223 require.NoError(t, err)
224 require.Equal(t, uint64(1), stale.Revision, "expired content remains available during revalidation")
225 <-client.started
226 key := remotePreviewKeyFor(target, 1, 1)
227 d.remotePreview.mu.Lock()
228 flight := d.remotePreview.flights[key]
229 d.remotePreview.mu.Unlock()
230 require.NotNil(t, flight)
231 close(client.release)
232 <-flight.done
233
234 fresh, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
235 require.NoError(t, err)
236 require.Equal(t, uint64(2), fresh.Revision)
237 require.Equal(t, 2, client.Calls())
238}
239
240type immediateRemotePreviewTimer struct{ ch <-chan time.Time }
241
242func (t immediateRemotePreviewTimer) C() <-chan time.Time { return t.ch }
243func (t immediateRemotePreviewTimer) Reset(time.Duration) bool { return false }
244func (t immediateRemotePreviewTimer) Stop() bool { return true }
245
246type immediateRemotePreviewClock struct{}
247
248func (immediateRemotePreviewClock) Now() time.Time { return time.Unix(500, 0) }
249func (immediateRemotePreviewClock) NewTimer(time.Duration) ports.Timer {
250 ch := make(chan time.Time, 1)
251 ch <- time.Unix(500, 0)
252 return immediateRemotePreviewTimer{ch: ch}
253}
254
255func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *testing.T) {
256 d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
257 firstTarget := remotePreviewCacheTarget()
258 firstStarted := make(chan struct{})
259 firstRelease := make(chan struct{})
260 client := &remotePreviewTestClient{
261 result: remotePreviewCacheResult(firstTarget, 1),
262 started: firstStarted,
263 release: firstRelease,
264 ignoreCancel: true,
265 }
266 d.remotePreviewClient = client
267 _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
268
269 firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
270 firstView := picker.SessionView{
271 ID: firstKey.ID(), Name: firstTarget.SessionName,
272 Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}},
273 RemoteKey: &firstKey, RemoteTarget: &firstTarget,
274 RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
275 }
276 firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
277 firstSelected, ok := firstModel.Selected()
278 require.True(t, ok)
279 ac.overlays.pickerMu.Lock()
280 ac.overlays.picker = firstModel
281 ac.overlays.pickerIntent = pickerNavigate
282 ac.overlays.pickerPreviewGeneration = 1
283 ac.overlays.pickerMu.Unlock()
284 d.startRemotePickerPreview(ac, firstSelected, 1)
285 <-firstStarted
286
287 d.closePicker(ac)
288
289 secondTarget := firstTarget
290 secondTarget.LifecycleID[0]++
291 secondTarget.LiveTabID = "tab-2"
292 client.mu.Lock()
293 client.result = remotePreviewCacheResultRune(secondTarget, 2, 'y')
294 client.started = make(chan struct{})
295 client.release = make(chan struct{})
296 client.startedSignal = false
297 client.finishedSignal = false
298 client.mu.Unlock()
299 secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
300 secondView := picker.SessionView{
301 ID: secondKey.ID(), Name: secondTarget.SessionName,
302 Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}},
303 RemoteKey: &secondKey, RemoteTarget: &secondTarget,
304 RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
305 }
306 secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
307 secondSelected, ok := secondModel.Selected()
308 require.True(t, ok)
309 ac.overlays.pickerMu.Lock()
310 ac.overlays.picker = secondModel
311 ac.overlays.pickerIntent = pickerNavigate
312 ac.overlays.pickerPreviewGeneration++
313 secondGeneration := ac.overlays.pickerPreviewGeneration
314 ac.overlays.pickerMu.Unlock()
315 d.startRemotePickerPreview(ac, secondSelected, secondGeneration)
316 <-client.started
317
318 close(firstRelease)
319 close(client.release)
320 width, height := remotePickerPreviewSize(ac.sizeSnapshot())
321 for _, key := range []remotePreviewCacheKey{
322 remotePreviewKeyFor(firstTarget, width, height),
323 remotePreviewKeyFor(secondTarget, width, height),
324 } {
325 d.remotePreview.mu.Lock()
326 flight := d.remotePreview.flights[key]
327 d.remotePreview.mu.Unlock()
328 if flight != nil {
329 <-flight.done
330 }
331 }
332 var preview picker.Preview
333 require.Eventually(t, func() bool {
334 ac.overlays.pickerMu.Lock()
335 preview = ac.overlays.pickerRemotePreview
336 currentGeneration := ac.overlays.pickerPreviewGeneration
337 ac.overlays.pickerMu.Unlock()
338 return currentGeneration == secondGeneration && len(preview.Rows) != 0 && len(preview.Rows[0]) != 0 && preview.Rows[0][0].Rune == 'y'
339 }, time.Second, time.Millisecond)
340 require.Equal(t, rune('y'), preview.Rows[0][0].Rune)
341 require.Equal(t, 2, client.Calls())
342}
343
344func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) {
345 d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
346 firstTarget := remotePreviewCacheTarget()
347 secondTarget := firstTarget
348 secondTarget.LifecycleID[0]++
349 secondTarget.LiveTabID = "tab-2"
350 client := &remotePreviewTestClient{result: remotePreviewCacheResult(firstTarget, 1), started: make(chan struct{})}
351 d.remotePreviewClient = client
352 _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
353 firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
354 secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
355 views := []picker.SessionView{
356 {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
357 {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
358 }
359 model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
360 ac.overlays.pickerMu.Lock()
361 ac.overlays.picker = model
362 ac.overlays.pickerIntent = pickerNavigate
363 ac.overlays.pickerPreviewGeneration = 1
364 ac.overlays.pickerMu.Unlock()
365 d.registerPreviewForSelection(ac)
366 <-client.started
367 require.Equal(t, firstTarget, client.LastTarget())
368
369 client.mu.Lock()
370 client.result = remotePreviewCacheResult(secondTarget, 2)
371 client.started = make(chan struct{})
372 client.startedSignal = false
373 client.mu.Unlock()
374 ac.overlays.pickerMu.Lock()
375 model.Down()
376 ac.overlays.pickerMu.Unlock()
377 d.registerPreviewForSelection(ac)
378 <-client.started
379 require.Equal(t, secondTarget, client.LastTarget())
380 require.Equal(t, 2, client.Calls())
381}
382
383func TestRemotePickerPreviewErrorCannotPublishAfterSelectionChange(t *testing.T) {
384 d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
385 firstTarget := remotePreviewCacheTarget()
386 secondTarget := firstTarget
387 secondTarget.LifecycleID[0]++
388 secondTarget.LiveTabID = "tab-2"
389 client := &remotePreviewTestClient{
390 err: errors.New("remote unavailable"),
391 started: make(chan struct{}),
392 release: make(chan struct{}),
393 }
394 d.remotePreviewClient = client
395 _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
396 firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
397 secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
398 model := picker.New([]picker.SessionView{
399 {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
400 {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
401 }, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
402 selected, ok := model.Selected()
403 require.True(t, ok)
404 ac.overlays.pickerMu.Lock()
405 ac.overlays.picker = model
406 ac.overlays.pickerIntent = pickerNavigate
407 ac.overlays.pickerPreviewGeneration = 1
408 ac.overlays.pickerMu.Unlock()
409 d.startRemotePickerPreview(ac, selected, 1)
410 <-client.started
411
412 ac.overlays.pickerMu.Lock()
413 ac.overlays.pickerRemotePreview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: 'k', Style: renderer.DefaultStyle()}}}}
414 model.Down()
415 ac.overlays.pickerMu.Unlock()
416 close(client.release)
417
418 require.Eventually(t, func() bool {
419 ac.overlays.pickerMu.Lock()
420 defer ac.overlays.pickerMu.Unlock()
421 return len(ac.overlays.pickerRemotePreview.Rows) == 1 && ac.overlays.pickerRemotePreview.Rows[0][0].Rune == 'k'
422 }, time.Second, time.Millisecond)
423}
424
425func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) {
426 d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
427 target := remotePreviewCacheTarget()
428 firstStarted := make(chan struct{})
429 client := &remotePreviewTestClient{
430 result: remotePreviewCacheResult(target, 1),
431 started: firstStarted,
432 release: make(chan struct{}),
433 }
434 d.remotePreviewClient = client
435 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
436 key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
437 view := picker.SessionView{
438 ID: key.ID(), Name: target.SessionName,
439 Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}},
440 RemoteKey: &key, RemoteTarget: &target,
441 RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
442 }
443 model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
444 selected, ok := model.Selected()
445 require.True(t, ok)
446 ac.overlays.pickerMu.Lock()
447 ac.overlays.picker = model
448 ac.overlays.pickerIntent = pickerNavigate
449 ac.overlays.pickerPreviewGeneration = 1
450 ac.overlays.pickerMu.Unlock()
451 d.startRemotePickerPreview(ac, selected, 1)
452 <-firstStarted
453
454 secondStarted := make(chan struct{})
455 client.mu.Lock()
456 client.started = secondStarted
457 client.release = nil
458 client.startedSignal = false
459 client.mu.Unlock()
460 token := sess.attachmentToken(ac, ac.transport())
461 effect, admitted := ac.beginAttachmentEffect(token)
462 require.True(t, admitted)
463 token.effect = effect
464 defer effect.End()
465 require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 100, Rows: 30}))
466 <-secondStarted
467 wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30})
468 gotWidth, gotHeight := client.LastSize()
469 require.Equal(t, wantWidth, gotWidth)
470 require.Equal(t, wantHeight, gotHeight)
471
472 require.Eventually(t, func() bool {
473 ac.overlays.pickerMu.Lock()
474 defer ac.overlays.pickerMu.Unlock()
475 return ac.overlays.pickerRemotePreview.Width == 1 && ac.overlays.pickerRemotePreview.Height == 1
476 }, time.Second, time.Millisecond)
477 require.Equal(t, 2, client.Calls())
478}
479
480func TestFetchRemotePreviewRejectsInvalidDimensionsBeforeRemoteIO(t *testing.T) {
481 clock := &remotePreviewTestClock{now: time.Unix(400, 0)}
482 client := &remotePreviewTestClient{result: remotePreviewCacheResult(remotePreviewCacheTarget(), 1)}
483 d := newTestDaemon(t, nil, clock)
484 d.remotePreviewClient = client
485
486 _, err := d.fetchRemotePreview(context.Background(), remotePreviewCacheTarget(), ports.RemotePreviewMaxWidth+1, 1)
487 require.ErrorIs(t, err, ports.ErrInvalidRemotePreviewRequest)
488 require.Zero(t, client.Calls())
489}