1package daemon
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15
16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/usecase/picker"
20)
21
22const remotePickerReceiveTimeout = time.Second
23
24func receiveRemotePicker[T any](t *testing.T, ch <-chan T, what string) T {
25 t.Helper()
26 select {
27 case value, ok := <-ch:
28 if !ok {
29 t.Fatalf("channel closed unexpectedly while waiting for %s", what)
30 }
31 return value
32 case <-time.After(remotePickerReceiveTimeout):
33 t.Fatalf("timed out waiting for %s", what)
34 var zero T
35 return zero
36 }
37}
38
39func waitRemotePickerClose(ch <-chan struct{}, what string) error {
40 select {
41 case _, ok := <-ch:
42 if ok {
43 return fmt.Errorf("received signal instead of closure while waiting for %s", what)
44 }
45 return nil
46 case <-time.After(remotePickerReceiveTimeout):
47 return fmt.Errorf("timed out waiting for %s", what)
48 }
49}
50
51func receiveRemotePickerClose(t *testing.T, ch <-chan struct{}, what string) {
52 t.Helper()
53 require.NoError(t, waitRemotePickerClose(ch, what))
54}
55
56func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
57 return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
58}
59
60func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) {
61 for _, state := range []string{"down", "stopped"} {
62 t.Run(state, func(t *testing.T) {
63 lifecycle := remoteLifecycleForTest()
64 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle}
65 session := ports.RemoteCatalogSession{
66 LifecycleID: lifecycle, Name: "work", State: state,
67 Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
68 }
69 view := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0))
70 require.True(t, view.Stopped)
71 require.NotNil(t, view.RemoteTarget)
72 require.True(t, view.RemoteTarget.Stopped)
73 require.True(t, view.RemoteAttachReady, "structured stopped rows have an explicit safe restore target")
74
75 legacy := session
76 legacy.LifecycleID = domain.SessionLifecycleID{}
77 legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0))
78 require.True(t, legacyView.Stopped)
79 require.True(t, legacyView.ConnectOnly)
80 require.False(t, legacyView.RemoteAttachReady, "legacy stopped rows cannot be selected without lifecycle identity")
81 })
82 }
83}
84
85func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) {
86 for _, attached := range []bool{false, true} {
87 view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, ports.RemoteCatalogSession{
88 LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up", Attached: attached,
89 Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0}},
90 }, remoteHostFresh, time.Now())
91 require.Equal(t, "up", view.RemoteDetail)
92 }
93}
94
95func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testing.T) {
96 store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}
97 d := newRemotePickerDaemon(store)
98 lifecycle := remoteLifecycleForTest()
99 session := ports.RemoteCatalogSession{
100 LifecycleID: lifecycle, Name: "work", State: "up",
101 Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
102 }
103 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
104 {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},
105 {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},
106 })
107 d.remoteCatalog.mu.Lock()
108 d.remoteCatalog.status["remote"] = remoteHostFresh
109 d.remoteCatalog.status["vev@remote"] = remoteHostFresh
110 d.remoteCatalog.mu.Unlock()
111
112 views, _ := d.pickerViews(nil, nil)
113 var targets []*domain.RemoteSessionTarget
114 for _, view := range views {
115 if view.RemoteTarget != nil {
116 targets = append(targets, view.RemoteTarget)
117 }
118 }
119 require.Len(t, targets, 1)
120 require.Equal(t, "remote", targets[0].Endpoint, "first pinned alias must remain the attach endpoint")
121}
122
123func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
124 now := time.Unix(100, 0)
125 clock := &remotePickerClock{now: now}
126 d := newTestDaemon(t, nil, clock)
127 lifecycle := remoteLifecycleForTest()
128 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
129 Host: "arch", FetchedAt: now.Add(-remotePickerAttachTTL), Sessions: []ports.RemoteCatalogSession{{
130 LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
131 }},
132 }})
133 d.remoteCatalog.mu.Lock()
134 d.remoteCatalog.status["arch"] = remoteHostFresh
135 d.remoteCatalog.mu.Unlock()
136
137 entries := d.remotePickerCatalogSnapshot()
138 require.Len(t, entries, 1)
139 require.Equal(t, remoteHostStale, entries[0].status)
140 target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
141 require.False(t, d.remotePickerTargetReadyTarget(target), "attachment must reject a catalogue exactly at the TTL boundary")
142}
143
144func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) {
145 now := time.Unix(100, 0)
146 clock := &remotePickerClock{now: now}
147 d := newTestDaemon(t, nil, clock)
148 lifecycle := remoteLifecycleForTest()
149 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
150 Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{
151 LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
152 }},
153 }})
154 d.remoteCatalog.mu.Lock()
155 d.remoteCatalog.status["arch"] = remoteHostFresh
156 d.remoteCatalog.mu.Unlock()
157
158 model := d.newPickerModel(nil, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
159 target, ok := model.Selected()
160 require.True(t, ok)
161 require.NotNil(t, target.RemoteTarget)
162 require.True(t, d.remotePickerTargetReadyTarget(*target.RemoteTarget), "fresh picker target must be ready before the attach TTL")
163
164 clock.Advance(remotePickerAttachTTL)
165 require.False(t, d.remotePickerTargetReadyTarget(*target.RemoteTarget), "handoff must reject a picker target whose catalog reached the attach TTL")
166 d.remoteCatalog.mu.Lock()
167 require.Equal(t, remoteHostStale, d.remoteCatalog.status["arch"])
168 d.remoteCatalog.mu.Unlock()
169}
170
171func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
172 tests := []struct {
173 name string
174 tabs []ports.RemoteCatalogTab
175 activeTabID string
176 wantActive int
177 wantTabID domain.TabStableID
178 }{
179 {
180 name: "active first",
181 tabs: []ports.RemoteCatalogTab{
182 {ID: "tab-active", Index: 0, Name: "active"},
183 {ID: "tab-last", Index: 1, Name: "last"},
184 },
185 activeTabID: "tab-active",
186 wantActive: 0,
187 wantTabID: "tab-active",
188 },
189 {
190 name: "active last",
191 tabs: []ports.RemoteCatalogTab{
192 {ID: "tab-first", Index: 0, Name: "first"},
193 {ID: "tab-active", Index: 1, Name: "active"},
194 },
195 activeTabID: "tab-active",
196 wantActive: 1,
197 wantTabID: "tab-active",
198 },
199 {
200 name: "empty active tab ID falls back to first stable tab",
201 tabs: []ports.RemoteCatalogTab{
202 {ID: "tab-first", Index: 0, Name: "first"},
203 {ID: "", Index: 1, Name: "malformed"},
204 },
205 wantActive: 0,
206 wantTabID: "tab-first",
207 },
208 }
209
210 for _, test := range tests {
211 t.Run(test.name, func(t *testing.T) {
212 d := newRemotePickerDaemon(nil)
213 lifecycle := remoteLifecycleForTest()
214 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
215 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
216 LifecycleID: lifecycle,
217 Name: "vive",
218 State: "up",
219 Tabs: test.tabs,
220 ActiveTabID: test.activeTabID,
221 }},
222 }})
223 d.remoteCatalog.mu.Lock()
224 d.remoteCatalog.status["arch"] = remoteHostFresh
225 d.remoteCatalog.mu.Unlock()
226
227 views, _ := d.pickerViews(nil, nil)
228 require.Len(t, views, 1)
229 require.Equal(t, test.wantActive, views[0].Active)
230 require.NotNil(t, views[0].RemoteTarget)
231 require.Equal(t, test.wantTabID, views[0].RemoteTarget.LiveTabID)
232
233 model := d.newPickerModel(nil, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
234 target, ok := model.Selected()
235 require.True(t, ok)
236 require.NotNil(t, target.RemoteTarget)
237 require.Equal(t, test.wantTabID, target.RemoteTarget.LiveTabID)
238 })
239 }
240}
241
242func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
243 tests := []struct {
244 name string
245 status remoteHostStatus
246 want picker.RemoteAvailability
247 }{
248 {name: "cached", status: remoteHostCached, want: picker.RemoteCached},
249 {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale},
250 {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch},
251 }
252
253 for _, test := range tests {
254 t.Run(test.name, func(t *testing.T) {
255 d := newRemotePickerDaemon(nil)
256 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
257 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
258 }})
259 d.remoteCatalog.mu.Lock()
260 d.remoteCatalog.status["arch"] = test.status
261 d.remoteCatalog.mu.Unlock()
262
263 views, _ := d.pickerViews(nil, nil)
264
265 require.Len(t, views, 1)
266 require.Equal(t, test.want, views[0].RemoteAvailability)
267 })
268 }
269}
270
271func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
272 d := newRemotePickerDaemon(nil)
273 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
274 failedAt := fetchedAt.Add(6 * time.Hour)
275 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
276 Host: "arch", FetchedAt: fetchedAt, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
277 }})
278 d.remoteCatalog.mu.Lock()
279 d.remoteCatalog.status["arch"] = remoteHostUnreachable
280 d.remoteCatalog.failure["arch"] = failedAt
281 d.remoteCatalog.mu.Unlock()
282
283 views, _ := d.pickerViews(nil, nil)
284
285 require.Len(t, views, 1)
286 require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail)
287 require.NotContains(t, views[0].RemoteDetail, failedAt.Format(time.RFC3339), "failure time remains runtime-only and is not the freshness authority")
288}
289
290func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
291 for _, test := range []struct {
292 name string
293 status remoteHostStatus
294 want picker.RemoteAvailability
295 wantDetail string
296 }{
297 {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale, wantDetail: "unreachable"},
298 {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch, wantDetail: "version mismatch"},
299 } {
300 t.Run(test.name, func(t *testing.T) {
301 d := newRemotePickerDaemon(nil)
302 d.remoteCatalog.mu.Lock()
303 d.remoteCatalog.status["arch"] = test.status
304 d.remoteCatalog.failure["arch"] = time.Unix(50, 0)
305 d.remoteCatalog.mu.Unlock()
306
307 views, _ := d.pickerViews(nil, nil)
308
309 require.Len(t, views, 1)
310 require.Equal(t, "arch", views[0].Name)
311 require.Equal(t, test.want, views[0].RemoteAvailability)
312 require.Equal(t, test.wantDetail, views[0].RemoteDetail)
313 model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
314 _, selectable := model.Selected()
315 require.False(t, selectable)
316 })
317 }
318}
319
320type remoteRefreshHostStore struct {
321 mu sync.Mutex
322 hosts []string
323}
324
325func (s *remoteRefreshHostStore) Hosts() ([]string, []string, error) {
326 s.mu.Lock()
327 defer s.mu.Unlock()
328 return append([]string(nil), s.hosts...), nil, nil
329}
330
331func (s *remoteRefreshHostStore) set(hosts ...string) {
332 s.mu.Lock()
333 s.hosts = append([]string(nil), hosts...)
334 s.mu.Unlock()
335}
336
337func (*remoteRefreshHostStore) AddPinned(string) error { return nil }
338func (*remoteRefreshHostStore) RemovePinned(string) error { return nil }
339func (*remoteRefreshHostStore) Remember(string) error { return nil }
340func (*remoteRefreshHostStore) Forget(string) error { return nil }
341func (*remoteRefreshHostStore) Remove(string) (bool, error) {
342 return false, nil
343}
344
345type remoteRefreshRequest struct {
346 ctx context.Context
347 host string
348 result chan remoteRefreshResult
349}
350
351type remoteRefreshResult struct {
352 catalog ports.RemoteCatalog
353 err error
354}
355
356type channelRemoteCatalog struct {
357 d *Daemon
358 requests chan remoteRefreshRequest
359 probeMu sync.Mutex
360 probes bool
361 violations []string
362}
363
364func (c *channelRemoteCatalog) List(ctx context.Context, host string) (ports.RemoteCatalog, error) {
365 c.probeMu.Lock()
366 if c.probes && !c.d.mu.TryLock() {
367 c.violations = append(c.violations, "daemon lock held during remote catalog I/O")
368 } else if c.probes {
369 c.d.mu.Unlock()
370 }
371 if c.probes && !c.d.remoteCatalog.mu.TryLock() {
372 c.violations = append(c.violations, "remote catalog lock held during remote catalog I/O")
373 } else if c.probes {
374 c.d.remoteCatalog.mu.Unlock()
375 }
376 c.probeMu.Unlock()
377 request := remoteRefreshRequest{ctx: ctx, host: host, result: make(chan remoteRefreshResult, 1)}
378 c.requests <- request
379 select {
380 case result := <-request.result:
381 return result.catalog, result.err
382 case <-ctx.Done():
383 return ports.RemoteCatalog{}, ctx.Err()
384 }
385}
386
387type recordingRemoteCache struct {
388 d *Daemon
389 stores chan []ports.RemoteCatalogCacheEntry
390 probeMu sync.Mutex
391 probes bool
392 violations []string
393}
394
395func (*recordingRemoteCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil }
396
397func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
398 c.probeMu.Lock()
399 if c.probes && !c.d.mu.TryLock() {
400 c.violations = append(c.violations, "daemon lock held during remote cache write")
401 } else if c.probes {
402 c.d.mu.Unlock()
403 }
404 if c.probes && !c.d.remoteCatalog.mu.TryLock() {
405 c.violations = append(c.violations, "remote catalog lock held during remote cache write")
406 } else if c.probes {
407 c.d.remoteCatalog.mu.Unlock()
408 }
409 c.probeMu.Unlock()
410 copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries))
411 for i, entry := range entries {
412 copyEntries[i] = cloneRemoteCatalogEntry(entry)
413 }
414 c.stores <- copyEntries
415 return nil
416}
417
418type fixedRemoteRefreshClock struct{ now time.Time }
419
420func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
421func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
422
423type remotePickerClock struct {
424 mu sync.Mutex
425 now time.Time
426}
427
428func (c *remotePickerClock) Now() time.Time {
429 c.mu.Lock()
430 defer c.mu.Unlock()
431 return c.now
432}
433func (*remotePickerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
434func (c *remotePickerClock) Advance(d time.Duration) {
435 c.mu.Lock()
436 c.now = c.now.Add(d)
437 c.mu.Unlock()
438}
439
440func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {
441 t.Helper()
442 if catalog != nil {
443 catalog.probeMu.Lock()
444 require.Empty(t, catalog.violations)
445 catalog.probeMu.Unlock()
446 }
447 if cache != nil {
448 cache.probeMu.Lock()
449 require.Empty(t, cache.violations)
450 cache.probeMu.Unlock()
451 }
452}
453
454func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {
455 t.Helper()
456 catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}
457 cache := &recordingRemoteCache{stores: make(chan []ports.RemoteCatalogCacheEntry, 8)}
458 d := New(nil, fixedRemoteRefreshClock{now: now}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(hosts, catalog, cache, nil, ports.RemoteTransportUDP))
459 catalog.d, cache.d = d, d
460 return d, catalog, cache
461}
462
463func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T) {
464 hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
465 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(100, 0))
466 ac := &attachedClient{}
467 ac.initOverlays()
468 model := picker.New(nil, picker.SelectionConfig{})
469 ac.overlays.pickerMu.Lock()
470 ac.overlays.picker = model
471 ac.overlays.pickerGeneration++
472 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
473 ac.overlays.pickerMu.Unlock()
474 require.True(t, d.registerRemotePicker(instance))
475 lifecycle := remoteLifecycleForTest()
476 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
477 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
478 LifecycleID: lifecycle, Name: "cached", State: "up",
479 Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
480 }},
481 }})
482
483 firstGeneration := d.startRemotePickerRefresh(instance)
484 first := map[string]remoteRefreshRequest{}
485 for range 2 {
486 request := receiveRemotePicker(t, catalog.requests, "catalog request")
487 first[request.host] = request
488 }
489 require.Contains(t, first, "arch")
490 require.Contains(t, first, "mule")
491 requireNoRemoteLockViolations(t, catalog, nil)
492 d.remoteCatalog.mu.Lock()
493 require.Equal(t, []ports.RemoteCatalogSession{{
494 LifecycleID: lifecycle, Name: "cached", State: "up",
495 Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
496 }}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
497 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
498 d.remoteCatalog.mu.Unlock()
499 ac.overlays.pickerMu.Lock()
500 refreshedModel := ac.overlays.picker
501 ac.overlays.pickerMu.Unlock()
502 _, selectable := refreshedModel.Selected()
503 require.False(t, selectable, "refreshing status must gate picker activation")
504
505 secondGeneration := d.startRemotePickerRefresh(instance)
506 require.Greater(t, secondGeneration, firstGeneration)
507 for _, request := range first {
508 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
509 }
510 for range 2 {
511 request := receiveRemotePicker(t, catalog.requests, "catalog request")
512 request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}
513 }
514 for range 2 {
515 receiveRemotePicker(t, cache.stores, "remote cache store")
516 }
517 requireNoRemoteLockViolations(t, nil, cache)
518
519 d.remoteCatalog.mu.Lock()
520 require.Equal(t, secondGeneration, d.remoteCatalog.refresh)
521 require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
522 require.Equal(t, remoteHostFresh, d.remoteCatalog.status["mule"])
523 d.remoteCatalog.mu.Unlock()
524}
525
526func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
527 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
528 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
529 catalog.probes, cache.probes = true, true
530 ac := &attachedClient{}
531 ac.initOverlays()
532 model := picker.New(nil, picker.SelectionConfig{})
533 ac.overlays.pickerMu.Lock()
534 ac.overlays.picker = model
535 ac.overlays.pickerGeneration++
536 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
537 ac.overlays.pickerMu.Unlock()
538 require.True(t, d.registerRemotePicker(instance))
539
540 d.startRemotePickerRefresh(instance)
541 request := receiveRemotePicker(t, catalog.requests, "catalog request")
542 request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}
543 receiveRemotePicker(t, cache.stores, "remote cache store")
544 requireNoRemoteLockViolations(t, catalog, cache)
545}
546
547func TestRemoteRefreshWithoutCatalogClientDoesNotMarkHostsRefreshing(t *testing.T) {
548 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
549 d := newRemotePickerDaemon(hosts)
550 ac := &attachedClient{}
551 ac.initOverlays()
552 model := picker.New(nil, picker.SelectionConfig{})
553 ac.overlays.pickerMu.Lock()
554 ac.overlays.picker = model
555 ac.overlays.pickerGeneration++
556 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
557 ac.overlays.pickerMu.Unlock()
558 require.True(t, d.registerRemotePicker(instance))
559
560 require.NotZero(t, d.startRemotePickerRefresh(instance))
561 d.remoteCatalog.mu.Lock()
562 require.NotEqual(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
563 d.remoteCatalog.mu.Unlock()
564}
565
566func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *testing.T) {
567 now := time.Unix(200, 0)
568 hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
569 d, _, cache := newRemoteRefreshDaemon(t, hosts, now)
570 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
571 {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "up"}}},
572 {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}},
573 })
574 d.remoteCatalog.mu.Lock()
575 d.remoteCatalog.refresh = 7
576 d.remoteCatalog.mu.Unlock()
577
578 require.False(t, d.applyRemoteRefreshResult(7, "mule", ports.RemoteCatalog{}, errors.New("offline")))
579 d.remoteCatalog.mu.Lock()
580 require.Equal(t, []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}, d.remoteCatalog.cache["mule"].Sessions)
581 require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)
582 require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
583 d.remoteCatalog.mu.Unlock()
584
585 require.True(t, d.applyRemoteRefreshResult(7, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}, nil))
586 stored := receiveRemotePicker(t, cache.stores, "remote cache store")
587 require.Equal(t, []ports.RemoteCatalogCacheEntry{
588 {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},
589 {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}},
590 }, stored, "every successful write persists the newest full cache atomically")
591 requireNoRemoteLockViolations(t, nil, cache)
592 d.remoteCatalog.mu.Lock()
593 require.Empty(t, d.remoteCatalog.cache["arch"].Sessions, "a successful omission authoritatively evicts old rows")
594 require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
595 d.remoteCatalog.mu.Unlock()
596}
597
598func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
599 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
600 d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))
601 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
602 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "up"}},
603 }})
604 d.remoteCatalog.mu.Lock()
605 d.remoteCatalog.refresh = 9
606 d.remoteCatalog.mu.Unlock()
607
608 require.False(t, d.applyRemoteRefreshResult(8, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "late", State: "up"}}}, nil))
609 hosts.set()
610 require.True(t, d.applyRemoteRefreshResult(9, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "removed", State: "up"}}}, nil))
611 require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))
612 requireNoRemoteLockViolations(t, nil, cache)
613 d.remoteCatalog.mu.Lock()
614 require.NotContains(t, d.remoteCatalog.cache, "arch")
615 require.NotContains(t, d.remoteCatalog.status, "arch")
616 d.remoteCatalog.mu.Unlock()
617}
618
619func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
620 lifecycle := remoteLifecycleForTest()
621 for _, test := range []struct {
622 name string
623 session ports.RemoteCatalogSession
624 }{
625 {name: "unknown state", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "unknown"}},
626 {name: "invalid reason", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "up", Reason: "bogus"}},
627 } {
628 t.Run(test.name, func(t *testing.T) {
629 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
630 d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
631 d.remoteCatalog.refresh = 1
632 catalog := ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{test.session}}
633 require.False(t, d.applyRemoteRefreshResult(1, "arch", catalog, nil))
634 d.remoteCatalog.mu.Lock()
635 require.Equal(t, remoteHostMalformed, d.remoteCatalog.status["arch"])
636 d.remoteCatalog.mu.Unlock()
637 })
638 }
639}
640
641func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
642 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
643 d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
644 old := ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}}
645 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{old})
646 d.remoteCatalog.mu.Lock()
647 d.remoteCatalog.refresh = 3
648 d.remoteCatalog.mu.Unlock()
649
650 require.False(t, d.applyRemoteRefreshResult(3, "arch", ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{Got: 19, Want: ports.ProtocolVersion}))
651 d.remoteCatalog.mu.Lock()
652 require.Equal(t, old, d.remoteCatalog.cache["arch"])
653 require.Equal(t, remoteHostVersionMismatch, d.remoteCatalog.status["arch"])
654 d.remoteCatalog.mu.Unlock()
655}
656
657func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
658 d := newRemotePickerDaemon(nil)
659 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
660 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
661 }})
662 d.remoteCatalog.mu.Lock()
663 d.remoteCatalog.status["arch"] = remoteHostFresh
664 d.remoteCatalog.mu.Unlock()
665 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
666 token := sess.attachmentToken(ac, ac.transport())
667 effect, admitted := ac.beginAttachmentEffect(token)
668 require.True(t, admitted)
669 token.effect = effect
670 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
671 target := picker.Target{Session: key.ID(), RemoteKey: &key}
672 require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
673
674 frame := receiveRemotePicker(t, sends, "attach target")
675 require.Equal(t, ports.MsgAttachTarget, frame.Type)
676 got, err := ports.UnmarshalAttachTarget(frame.Payload)
677 require.NoError(t, err)
678 require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, got)
679 require.Nil(t, ac.currentAttachmentSession())
680 d.mu.Lock()
681 require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow")
682 d.mu.Unlock()
683}
684
685func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
686 tests := []struct {
687 name string
688 action ports.NavigationAction
689 }{
690 {name: "home picker", action: ports.NavigationOpenHomePicker},
691 {name: "back", action: ports.NavigationBack},
692 }
693
694 for _, tt := range tests {
695 t.Run(tt.name, func(t *testing.T) {
696 d := newRemotePickerDaemon(nil)
697 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
698 token := sess.attachmentToken(ac, ac.transport())
699 effect, admitted := ac.beginAttachmentEffect(token)
700 require.True(t, admitted)
701 token.effect = effect
702 require.NoError(t, d.sendNavigationActionForAttachment(token, tt.action))
703 frame := receiveRemotePicker(t, sends, "navigation action")
704 require.Equal(t, ports.MsgNavigationAction, frame.Type)
705 got, err := ports.UnmarshalNavigationAction(frame.Payload)
706 require.NoError(t, err)
707 require.Equal(t, tt.action, got)
708 })
709 }
710}
711
712func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
713 d := newRemotePickerDaemon(nil)
714 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
715 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
716 }})
717 d.remoteCatalog.mu.Lock()
718 d.remoteCatalog.status["arch"] = remoteHostFresh
719 d.remoteCatalog.mu.Unlock()
720 cause := errors.New("remote attach send failed")
721 tr := portsmocks.NewMockTransport(t)
722 tr.EXPECT().Send(mock.Anything).Return(cause)
723 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local", tr)
724 ac.overlays.pickerMu.Lock()
725 ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
726 ac.overlays.pickerGeneration++
727 ac.overlays.pickerMu.Unlock()
728
729 gone := make(chan struct{})
730 d.afterClientGoneDetach = func() { close(gone) }
731 token := sess.attachmentToken(ac, tr)
732 effect, admitted := ac.beginAttachmentEffect(token)
733 require.True(t, admitted)
734 token.effect = effect
735 defer effect.End()
736 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
737 target := picker.Target{Session: key.ID(), RemoteKey: &key}
738 err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")
739 var userErr *domain.UserError
740 require.ErrorAs(t, err, &userErr)
741 require.Equal(t, "couldn't attach to remote session", userErr.Msg)
742 require.ErrorIs(t, err, cause)
743 require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")
744 select {
745 case <-gone:
746 t.Fatal("failed control send reached clientGoneForAttachment")
747 default:
748 }
749 require.Same(t, sess, ac.currentAttachmentSession())
750}
751
752func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, transports ...ports.Transport) (*session, *attachedClient, chan ports.Frame) {
753 t.Helper()
754 var tr ports.Transport
755 var sends chan ports.Frame
756 if len(transports) != 0 {
757 tr = transports[0]
758 } else {
759 tr, sends = newCapturingTransport(t)
760 }
761 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
762 ac.initOverlays()
763 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
764 tb.stableID = string(id) + "-tab"
765 sess := &session{sessionCore: sessionCore{id: id, name: string(id), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}
766 publishTiledPaneOwners(sess, tb)
767 ac.setSession(sess)
768 d.sessions[id] = sess
769 return sess, ac, sends
770}
771
772func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
773 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
774 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(450, 0))
775 firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
776 _, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
777 before := make(map[*attachedClient]*picker.Model)
778
779 for _, owner := range []*attachedClient{first, second} {
780 owner.overlays.pickerMu.Lock()
781 owner.overlays.picker = d.newPickerModel(firstSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: "second", TabID: "second-tab"})
782 owner.overlays.pickerIntent = pickerNavigate
783 owner.overlays.pickerGeneration++
784 instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker}
785 before[owner] = owner.overlays.picker
786 owner.overlays.pickerMu.Unlock()
787 require.True(t, d.registerRemotePicker(instance))
788 }
789 d.remoteCatalog.mu.Lock()
790 d.remoteCatalog.refresh = 4
791 d.remoteCatalog.mu.Unlock()
792
793 require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{
794 ProtocolVersion: ports.ProtocolVersion,
795 Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up", Tabs: 2}},
796 }, nil))
797 receiveRemotePicker(t, cache.stores, "remote cache store")
798 requireNoRemoteLockViolations(t, catalog, cache)
799
800 for _, owner := range []*attachedClient{first, second} {
801 owner.overlays.pickerMu.Lock()
802 updated := owner.overlays.picker
803 selected, ok := updated.Selected()
804 owner.overlays.pickerMu.Unlock()
805 require.NotSame(t, before[owner], updated, "every open picker must receive the refreshed model")
806 require.True(t, ok)
807 require.Equal(t, domain.SessionID("second"), selected.Session)
808 }
809}
810
811func TestRemotePickerTeardownLifecycle(t *testing.T) {
812 for _, test := range []struct {
813 name string
814 resumeCapable bool
815 teardown func(*Daemon, *session, *attachedClient)
816 }{
817 {name: "client gone parks resumable attachment", resumeCapable: true, teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), false) }},
818 {name: "send error parks resumable attachment", resumeCapable: true, teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
819 {name: "client gone removes non-resumable attachment", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), false) }},
820 {name: "send error removes non-resumable attachment", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
821 } {
822 t.Run(test.name, func(t *testing.T) {
823 d := newRemotePickerDaemon(nil)
824 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
825 ac.resumeCapable = test.resumeCapable
826 model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
827 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
828 ac.overlays.pickerMu.Lock()
829 generation := ac.overlays.pickerGeneration
830 ac.overlays.pickerMu.Unlock()
831
832 test.teardown(d, sess, ac)
833
834 d.remoteCatalog.mu.Lock()
835 _, registered := d.remoteCatalog.pickers[ac]
836 d.remoteCatalog.mu.Unlock()
837 ac.overlays.pickerMu.Lock()
838 current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration
839 ac.overlays.pickerMu.Unlock()
840 if !test.resumeCapable {
841 require.False(t, registered)
842 require.Nil(t, current)
843 return
844 }
845 require.True(t, ac.parked)
846 require.True(t, registered)
847 require.Same(t, model, current)
848 require.Equal(t, generation, currentGeneration)
849 d.mu.Lock()
850 parked := d.parked[ac.resumeToken]
851 d.mu.Unlock()
852 require.NotNil(t, parked)
853 require.Same(t, ac, parked.ac)
854 })
855 }
856}
857
858func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.T) {
859 clock := &signalClock{timers: make(chan *signalTimer, 1)}
860 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
861 d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(475, 0))
862 d.clock = clock
863 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
864 ac.resumeCapable = true
865 d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
866 request := receiveRemotePicker(t, catalog.requests, "catalog request")
867
868 require.True(t, d.parkAttachment(sess, ac))
869 timer := receiveRemotePicker(t, clock.timers, "park expiry timer")
870 timer.ch <- clock.Now()
871 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
872
873 ac.overlays.pickerMu.Lock()
874 require.Nil(t, ac.overlays.picker)
875 ac.overlays.pickerMu.Unlock()
876 d.remoteCatalog.mu.Lock()
877 require.NotContains(t, d.remoteCatalog.pickers, ac)
878 d.remoteCatalog.mu.Unlock()
879}
880
881func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) {
882 d := newRemotePickerDaemon(nil)
883 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
884 ac.resumeCapable = true
885 d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
886 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
887 ac.overlays.pickerMu.Lock()
888 refreshed := ac.overlays.picker
889 ac.overlays.pickerMu.Unlock()
890 require.NotNil(t, refreshed)
891 require.True(t, d.parkAttachment(sess, ac))
892 token := ac.resumeToken
893 d.mu.Lock()
894 parked := d.parked[token]
895 d.mu.Unlock()
896 require.NotNil(t, parked)
897
898 d.mu.Lock()
899 retirement := d.retireParkedAttachmentLocked(token, parked)
900 d.mu.Unlock()
901 d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{retirement})
902 ac.overlays.pickerMu.Lock()
903 require.Nil(t, ac.overlays.picker, "terminal retirement closes a refreshed model in its captured generation")
904 ac.overlays.pickerMu.Unlock()
905}
906
907func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) {
908 d := newRemotePickerDaemon(nil)
909 firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
910 secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
911 first.resumeCapable, second.resumeCapable = true, true
912 d.publishPicker(firstSession, first, d.newPickerModel(firstSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
913 require.True(t, d.parkAttachment(firstSession, first))
914 token := first.resumeToken
915 d.publishPicker(secondSession, second, d.newPickerModel(secondSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
916 second.resumeToken = token
917 require.True(t, d.parkAttachment(secondSession, second))
918
919 first.overlays.pickerMu.Lock()
920 require.Nil(t, first.overlays.picker)
921 first.overlays.pickerMu.Unlock()
922 second.overlays.pickerMu.Lock()
923 require.NotNil(t, second.overlays.picker)
924 second.overlays.pickerMu.Unlock()
925}
926
927func TestParkedPickerResumePreservesGeneration(t *testing.T) {
928 d := newRemotePickerDaemon(nil)
929 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
930 ac.resumeCapable = true
931 ac.clientID = [16]byte{1, 2, 3, 4}
932 d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
933 ac.overlays.pickerMu.Lock()
934 model, generation := ac.overlays.picker, ac.overlays.pickerGeneration
935 ac.overlays.pickerMu.Unlock()
936 d.clientGone(sess, ac, ac.transport(), false)
937 token := ac.resumeToken
938 tr := &closeTrackingTransport{}
939 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, sess.name, token), tr, domain.Size{Cols: 80, Rows: 24})
940 require.NoError(t, err)
941 require.True(t, ok)
942 require.Same(t, sess, resumedSess)
943 require.Same(t, ac, resumedAC)
944 ac.overlays.pickerMu.Lock()
945 require.Same(t, model, ac.overlays.picker)
946 require.Equal(t, generation, ac.overlays.pickerGeneration)
947 ac.overlays.pickerMu.Unlock()
948}
949
950func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
951 d := newRemotePickerDaemon(nil)
952 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
953 ac.resumeCapable = true
954 d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
955 require.True(t, d.parkAttachment(sess, ac))
956 token := ac.resumeToken
957 d.mu.Lock()
958 parked := d.parked[token]
959 d.mu.Unlock()
960 require.NotNil(t, parked)
961
962 newer := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
963 d.publishPicker(sess, ac, newer, pickerNavigate, moveSourceLocator{})
964 d.expireParked(token, parked)
965 ac.overlays.pickerMu.Lock()
966 require.Same(t, newer, ac.overlays.picker)
967 ac.overlays.pickerMu.Unlock()
968}
969
970func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
971 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
972 d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(500, 0))
973 newInstance := func() remotePickerInstance {
974 ac := &attachedClient{}
975 ac.initOverlays()
976 model := picker.New(nil, picker.SelectionConfig{})
977 ac.overlays.pickerMu.Lock()
978 ac.overlays.picker = model
979 ac.overlays.pickerGeneration++
980 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
981 ac.overlays.pickerMu.Unlock()
982 return instance
983 }
984 closeInstance := func(instance remotePickerInstance) {
985 instance.ac.overlays.pickerMu.Lock()
986 instance.ac.overlays.picker = nil
987 instance.ac.overlays.pickerMu.Unlock()
988 d.remotePickerClosed(instance)
989 }
990 first, second := newInstance(), newInstance()
991 d.remotePickerOpened(first)
992 request := receiveRemotePicker(t, catalog.requests, "catalog request")
993 d.remotePickerOpened(second)
994 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
995 request = receiveRemotePicker(t, catalog.requests, "catalog request")
996
997 closeInstance(first)
998 select {
999 case <-request.ctx.Done():
1000 t.Fatal("refresh canceled while another picker remained open")
1001 default:
1002 }
1003 closeInstance(second)
1004 receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
1005
1006 d.remoteCatalog.mu.Lock()
1007 require.Empty(t, d.remoteCatalog.pickers)
1008 require.Nil(t, d.remoteCatalog.cancel)
1009 d.remoteCatalog.mu.Unlock()
1010}
1011
1012func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
1013 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
1014 d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(525, 0))
1015 ac := &attachedClient{}
1016 ac.initOverlays()
1017 model := picker.New(nil, picker.SelectionConfig{})
1018 ac.overlays.pickerMu.Lock()
1019 ac.overlays.picker = model
1020 ac.overlays.pickerGeneration++
1021 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
1022 ac.overlays.pickerMu.Unlock()
1023
1024 // Stop at the production seam after exact picker ownership registration but
1025 // before refresh startup, then let the last-picker close linearize first.
1026 require.True(t, d.registerRemotePicker(instance))
1027 ac.overlays.pickerMu.Lock()
1028 ac.overlays.picker = nil
1029 ac.overlays.pickerMu.Unlock()
1030 d.remotePickerClosed(instance)
1031
1032 d.remoteCatalog.mu.Lock()
1033 refreshBefore := d.remoteCatalog.refresh
1034 d.remoteCatalog.mu.Unlock()
1035 require.Zero(t, d.startRemotePickerRefresh(instance))
1036
1037 d.remoteCatalog.mu.Lock()
1038 require.Empty(t, d.remoteCatalog.pickers, "the closing picker must remain unregistered")
1039 require.Equal(t, refreshBefore, d.remoteCatalog.refresh, "a close that wins before startup must prevent a later refresh generation")
1040 require.Nil(t, d.remoteCatalog.cancel, "a close that wins before startup must prevent a later cancel installation")
1041 d.remoteCatalog.mu.Unlock()
1042 select {
1043 case request := <-catalog.requests:
1044 t.Fatalf("catalog call remained running after the last picker closed: %s", request.host)
1045 default:
1046 }
1047}
1048
1049func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
1050 d := newRemotePickerDaemon(nil)
1051 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1052 first := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1053 d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
1054
1055 staleClose := d.pickerListInputState(ac)
1056 ac.overlays.pickerMu.Lock()
1057 staleClose.closeLocked()
1058 ac.overlays.pickerMu.Unlock()
1059
1060 reopened := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1061 d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
1062 staleClose.afterClose()
1063
1064 d.remoteCatalog.mu.Lock()
1065 _, registered := d.remoteCatalog.pickers[ac]
1066 d.remoteCatalog.mu.Unlock()
1067 require.True(t, registered, "stale close cleanup must retain the reopened picker registration")
1068 ac.overlays.pickerMu.Lock()
1069 require.Same(t, reopened, ac.overlays.picker)
1070 ac.overlays.pickerMu.Unlock()
1071}
1072
1073func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
1074 d := newRemotePickerDaemon(nil)
1075 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1076 first := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1077 d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
1078
1079 rebuildReached := make(chan struct{})
1080 allowPublication := make(chan struct{})
1081 publicationWait := make(chan error, 1)
1082 ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
1083 close(rebuildReached)
1084 publicationWait <- waitRemotePickerClose(allowPublication, "picker publication release")
1085 }
1086 refreshed := make(chan struct{})
1087 go func() {
1088 d.refreshRemoteOpenPickers()
1089 close(refreshed)
1090 }()
1091 receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
1092
1093 d.closePicker(ac)
1094 reopened := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1095 d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
1096 close(allowPublication)
1097 require.NoError(t, receiveRemotePicker(t, publicationWait, "picker publication release"))
1098 receiveRemotePickerClose(t, refreshed, "picker refresh")
1099
1100 ac.overlays.pickerMu.Lock()
1101 require.Same(t, reopened, ac.overlays.picker, "a rebuild for the old picker must not publish into its replacement")
1102 ac.overlays.pickerMu.Unlock()
1103}
1104
1105func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) {
1106 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1107 for _, tt := range []struct {
1108 status remoteHostStatus
1109 ready bool
1110 }{
1111 {status: remoteHostCached, ready: false},
1112 {status: remoteHostRefreshing, ready: false},
1113 {status: remoteHostStale, ready: false},
1114 {status: remoteHostMalformed, ready: false},
1115 {status: remoteHostFresh, ready: true},
1116 {status: remoteHostUnreachable, ready: false},
1117 } {
1118 view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, tt.status, time.Unix(1, 0))
1119 require.Equal(t, tt.ready, view.RemoteAttachReady, "status %d readiness", tt.status)
1120 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1121 require.Equal(t, tt.ready, selectable, "status %d selection", tt.status)
1122 }
1123 mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{})
1124 require.False(t, mismatch.RemoteAttachReady)
1125 _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1126 require.False(t, selectable)
1127}