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