v0.2.0 80c61e3

remote_picker_test.go RawHistoryFolder

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}

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close