v0.3.0 99dc266

remote_picker_test.go RawHistoryFolder

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}

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