detached 7d5c4cb

Merge pull request #160 from bnema/feat/remote-picker

feat(remote): unify remote sessions in the picker
commit
7d5c4cb17892457667a6f7085d175da9df249203
author
bnema · · 2026-07-31 18:01 UTC
committer
GitHub · · 2026-07-31 18:01 UTC
merge of
3f4b8b3 + bd9d2c0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
19 files · +1958 -87
M internal/usecase/daemon/client_transport_errors.go +2 -3
@@ -54,7 +54,6 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
5454 if rc := sess.renderCoordinator(); rc != nil {
5555 rc.noteDetach(ac)
5656 }
57- d.unregisterPreview(ac)
5857 sess.mu.Lock()
5958 ephemeral := sess.ephemeral
6059 sess.mu.Unlock()
@@ -75,6 +74,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
7574 // attachment parking marker left by a raced non-explicit teardown so
7675 // IntentResume waiters are not stranded on a never-published park.
7776 d.clearParkingInFlight(ac.resumeToken, ac)
77+ d.closePicker(ac)
7878
7979 d.resetScreenDefaultColors(sess)
8080 ac.clearPreviousSession()
@@ -131,7 +131,6 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai
131131 d.unregisterPreview(ac)
132132 ac.clearPreviousSession()
133133 _ = ac.closeCapturedTransport(failed)
134- d.armProxyWarm(p)
135134 d.log.Warn("detached proxy client after send error", "host", p.key.Host, "session", p.key.Name)
136135 }
137136
@@ -177,13 +176,13 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
177176 if rc := sess.renderCoordinator(); rc != nil {
178177 rc.noteDetach(ac)
179178 }
180- d.unregisterPreview(ac)
181179 if d.parkAttachment(sess, ac) {
182180 _ = ac.closeCapturedTransport(failed)
183181 d.log.Warn("parked client after send error", "session", sess.name)
184182 return
185183 }
186184 d.clearParkingInFlight(ac.resumeToken, ac)
185+ d.closePicker(ac)
187186 d.resetScreenDefaultColors(sess)
188187 ac.clearPreviousSession()
189188 _ = ac.closeCapturedTransport(failed)
@@ -54,7 +54,6 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
54 if rc := sess.renderCoordinator(); rc != nil {54 if rc := sess.renderCoordinator(); rc != nil {
55 rc.noteDetach(ac)55 rc.noteDetach(ac)
56 }56 }
57- d.unregisterPreview(ac)
58 sess.mu.Lock()57 sess.mu.Lock()
59 ephemeral := sess.ephemeral58 ephemeral := sess.ephemeral
60 sess.mu.Unlock()59 sess.mu.Unlock()
@@ -75,6 +74,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
75 // attachment parking marker left by a raced non-explicit teardown so74 // attachment parking marker left by a raced non-explicit teardown so
76 // IntentResume waiters are not stranded on a never-published park.75 // IntentResume waiters are not stranded on a never-published park.
77 d.clearParkingInFlight(ac.resumeToken, ac)76 d.clearParkingInFlight(ac.resumeToken, ac)
77+ d.closePicker(ac)
78 78
79 d.resetScreenDefaultColors(sess)79 d.resetScreenDefaultColors(sess)
80 ac.clearPreviousSession()80 ac.clearPreviousSession()
@@ -131,7 +131,6 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai
131 d.unregisterPreview(ac)131 d.unregisterPreview(ac)
132 ac.clearPreviousSession()132 ac.clearPreviousSession()
133 _ = ac.closeCapturedTransport(failed)133 _ = ac.closeCapturedTransport(failed)
134- d.armProxyWarm(p)
135 d.log.Warn("detached proxy client after send error", "host", p.key.Host, "session", p.key.Name)134 d.log.Warn("detached proxy client after send error", "host", p.key.Host, "session", p.key.Name)
136 }135 }
137 136
@@ -177,13 +176,13 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
177 if rc := sess.renderCoordinator(); rc != nil {176 if rc := sess.renderCoordinator(); rc != nil {
178 rc.noteDetach(ac)177 rc.noteDetach(ac)
179 }178 }
180- d.unregisterPreview(ac)
181 if d.parkAttachment(sess, ac) {179 if d.parkAttachment(sess, ac) {
182 _ = ac.closeCapturedTransport(failed)180 _ = ac.closeCapturedTransport(failed)
183 d.log.Warn("parked client after send error", "session", sess.name)181 d.log.Warn("parked client after send error", "session", sess.name)
184 return182 return
185 }183 }
186 d.clearParkingInFlight(ac.resumeToken, ac)184 d.clearParkingInFlight(ac.resumeToken, ac)
185+ d.closePicker(ac)
187 d.resetScreenDefaultColors(sess)186 d.resetScreenDefaultColors(sess)
188 ac.clearPreviousSession()187 ac.clearPreviousSession()
189 _ = ac.closeCapturedTransport(failed)188 _ = ac.closeCapturedTransport(failed)
M internal/usecase/daemon/control_test.go +14 -6
@@ -150,7 +150,7 @@ func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
150150 tr, sends, _ := newConn(t, frame)
151151
152152 d := newTestDaemon(t, nil, stubClock{})
153- d.handleCommand(tr, frame)
153+ require.NoError(t, d.handleCommand(tr, frame))
154154
155155 result := awaitCommandResult(t, sends)
156156 require.False(t, result.OK)
@@ -773,7 +773,9 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
773773 firstTransport, firstSends, _ := newConn(t, firstFrame)
774774 firstDone := make(chan struct{})
775775 go func() {
776- d.handleCommand(firstTransport, firstFrame)
776+ if err := d.handleCommand(firstTransport, firstFrame); err != nil {
777+ t.Errorf("handle first command: %v", err)
778+ }
777779 close(firstDone)
778780 }()
779781 select {
@@ -789,7 +791,9 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
789791 secondTransport, secondSends, _ := newConn(t, secondFrame)
790792 secondDone := make(chan struct{})
791793 go func() {
792- d.handleCommand(secondTransport, secondFrame)
794+ if err := d.handleCommand(secondTransport, secondFrame); err != nil {
795+ t.Errorf("handle second command: %v", err)
796+ }
793797 close(secondDone)
794798 }()
795799 select {
@@ -1146,12 +1150,16 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
11461150 done := make(chan struct{}, 2)
11471151 go func() {
11481152 <-start
1149- d.handleCommand(leftTransport, leftFrame)
1153+ if err := d.handleCommand(leftTransport, leftFrame); err != nil {
1154+ t.Errorf("handle left command: %v", err)
1155+ }
11501156 done <- struct{}{}
11511157 }()
11521158 go func() {
11531159 <-start
1154- d.handleCommand(rightTransport, rightFrame)
1160+ if err := d.handleCommand(rightTransport, rightFrame); err != nil {
1161+ t.Errorf("handle right command: %v", err)
1162+ }
11551163 done <- struct{}{}
11561164 }()
11571165 close(start)
@@ -1194,7 +1202,7 @@ func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.Co
11941202 t.Helper()
11951203 frame := commandFrame(t, request)
11961204 tr, sends, _ := newConn(t, frame)
1197- d.handleCommand(tr, frame)
1205+ require.NoError(t, d.handleCommand(tr, frame))
11981206 return awaitCommandResult(t, sends)
11991207 }
12001208
@@ -150,7 +150,7 @@ func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
150 tr, sends, _ := newConn(t, frame)150 tr, sends, _ := newConn(t, frame)
151 151
152 d := newTestDaemon(t, nil, stubClock{})152 d := newTestDaemon(t, nil, stubClock{})
153- d.handleCommand(tr, frame)153+ require.NoError(t, d.handleCommand(tr, frame))
154 154
155 result := awaitCommandResult(t, sends)155 result := awaitCommandResult(t, sends)
156 require.False(t, result.OK)156 require.False(t, result.OK)
@@ -773,7 +773,9 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
773 firstTransport, firstSends, _ := newConn(t, firstFrame)773 firstTransport, firstSends, _ := newConn(t, firstFrame)
774 firstDone := make(chan struct{})774 firstDone := make(chan struct{})
775 go func() {775 go func() {
776- d.handleCommand(firstTransport, firstFrame)776+ if err := d.handleCommand(firstTransport, firstFrame); err != nil {
777+ t.Errorf("handle first command: %v", err)
778+ }
777 close(firstDone)779 close(firstDone)
778 }()780 }()
779 select {781 select {
@@ -789,7 +791,9 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
789 secondTransport, secondSends, _ := newConn(t, secondFrame)791 secondTransport, secondSends, _ := newConn(t, secondFrame)
790 secondDone := make(chan struct{})792 secondDone := make(chan struct{})
791 go func() {793 go func() {
792- d.handleCommand(secondTransport, secondFrame)794+ if err := d.handleCommand(secondTransport, secondFrame); err != nil {
795+ t.Errorf("handle second command: %v", err)
796+ }
793 close(secondDone)797 close(secondDone)
794 }()798 }()
795 select {799 select {
@@ -1146,12 +1150,16 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1146 done := make(chan struct{}, 2)1150 done := make(chan struct{}, 2)
1147 go func() {1151 go func() {
1148 <-start1152 <-start
1149- d.handleCommand(leftTransport, leftFrame)1153+ if err := d.handleCommand(leftTransport, leftFrame); err != nil {
1154+ t.Errorf("handle left command: %v", err)
1155+ }
1150 done <- struct{}{}1156 done <- struct{}{}
1151 }()1157 }()
1152 go func() {1158 go func() {
1153 <-start1159 <-start
1154- d.handleCommand(rightTransport, rightFrame)1160+ if err := d.handleCommand(rightTransport, rightFrame); err != nil {
1161+ t.Errorf("handle right command: %v", err)
1162+ }
1155 done <- struct{}{}1163 done <- struct{}{}
1156 }()1164 }()
1157 close(start)1165 close(start)
@@ -1194,7 +1202,7 @@ func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.Co
1194 t.Helper()1202 t.Helper()
1195 frame := commandFrame(t, request)1203 frame := commandFrame(t, request)
1196 tr, sends, _ := newConn(t, frame)1204 tr, sends, _ := newConn(t, frame)
1197- d.handleCommand(tr, frame)1205+ require.NoError(t, d.handleCommand(tr, frame))
1198 return awaitCommandResult(t, sends)1206 return awaitCommandResult(t, sends)
1199 }1207 }
1200 1208
M internal/usecase/daemon/daemon.go +12 -8
@@ -334,12 +334,13 @@ type Daemon struct {
334334 }
335335
336336 type parkedAttachment struct {
337- sess *session
338- ac *attachedClient
339- role attachmentRole
340- timer ports.Timer
341- done chan struct{}
342- doneOnce sync.Once
337+ sess *session
338+ ac *attachedClient
339+ role attachmentRole
340+ pickerGeneration uint64
341+ timer ports.Timer
342+ done chan struct{}
343+ doneOnce sync.Once
343344 }
344345
345346 // parkingAttachment is the observable detach→park lifecycle for one resume
@@ -795,6 +796,7 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
795796 }
796797
797798 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
799+ d.cancelRemotePickerRefresh()
798800 d.closeMoveLifecycles()
799801 d.mu.Lock()
800802 d.closing = true
@@ -803,7 +805,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
803805 snapshot := d.sessionsSnapshotLocked()
804806 empty := len(snapshot) == 0
805807 d.mu.Unlock()
806- finishParkedAttachmentRetirements(parkedRetirements)
808+ d.finishParkedAttachmentRetirements(parkedRetirements)
807809 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
808810 if empty {
809811 d.doneOnce.Do(func() { close(d.done) })
@@ -970,7 +972,9 @@ func (d *Daemon) handleConn(tr ports.Transport) {
970972 case ports.MsgList:
971973 d.handleList(tr)
972974 case ports.MsgCommand:
973- d.handleCommand(tr, first)
975+ if err := d.handleCommand(tr, first); err != nil {
976+ d.log.Warn("command handler failed", "err", err)
977+ }
974978 case ports.MsgKill:
975979 d.handleKill(tr, first)
976980 case ports.MsgHello:
@@ -334,12 +334,13 @@ type Daemon struct {
334 }334 }
335 335
336 type parkedAttachment struct {336 type parkedAttachment struct {
337- sess *session337+ sess *session
338- ac *attachedClient338+ ac *attachedClient
339- role attachmentRole339+ role attachmentRole
340- timer ports.Timer340+ pickerGeneration uint64
341- done chan struct{}341+ timer ports.Timer
342- doneOnce sync.Once342+ done chan struct{}
343+ doneOnce sync.Once
343 }344 }
344 345
345 // parkingAttachment is the observable detach→park lifecycle for one resume346 // parkingAttachment is the observable detach→park lifecycle for one resume
@@ -795,6 +796,7 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
795 }796 }
796 797
797 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {798 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
799+ d.cancelRemotePickerRefresh()
798 d.closeMoveLifecycles()800 d.closeMoveLifecycles()
799 d.mu.Lock()801 d.mu.Lock()
800 d.closing = true802 d.closing = true
@@ -803,7 +805,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
803 snapshot := d.sessionsSnapshotLocked()805 snapshot := d.sessionsSnapshotLocked()
804 empty := len(snapshot) == 0806 empty := len(snapshot) == 0
805 d.mu.Unlock()807 d.mu.Unlock()
806- finishParkedAttachmentRetirements(parkedRetirements)808+ d.finishParkedAttachmentRetirements(parkedRetirements)
807 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))809 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
808 if empty {810 if empty {
809 d.doneOnce.Do(func() { close(d.done) })811 d.doneOnce.Do(func() { close(d.done) })
@@ -970,7 +972,9 @@ func (d *Daemon) handleConn(tr ports.Transport) {
970 case ports.MsgList:972 case ports.MsgList:
971 d.handleList(tr)973 d.handleList(tr)
972 case ports.MsgCommand:974 case ports.MsgCommand:
973- d.handleCommand(tr, first)975+ if err := d.handleCommand(tr, first); err != nil {
976+ d.log.Warn("command handler failed", "err", err)
977+ }
974 case ports.MsgKill:978 case ports.MsgKill:
975 d.handleKill(tr, first)979 d.handleKill(tr, first)
976 case ports.MsgHello:980 case ports.MsgHello:
M internal/usecase/daemon/move_pane_postcommit.go +1 -1
@@ -132,7 +132,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
132132 d.unregisterPreview(retirement.parked.ac)
133133 retirement.parked.ac.clearCaptureFrames()
134134 }
135- finishParkedAttachmentRetirements(p.retiredParked)
135+ d.finishParkedAttachmentRetirements(p.retiredParked)
136136 if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {
137137 if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil {
138138 if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil {
@@ -132,7 +132,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
132 d.unregisterPreview(retirement.parked.ac)132 d.unregisterPreview(retirement.parked.ac)
133 retirement.parked.ac.clearCaptureFrames()133 retirement.parked.ac.clearCaptureFrames()
134 }134 }
135- finishParkedAttachmentRetirements(p.retiredParked)135+ d.finishParkedAttachmentRetirements(p.retiredParked)
136 if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {136 if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {
137 if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil {137 if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil {
138 if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil {138 if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil {
M internal/usecase/daemon/move_picker.go +10 -4
@@ -161,16 +161,22 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
161161 return
162162 }
163163 intent, source := rt.pickerIntent, rt.pickerSource
164+ observedModel, observedGeneration := rt.picker, rt.pickerGeneration
164165 current := picker.SourceFilter{}
165166 if intent != pickerNavigate || opts.preserveSelection {
166- selected, _ := rt.picker.Selected()
167- current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
167+ cursor, _ := rt.picker.Cursor()
168+ current = picker.SourceFilter{
169+ Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
170+ }
168171 }
169172 rt.pickerMu.Unlock()
170173 model := d.newPickerModel(sess, intent, source, current)
174+ if rt.afterPickerRefreshBuild != nil {
175+ rt.afterPickerRefreshBuild(model)
176+ }
171177 if intent != pickerNavigate {
172178 if _, ok := model.Selected(); !ok {
173- d.closePicker(ac)
179+ d.closePickerIfCurrent(ac, observedModel, observedGeneration)
174180 return
175181 }
176182 }
@@ -180,7 +186,7 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
180186 model.SelectNearestRow(opts.nearestRow)
181187 }
182188 rt.pickerMu.Lock()
183- updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
189+ updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerIntent == intent && rt.pickerSource == source
184190 if updated {
185191 rt.picker = model
186192 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
@@ -161,16 +161,22 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
161 return161 return
162 }162 }
163 intent, source := rt.pickerIntent, rt.pickerSource163 intent, source := rt.pickerIntent, rt.pickerSource
164+ observedModel, observedGeneration := rt.picker, rt.pickerGeneration
164 current := picker.SourceFilter{}165 current := picker.SourceFilter{}
165 if intent != pickerNavigate || opts.preserveSelection {166 if intent != pickerNavigate || opts.preserveSelection {
166- selected, _ := rt.picker.Selected()167+ cursor, _ := rt.picker.Cursor()
167- current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}168+ current = picker.SourceFilter{
169+ Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
170+ }
168 }171 }
169 rt.pickerMu.Unlock()172 rt.pickerMu.Unlock()
170 model := d.newPickerModel(sess, intent, source, current)173 model := d.newPickerModel(sess, intent, source, current)
174+ if rt.afterPickerRefreshBuild != nil {
175+ rt.afterPickerRefreshBuild(model)
176+ }
171 if intent != pickerNavigate {177 if intent != pickerNavigate {
172 if _, ok := model.Selected(); !ok {178 if _, ok := model.Selected(); !ok {
173- d.closePicker(ac)179+ d.closePickerIfCurrent(ac, observedModel, observedGeneration)
174 return180 return
175 }181 }
176 }182 }
@@ -180,7 +186,7 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
180 model.SelectNearestRow(opts.nearestRow)186 model.SelectNearestRow(opts.nearestRow)
181 }187 }
182 rt.pickerMu.Lock()188 rt.pickerMu.Lock()
183- updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source189+ updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerIntent == intent && rt.pickerSource == source
184 if updated {190 if updated {
185 rt.picker = model191 rt.picker = model
186 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))192 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
M internal/usecase/daemon/move_picker_test.go +42 -0
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/bnema/vev/internal/domain"
1111 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/usecase/picker"
1213 )
1314
1415 func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
@@ -314,6 +315,47 @@ func TestMovePickerFinalSourceClosesBeforeSnatchFinalization(t *testing.T) {
314315 require.Equal(t, ports.MsgOutput, frames[0].Type)
315316 }
316317
318+func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
319+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
320+ defer releaseAll(releases)
321+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
322+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
323+ TabID: "source-tab", PaneID: "source-pane",
324+ }))
325+ d.mu.Lock()
326+ delete(d.sessions, destination.id)
327+ d.mu.Unlock()
328+
329+ rebuildReached := make(chan struct{})
330+ allowClose := make(chan struct{})
331+ refreshed := make(chan struct{})
332+ ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
333+ close(rebuildReached)
334+ <-allowClose
335+ }
336+ go func() {
337+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
338+ close(refreshed)
339+ }()
340+ select {
341+ case <-rebuildReached:
342+ case <-time.After(time.Second):
343+ t.Fatal("timed out waiting for move-picker refresh rebuild")
344+ }
345+
346+ replacement := d.newPickerModel(source, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
347+ d.publishPicker(source, ac, replacement, pickerNavigate, moveSourceLocator{})
348+ close(allowClose)
349+ select {
350+ case <-refreshed:
351+ case <-time.After(time.Second):
352+ t.Fatal("timed out waiting for move-picker refresh")
353+ }
354+ ac.overlays.pickerMu.Lock()
355+ require.Same(t, replacement, ac.overlays.picker)
356+ ac.overlays.pickerMu.Unlock()
357+}
358+
317359 func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) {
318360 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
319361 defer releaseAll(releases)
@@ -9,6 +9,7 @@ import (
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/usecase/picker"
12 )13 )
13 14
14 func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {15 func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
@@ -314,6 +315,47 @@ func TestMovePickerFinalSourceClosesBeforeSnatchFinalization(t *testing.T) {
314 require.Equal(t, ports.MsgOutput, frames[0].Type)315 require.Equal(t, ports.MsgOutput, frames[0].Type)
315 }316 }
316 317
318+func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
319+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
320+ defer releaseAll(releases)
321+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
322+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
323+ TabID: "source-tab", PaneID: "source-pane",
324+ }))
325+ d.mu.Lock()
326+ delete(d.sessions, destination.id)
327+ d.mu.Unlock()
328+
329+ rebuildReached := make(chan struct{})
330+ allowClose := make(chan struct{})
331+ refreshed := make(chan struct{})
332+ ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
333+ close(rebuildReached)
334+ <-allowClose
335+ }
336+ go func() {
337+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
338+ close(refreshed)
339+ }()
340+ select {
341+ case <-rebuildReached:
342+ case <-time.After(time.Second):
343+ t.Fatal("timed out waiting for move-picker refresh rebuild")
344+ }
345+
346+ replacement := d.newPickerModel(source, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
347+ d.publishPicker(source, ac, replacement, pickerNavigate, moveSourceLocator{})
348+ close(allowClose)
349+ select {
350+ case <-refreshed:
351+ case <-time.After(time.Second):
352+ t.Fatal("timed out waiting for move-picker refresh")
353+ }
354+ ac.overlays.pickerMu.Lock()
355+ require.Same(t, replacement, ac.overlays.picker)
356+ ac.overlays.pickerMu.Unlock()
357+}
358+
317 func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) {359 func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) {
318 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)360 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
319 defer releaseAll(releases)361 defer releaseAll(releases)
M internal/usecase/daemon/overlay_runtime.go +16 -2
@@ -16,8 +16,12 @@ import (
1616 type overlayRuntime struct {
1717 ac *attachedClient
1818
19- pickerMu sync.Mutex
20- picker *picker.Model
19+ pickerMu sync.Mutex
20+ picker *picker.Model
21+ // pickerGeneration identifies one open lifecycle. It advances only when a
22+ // picker is published, so delayed close and registration work can prove it
23+ // still owns the exact lifecycle it captured.
24+ pickerGeneration uint64
2125 pickerTitle string
2226 pickerIntent pickerIntent
2327 pickerSource moveSourceLocator
@@ -27,6 +31,12 @@ type overlayRuntime struct {
2731 pickerPending []byte
2832 pickerESC pendingByteTimer
2933
34+ // Test-only, unsynchronized lifecycle seams. Assign them before picker
35+ // publication or goroutine startup. Hooks run without pickerMu or
36+ // remoteCatalog.mu held.
37+ beforeRemotePickerRegistration func()
38+ afterPickerRefreshBuild func(*picker.Model)
39+
3040 paletteMu sync.Mutex
3141 palette *palette.Model
3242 paletteRecent []recentSession // immutable for this palette interaction
@@ -257,6 +267,7 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
257267 }
258268 previewSession := rt.pickerPreviewSession
259269 previewGeneration := rt.pickerPreviewGeneration
270+ pickerInstance := remotePickerInstance{ac: token.ac, generation: rt.pickerGeneration, model: rt.picker}
260271 rt.picker = nil
261272 rt.pickerIntent = pickerNavigate
262273 rt.pickerSource = moveSourceLocator{}
@@ -270,6 +281,9 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
270281 // Subscription teardown may enter a coordinator and therefore runs outside
271282 // every overlay-family lock.
272283 d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration)
284+ if pickerInstance.model != nil {
285+ d.remotePickerClosed(pickerInstance)
286+ }
273287
274288 rt.paletteMu.Lock()
275289 if !current() {
@@ -16,8 +16,12 @@ import (
16 type overlayRuntime struct {16 type overlayRuntime struct {
17 ac *attachedClient17 ac *attachedClient
18 18
19- pickerMu sync.Mutex19+ pickerMu sync.Mutex
20- picker *picker.Model20+ picker *picker.Model
21+ // pickerGeneration identifies one open lifecycle. It advances only when a
22+ // picker is published, so delayed close and registration work can prove it
23+ // still owns the exact lifecycle it captured.
24+ pickerGeneration uint64
21 pickerTitle string25 pickerTitle string
22 pickerIntent pickerIntent26 pickerIntent pickerIntent
23 pickerSource moveSourceLocator27 pickerSource moveSourceLocator
@@ -27,6 +31,12 @@ type overlayRuntime struct {
27 pickerPending []byte31 pickerPending []byte
28 pickerESC pendingByteTimer32 pickerESC pendingByteTimer
29 33
34+ // Test-only, unsynchronized lifecycle seams. Assign them before picker
35+ // publication or goroutine startup. Hooks run without pickerMu or
36+ // remoteCatalog.mu held.
37+ beforeRemotePickerRegistration func()
38+ afterPickerRefreshBuild func(*picker.Model)
39+
30 paletteMu sync.Mutex40 paletteMu sync.Mutex
31 palette *palette.Model41 palette *palette.Model
32 paletteRecent []recentSession // immutable for this palette interaction42 paletteRecent []recentSession // immutable for this palette interaction
@@ -257,6 +267,7 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
257 }267 }
258 previewSession := rt.pickerPreviewSession268 previewSession := rt.pickerPreviewSession
259 previewGeneration := rt.pickerPreviewGeneration269 previewGeneration := rt.pickerPreviewGeneration
270+ pickerInstance := remotePickerInstance{ac: token.ac, generation: rt.pickerGeneration, model: rt.picker}
260 rt.picker = nil271 rt.picker = nil
261 rt.pickerIntent = pickerNavigate272 rt.pickerIntent = pickerNavigate
262 rt.pickerSource = moveSourceLocator{}273 rt.pickerSource = moveSourceLocator{}
@@ -270,6 +281,9 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
270 // Subscription teardown may enter a coordinator and therefore runs outside281 // Subscription teardown may enter a coordinator and therefore runs outside
271 // every overlay-family lock.282 // every overlay-family lock.
272 d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration)283 d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration)
284+ if pickerInstance.model != nil {
285+ d.remotePickerClosed(pickerInstance)
286+ }
273 287
274 rt.paletteMu.Lock()288 rt.paletteMu.Lock()
275 if !current() {289 if !current() {
M internal/usecase/daemon/picker.go +95 -25
@@ -64,6 +64,8 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
6464 rt.pickerPreviewSession = nil
6565 rt.pickerPreview = nil
6666 rt.picker = model
67+ rt.pickerGeneration++
68+ instance := remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: model}
6769 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
6870 rt.pickerIntent = intent
6971 rt.pickerSource = source
@@ -73,13 +75,24 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
7375 d.teardownPreviewSubscription(ac, previous, previousGeneration)
7476 d.registerPreviewForSelection(ac)
7577 d.invalidateRender(sess, ac, true, "picker.go")
78+ if rt.beforeRemotePickerRegistration != nil {
79+ rt.beforeRemotePickerRegistration()
80+ }
81+ d.remotePickerOpened(instance)
7682 }
7783
7884 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
7985 // knows nothing about picker intent; picker.New owns all destination policy.
8086 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) {
8187 d.mu.Lock()
82- sessions := d.sessionsSnapshotLocked()
88+ sessions := make([]attachmentSession, 0, len(d.sessions))
89+ proxies := make(map[domain.SessionID]*proxySession)
90+ for id, entry := range d.sessions {
91+ sessions = append(sessions, entry)
92+ if proxy, ok := entry.(*proxySession); ok {
93+ proxies[id] = proxy
94+ }
95+ }
8396 stopped := make([]stoppedSession, 0, len(d.stopped))
8497 for _, s := range d.stopped {
8598 if !s.purging {
@@ -88,29 +101,38 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
88101 }
89102 d.mu.Unlock()
90103
91- for _, s := range sessions {
92- d.refreshSessionFocusedTitles(s)
104+ for _, entry := range sessions {
105+ if local, ok := localSession(entry); ok {
106+ d.refreshSessionFocusedTitles(local)
107+ }
93108 }
94109 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
95110
96- // One snapshot per session: sorting and view building read the same
111+ type liveSnapshot struct {
112+ entry attachmentSession
113+ view sessionView
114+ }
115+ // One snapshot per live session: sorting and view building read the same
97116 // capture, so comparators cannot observe a concurrent touchMRU or
98- // renameSession mid-sort (the invariant previously enforced by the
99- // pickerSortSnapshot map).
100- snaps := make([]sessionView, 0, len(sessions))
117+ // renameSession mid-sort. Proxy and remote-catalog locks are released before
118+ // sorting and picker-row construction.
119+ live := make([]liveSnapshot, 0, len(sessions))
101120 var current picker.SourceFilter
102- for _, s := range sessions {
103- snap := s.snapshotView(opts)
104- if s == cur && snap.active >= 0 && snap.active < len(snap.tabs) {
121+ for _, entry := range sessions {
122+ snap := entry.snapshotView(opts)
123+ if entry == cur && snap.active >= 0 && snap.active < len(snap.tabs) {
105124 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}
106125 }
107- snaps = append(snaps, snap)
126+ live = append(live, liveSnapshot{entry: entry, view: snap})
108127 }
109- sort.Slice(snaps, func(i, j int) bool {
110- if snaps[i].mruAt != snaps[j].mruAt {
111- return snaps[i].mruAt > snaps[j].mruAt
128+ sort.Slice(live, func(i, j int) bool {
129+ if live[i].view.mruAt != live[j].view.mruAt {
130+ return live[i].view.mruAt > live[j].view.mruAt
131+ }
132+ if live[i].view.name != live[j].view.name {
133+ return live[i].view.name < live[j].view.name
112134 }
113- return snaps[i].name < snaps[j].name
135+ return live[i].view.id < live[j].view.id
114136 })
115137 sort.Slice(stopped, func(i, j int) bool {
116138 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
@@ -121,14 +143,43 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
121143 if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
122144 // Stable partition: named sessions first, ephemeral after, each keeping
123145 // its MRU order from the sort above.
124- sort.SliceStable(snaps, func(i, j int) bool {
125- return !snaps[i].ephemeral && snaps[j].ephemeral
146+ sort.SliceStable(live, func(i, j int) bool {
147+ return !live[i].view.ephemeral && live[j].view.ephemeral
126148 })
127149 }
128150
129- views := make([]picker.SessionView, 0, len(snaps)+len(stopped))
130- for _, snap := range snaps {
131- views = append(views, snap.pickerView())
151+ catalog := d.remotePickerCatalogSnapshot()
152+ sortRemotePickerCatalog(catalog, d.remotePickerHostRanks())
153+
154+ catalogRows := 0
155+ for _, host := range catalog {
156+ catalogRows += len(host.entry.Sessions)
157+ if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
158+ catalogRows++
159+ }
160+ }
161+ views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)
162+ for _, item := range live {
163+ if proxy, ok := item.entry.(*proxySession); ok {
164+ views = append(views, remoteProxyPickerView(proxy.key, item.view))
165+ continue
166+ }
167+ views = append(views, item.view.pickerView())
168+ }
169+ for _, host := range catalog {
170+ for _, session := range host.entry.Sessions {
171+ key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
172+ if key.Validate() != nil {
173+ continue
174+ }
175+ if proxy, ok := proxies[key.ID()]; ok && proxy.key == key {
176+ continue
177+ }
178+ views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
179+ }
180+ if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
181+ views = append(views, remotePickerHostView(host.entry.Host, host.status))
182+ }
132183 }
133184 for _, s := range stopped {
134185 createdAt := s.createdAt
@@ -165,7 +216,8 @@ func attentionSuffix(label string) string {
165216
166217 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
167218 rt := ac.overlays
168- var generation uint64
219+ var previewGeneration uint64
220+ var instance remotePickerInstance
169221 return listInputState{
170222 pending: &rt.pickerPending,
171223 esc: &rt.pickerESC,
@@ -175,13 +227,15 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
175227 unlock: rt.pickerMu.Unlock,
176228 active: func() bool { return rt.picker != nil },
177229 closeLocked: func() {
230+ instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
178231 rt.picker = nil
179232 rt.pickerIntent = pickerNavigate
180233 rt.pickerSource = moveSourceLocator{}
181- generation = rt.pickerPreviewGeneration
234+ previewGeneration = rt.pickerPreviewGeneration
182235 },
183236 afterClose: func() {
184- d.clearPreviewGeneration(ac, generation)
237+ d.clearPreviewGeneration(ac, previewGeneration)
238+ d.remotePickerClosed(instance)
185239 if sess := ac.currentSession(); sess != nil {
186240 d.invalidateRender(sess, ac, true, "picker.go")
187241 }
@@ -451,16 +505,32 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
451505 }
452506
453507 func (d *Daemon) closePicker(ac *attachedClient) {
508+ d.closePickerIfCurrent(ac, nil, 0)
509+}
510+
511+// closePickerIfCurrent closes only the observed picker lifecycle. A nonzero
512+// generation guards terminal parked cleanup even when a refresh replaced the
513+// model within that generation.
514+func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
454515 ac.overlays.pickerMu.Lock()
516+ if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation {
517+ ac.overlays.pickerMu.Unlock()
518+ return false
519+ }
520+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
455521 ac.overlays.picker = nil
456522 ac.overlays.pickerTitle = ""
457523 ac.overlays.pickerIntent = pickerNavigate
458524 ac.overlays.pickerSource = moveSourceLocator{}
459525 ac.overlays.pickerPending = nil
460526 ac.overlays.pickerESC.stop()
461- generation := ac.overlays.pickerPreviewGeneration
527+ previewGeneration := ac.overlays.pickerPreviewGeneration
462528 ac.overlays.pickerMu.Unlock()
463- d.clearPreviewGeneration(ac, generation)
529+ d.clearPreviewGeneration(ac, previewGeneration)
530+ if instance.model != nil {
531+ d.remotePickerClosed(instance)
532+ }
533+ return true
464534 }
465535
466536 func pickerTargetsEqual(left, right picker.Target) bool {
@@ -64,6 +64,8 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
64 rt.pickerPreviewSession = nil64 rt.pickerPreviewSession = nil
65 rt.pickerPreview = nil65 rt.pickerPreview = nil
66 rt.picker = model66 rt.picker = model
67+ rt.pickerGeneration++
68+ instance := remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: model}
67 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))69 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
68 rt.pickerIntent = intent70 rt.pickerIntent = intent
69 rt.pickerSource = source71 rt.pickerSource = source
@@ -73,13 +75,24 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
73 d.teardownPreviewSubscription(ac, previous, previousGeneration)75 d.teardownPreviewSubscription(ac, previous, previousGeneration)
74 d.registerPreviewForSelection(ac)76 d.registerPreviewForSelection(ac)
75 d.invalidateRender(sess, ac, true, "picker.go")77 d.invalidateRender(sess, ac, true, "picker.go")
78+ if rt.beforeRemotePickerRegistration != nil {
79+ rt.beforeRemotePickerRegistration()
80+ }
81+ d.remotePickerOpened(instance)
76 }82 }
77 83
78 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally84 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
79 // knows nothing about picker intent; picker.New owns all destination policy.85 // knows nothing about picker intent; picker.New owns all destination policy.
80 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) {86 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) {
81 d.mu.Lock()87 d.mu.Lock()
82- sessions := d.sessionsSnapshotLocked()88+ sessions := make([]attachmentSession, 0, len(d.sessions))
89+ proxies := make(map[domain.SessionID]*proxySession)
90+ for id, entry := range d.sessions {
91+ sessions = append(sessions, entry)
92+ if proxy, ok := entry.(*proxySession); ok {
93+ proxies[id] = proxy
94+ }
95+ }
83 stopped := make([]stoppedSession, 0, len(d.stopped))96 stopped := make([]stoppedSession, 0, len(d.stopped))
84 for _, s := range d.stopped {97 for _, s := range d.stopped {
85 if !s.purging {98 if !s.purging {
@@ -88,29 +101,38 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
88 }101 }
89 d.mu.Unlock()102 d.mu.Unlock()
90 103
91- for _, s := range sessions {104+ for _, entry := range sessions {
92- d.refreshSessionFocusedTitles(s)105+ if local, ok := localSession(entry); ok {
106+ d.refreshSessionFocusedTitles(local)
107+ }
93 }108 }
94 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}109 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
95 110
96- // One snapshot per session: sorting and view building read the same111+ type liveSnapshot struct {
112+ entry attachmentSession
113+ view sessionView
114+ }
115+ // One snapshot per live session: sorting and view building read the same
97 // capture, so comparators cannot observe a concurrent touchMRU or116 // capture, so comparators cannot observe a concurrent touchMRU or
98- // renameSession mid-sort (the invariant previously enforced by the117+ // renameSession mid-sort. Proxy and remote-catalog locks are released before
99- // pickerSortSnapshot map).118+ // sorting and picker-row construction.
100- snaps := make([]sessionView, 0, len(sessions))119+ live := make([]liveSnapshot, 0, len(sessions))
101 var current picker.SourceFilter120 var current picker.SourceFilter
102- for _, s := range sessions {121+ for _, entry := range sessions {
103- snap := s.snapshotView(opts)122+ snap := entry.snapshotView(opts)
104- if s == cur && snap.active >= 0 && snap.active < len(snap.tabs) {123+ if entry == cur && snap.active >= 0 && snap.active < len(snap.tabs) {
105 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}124 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}
106 }125 }
107- snaps = append(snaps, snap)126+ live = append(live, liveSnapshot{entry: entry, view: snap})
108 }127 }
109- sort.Slice(snaps, func(i, j int) bool {128+ sort.Slice(live, func(i, j int) bool {
110- if snaps[i].mruAt != snaps[j].mruAt {129+ if live[i].view.mruAt != live[j].view.mruAt {
111- return snaps[i].mruAt > snaps[j].mruAt130+ return live[i].view.mruAt > live[j].view.mruAt
131+ }
132+ if live[i].view.name != live[j].view.name {
133+ return live[i].view.name < live[j].view.name
112 }134 }
113- return snaps[i].name < snaps[j].name135+ return live[i].view.id < live[j].view.id
114 })136 })
115 sort.Slice(stopped, func(i, j int) bool {137 sort.Slice(stopped, func(i, j int) bool {
116 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {138 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
@@ -121,14 +143,43 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
121 if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {143 if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
122 // Stable partition: named sessions first, ephemeral after, each keeping144 // Stable partition: named sessions first, ephemeral after, each keeping
123 // its MRU order from the sort above.145 // its MRU order from the sort above.
124- sort.SliceStable(snaps, func(i, j int) bool {146+ sort.SliceStable(live, func(i, j int) bool {
125- return !snaps[i].ephemeral && snaps[j].ephemeral147+ return !live[i].view.ephemeral && live[j].view.ephemeral
126 })148 })
127 }149 }
128 150
129- views := make([]picker.SessionView, 0, len(snaps)+len(stopped))151+ catalog := d.remotePickerCatalogSnapshot()
130- for _, snap := range snaps {152+ sortRemotePickerCatalog(catalog, d.remotePickerHostRanks())
131- views = append(views, snap.pickerView())153+
154+ catalogRows := 0
155+ for _, host := range catalog {
156+ catalogRows += len(host.entry.Sessions)
157+ if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
158+ catalogRows++
159+ }
160+ }
161+ views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)
162+ for _, item := range live {
163+ if proxy, ok := item.entry.(*proxySession); ok {
164+ views = append(views, remoteProxyPickerView(proxy.key, item.view))
165+ continue
166+ }
167+ views = append(views, item.view.pickerView())
168+ }
169+ for _, host := range catalog {
170+ for _, session := range host.entry.Sessions {
171+ key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
172+ if key.Validate() != nil {
173+ continue
174+ }
175+ if proxy, ok := proxies[key.ID()]; ok && proxy.key == key {
176+ continue
177+ }
178+ views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
179+ }
180+ if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
181+ views = append(views, remotePickerHostView(host.entry.Host, host.status))
182+ }
132 }183 }
133 for _, s := range stopped {184 for _, s := range stopped {
134 createdAt := s.createdAt185 createdAt := s.createdAt
@@ -165,7 +216,8 @@ func attentionSuffix(label string) string {
165 216
166 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {217 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
167 rt := ac.overlays218 rt := ac.overlays
168- var generation uint64219+ var previewGeneration uint64
220+ var instance remotePickerInstance
169 return listInputState{221 return listInputState{
170 pending: &rt.pickerPending,222 pending: &rt.pickerPending,
171 esc: &rt.pickerESC,223 esc: &rt.pickerESC,
@@ -175,13 +227,15 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
175 unlock: rt.pickerMu.Unlock,227 unlock: rt.pickerMu.Unlock,
176 active: func() bool { return rt.picker != nil },228 active: func() bool { return rt.picker != nil },
177 closeLocked: func() {229 closeLocked: func() {
230+ instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
178 rt.picker = nil231 rt.picker = nil
179 rt.pickerIntent = pickerNavigate232 rt.pickerIntent = pickerNavigate
180 rt.pickerSource = moveSourceLocator{}233 rt.pickerSource = moveSourceLocator{}
181- generation = rt.pickerPreviewGeneration234+ previewGeneration = rt.pickerPreviewGeneration
182 },235 },
183 afterClose: func() {236 afterClose: func() {
184- d.clearPreviewGeneration(ac, generation)237+ d.clearPreviewGeneration(ac, previewGeneration)
238+ d.remotePickerClosed(instance)
185 if sess := ac.currentSession(); sess != nil {239 if sess := ac.currentSession(); sess != nil {
186 d.invalidateRender(sess, ac, true, "picker.go")240 d.invalidateRender(sess, ac, true, "picker.go")
187 }241 }
@@ -451,16 +505,32 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
451 }505 }
452 506
453 func (d *Daemon) closePicker(ac *attachedClient) {507 func (d *Daemon) closePicker(ac *attachedClient) {
508+ d.closePickerIfCurrent(ac, nil, 0)
509+}
510+
511+// closePickerIfCurrent closes only the observed picker lifecycle. A nonzero
512+// generation guards terminal parked cleanup even when a refresh replaced the
513+// model within that generation.
514+func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
454 ac.overlays.pickerMu.Lock()515 ac.overlays.pickerMu.Lock()
516+ if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation {
517+ ac.overlays.pickerMu.Unlock()
518+ return false
519+ }
520+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
455 ac.overlays.picker = nil521 ac.overlays.picker = nil
456 ac.overlays.pickerTitle = ""522 ac.overlays.pickerTitle = ""
457 ac.overlays.pickerIntent = pickerNavigate523 ac.overlays.pickerIntent = pickerNavigate
458 ac.overlays.pickerSource = moveSourceLocator{}524 ac.overlays.pickerSource = moveSourceLocator{}
459 ac.overlays.pickerPending = nil525 ac.overlays.pickerPending = nil
460 ac.overlays.pickerESC.stop()526 ac.overlays.pickerESC.stop()
461- generation := ac.overlays.pickerPreviewGeneration527+ previewGeneration := ac.overlays.pickerPreviewGeneration
462 ac.overlays.pickerMu.Unlock()528 ac.overlays.pickerMu.Unlock()
463- d.clearPreviewGeneration(ac, generation)529+ d.clearPreviewGeneration(ac, previewGeneration)
530+ if instance.model != nil {
531+ d.remotePickerClosed(instance)
532+ }
533+ return true
464 }534 }
465 535
466 func pickerTargetsEqual(left, right picker.Target) bool {536 func pickerTargetsEqual(left, right picker.Target) bool {
M internal/usecase/daemon/proxy_lifecycle.go +3 -1
@@ -117,7 +117,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool {
117117 // expireWarmProxy removes only the exact dormant proxy pointer and lifecycle
118118 // generation. The canonical d.mu -> core.mu -> proxy.mu order is held only for
119119 // validation/publication; cancellation, coordinator teardown, transport close,
120-// and daemon completion happen afterward.
120+// picker refresh, and daemon completion happen afterward.
121121 func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
122122 if d == nil || p == nil || token == nil {
123123 return false
@@ -176,6 +176,8 @@ func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
176176 p.finish()
177177 if empty {
178178 d.doneOnce.Do(func() { close(d.done) })
179+ } else {
180+ d.refreshRemoteOpenPickers()
179181 }
180182 return true
181183 }
@@ -117,7 +117,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool {
117 // expireWarmProxy removes only the exact dormant proxy pointer and lifecycle117 // expireWarmProxy removes only the exact dormant proxy pointer and lifecycle
118 // generation. The canonical d.mu -> core.mu -> proxy.mu order is held only for118 // generation. The canonical d.mu -> core.mu -> proxy.mu order is held only for
119 // validation/publication; cancellation, coordinator teardown, transport close,119 // validation/publication; cancellation, coordinator teardown, transport close,
120-// and daemon completion happen afterward.120+// picker refresh, and daemon completion happen afterward.
121 func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {121 func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
122 if d == nil || p == nil || token == nil {122 if d == nil || p == nil || token == nil {
123 return false123 return false
@@ -176,6 +176,8 @@ func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
176 p.finish()176 p.finish()
177 if empty {177 if empty {
178 d.doneOnce.Do(func() { close(d.done) })178 d.doneOnce.Do(func() { close(d.done) })
179+ } else {
180+ d.refreshRemoteOpenPickers()
179 }181 }
180 return true182 return true
181 }183 }
M internal/usecase/daemon/proxy_lifecycle_test.go +22 -0
@@ -162,6 +162,28 @@ func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
162162 }
163163 }
164164
165+func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) {
166+ d, p, clock := newProxyLifecycleFixture(t)
167+ transport := newProxyTestTransport()
168+ t.Cleanup(func() { _ = transport.Close() })
169+ ac := &attachedClient{tr: transport, output: newOutputStateStream()}
170+ ac.setSession(p)
171+ setProxyLifecycleClient(p, ac)
172+ p.mu.Lock()
173+ p.expired = true
174+ p.mu.Unlock()
175+
176+ require.True(t, d.detachProxyIfCurrentTransport(p, ac, ac.transportSnapshot()))
177+ awaitTestValue(t, clock.timers, "expired proxy detach did not arm a warm timer")
178+ token, generation := proxyWarmToken(p)
179+ require.NotNil(t, token, "expired headless proxy has no expiry path")
180+ require.Equal(t, generation, token.generation)
181+ require.Nil(t, ac.currentAttachmentSession())
182+
183+ token.stop()
184+ awaitTestCompletion(t, token.done, "warm timer did not stop during cleanup")
185+}
186+
165187 func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
166188 t.Run("rekeys an already armed timer", func(t *testing.T) {
167189 d, p, clock := newProxyLifecycleFixture(t)
@@ -162,6 +162,28 @@ func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
162 }162 }
163 }163 }
164 164
165+func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) {
166+ d, p, clock := newProxyLifecycleFixture(t)
167+ transport := newProxyTestTransport()
168+ t.Cleanup(func() { _ = transport.Close() })
169+ ac := &attachedClient{tr: transport, output: newOutputStateStream()}
170+ ac.setSession(p)
171+ setProxyLifecycleClient(p, ac)
172+ p.mu.Lock()
173+ p.expired = true
174+ p.mu.Unlock()
175+
176+ require.True(t, d.detachProxyIfCurrentTransport(p, ac, ac.transportSnapshot()))
177+ awaitTestValue(t, clock.timers, "expired proxy detach did not arm a warm timer")
178+ token, generation := proxyWarmToken(p)
179+ require.NotNil(t, token, "expired headless proxy has no expiry path")
180+ require.Equal(t, generation, token.generation)
181+ require.Nil(t, ac.currentAttachmentSession())
182+
183+ token.stop()
184+ awaitTestCompletion(t, token.done, "warm timer did not stop during cleanup")
185+}
186+
165 func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {187 func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
166 t.Run("rekeys an already armed timer", func(t *testing.T) {188 t.Run("rekeys an already armed timer", func(t *testing.T) {
167 d, p, clock := newProxyLifecycleFixture(t)189 d, p, clock := newProxyLifecycleFixture(t)
M internal/usecase/daemon/remote_catalog_state.go +8 -1
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "context"
45 "sort"
56 "sync"
67 "time"
@@ -25,6 +26,9 @@ type remoteCatalogState struct {
2526 cache map[string]ports.RemoteCatalogCacheEntry
2627 status map[string]remoteHostStatus
2728 failure map[string]time.Time
29+ refresh uint64
30+ cancel context.CancelFunc
31+ pickers map[*attachedClient]struct{}
2832 writeMu sync.Mutex
2933 }
3034
@@ -33,14 +37,17 @@ func newRemoteCatalogState() remoteCatalogState {
3337 cache: make(map[string]ports.RemoteCatalogCacheEntry),
3438 status: make(map[string]remoteHostStatus),
3539 failure: make(map[string]time.Time),
40+ pickers: make(map[*attachedClient]struct{}),
3641 }
3742 }
3843
3944 func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {
45+ sessions := make([]ports.RemoteCatalogSession, len(entry.Sessions))
46+ copy(sessions, entry.Sessions)
4047 return ports.RemoteCatalogCacheEntry{
4148 Host: entry.Host,
4249 FetchedAt: entry.FetchedAt,
43- Sessions: append([]ports.RemoteCatalogSession(nil), entry.Sessions...),
50+ Sessions: sessions,
4451 }
4552 }
4653
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
4 "sort"5 "sort"
5 "sync"6 "sync"
6 "time"7 "time"
@@ -25,6 +26,9 @@ type remoteCatalogState struct {
25 cache map[string]ports.RemoteCatalogCacheEntry26 cache map[string]ports.RemoteCatalogCacheEntry
26 status map[string]remoteHostStatus27 status map[string]remoteHostStatus
27 failure map[string]time.Time28 failure map[string]time.Time
29+ refresh uint64
30+ cancel context.CancelFunc
31+ pickers map[*attachedClient]struct{}
28 writeMu sync.Mutex32 writeMu sync.Mutex
29 }33 }
30 34
@@ -33,14 +37,17 @@ func newRemoteCatalogState() remoteCatalogState {
33 cache: make(map[string]ports.RemoteCatalogCacheEntry),37 cache: make(map[string]ports.RemoteCatalogCacheEntry),
34 status: make(map[string]remoteHostStatus),38 status: make(map[string]remoteHostStatus),
35 failure: make(map[string]time.Time),39 failure: make(map[string]time.Time),
40+ pickers: make(map[*attachedClient]struct{}),
36 }41 }
37 }42 }
38 43
39 func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {44 func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {
45+ sessions := make([]ports.RemoteCatalogSession, len(entry.Sessions))
46+ copy(sessions, entry.Sessions)
40 return ports.RemoteCatalogCacheEntry{47 return ports.RemoteCatalogCacheEntry{
41 Host: entry.Host,48 Host: entry.Host,
42 FetchedAt: entry.FetchedAt,49 FetchedAt: entry.FetchedAt,
43- Sessions: append([]ports.RemoteCatalogSession(nil), entry.Sessions...),50+ Sessions: sessions,
44 }51 }
45 }52 }
46 53
A internal/usecase/daemon/remote_picker.go +461 -0
@@ -0,0 +1,461 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "sort"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/picker"
14+)
15+
16+type remotePickerCatalogEntry struct {
17+ entry ports.RemoteCatalogCacheEntry
18+ status remoteHostStatus
19+}
20+
21+// remotePickerInstance is an immutable ownership capability for one published
22+// picker lifecycle. The model identity prevents delayed registration from
23+// attaching ownership to a replacement within the same client.
24+type remotePickerInstance struct {
25+ ac *attachedClient
26+ generation uint64
27+ model *picker.Model
28+}
29+
30+// remotePickerCatalogSnapshot copies the cache-derived state needed for a
31+// picker before any row sorting begins. No remote-state lock survives this
32+// snapshot.
33+func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
34+ if d == nil {
35+ return nil
36+ }
37+ d.remoteCatalog.mu.Lock()
38+ entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
39+ seen := make(map[string]struct{}, len(d.remoteCatalog.cache))
40+ for host, entry := range d.remoteCatalog.cache {
41+ entries = append(entries, remotePickerCatalogEntry{
42+ entry: cloneRemoteCatalogEntry(entry),
43+ status: d.remoteCatalog.status[host],
44+ })
45+ seen[host] = struct{}{}
46+ }
47+ for host, status := range d.remoteCatalog.status {
48+ if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch) {
49+ continue
50+ }
51+ entries = append(entries, remotePickerCatalogEntry{
52+ entry: ports.RemoteCatalogCacheEntry{Host: host},
53+ status: status,
54+ })
55+ }
56+ d.remoteCatalog.mu.Unlock()
57+ return entries
58+}
59+
60+func (d *Daemon) remotePickerHostRanks() map[string]int {
61+ ranks := make(map[string]int)
62+ if d == nil || d.remoteHostStore == nil {
63+ return ranks
64+ }
65+ pinned, learned, err := d.remoteHostStore.Hosts()
66+ if err != nil {
67+ d.log.Warn("listing remote hosts for picker failed", "err", err)
68+ return ranks
69+ }
70+ for _, host := range append(pinned, learned...) {
71+ if _, exists := ranks[host]; !exists {
72+ ranks[host] = len(ranks)
73+ }
74+ }
75+ return ranks
76+}
77+
78+func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {
79+ switch status {
80+ case remoteHostFresh:
81+ return picker.RemoteFresh
82+ case remoteHostUnreachable:
83+ return picker.RemoteStale
84+ case remoteHostVersionMismatch:
85+ return picker.RemoteVersionMismatch
86+ default:
87+ return picker.RemoteCached
88+ }
89+}
90+
91+func remotePickerDetail(tabs uint16) string {
92+ if tabs == 1 {
93+ return "1 tab"
94+ }
95+ return fmt.Sprintf("%d tabs", tabs)
96+}
97+
98+func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
99+ switch status {
100+ case remoteHostUnreachable:
101+ if !fetchedAt.IsZero() {
102+ return "stale since " + fetchedAt.Format(time.RFC3339)
103+ }
104+ return "unreachable"
105+ case remoteHostVersionMismatch:
106+ return "version mismatch"
107+ default:
108+ return ""
109+ }
110+}
111+
112+func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
113+ detail := remotePickerStatusDetail(status, fetchedAt)
114+ if detail == "" {
115+ detail = remotePickerDetail(session.Tabs)
116+ }
117+ return picker.SessionView{
118+ ID: key.ID(),
119+ Name: key.Display(),
120+ RemoteKey: &key,
121+ RemoteHost: key.Host,
122+ RemoteAvailability: remotePickerAvailability(status),
123+ RemoteDetail: detail,
124+ ConnectOnly: true,
125+ RemoteAttachReady: false,
126+ CannotAcceptMoves: true,
127+ }
128+}
129+
130+func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView {
131+ return picker.SessionView{
132+ ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))),
133+ Name: host,
134+ RemoteHost: host,
135+ RemoteAvailability: remotePickerAvailability(status),
136+ RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
137+ ConnectOnly: true,
138+ CannotAcceptMoves: true,
139+ }
140+}
141+
142+func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker.SessionView {
143+ view := snap.pickerView()
144+ // Proxy construction uses this same ID and label. Restating them from the
145+ // structured key makes the cache-to-live row upgrade independent of mutable
146+ // presentation or relayed metadata. Lifecycle is carried separately from
147+ // presentation so a display-name change can never spoof expiry.
148+ view.ID = key.ID()
149+ view.Name = key.Display()
150+ if snap.expired {
151+ view.Name += " [expired]"
152+ }
153+ view.TargetName = ""
154+ view.RemoteKey = &key
155+ view.RemoteAvailability = picker.RemoteFresh
156+ view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
157+ view.ConnectOnly = true
158+ view.RemoteAttachReady = false
159+ if snap.expired {
160+ view.RemoteAvailability = picker.RemoteStale
161+ view.RemoteDetail = "expired"
162+ view.RemoteAttachReady = false
163+ }
164+ view.CannotAcceptMoves = true
165+ return view
166+}
167+
168+func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
169+ for i := range entries {
170+ sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
171+ return entries[i].entry.Sessions[left].Name < entries[i].entry.Sessions[right].Name
172+ })
173+ }
174+ sort.Slice(entries, func(i, j int) bool {
175+ left, right := entries[i].entry.Host, entries[j].entry.Host
176+ leftRank, leftKnown := ranks[left]
177+ rightRank, rightKnown := ranks[right]
178+ if leftKnown != rightKnown {
179+ return leftKnown
180+ }
181+ if leftKnown && leftRank != rightRank {
182+ return leftRank < rightRank
183+ }
184+ return left < right
185+ })
186+}
187+
188+func (d *Daemon) remotePickerOpened(instance remotePickerInstance) {
189+ if d.registerRemotePicker(instance) {
190+ d.startRemotePickerRefresh(instance)
191+ }
192+}
193+
194+// registerRemotePicker installs one exact picker generation as a refresh owner.
195+// Keeping this step separate gives refresh startup a second atomic validation
196+// point: a close can win between registration and startup without being undone
197+// by a delayed opener.
198+func (d *Daemon) registerRemotePicker(instance remotePickerInstance) bool {
199+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
200+ return false
201+ }
202+ rt := instance.ac.overlays
203+ // Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may
204+ // acquire pickerMu while retaining the catalog lock.
205+ rt.pickerMu.Lock()
206+ defer rt.pickerMu.Unlock()
207+ if rt.pickerGeneration != instance.generation || rt.picker != instance.model {
208+ return false
209+ }
210+ d.remoteCatalog.mu.Lock()
211+ d.remoteCatalog.pickers[instance.ac] = struct{}{}
212+ d.remoteCatalog.mu.Unlock()
213+ return true
214+}
215+
216+func (d *Daemon) remotePickerClosed(instance remotePickerInstance) {
217+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
218+ return
219+ }
220+ rt := instance.ac.overlays
221+ rt.pickerMu.Lock()
222+ current := rt.pickerGeneration == instance.generation && rt.picker == nil
223+ if !current {
224+ rt.pickerMu.Unlock()
225+ return
226+ }
227+ d.remoteCatalog.mu.Lock()
228+ delete(d.remoteCatalog.pickers, instance.ac)
229+ if len(d.remoteCatalog.pickers) != 0 {
230+ d.remoteCatalog.mu.Unlock()
231+ rt.pickerMu.Unlock()
232+ return
233+ }
234+ d.remoteCatalog.refresh++
235+ cancel := d.remoteCatalog.cancel
236+ d.remoteCatalog.cancel = nil
237+ d.remoteCatalog.mu.Unlock()
238+ rt.pickerMu.Unlock()
239+ if cancel != nil {
240+ cancel()
241+ }
242+}
243+
244+func (d *Daemon) cancelRemotePickerRefresh() {
245+ if d == nil {
246+ return
247+ }
248+ d.remoteCatalog.mu.Lock()
249+ d.remoteCatalog.refresh++
250+ cancel := d.remoteCatalog.cancel
251+ d.remoteCatalog.cancel = nil
252+ d.remoteCatalog.mu.Unlock()
253+ if cancel != nil {
254+ cancel()
255+ }
256+}
257+
258+func (d *Daemon) remotePickerHosts() ([]string, error) {
259+ if d == nil || d.remoteHostStore == nil {
260+ return nil, nil
261+ }
262+ pinned, learned, err := d.remoteHostStore.Hosts()
263+ if err != nil {
264+ return nil, err
265+ }
266+ hosts := make([]string, 0, len(pinned)+len(learned))
267+ seen := make(map[string]struct{}, cap(hosts))
268+ add := func(candidates []string) {
269+ for _, host := range candidates {
270+ if _, exists := seen[host]; exists {
271+ continue
272+ }
273+ seen[host] = struct{}{}
274+ hosts = append(hosts, host)
275+ }
276+ }
277+ add(pinned)
278+ add(learned)
279+ return hosts, nil
280+}
281+
282+// startRemotePickerRefresh replaces the current refresh generation and launches
283+// one bounded catalog call per known host. Installing the generation is gated
284+// by the exact picker generation that registered ownership. Host-store and
285+// remote I/O happen after every architecture lock has been released.
286+func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 {
287+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
288+ return 0
289+ }
290+ root := context.Background()
291+ if d.serveCtx != nil {
292+ root = d.serveCtx
293+ }
294+ ctx, cancel := context.WithCancel(root)
295+
296+ rt := instance.ac.overlays
297+ rt.pickerMu.Lock()
298+ d.remoteCatalog.mu.Lock()
299+ _, owner := d.remoteCatalog.pickers[instance.ac]
300+ current := rt.pickerGeneration == instance.generation && rt.picker == instance.model
301+ if !current || !owner || len(d.remoteCatalog.pickers) == 0 {
302+ d.remoteCatalog.mu.Unlock()
303+ rt.pickerMu.Unlock()
304+ cancel()
305+ return 0
306+ }
307+ previous := d.remoteCatalog.cancel
308+ d.remoteCatalog.refresh++
309+ generation := d.remoteCatalog.refresh
310+ d.remoteCatalog.cancel = cancel
311+ d.remoteCatalog.mu.Unlock()
312+ rt.pickerMu.Unlock()
313+ if previous != nil {
314+ previous()
315+ }
316+ if d.remoteCatalogClient == nil {
317+ return generation
318+ }
319+
320+ hosts, err := d.remotePickerHosts()
321+ if err != nil {
322+ d.log.Warn("listing remote hosts for refresh failed", "err", err)
323+ return generation
324+ }
325+ known := make(map[string]struct{}, len(hosts))
326+ for _, host := range hosts {
327+ known[host] = struct{}{}
328+ }
329+
330+ d.remoteCatalog.mu.Lock()
331+ if d.remoteCatalog.refresh != generation {
332+ d.remoteCatalog.mu.Unlock()
333+ cancel()
334+ return generation
335+ }
336+ cacheChanged := false
337+ for host := range d.remoteCatalog.cache {
338+ if _, exists := known[host]; exists {
339+ continue
340+ }
341+ delete(d.remoteCatalog.cache, host)
342+ delete(d.remoteCatalog.status, host)
343+ delete(d.remoteCatalog.failure, host)
344+ cacheChanged = true
345+ }
346+ for _, host := range hosts {
347+ d.remoteCatalog.status[host] = remoteHostRefreshing
348+ }
349+ d.remoteCatalog.mu.Unlock()
350+ if cacheChanged {
351+ d.persistRemoteCatalogCache()
352+ d.refreshRemoteOpenPickers()
353+ }
354+
355+ for _, host := range hosts {
356+ d.sessWg.Go(func() {
357+ catalog, listErr := d.remoteCatalogClient.List(ctx, host)
358+ d.applyRemoteRefreshResult(generation, host, catalog, listErr)
359+ })
360+ }
361+ return generation
362+}
363+
364+func containsRemotePickerHost(hosts []string, target string) bool {
365+ for _, host := range hosts {
366+ if host == target {
367+ return true
368+ }
369+ }
370+ return false
371+}
372+
373+// applyRemoteRefreshResult publishes one host completion only if both its
374+// refresh generation and current host-registry membership remain authoritative.
375+// Its return reports whether durable cache state changed.
376+func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog ports.RemoteCatalog, listErr error) bool {
377+ if d == nil {
378+ return false
379+ }
380+ d.remoteCatalog.mu.Lock()
381+ current := d.remoteCatalog.refresh == generation
382+ d.remoteCatalog.mu.Unlock()
383+ if !current {
384+ return false
385+ }
386+
387+ hosts, hostsErr := d.remotePickerHosts()
388+ if hostsErr != nil {
389+ d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
390+ return false
391+ }
392+ stillKnown := containsRemotePickerHost(hosts, host)
393+ now := d.clock.Now()
394+ if listErr == nil && catalog.ProtocolVersion != ports.ProtocolVersion {
395+ listErr = &ports.RemoteCatalogVersionMismatchError{Got: catalog.ProtocolVersion, Want: ports.ProtocolVersion}
396+ }
397+
398+ d.remoteCatalog.mu.Lock()
399+ if d.remoteCatalog.refresh != generation {
400+ d.remoteCatalog.mu.Unlock()
401+ return false
402+ }
403+ if !stillKnown {
404+ _, cacheChanged := d.remoteCatalog.cache[host]
405+ delete(d.remoteCatalog.cache, host)
406+ delete(d.remoteCatalog.status, host)
407+ delete(d.remoteCatalog.failure, host)
408+ d.remoteCatalog.mu.Unlock()
409+ if cacheChanged {
410+ d.persistRemoteCatalogCache()
411+ d.refreshRemoteOpenPickers()
412+ }
413+ return cacheChanged
414+ }
415+ if listErr != nil {
416+ status := remoteHostUnreachable
417+ var mismatch *ports.RemoteCatalogVersionMismatchError
418+ if errors.As(listErr, &mismatch) {
419+ status = remoteHostVersionMismatch
420+ }
421+ d.remoteCatalog.status[host] = status
422+ d.remoteCatalog.failure[host] = now
423+ d.remoteCatalog.mu.Unlock()
424+ d.refreshRemoteOpenPickers()
425+ return false
426+ }
427+ d.remoteCatalog.cache[host] = ports.RemoteCatalogCacheEntry{
428+ Host: host,
429+ FetchedAt: now,
430+ Sessions: append([]ports.RemoteCatalogSession{}, catalog.Sessions...),
431+ }
432+ d.remoteCatalog.status[host] = remoteHostFresh
433+ delete(d.remoteCatalog.failure, host)
434+ d.remoteCatalog.mu.Unlock()
435+
436+ d.persistRemoteCatalogCache()
437+ d.refreshRemoteOpenPickers()
438+ return true
439+}
440+
441+func (d *Daemon) refreshRemoteOpenPickers() {
442+ if d == nil {
443+ return
444+ }
445+ d.remoteCatalog.mu.Lock()
446+ clients := make([]*attachedClient, 0, len(d.remoteCatalog.pickers))
447+ for ac := range d.remoteCatalog.pickers {
448+ clients = append(clients, ac)
449+ }
450+ d.remoteCatalog.mu.Unlock()
451+
452+ for _, ac := range clients {
453+ if ac == nil || ac.overlays == nil {
454+ continue
455+ }
456+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
457+ if entry := ac.currentAttachmentSession(); entry != nil {
458+ d.invalidateRender(entry, ac, true, "remote_picker.go")
459+ }
460+ }
461+}
@@ -0,0 +1,461 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "sort"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/picker"
14+)
15+
16+type remotePickerCatalogEntry struct {
17+ entry ports.RemoteCatalogCacheEntry
18+ status remoteHostStatus
19+}
20+
21+// remotePickerInstance is an immutable ownership capability for one published
22+// picker lifecycle. The model identity prevents delayed registration from
23+// attaching ownership to a replacement within the same client.
24+type remotePickerInstance struct {
25+ ac *attachedClient
26+ generation uint64
27+ model *picker.Model
28+}
29+
30+// remotePickerCatalogSnapshot copies the cache-derived state needed for a
31+// picker before any row sorting begins. No remote-state lock survives this
32+// snapshot.
33+func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
34+ if d == nil {
35+ return nil
36+ }
37+ d.remoteCatalog.mu.Lock()
38+ entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
39+ seen := make(map[string]struct{}, len(d.remoteCatalog.cache))
40+ for host, entry := range d.remoteCatalog.cache {
41+ entries = append(entries, remotePickerCatalogEntry{
42+ entry: cloneRemoteCatalogEntry(entry),
43+ status: d.remoteCatalog.status[host],
44+ })
45+ seen[host] = struct{}{}
46+ }
47+ for host, status := range d.remoteCatalog.status {
48+ if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch) {
49+ continue
50+ }
51+ entries = append(entries, remotePickerCatalogEntry{
52+ entry: ports.RemoteCatalogCacheEntry{Host: host},
53+ status: status,
54+ })
55+ }
56+ d.remoteCatalog.mu.Unlock()
57+ return entries
58+}
59+
60+func (d *Daemon) remotePickerHostRanks() map[string]int {
61+ ranks := make(map[string]int)
62+ if d == nil || d.remoteHostStore == nil {
63+ return ranks
64+ }
65+ pinned, learned, err := d.remoteHostStore.Hosts()
66+ if err != nil {
67+ d.log.Warn("listing remote hosts for picker failed", "err", err)
68+ return ranks
69+ }
70+ for _, host := range append(pinned, learned...) {
71+ if _, exists := ranks[host]; !exists {
72+ ranks[host] = len(ranks)
73+ }
74+ }
75+ return ranks
76+}
77+
78+func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {
79+ switch status {
80+ case remoteHostFresh:
81+ return picker.RemoteFresh
82+ case remoteHostUnreachable:
83+ return picker.RemoteStale
84+ case remoteHostVersionMismatch:
85+ return picker.RemoteVersionMismatch
86+ default:
87+ return picker.RemoteCached
88+ }
89+}
90+
91+func remotePickerDetail(tabs uint16) string {
92+ if tabs == 1 {
93+ return "1 tab"
94+ }
95+ return fmt.Sprintf("%d tabs", tabs)
96+}
97+
98+func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
99+ switch status {
100+ case remoteHostUnreachable:
101+ if !fetchedAt.IsZero() {
102+ return "stale since " + fetchedAt.Format(time.RFC3339)
103+ }
104+ return "unreachable"
105+ case remoteHostVersionMismatch:
106+ return "version mismatch"
107+ default:
108+ return ""
109+ }
110+}
111+
112+func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
113+ detail := remotePickerStatusDetail(status, fetchedAt)
114+ if detail == "" {
115+ detail = remotePickerDetail(session.Tabs)
116+ }
117+ return picker.SessionView{
118+ ID: key.ID(),
119+ Name: key.Display(),
120+ RemoteKey: &key,
121+ RemoteHost: key.Host,
122+ RemoteAvailability: remotePickerAvailability(status),
123+ RemoteDetail: detail,
124+ ConnectOnly: true,
125+ RemoteAttachReady: false,
126+ CannotAcceptMoves: true,
127+ }
128+}
129+
130+func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView {
131+ return picker.SessionView{
132+ ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))),
133+ Name: host,
134+ RemoteHost: host,
135+ RemoteAvailability: remotePickerAvailability(status),
136+ RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
137+ ConnectOnly: true,
138+ CannotAcceptMoves: true,
139+ }
140+}
141+
142+func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker.SessionView {
143+ view := snap.pickerView()
144+ // Proxy construction uses this same ID and label. Restating them from the
145+ // structured key makes the cache-to-live row upgrade independent of mutable
146+ // presentation or relayed metadata. Lifecycle is carried separately from
147+ // presentation so a display-name change can never spoof expiry.
148+ view.ID = key.ID()
149+ view.Name = key.Display()
150+ if snap.expired {
151+ view.Name += " [expired]"
152+ }
153+ view.TargetName = ""
154+ view.RemoteKey = &key
155+ view.RemoteAvailability = picker.RemoteFresh
156+ view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
157+ view.ConnectOnly = true
158+ view.RemoteAttachReady = false
159+ if snap.expired {
160+ view.RemoteAvailability = picker.RemoteStale
161+ view.RemoteDetail = "expired"
162+ view.RemoteAttachReady = false
163+ }
164+ view.CannotAcceptMoves = true
165+ return view
166+}
167+
168+func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
169+ for i := range entries {
170+ sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
171+ return entries[i].entry.Sessions[left].Name < entries[i].entry.Sessions[right].Name
172+ })
173+ }
174+ sort.Slice(entries, func(i, j int) bool {
175+ left, right := entries[i].entry.Host, entries[j].entry.Host
176+ leftRank, leftKnown := ranks[left]
177+ rightRank, rightKnown := ranks[right]
178+ if leftKnown != rightKnown {
179+ return leftKnown
180+ }
181+ if leftKnown && leftRank != rightRank {
182+ return leftRank < rightRank
183+ }
184+ return left < right
185+ })
186+}
187+
188+func (d *Daemon) remotePickerOpened(instance remotePickerInstance) {
189+ if d.registerRemotePicker(instance) {
190+ d.startRemotePickerRefresh(instance)
191+ }
192+}
193+
194+// registerRemotePicker installs one exact picker generation as a refresh owner.
195+// Keeping this step separate gives refresh startup a second atomic validation
196+// point: a close can win between registration and startup without being undone
197+// by a delayed opener.
198+func (d *Daemon) registerRemotePicker(instance remotePickerInstance) bool {
199+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
200+ return false
201+ }
202+ rt := instance.ac.overlays
203+ // Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may
204+ // acquire pickerMu while retaining the catalog lock.
205+ rt.pickerMu.Lock()
206+ defer rt.pickerMu.Unlock()
207+ if rt.pickerGeneration != instance.generation || rt.picker != instance.model {
208+ return false
209+ }
210+ d.remoteCatalog.mu.Lock()
211+ d.remoteCatalog.pickers[instance.ac] = struct{}{}
212+ d.remoteCatalog.mu.Unlock()
213+ return true
214+}
215+
216+func (d *Daemon) remotePickerClosed(instance remotePickerInstance) {
217+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
218+ return
219+ }
220+ rt := instance.ac.overlays
221+ rt.pickerMu.Lock()
222+ current := rt.pickerGeneration == instance.generation && rt.picker == nil
223+ if !current {
224+ rt.pickerMu.Unlock()
225+ return
226+ }
227+ d.remoteCatalog.mu.Lock()
228+ delete(d.remoteCatalog.pickers, instance.ac)
229+ if len(d.remoteCatalog.pickers) != 0 {
230+ d.remoteCatalog.mu.Unlock()
231+ rt.pickerMu.Unlock()
232+ return
233+ }
234+ d.remoteCatalog.refresh++
235+ cancel := d.remoteCatalog.cancel
236+ d.remoteCatalog.cancel = nil
237+ d.remoteCatalog.mu.Unlock()
238+ rt.pickerMu.Unlock()
239+ if cancel != nil {
240+ cancel()
241+ }
242+}
243+
244+func (d *Daemon) cancelRemotePickerRefresh() {
245+ if d == nil {
246+ return
247+ }
248+ d.remoteCatalog.mu.Lock()
249+ d.remoteCatalog.refresh++
250+ cancel := d.remoteCatalog.cancel
251+ d.remoteCatalog.cancel = nil
252+ d.remoteCatalog.mu.Unlock()
253+ if cancel != nil {
254+ cancel()
255+ }
256+}
257+
258+func (d *Daemon) remotePickerHosts() ([]string, error) {
259+ if d == nil || d.remoteHostStore == nil {
260+ return nil, nil
261+ }
262+ pinned, learned, err := d.remoteHostStore.Hosts()
263+ if err != nil {
264+ return nil, err
265+ }
266+ hosts := make([]string, 0, len(pinned)+len(learned))
267+ seen := make(map[string]struct{}, cap(hosts))
268+ add := func(candidates []string) {
269+ for _, host := range candidates {
270+ if _, exists := seen[host]; exists {
271+ continue
272+ }
273+ seen[host] = struct{}{}
274+ hosts = append(hosts, host)
275+ }
276+ }
277+ add(pinned)
278+ add(learned)
279+ return hosts, nil
280+}
281+
282+// startRemotePickerRefresh replaces the current refresh generation and launches
283+// one bounded catalog call per known host. Installing the generation is gated
284+// by the exact picker generation that registered ownership. Host-store and
285+// remote I/O happen after every architecture lock has been released.
286+func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 {
287+ if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
288+ return 0
289+ }
290+ root := context.Background()
291+ if d.serveCtx != nil {
292+ root = d.serveCtx
293+ }
294+ ctx, cancel := context.WithCancel(root)
295+
296+ rt := instance.ac.overlays
297+ rt.pickerMu.Lock()
298+ d.remoteCatalog.mu.Lock()
299+ _, owner := d.remoteCatalog.pickers[instance.ac]
300+ current := rt.pickerGeneration == instance.generation && rt.picker == instance.model
301+ if !current || !owner || len(d.remoteCatalog.pickers) == 0 {
302+ d.remoteCatalog.mu.Unlock()
303+ rt.pickerMu.Unlock()
304+ cancel()
305+ return 0
306+ }
307+ previous := d.remoteCatalog.cancel
308+ d.remoteCatalog.refresh++
309+ generation := d.remoteCatalog.refresh
310+ d.remoteCatalog.cancel = cancel
311+ d.remoteCatalog.mu.Unlock()
312+ rt.pickerMu.Unlock()
313+ if previous != nil {
314+ previous()
315+ }
316+ if d.remoteCatalogClient == nil {
317+ return generation
318+ }
319+
320+ hosts, err := d.remotePickerHosts()
321+ if err != nil {
322+ d.log.Warn("listing remote hosts for refresh failed", "err", err)
323+ return generation
324+ }
325+ known := make(map[string]struct{}, len(hosts))
326+ for _, host := range hosts {
327+ known[host] = struct{}{}
328+ }
329+
330+ d.remoteCatalog.mu.Lock()
331+ if d.remoteCatalog.refresh != generation {
332+ d.remoteCatalog.mu.Unlock()
333+ cancel()
334+ return generation
335+ }
336+ cacheChanged := false
337+ for host := range d.remoteCatalog.cache {
338+ if _, exists := known[host]; exists {
339+ continue
340+ }
341+ delete(d.remoteCatalog.cache, host)
342+ delete(d.remoteCatalog.status, host)
343+ delete(d.remoteCatalog.failure, host)
344+ cacheChanged = true
345+ }
346+ for _, host := range hosts {
347+ d.remoteCatalog.status[host] = remoteHostRefreshing
348+ }
349+ d.remoteCatalog.mu.Unlock()
350+ if cacheChanged {
351+ d.persistRemoteCatalogCache()
352+ d.refreshRemoteOpenPickers()
353+ }
354+
355+ for _, host := range hosts {
356+ d.sessWg.Go(func() {
357+ catalog, listErr := d.remoteCatalogClient.List(ctx, host)
358+ d.applyRemoteRefreshResult(generation, host, catalog, listErr)
359+ })
360+ }
361+ return generation
362+}
363+
364+func containsRemotePickerHost(hosts []string, target string) bool {
365+ for _, host := range hosts {
366+ if host == target {
367+ return true
368+ }
369+ }
370+ return false
371+}
372+
373+// applyRemoteRefreshResult publishes one host completion only if both its
374+// refresh generation and current host-registry membership remain authoritative.
375+// Its return reports whether durable cache state changed.
376+func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog ports.RemoteCatalog, listErr error) bool {
377+ if d == nil {
378+ return false
379+ }
380+ d.remoteCatalog.mu.Lock()
381+ current := d.remoteCatalog.refresh == generation
382+ d.remoteCatalog.mu.Unlock()
383+ if !current {
384+ return false
385+ }
386+
387+ hosts, hostsErr := d.remotePickerHosts()
388+ if hostsErr != nil {
389+ d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
390+ return false
391+ }
392+ stillKnown := containsRemotePickerHost(hosts, host)
393+ now := d.clock.Now()
394+ if listErr == nil && catalog.ProtocolVersion != ports.ProtocolVersion {
395+ listErr = &ports.RemoteCatalogVersionMismatchError{Got: catalog.ProtocolVersion, Want: ports.ProtocolVersion}
396+ }
397+
398+ d.remoteCatalog.mu.Lock()
399+ if d.remoteCatalog.refresh != generation {
400+ d.remoteCatalog.mu.Unlock()
401+ return false
402+ }
403+ if !stillKnown {
404+ _, cacheChanged := d.remoteCatalog.cache[host]
405+ delete(d.remoteCatalog.cache, host)
406+ delete(d.remoteCatalog.status, host)
407+ delete(d.remoteCatalog.failure, host)
408+ d.remoteCatalog.mu.Unlock()
409+ if cacheChanged {
410+ d.persistRemoteCatalogCache()
411+ d.refreshRemoteOpenPickers()
412+ }
413+ return cacheChanged
414+ }
415+ if listErr != nil {
416+ status := remoteHostUnreachable
417+ var mismatch *ports.RemoteCatalogVersionMismatchError
418+ if errors.As(listErr, &mismatch) {
419+ status = remoteHostVersionMismatch
420+ }
421+ d.remoteCatalog.status[host] = status
422+ d.remoteCatalog.failure[host] = now
423+ d.remoteCatalog.mu.Unlock()
424+ d.refreshRemoteOpenPickers()
425+ return false
426+ }
427+ d.remoteCatalog.cache[host] = ports.RemoteCatalogCacheEntry{
428+ Host: host,
429+ FetchedAt: now,
430+ Sessions: append([]ports.RemoteCatalogSession{}, catalog.Sessions...),
431+ }
432+ d.remoteCatalog.status[host] = remoteHostFresh
433+ delete(d.remoteCatalog.failure, host)
434+ d.remoteCatalog.mu.Unlock()
435+
436+ d.persistRemoteCatalogCache()
437+ d.refreshRemoteOpenPickers()
438+ return true
439+}
440+
441+func (d *Daemon) refreshRemoteOpenPickers() {
442+ if d == nil {
443+ return
444+ }
445+ d.remoteCatalog.mu.Lock()
446+ clients := make([]*attachedClient, 0, len(d.remoteCatalog.pickers))
447+ for ac := range d.remoteCatalog.pickers {
448+ clients = append(clients, ac)
449+ }
450+ d.remoteCatalog.mu.Unlock()
451+
452+ for _, ac := range clients {
453+ if ac == nil || ac.overlays == nil {
454+ continue
455+ }
456+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
457+ if entry := ac.currentAttachmentSession(); entry != nil {
458+ d.invalidateRender(entry, ac, true, "remote_picker.go")
459+ }
460+ }
461+}
A internal/usecase/daemon/remote_picker_test.go +964 -0
@@ -0,0 +1,964 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/picker"
19+)
20+
21+const remotePickerReceiveTimeout = time.Second
22+
23+func receiveRemotePicker[T any](t *testing.T, ch <-chan T, what string) T {
24+ t.Helper()
25+ select {
26+ case value, ok := <-ch:
27+ if !ok {
28+ t.Fatalf("channel closed unexpectedly while waiting for %s", what)
29+ }
30+ return value
31+ case <-time.After(remotePickerReceiveTimeout):
32+ t.Fatalf("timed out waiting for %s", what)
33+ var zero T
34+ return zero
35+ }
36+}
37+
38+func waitRemotePickerClose(ch <-chan struct{}, what string) error {
39+ select {
40+ case _, ok := <-ch:
41+ if ok {
42+ return fmt.Errorf("received signal instead of closure while waiting for %s", what)
43+ }
44+ return nil
45+ case <-time.After(remotePickerReceiveTimeout):
46+ return fmt.Errorf("timed out waiting for %s", what)
47+ }
48+}
49+
50+func receiveRemotePickerClose(t *testing.T, ch <-chan struct{}, what string) {
51+ t.Helper()
52+ require.NoError(t, waitRemotePickerClose(ch, what))
53+}
54+
55+func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
56+ return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
57+}
58+
59+func TestRemotePickerMerge(t *testing.T) {
60+ store := portsmocks.NewMockRemoteHostStore(t)
61+ store.EXPECT().Hosts().Return([]string{"pinned"}, []string{"learned"}, nil).Once()
62+ d := newRemotePickerDaemon(store)
63+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{{}}}
64+ local.mruAt.Store(6)
65+ key := domain.RemoteSessionKey{Host: "pinned", Name: "work"}
66+ proxy := &proxySession{
67+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
68+ key: key,
69+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
70+ {Index: 0, Name: "shell"},
71+ }},
72+ }
73+ proxy.mruAt.Store(5)
74+ d.sessions[local.id] = local
75+ d.sessions[proxy.id] = proxy
76+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 2, lastUsedSeq: 9}
77+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
78+ {Host: "learned", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "alpha", State: "running", Tabs: 3}}},
79+ {Host: "pinned", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 9}, {Name: "alpha", State: "running", Tabs: 2}}},
80+ })
81+ d.remoteCatalog.mu.Lock()
82+ d.remoteCatalog.status["pinned"] = remoteHostFresh
83+ d.remoteCatalog.status["learned"] = remoteHostFresh
84+ d.remoteCatalog.mu.Unlock()
85+
86+ views, _ := d.pickerViews(local)
87+
88+ require.Equal(t, []string{"local", "work@pinned", "alpha@pinned", "alpha@learned", "stopped"}, pickerViewNames(views))
89+ require.False(t, views[0].Stopped)
90+ require.Equal(t, key, *views[1].RemoteKey)
91+ require.Equal(t, picker.RemoteFresh, views[1].RemoteAvailability)
92+ require.True(t, views[1].ConnectOnly)
93+ require.Equal(t, "2 tabs", views[2].RemoteDetail)
94+ require.Equal(t, picker.RemoteFresh, views[2].RemoteAvailability)
95+ require.Equal(t, picker.RemoteFresh, views[3].RemoteAvailability)
96+ require.True(t, views[4].Stopped)
97+}
98+
99+func TestRemotePickerDedupeUsesExactKeyAndKeepsLiveID(t *testing.T) {
100+ d := newRemotePickerDaemon(nil)
101+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
102+ proxy := &proxySession{
103+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
104+ key: key,
105+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
106+ {Index: 0, Name: "live tab"},
107+ }},
108+ }
109+ d.sessions[key.ID()] = proxy
110+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
111+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{
112+ {Name: "work", State: "running", Tabs: 7},
113+ },
114+ }})
115+
116+ views, _ := d.pickerViews(nil)
117+
118+ require.Len(t, views, 1)
119+ require.Equal(t, key.ID(), views[0].ID)
120+ require.Equal(t, key, *views[0].RemoteKey)
121+ require.Equal(t, []picker.TabEntry{{Name: "live tab"}}, views[0].Tabs, "live proxy snapshot wins over cached discovery")
122+ require.Equal(t, picker.RemoteFresh, views[0].RemoteAvailability)
123+}
124+
125+func TestRemotePickerSnapshotsDoNotRetainProxyLock(t *testing.T) {
126+ d := newRemotePickerDaemon(nil)
127+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
128+ proxy := &proxySession{
129+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
130+ key: key,
131+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}},
132+ }
133+ d.sessions[key.ID()] = proxy
134+
135+ views, _ := d.pickerViews(nil)
136+
137+ require.Len(t, views, 1)
138+ require.True(t, proxy.mu.TryLock(), "picker construction must release the proxy leaf lock")
139+ proxy.mu.Unlock()
140+ require.True(t, d.remoteCatalog.mu.TryLock(), "picker construction must release the remote catalog lock")
141+ d.remoteCatalog.mu.Unlock()
142+}
143+
144+func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
145+ tests := []struct {
146+ name string
147+ status remoteHostStatus
148+ want picker.RemoteAvailability
149+ }{
150+ {name: "cached", status: remoteHostCached, want: picker.RemoteCached},
151+ {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale},
152+ {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch},
153+ }
154+
155+ for _, test := range tests {
156+ t.Run(test.name, func(t *testing.T) {
157+ d := newRemotePickerDaemon(nil)
158+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
159+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
160+ }})
161+ d.remoteCatalog.mu.Lock()
162+ d.remoteCatalog.status["arch"] = test.status
163+ d.remoteCatalog.mu.Unlock()
164+
165+ views, _ := d.pickerViews(nil)
166+
167+ require.Len(t, views, 1)
168+ require.Equal(t, test.want, views[0].RemoteAvailability)
169+ })
170+ }
171+}
172+
173+func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
174+ d := newRemotePickerDaemon(nil)
175+ fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
176+ failedAt := fetchedAt.Add(6 * time.Hour)
177+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
178+ Host: "arch", FetchedAt: fetchedAt, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
179+ }})
180+ d.remoteCatalog.mu.Lock()
181+ d.remoteCatalog.status["arch"] = remoteHostUnreachable
182+ d.remoteCatalog.failure["arch"] = failedAt
183+ d.remoteCatalog.mu.Unlock()
184+
185+ views, _ := d.pickerViews(nil)
186+
187+ require.Len(t, views, 1)
188+ require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail)
189+ require.NotContains(t, views[0].RemoteDetail, failedAt.Format(time.RFC3339), "failure time remains runtime-only and is not the freshness authority")
190+}
191+
192+func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
193+ for _, test := range []struct {
194+ name string
195+ status remoteHostStatus
196+ want picker.RemoteAvailability
197+ wantDetail string
198+ }{
199+ {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale, wantDetail: "unreachable"},
200+ {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch, wantDetail: "version mismatch"},
201+ } {
202+ t.Run(test.name, func(t *testing.T) {
203+ d := newRemotePickerDaemon(nil)
204+ d.remoteCatalog.mu.Lock()
205+ d.remoteCatalog.status["arch"] = test.status
206+ d.remoteCatalog.failure["arch"] = time.Unix(50, 0)
207+ d.remoteCatalog.mu.Unlock()
208+
209+ views, _ := d.pickerViews(nil)
210+
211+ require.Len(t, views, 1)
212+ require.Equal(t, "arch", views[0].Name)
213+ require.Equal(t, test.want, views[0].RemoteAvailability)
214+ require.Equal(t, test.wantDetail, views[0].RemoteDetail)
215+ model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
216+ _, selectable := model.Selected()
217+ require.False(t, selectable)
218+ })
219+ }
220+}
221+
222+func TestRemotePickerGroupedSortTreatsProxiesAsLiveSessions(t *testing.T) {
223+ d := newRemotePickerDaemon(nil)
224+ d.pickerSort.Store(uint32(pickerSortGrouped))
225+ ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
226+ ephemeral.mruAt.Store(9)
227+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
228+ proxy := &proxySession{sessionCore: sessionCore{id: key.ID(), name: key.Display()}, key: key}
229+ proxy.mruAt.Store(1)
230+ named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
231+ named.mruAt.Store(2)
232+ d.sessions[ephemeral.id] = ephemeral
233+ d.sessions[proxy.id] = proxy
234+ d.sessions[named.id] = named
235+
236+ views, _ := d.pickerViews(nil)
237+
238+ require.Equal(t, []string{"named", "work@arch", "1"}, pickerViewNames(views))
239+}
240+
241+func pickerViewNames(views []picker.SessionView) []string {
242+ names := make([]string, len(views))
243+ for i, view := range views {
244+ names[i] = view.Name
245+ }
246+ return names
247+}
248+
249+type remoteRefreshHostStore struct {
250+ mu sync.Mutex
251+ hosts []string
252+}
253+
254+func (s *remoteRefreshHostStore) Hosts() ([]string, []string, error) {
255+ s.mu.Lock()
256+ defer s.mu.Unlock()
257+ return append([]string(nil), s.hosts...), nil, nil
258+}
259+
260+func (s *remoteRefreshHostStore) set(hosts ...string) {
261+ s.mu.Lock()
262+ s.hosts = append([]string(nil), hosts...)
263+ s.mu.Unlock()
264+}
265+
266+func (*remoteRefreshHostStore) AddPinned(string) error { return nil }
267+func (*remoteRefreshHostStore) RemovePinned(string) error { return nil }
268+func (*remoteRefreshHostStore) Remember(string) error { return nil }
269+func (*remoteRefreshHostStore) Forget(string) error { return nil }
270+func (*remoteRefreshHostStore) Remove(string) (bool, error) {
271+ return false, nil
272+}
273+
274+type remoteRefreshRequest struct {
275+ ctx context.Context
276+ host string
277+ result chan remoteRefreshResult
278+}
279+
280+type remoteRefreshResult struct {
281+ catalog ports.RemoteCatalog
282+ err error
283+}
284+
285+type channelRemoteCatalog struct {
286+ d *Daemon
287+ requests chan remoteRefreshRequest
288+ probeMu sync.Mutex
289+ probes bool
290+ violations []string
291+}
292+
293+func (c *channelRemoteCatalog) List(ctx context.Context, host string) (ports.RemoteCatalog, error) {
294+ c.probeMu.Lock()
295+ if c.probes && !c.d.mu.TryLock() {
296+ c.violations = append(c.violations, "daemon lock held during remote catalog I/O")
297+ } else if c.probes {
298+ c.d.mu.Unlock()
299+ }
300+ if c.probes && !c.d.remoteCatalog.mu.TryLock() {
301+ c.violations = append(c.violations, "remote catalog lock held during remote catalog I/O")
302+ } else if c.probes {
303+ c.d.remoteCatalog.mu.Unlock()
304+ }
305+ c.probeMu.Unlock()
306+ request := remoteRefreshRequest{ctx: ctx, host: host, result: make(chan remoteRefreshResult, 1)}
307+ c.requests <- request
308+ select {
309+ case result := <-request.result:
310+ return result.catalog, result.err
311+ case <-ctx.Done():
312+ return ports.RemoteCatalog{}, ctx.Err()
313+ }
314+}
315+
316+type recordingRemoteCache struct {
317+ d *Daemon
318+ stores chan []ports.RemoteCatalogCacheEntry
319+ probeMu sync.Mutex
320+ probes bool
321+ violations []string
322+}
323+
324+func (*recordingRemoteCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil }
325+
326+func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
327+ c.probeMu.Lock()
328+ if c.probes && !c.d.mu.TryLock() {
329+ c.violations = append(c.violations, "daemon lock held during remote cache write")
330+ } else if c.probes {
331+ c.d.mu.Unlock()
332+ }
333+ if c.probes && !c.d.remoteCatalog.mu.TryLock() {
334+ c.violations = append(c.violations, "remote catalog lock held during remote cache write")
335+ } else if c.probes {
336+ c.d.remoteCatalog.mu.Unlock()
337+ }
338+ c.probeMu.Unlock()
339+ copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries))
340+ for i, entry := range entries {
341+ copyEntries[i] = cloneRemoteCatalogEntry(entry)
342+ }
343+ c.stores <- copyEntries
344+ return nil
345+}
346+
347+type fixedRemoteRefreshClock struct{ now time.Time }
348+
349+func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
350+func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
351+
352+func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {
353+ t.Helper()
354+ if catalog != nil {
355+ catalog.probeMu.Lock()
356+ require.Empty(t, catalog.violations)
357+ catalog.probeMu.Unlock()
358+ }
359+ if cache != nil {
360+ cache.probeMu.Lock()
361+ require.Empty(t, cache.violations)
362+ cache.probeMu.Unlock()
363+ }
364+}
365+
366+func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {
367+ t.Helper()
368+ catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}
369+ cache := &recordingRemoteCache{stores: make(chan []ports.RemoteCatalogCacheEntry, 8)}
370+ d := New(nil, fixedRemoteRefreshClock{now: now}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(hosts, catalog, cache, nil, ports.RemoteTransportUDP))
371+ catalog.d, cache.d = d, d
372+ return d, catalog, cache
373+}
374+
375+func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T) {
376+ hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
377+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(100, 0))
378+ ac := &attachedClient{}
379+ ac.initOverlays()
380+ model := picker.New(nil, picker.SelectionConfig{})
381+ ac.overlays.pickerMu.Lock()
382+ ac.overlays.picker = model
383+ ac.overlays.pickerGeneration++
384+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
385+ ac.overlays.pickerMu.Unlock()
386+ require.True(t, d.registerRemotePicker(instance))
387+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
388+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "cached", State: "running"}},
389+ }})
390+
391+ firstGeneration := d.startRemotePickerRefresh(instance)
392+ first := map[string]remoteRefreshRequest{}
393+ for range 2 {
394+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
395+ first[request.host] = request
396+ }
397+ require.Contains(t, first, "arch")
398+ require.Contains(t, first, "mule")
399+ requireNoRemoteLockViolations(t, catalog, nil)
400+ d.remoteCatalog.mu.Lock()
401+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "cached", State: "running"}}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
402+ require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
403+ d.remoteCatalog.mu.Unlock()
404+
405+ secondGeneration := d.startRemotePickerRefresh(instance)
406+ require.Greater(t, secondGeneration, firstGeneration)
407+ for _, request := range first {
408+ receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
409+ }
410+ for range 2 {
411+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
412+ request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}
413+ }
414+ for range 2 {
415+ receiveRemotePicker(t, cache.stores, "remote cache store")
416+ }
417+ requireNoRemoteLockViolations(t, nil, cache)
418+
419+ d.remoteCatalog.mu.Lock()
420+ require.Equal(t, secondGeneration, d.remoteCatalog.refresh)
421+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
422+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["mule"])
423+ d.remoteCatalog.mu.Unlock()
424+}
425+
426+func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
427+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
428+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
429+ catalog.probes, cache.probes = true, true
430+ ac := &attachedClient{}
431+ ac.initOverlays()
432+ model := picker.New(nil, picker.SelectionConfig{})
433+ ac.overlays.pickerMu.Lock()
434+ ac.overlays.picker = model
435+ ac.overlays.pickerGeneration++
436+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
437+ ac.overlays.pickerMu.Unlock()
438+ require.True(t, d.registerRemotePicker(instance))
439+
440+ d.startRemotePickerRefresh(instance)
441+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
442+ request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}
443+ receiveRemotePicker(t, cache.stores, "remote cache store")
444+ requireNoRemoteLockViolations(t, catalog, cache)
445+}
446+
447+func TestRemoteRefreshWithoutCatalogClientDoesNotMarkHostsRefreshing(t *testing.T) {
448+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
449+ d := newRemotePickerDaemon(hosts)
450+ ac := &attachedClient{}
451+ ac.initOverlays()
452+ model := picker.New(nil, picker.SelectionConfig{})
453+ ac.overlays.pickerMu.Lock()
454+ ac.overlays.picker = model
455+ ac.overlays.pickerGeneration++
456+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
457+ ac.overlays.pickerMu.Unlock()
458+ require.True(t, d.registerRemotePicker(instance))
459+
460+ require.NotZero(t, d.startRemotePickerRefresh(instance))
461+ d.remoteCatalog.mu.Lock()
462+ require.NotEqual(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
463+ d.remoteCatalog.mu.Unlock()
464+}
465+
466+func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *testing.T) {
467+ now := time.Unix(200, 0)
468+ hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
469+ d, _, cache := newRemoteRefreshDaemon(t, hosts, now)
470+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
471+ {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}}},
472+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},
473+ })
474+ d.remoteCatalog.mu.Lock()
475+ d.remoteCatalog.refresh = 7
476+ d.remoteCatalog.mu.Unlock()
477+
478+ require.False(t, d.applyRemoteRefreshResult(7, "mule", ports.RemoteCatalog{}, errors.New("offline")))
479+ d.remoteCatalog.mu.Lock()
480+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}, d.remoteCatalog.cache["mule"].Sessions)
481+ require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)
482+ require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
483+ d.remoteCatalog.mu.Unlock()
484+
485+ require.True(t, d.applyRemoteRefreshResult(7, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}, nil))
486+ stored := receiveRemotePicker(t, cache.stores, "remote cache store")
487+ require.Equal(t, []ports.RemoteCatalogCacheEntry{
488+ {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},
489+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},
490+ }, stored, "every successful write persists the newest full cache atomically")
491+ requireNoRemoteLockViolations(t, nil, cache)
492+ d.remoteCatalog.mu.Lock()
493+ require.Empty(t, d.remoteCatalog.cache["arch"].Sessions, "a successful omission authoritatively evicts old rows")
494+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
495+ d.remoteCatalog.mu.Unlock()
496+}
497+
498+func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
499+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
500+ d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))
501+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
502+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}},
503+ }})
504+ d.remoteCatalog.mu.Lock()
505+ d.remoteCatalog.refresh = 9
506+ d.remoteCatalog.mu.Unlock()
507+
508+ require.False(t, d.applyRemoteRefreshResult(8, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "late", State: "running"}}}, nil))
509+ hosts.set()
510+ require.True(t, d.applyRemoteRefreshResult(9, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "removed", State: "running"}}}, nil))
511+ require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))
512+ requireNoRemoteLockViolations(t, nil, cache)
513+ d.remoteCatalog.mu.Lock()
514+ require.NotContains(t, d.remoteCatalog.cache, "arch")
515+ require.NotContains(t, d.remoteCatalog.status, "arch")
516+ d.remoteCatalog.mu.Unlock()
517+}
518+
519+func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
520+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
521+ d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
522+ old := ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}
523+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{old})
524+ d.remoteCatalog.mu.Lock()
525+ d.remoteCatalog.refresh = 3
526+ d.remoteCatalog.mu.Unlock()
527+
528+ require.False(t, d.applyRemoteRefreshResult(3, "arch", ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{Got: 19, Want: ports.ProtocolVersion}))
529+ d.remoteCatalog.mu.Lock()
530+ require.Equal(t, old, d.remoteCatalog.cache["arch"])
531+ require.Equal(t, remoteHostVersionMismatch, d.remoteCatalog.status["arch"])
532+ d.remoteCatalog.mu.Unlock()
533+}
534+
535+func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
536+ t.Helper()
537+ tr, sends := newCapturingTransport(t)
538+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
539+ ac.initOverlays()
540+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
541+ tb.stableID = string(id) + "-tab"
542+ sess := &session{sessionCore: sessionCore{id: id, name: string(id), client: ac}, tabs: []*tab{tb}, active: 0}
543+ publishTiledPaneOwners(sess, tb)
544+ ac.setSession(sess)
545+ d.sessions[id] = sess
546+ return sess, ac, sends
547+}
548+
549+func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
550+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
551+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(450, 0))
552+ firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
553+ _, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
554+ before := make(map[*attachedClient]*picker.Model)
555+
556+ for _, owner := range []*attachedClient{first, second} {
557+ owner.overlays.pickerMu.Lock()
558+ owner.overlays.picker = d.newPickerModel(firstSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: "second", TabID: "second-tab"})
559+ owner.overlays.pickerIntent = pickerNavigate
560+ owner.overlays.pickerGeneration++
561+ instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker}
562+ before[owner] = owner.overlays.picker
563+ owner.overlays.pickerMu.Unlock()
564+ require.True(t, d.registerRemotePicker(instance))
565+ }
566+ d.remoteCatalog.mu.Lock()
567+ d.remoteCatalog.refresh = 4
568+ d.remoteCatalog.mu.Unlock()
569+
570+ require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{
571+ ProtocolVersion: ports.ProtocolVersion,
572+ Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 2}},
573+ }, nil))
574+ receiveRemotePicker(t, cache.stores, "remote cache store")
575+ requireNoRemoteLockViolations(t, catalog, cache)
576+
577+ for _, owner := range []*attachedClient{first, second} {
578+ owner.overlays.pickerMu.Lock()
579+ updated := owner.overlays.picker
580+ selected, ok := updated.Selected()
581+ owner.overlays.pickerMu.Unlock()
582+ require.NotSame(t, before[owner], updated, "every open picker must receive the refreshed model")
583+ require.True(t, ok)
584+ require.Equal(t, domain.SessionID("second"), selected.Session)
585+ }
586+}
587+
588+func TestRemotePickerTeardownLifecycle(t *testing.T) {
589+ for _, test := range []struct {
590+ name string
591+ resumeCapable bool
592+ teardown func(*Daemon, *session, *attachedClient)
593+ }{
594+ {name: "client gone parks resumable attachment", resumeCapable: true, teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), false) }},
595+ {name: "send error parks resumable attachment", resumeCapable: true, teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
596+ {name: "client gone removes non-resumable attachment", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), false) }},
597+ {name: "send error removes non-resumable attachment", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
598+ } {
599+ t.Run(test.name, func(t *testing.T) {
600+ d := newRemotePickerDaemon(nil)
601+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
602+ ac.resumeCapable = test.resumeCapable
603+ model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
604+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
605+ ac.overlays.pickerMu.Lock()
606+ generation := ac.overlays.pickerGeneration
607+ ac.overlays.pickerMu.Unlock()
608+
609+ test.teardown(d, sess, ac)
610+
611+ d.remoteCatalog.mu.Lock()
612+ _, registered := d.remoteCatalog.pickers[ac]
613+ d.remoteCatalog.mu.Unlock()
614+ ac.overlays.pickerMu.Lock()
615+ current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration
616+ ac.overlays.pickerMu.Unlock()
617+ if !test.resumeCapable {
618+ require.False(t, registered)
619+ require.Nil(t, current)
620+ return
621+ }
622+ require.True(t, ac.parked)
623+ require.True(t, registered)
624+ require.Same(t, model, current)
625+ require.Equal(t, generation, currentGeneration)
626+ d.mu.Lock()
627+ parked := d.parked[ac.resumeToken]
628+ d.mu.Unlock()
629+ require.NotNil(t, parked)
630+ require.Same(t, ac, parked.ac)
631+ })
632+ }
633+}
634+
635+func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.T) {
636+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
637+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
638+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(475, 0))
639+ d.clock = clock
640+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
641+ ac.resumeCapable = true
642+ d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
643+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
644+
645+ require.True(t, d.parkAttachment(sess, ac))
646+ timer := receiveRemotePicker(t, clock.timers, "park expiry timer")
647+ timer.ch <- clock.Now()
648+ receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
649+
650+ ac.overlays.pickerMu.Lock()
651+ require.Nil(t, ac.overlays.picker)
652+ ac.overlays.pickerMu.Unlock()
653+ d.remoteCatalog.mu.Lock()
654+ require.NotContains(t, d.remoteCatalog.pickers, ac)
655+ d.remoteCatalog.mu.Unlock()
656+}
657+
658+func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) {
659+ d := newRemotePickerDaemon(nil)
660+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
661+ ac.resumeCapable = true
662+ d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
663+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
664+ ac.overlays.pickerMu.Lock()
665+ refreshed := ac.overlays.picker
666+ ac.overlays.pickerMu.Unlock()
667+ require.NotNil(t, refreshed)
668+ require.True(t, d.parkAttachment(sess, ac))
669+ token := ac.resumeToken
670+ d.mu.Lock()
671+ parked := d.parked[token]
672+ d.mu.Unlock()
673+ require.NotNil(t, parked)
674+
675+ d.mu.Lock()
676+ retirement := d.retireParkedAttachmentLocked(token, parked)
677+ d.mu.Unlock()
678+ d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{retirement})
679+ ac.overlays.pickerMu.Lock()
680+ require.Nil(t, ac.overlays.picker, "terminal retirement closes a refreshed model in its captured generation")
681+ ac.overlays.pickerMu.Unlock()
682+}
683+
684+func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) {
685+ d := newRemotePickerDaemon(nil)
686+ firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
687+ secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
688+ first.resumeCapable, second.resumeCapable = true, true
689+ d.publishPicker(firstSession, first, d.newPickerModel(firstSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
690+ require.True(t, d.parkAttachment(firstSession, first))
691+ token := first.resumeToken
692+ d.publishPicker(secondSession, second, d.newPickerModel(secondSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
693+ second.resumeToken = token
694+ require.True(t, d.parkAttachment(secondSession, second))
695+
696+ first.overlays.pickerMu.Lock()
697+ require.Nil(t, first.overlays.picker)
698+ first.overlays.pickerMu.Unlock()
699+ second.overlays.pickerMu.Lock()
700+ require.NotNil(t, second.overlays.picker)
701+ second.overlays.pickerMu.Unlock()
702+}
703+
704+func TestParkedPickerResumePreservesGeneration(t *testing.T) {
705+ d := newRemotePickerDaemon(nil)
706+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
707+ ac.resumeCapable = true
708+ ac.clientID = [16]byte{1, 2, 3, 4}
709+ d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
710+ ac.overlays.pickerMu.Lock()
711+ model, generation := ac.overlays.picker, ac.overlays.pickerGeneration
712+ ac.overlays.pickerMu.Unlock()
713+ d.clientGone(sess, ac, ac.transport(), false)
714+ token := ac.resumeToken
715+ tr := &closeTrackingTransport{}
716+ resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, sess.name, token), tr, domain.Size{Cols: 80, Rows: 24})
717+ require.NoError(t, err)
718+ require.True(t, ok)
719+ require.Same(t, sess, resumedSess)
720+ require.Same(t, ac, resumedAC)
721+ ac.overlays.pickerMu.Lock()
722+ require.Same(t, model, ac.overlays.picker)
723+ require.Equal(t, generation, ac.overlays.pickerGeneration)
724+ ac.overlays.pickerMu.Unlock()
725+}
726+
727+func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
728+ d := newRemotePickerDaemon(nil)
729+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
730+ ac.resumeCapable = true
731+ d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
732+ require.True(t, d.parkAttachment(sess, ac))
733+ token := ac.resumeToken
734+ d.mu.Lock()
735+ parked := d.parked[token]
736+ d.mu.Unlock()
737+ require.NotNil(t, parked)
738+
739+ newer := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
740+ d.publishPicker(sess, ac, newer, pickerNavigate, moveSourceLocator{})
741+ d.expireParked(token, parked)
742+ ac.overlays.pickerMu.Lock()
743+ require.Same(t, newer, ac.overlays.picker)
744+ ac.overlays.pickerMu.Unlock()
745+}
746+
747+func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.T) {
748+ d := newRemotePickerDaemon(nil)
749+ ownerSession, owner, _ := addRemoteRefreshPickerOwner(t, d, "local")
750+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
751+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
752+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
753+ }})
754+ owner.overlays.pickerMu.Lock()
755+ owner.overlays.picker = d.newPickerModel(ownerSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
756+ owner.overlays.pickerIntent = pickerNavigate
757+ before, ok := owner.overlays.picker.Cursor()
758+ _, beforeSelectable := owner.overlays.picker.Selected()
759+ owner.overlays.pickerMu.Unlock()
760+ require.True(t, ok)
761+ require.Equal(t, &key, before.RemoteKey)
762+ require.False(t, beforeSelectable, "cache-derived remote row must stay gated until proxy activation lands")
763+
764+ proxy := &proxySession{
765+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
766+ key: key,
767+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "live"}}},
768+ }
769+ d.mu.Lock()
770+ d.sessions[key.ID()] = proxy
771+ d.mu.Unlock()
772+
773+ d.refreshPickerOpts(owner, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
774+
775+ owner.overlays.pickerMu.Lock()
776+ after, ok := owner.overlays.picker.Cursor()
777+ _, selectable := owner.overlays.picker.Selected()
778+ owner.overlays.pickerMu.Unlock()
779+ require.True(t, ok)
780+ require.Equal(t, before.Session, after.Session)
781+ require.Equal(t, before.RemoteKey, after.RemoteKey)
782+ require.False(t, selectable, "cache-to-live upgrade must preserve the proxy activation gate")
783+}
784+
785+func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
786+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
787+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(500, 0))
788+ newInstance := func() remotePickerInstance {
789+ ac := &attachedClient{}
790+ ac.initOverlays()
791+ model := picker.New(nil, picker.SelectionConfig{})
792+ ac.overlays.pickerMu.Lock()
793+ ac.overlays.picker = model
794+ ac.overlays.pickerGeneration++
795+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
796+ ac.overlays.pickerMu.Unlock()
797+ return instance
798+ }
799+ closeInstance := func(instance remotePickerInstance) {
800+ instance.ac.overlays.pickerMu.Lock()
801+ instance.ac.overlays.picker = nil
802+ instance.ac.overlays.pickerMu.Unlock()
803+ d.remotePickerClosed(instance)
804+ }
805+ first, second := newInstance(), newInstance()
806+ d.remotePickerOpened(first)
807+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
808+ d.remotePickerOpened(second)
809+ receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
810+ request = receiveRemotePicker(t, catalog.requests, "catalog request")
811+
812+ closeInstance(first)
813+ select {
814+ case <-request.ctx.Done():
815+ t.Fatal("refresh canceled while another picker remained open")
816+ default:
817+ }
818+ closeInstance(second)
819+ receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
820+
821+ d.remoteCatalog.mu.Lock()
822+ require.Empty(t, d.remoteCatalog.pickers)
823+ require.Nil(t, d.remoteCatalog.cancel)
824+ d.remoteCatalog.mu.Unlock()
825+}
826+
827+func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
828+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
829+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(525, 0))
830+ ac := &attachedClient{}
831+ ac.initOverlays()
832+ model := picker.New(nil, picker.SelectionConfig{})
833+ ac.overlays.pickerMu.Lock()
834+ ac.overlays.picker = model
835+ ac.overlays.pickerGeneration++
836+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
837+ ac.overlays.pickerMu.Unlock()
838+
839+ // Stop at the production seam after exact picker ownership registration but
840+ // before refresh startup, then let the last-picker close linearize first.
841+ require.True(t, d.registerRemotePicker(instance))
842+ ac.overlays.pickerMu.Lock()
843+ ac.overlays.picker = nil
844+ ac.overlays.pickerMu.Unlock()
845+ d.remotePickerClosed(instance)
846+
847+ d.remoteCatalog.mu.Lock()
848+ refreshBefore := d.remoteCatalog.refresh
849+ d.remoteCatalog.mu.Unlock()
850+ require.Zero(t, d.startRemotePickerRefresh(instance))
851+
852+ d.remoteCatalog.mu.Lock()
853+ require.Empty(t, d.remoteCatalog.pickers, "the closing picker must remain unregistered")
854+ require.Equal(t, refreshBefore, d.remoteCatalog.refresh, "a close that wins before startup must prevent a later refresh generation")
855+ require.Nil(t, d.remoteCatalog.cancel, "a close that wins before startup must prevent a later cancel installation")
856+ d.remoteCatalog.mu.Unlock()
857+ select {
858+ case request := <-catalog.requests:
859+ t.Fatalf("catalog call remained running after the last picker closed: %s", request.host)
860+ default:
861+ }
862+}
863+
864+func TestRemotePickerPublishCannotRegisterAfterSnatchClear(t *testing.T) {
865+ d := newRemotePickerDaemon(nil)
866+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
867+ ac.roleGeneration.Store(1)
868+ model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
869+ registrationReached := make(chan struct{})
870+ allowRegistration := make(chan struct{})
871+ registrationWait := make(chan error, 1)
872+ ac.overlays.beforeRemotePickerRegistration = func() {
873+ close(registrationReached)
874+ registrationWait <- waitRemotePickerClose(allowRegistration, "picker registration release")
875+ }
876+ published := make(chan struct{})
877+ go func() {
878+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
879+ close(published)
880+ }()
881+ receiveRemotePickerClose(t, registrationReached, "picker registration")
882+
883+ token := attachmentRoleToken{
884+ sess: sess, ac: ac, role: attachmentSnatched,
885+ generation: 1, transport: ac.transportSnapshot(),
886+ }
887+ require.True(t, d.clearForSnatch(token))
888+ close(allowRegistration)
889+ require.NoError(t, receiveRemotePicker(t, registrationWait, "picker registration release"))
890+ receiveRemotePickerClose(t, published, "picker publication")
891+
892+ d.remoteCatalog.mu.Lock()
893+ _, registered := d.remoteCatalog.pickers[ac]
894+ d.remoteCatalog.mu.Unlock()
895+ require.False(t, registered, "a picker cleared before delayed registration must not become a refresh owner")
896+}
897+
898+func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
899+ d := newRemotePickerDaemon(nil)
900+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
901+ first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
902+ d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
903+
904+ staleClose := d.pickerListInputState(ac)
905+ ac.overlays.pickerMu.Lock()
906+ staleClose.closeLocked()
907+ ac.overlays.pickerMu.Unlock()
908+
909+ reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
910+ d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
911+ staleClose.afterClose()
912+
913+ d.remoteCatalog.mu.Lock()
914+ _, registered := d.remoteCatalog.pickers[ac]
915+ d.remoteCatalog.mu.Unlock()
916+ require.True(t, registered, "stale close cleanup must retain the reopened picker registration")
917+ ac.overlays.pickerMu.Lock()
918+ require.Same(t, reopened, ac.overlays.picker)
919+ ac.overlays.pickerMu.Unlock()
920+}
921+
922+func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
923+ d := newRemotePickerDaemon(nil)
924+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
925+ first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
926+ d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
927+
928+ rebuildReached := make(chan struct{})
929+ allowPublication := make(chan struct{})
930+ publicationWait := make(chan error, 1)
931+ ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
932+ close(rebuildReached)
933+ publicationWait <- waitRemotePickerClose(allowPublication, "picker publication release")
934+ }
935+ refreshed := make(chan struct{})
936+ go func() {
937+ d.refreshRemoteOpenPickers()
938+ close(refreshed)
939+ }()
940+ receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
941+
942+ d.closePicker(ac)
943+ reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
944+ d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
945+ close(allowPublication)
946+ require.NoError(t, receiveRemotePicker(t, publicationWait, "picker publication release"))
947+ receiveRemotePickerClose(t, refreshed, "picker refresh")
948+
949+ ac.overlays.pickerMu.Lock()
950+ require.Same(t, reopened, ac.overlays.picker, "a rebuild for the old picker must not publish into its replacement")
951+ ac.overlays.pickerMu.Unlock()
952+}
953+
954+func TestRemoteRowsRemainNonSelectableDuringRefreshPhase(t *testing.T) {
955+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
956+ for _, availability := range []picker.RemoteAvailability{picker.RemoteCached, picker.RemoteFresh, picker.RemoteStale, picker.RemoteVersionMismatch} {
957+ model := picker.New([]picker.SessionView{{
958+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: availability,
959+ ConnectOnly: true, RemoteAttachReady: false,
960+ }}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
961+ _, selectable := model.Selected()
962+ require.False(t, selectable, "availability %d became remotely attachable in phase 5", availability)
963+ }
964+}
@@ -0,0 +1,964 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/picker"
19+)
20+
21+const remotePickerReceiveTimeout = time.Second
22+
23+func receiveRemotePicker[T any](t *testing.T, ch <-chan T, what string) T {
24+ t.Helper()
25+ select {
26+ case value, ok := <-ch:
27+ if !ok {
28+ t.Fatalf("channel closed unexpectedly while waiting for %s", what)
29+ }
30+ return value
31+ case <-time.After(remotePickerReceiveTimeout):
32+ t.Fatalf("timed out waiting for %s", what)
33+ var zero T
34+ return zero
35+ }
36+}
37+
38+func waitRemotePickerClose(ch <-chan struct{}, what string) error {
39+ select {
40+ case _, ok := <-ch:
41+ if ok {
42+ return fmt.Errorf("received signal instead of closure while waiting for %s", what)
43+ }
44+ return nil
45+ case <-time.After(remotePickerReceiveTimeout):
46+ return fmt.Errorf("timed out waiting for %s", what)
47+ }
48+}
49+
50+func receiveRemotePickerClose(t *testing.T, ch <-chan struct{}, what string) {
51+ t.Helper()
52+ require.NoError(t, waitRemotePickerClose(ch, what))
53+}
54+
55+func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
56+ return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
57+}
58+
59+func TestRemotePickerMerge(t *testing.T) {
60+ store := portsmocks.NewMockRemoteHostStore(t)
61+ store.EXPECT().Hosts().Return([]string{"pinned"}, []string{"learned"}, nil).Once()
62+ d := newRemotePickerDaemon(store)
63+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{{}}}
64+ local.mruAt.Store(6)
65+ key := domain.RemoteSessionKey{Host: "pinned", Name: "work"}
66+ proxy := &proxySession{
67+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
68+ key: key,
69+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
70+ {Index: 0, Name: "shell"},
71+ }},
72+ }
73+ proxy.mruAt.Store(5)
74+ d.sessions[local.id] = local
75+ d.sessions[proxy.id] = proxy
76+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 2, lastUsedSeq: 9}
77+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
78+ {Host: "learned", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "alpha", State: "running", Tabs: 3}}},
79+ {Host: "pinned", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 9}, {Name: "alpha", State: "running", Tabs: 2}}},
80+ })
81+ d.remoteCatalog.mu.Lock()
82+ d.remoteCatalog.status["pinned"] = remoteHostFresh
83+ d.remoteCatalog.status["learned"] = remoteHostFresh
84+ d.remoteCatalog.mu.Unlock()
85+
86+ views, _ := d.pickerViews(local)
87+
88+ require.Equal(t, []string{"local", "work@pinned", "alpha@pinned", "alpha@learned", "stopped"}, pickerViewNames(views))
89+ require.False(t, views[0].Stopped)
90+ require.Equal(t, key, *views[1].RemoteKey)
91+ require.Equal(t, picker.RemoteFresh, views[1].RemoteAvailability)
92+ require.True(t, views[1].ConnectOnly)
93+ require.Equal(t, "2 tabs", views[2].RemoteDetail)
94+ require.Equal(t, picker.RemoteFresh, views[2].RemoteAvailability)
95+ require.Equal(t, picker.RemoteFresh, views[3].RemoteAvailability)
96+ require.True(t, views[4].Stopped)
97+}
98+
99+func TestRemotePickerDedupeUsesExactKeyAndKeepsLiveID(t *testing.T) {
100+ d := newRemotePickerDaemon(nil)
101+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
102+ proxy := &proxySession{
103+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
104+ key: key,
105+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
106+ {Index: 0, Name: "live tab"},
107+ }},
108+ }
109+ d.sessions[key.ID()] = proxy
110+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
111+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{
112+ {Name: "work", State: "running", Tabs: 7},
113+ },
114+ }})
115+
116+ views, _ := d.pickerViews(nil)
117+
118+ require.Len(t, views, 1)
119+ require.Equal(t, key.ID(), views[0].ID)
120+ require.Equal(t, key, *views[0].RemoteKey)
121+ require.Equal(t, []picker.TabEntry{{Name: "live tab"}}, views[0].Tabs, "live proxy snapshot wins over cached discovery")
122+ require.Equal(t, picker.RemoteFresh, views[0].RemoteAvailability)
123+}
124+
125+func TestRemotePickerSnapshotsDoNotRetainProxyLock(t *testing.T) {
126+ d := newRemotePickerDaemon(nil)
127+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
128+ proxy := &proxySession{
129+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
130+ key: key,
131+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}},
132+ }
133+ d.sessions[key.ID()] = proxy
134+
135+ views, _ := d.pickerViews(nil)
136+
137+ require.Len(t, views, 1)
138+ require.True(t, proxy.mu.TryLock(), "picker construction must release the proxy leaf lock")
139+ proxy.mu.Unlock()
140+ require.True(t, d.remoteCatalog.mu.TryLock(), "picker construction must release the remote catalog lock")
141+ d.remoteCatalog.mu.Unlock()
142+}
143+
144+func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
145+ tests := []struct {
146+ name string
147+ status remoteHostStatus
148+ want picker.RemoteAvailability
149+ }{
150+ {name: "cached", status: remoteHostCached, want: picker.RemoteCached},
151+ {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale},
152+ {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch},
153+ }
154+
155+ for _, test := range tests {
156+ t.Run(test.name, func(t *testing.T) {
157+ d := newRemotePickerDaemon(nil)
158+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
159+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
160+ }})
161+ d.remoteCatalog.mu.Lock()
162+ d.remoteCatalog.status["arch"] = test.status
163+ d.remoteCatalog.mu.Unlock()
164+
165+ views, _ := d.pickerViews(nil)
166+
167+ require.Len(t, views, 1)
168+ require.Equal(t, test.want, views[0].RemoteAvailability)
169+ })
170+ }
171+}
172+
173+func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
174+ d := newRemotePickerDaemon(nil)
175+ fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
176+ failedAt := fetchedAt.Add(6 * time.Hour)
177+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
178+ Host: "arch", FetchedAt: fetchedAt, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
179+ }})
180+ d.remoteCatalog.mu.Lock()
181+ d.remoteCatalog.status["arch"] = remoteHostUnreachable
182+ d.remoteCatalog.failure["arch"] = failedAt
183+ d.remoteCatalog.mu.Unlock()
184+
185+ views, _ := d.pickerViews(nil)
186+
187+ require.Len(t, views, 1)
188+ require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail)
189+ require.NotContains(t, views[0].RemoteDetail, failedAt.Format(time.RFC3339), "failure time remains runtime-only and is not the freshness authority")
190+}
191+
192+func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
193+ for _, test := range []struct {
194+ name string
195+ status remoteHostStatus
196+ want picker.RemoteAvailability
197+ wantDetail string
198+ }{
199+ {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale, wantDetail: "unreachable"},
200+ {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch, wantDetail: "version mismatch"},
201+ } {
202+ t.Run(test.name, func(t *testing.T) {
203+ d := newRemotePickerDaemon(nil)
204+ d.remoteCatalog.mu.Lock()
205+ d.remoteCatalog.status["arch"] = test.status
206+ d.remoteCatalog.failure["arch"] = time.Unix(50, 0)
207+ d.remoteCatalog.mu.Unlock()
208+
209+ views, _ := d.pickerViews(nil)
210+
211+ require.Len(t, views, 1)
212+ require.Equal(t, "arch", views[0].Name)
213+ require.Equal(t, test.want, views[0].RemoteAvailability)
214+ require.Equal(t, test.wantDetail, views[0].RemoteDetail)
215+ model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
216+ _, selectable := model.Selected()
217+ require.False(t, selectable)
218+ })
219+ }
220+}
221+
222+func TestRemotePickerGroupedSortTreatsProxiesAsLiveSessions(t *testing.T) {
223+ d := newRemotePickerDaemon(nil)
224+ d.pickerSort.Store(uint32(pickerSortGrouped))
225+ ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
226+ ephemeral.mruAt.Store(9)
227+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
228+ proxy := &proxySession{sessionCore: sessionCore{id: key.ID(), name: key.Display()}, key: key}
229+ proxy.mruAt.Store(1)
230+ named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
231+ named.mruAt.Store(2)
232+ d.sessions[ephemeral.id] = ephemeral
233+ d.sessions[proxy.id] = proxy
234+ d.sessions[named.id] = named
235+
236+ views, _ := d.pickerViews(nil)
237+
238+ require.Equal(t, []string{"named", "work@arch", "1"}, pickerViewNames(views))
239+}
240+
241+func pickerViewNames(views []picker.SessionView) []string {
242+ names := make([]string, len(views))
243+ for i, view := range views {
244+ names[i] = view.Name
245+ }
246+ return names
247+}
248+
249+type remoteRefreshHostStore struct {
250+ mu sync.Mutex
251+ hosts []string
252+}
253+
254+func (s *remoteRefreshHostStore) Hosts() ([]string, []string, error) {
255+ s.mu.Lock()
256+ defer s.mu.Unlock()
257+ return append([]string(nil), s.hosts...), nil, nil
258+}
259+
260+func (s *remoteRefreshHostStore) set(hosts ...string) {
261+ s.mu.Lock()
262+ s.hosts = append([]string(nil), hosts...)
263+ s.mu.Unlock()
264+}
265+
266+func (*remoteRefreshHostStore) AddPinned(string) error { return nil }
267+func (*remoteRefreshHostStore) RemovePinned(string) error { return nil }
268+func (*remoteRefreshHostStore) Remember(string) error { return nil }
269+func (*remoteRefreshHostStore) Forget(string) error { return nil }
270+func (*remoteRefreshHostStore) Remove(string) (bool, error) {
271+ return false, nil
272+}
273+
274+type remoteRefreshRequest struct {
275+ ctx context.Context
276+ host string
277+ result chan remoteRefreshResult
278+}
279+
280+type remoteRefreshResult struct {
281+ catalog ports.RemoteCatalog
282+ err error
283+}
284+
285+type channelRemoteCatalog struct {
286+ d *Daemon
287+ requests chan remoteRefreshRequest
288+ probeMu sync.Mutex
289+ probes bool
290+ violations []string
291+}
292+
293+func (c *channelRemoteCatalog) List(ctx context.Context, host string) (ports.RemoteCatalog, error) {
294+ c.probeMu.Lock()
295+ if c.probes && !c.d.mu.TryLock() {
296+ c.violations = append(c.violations, "daemon lock held during remote catalog I/O")
297+ } else if c.probes {
298+ c.d.mu.Unlock()
299+ }
300+ if c.probes && !c.d.remoteCatalog.mu.TryLock() {
301+ c.violations = append(c.violations, "remote catalog lock held during remote catalog I/O")
302+ } else if c.probes {
303+ c.d.remoteCatalog.mu.Unlock()
304+ }
305+ c.probeMu.Unlock()
306+ request := remoteRefreshRequest{ctx: ctx, host: host, result: make(chan remoteRefreshResult, 1)}
307+ c.requests <- request
308+ select {
309+ case result := <-request.result:
310+ return result.catalog, result.err
311+ case <-ctx.Done():
312+ return ports.RemoteCatalog{}, ctx.Err()
313+ }
314+}
315+
316+type recordingRemoteCache struct {
317+ d *Daemon
318+ stores chan []ports.RemoteCatalogCacheEntry
319+ probeMu sync.Mutex
320+ probes bool
321+ violations []string
322+}
323+
324+func (*recordingRemoteCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil }
325+
326+func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
327+ c.probeMu.Lock()
328+ if c.probes && !c.d.mu.TryLock() {
329+ c.violations = append(c.violations, "daemon lock held during remote cache write")
330+ } else if c.probes {
331+ c.d.mu.Unlock()
332+ }
333+ if c.probes && !c.d.remoteCatalog.mu.TryLock() {
334+ c.violations = append(c.violations, "remote catalog lock held during remote cache write")
335+ } else if c.probes {
336+ c.d.remoteCatalog.mu.Unlock()
337+ }
338+ c.probeMu.Unlock()
339+ copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries))
340+ for i, entry := range entries {
341+ copyEntries[i] = cloneRemoteCatalogEntry(entry)
342+ }
343+ c.stores <- copyEntries
344+ return nil
345+}
346+
347+type fixedRemoteRefreshClock struct{ now time.Time }
348+
349+func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
350+func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
351+
352+func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {
353+ t.Helper()
354+ if catalog != nil {
355+ catalog.probeMu.Lock()
356+ require.Empty(t, catalog.violations)
357+ catalog.probeMu.Unlock()
358+ }
359+ if cache != nil {
360+ cache.probeMu.Lock()
361+ require.Empty(t, cache.violations)
362+ cache.probeMu.Unlock()
363+ }
364+}
365+
366+func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {
367+ t.Helper()
368+ catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}
369+ cache := &recordingRemoteCache{stores: make(chan []ports.RemoteCatalogCacheEntry, 8)}
370+ d := New(nil, fixedRemoteRefreshClock{now: now}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(hosts, catalog, cache, nil, ports.RemoteTransportUDP))
371+ catalog.d, cache.d = d, d
372+ return d, catalog, cache
373+}
374+
375+func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T) {
376+ hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
377+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(100, 0))
378+ ac := &attachedClient{}
379+ ac.initOverlays()
380+ model := picker.New(nil, picker.SelectionConfig{})
381+ ac.overlays.pickerMu.Lock()
382+ ac.overlays.picker = model
383+ ac.overlays.pickerGeneration++
384+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
385+ ac.overlays.pickerMu.Unlock()
386+ require.True(t, d.registerRemotePicker(instance))
387+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
388+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "cached", State: "running"}},
389+ }})
390+
391+ firstGeneration := d.startRemotePickerRefresh(instance)
392+ first := map[string]remoteRefreshRequest{}
393+ for range 2 {
394+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
395+ first[request.host] = request
396+ }
397+ require.Contains(t, first, "arch")
398+ require.Contains(t, first, "mule")
399+ requireNoRemoteLockViolations(t, catalog, nil)
400+ d.remoteCatalog.mu.Lock()
401+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "cached", State: "running"}}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
402+ require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
403+ d.remoteCatalog.mu.Unlock()
404+
405+ secondGeneration := d.startRemotePickerRefresh(instance)
406+ require.Greater(t, secondGeneration, firstGeneration)
407+ for _, request := range first {
408+ receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
409+ }
410+ for range 2 {
411+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
412+ request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}
413+ }
414+ for range 2 {
415+ receiveRemotePicker(t, cache.stores, "remote cache store")
416+ }
417+ requireNoRemoteLockViolations(t, nil, cache)
418+
419+ d.remoteCatalog.mu.Lock()
420+ require.Equal(t, secondGeneration, d.remoteCatalog.refresh)
421+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
422+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["mule"])
423+ d.remoteCatalog.mu.Unlock()
424+}
425+
426+func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
427+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
428+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
429+ catalog.probes, cache.probes = true, true
430+ ac := &attachedClient{}
431+ ac.initOverlays()
432+ model := picker.New(nil, picker.SelectionConfig{})
433+ ac.overlays.pickerMu.Lock()
434+ ac.overlays.picker = model
435+ ac.overlays.pickerGeneration++
436+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
437+ ac.overlays.pickerMu.Unlock()
438+ require.True(t, d.registerRemotePicker(instance))
439+
440+ d.startRemotePickerRefresh(instance)
441+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
442+ request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}
443+ receiveRemotePicker(t, cache.stores, "remote cache store")
444+ requireNoRemoteLockViolations(t, catalog, cache)
445+}
446+
447+func TestRemoteRefreshWithoutCatalogClientDoesNotMarkHostsRefreshing(t *testing.T) {
448+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
449+ d := newRemotePickerDaemon(hosts)
450+ ac := &attachedClient{}
451+ ac.initOverlays()
452+ model := picker.New(nil, picker.SelectionConfig{})
453+ ac.overlays.pickerMu.Lock()
454+ ac.overlays.picker = model
455+ ac.overlays.pickerGeneration++
456+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
457+ ac.overlays.pickerMu.Unlock()
458+ require.True(t, d.registerRemotePicker(instance))
459+
460+ require.NotZero(t, d.startRemotePickerRefresh(instance))
461+ d.remoteCatalog.mu.Lock()
462+ require.NotEqual(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
463+ d.remoteCatalog.mu.Unlock()
464+}
465+
466+func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *testing.T) {
467+ now := time.Unix(200, 0)
468+ hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
469+ d, _, cache := newRemoteRefreshDaemon(t, hosts, now)
470+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
471+ {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}}},
472+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},
473+ })
474+ d.remoteCatalog.mu.Lock()
475+ d.remoteCatalog.refresh = 7
476+ d.remoteCatalog.mu.Unlock()
477+
478+ require.False(t, d.applyRemoteRefreshResult(7, "mule", ports.RemoteCatalog{}, errors.New("offline")))
479+ d.remoteCatalog.mu.Lock()
480+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}, d.remoteCatalog.cache["mule"].Sessions)
481+ require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)
482+ require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
483+ d.remoteCatalog.mu.Unlock()
484+
485+ require.True(t, d.applyRemoteRefreshResult(7, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}, nil))
486+ stored := receiveRemotePicker(t, cache.stores, "remote cache store")
487+ require.Equal(t, []ports.RemoteCatalogCacheEntry{
488+ {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},
489+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},
490+ }, stored, "every successful write persists the newest full cache atomically")
491+ requireNoRemoteLockViolations(t, nil, cache)
492+ d.remoteCatalog.mu.Lock()
493+ require.Empty(t, d.remoteCatalog.cache["arch"].Sessions, "a successful omission authoritatively evicts old rows")
494+ require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
495+ d.remoteCatalog.mu.Unlock()
496+}
497+
498+func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
499+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
500+ d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))
501+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
502+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}},
503+ }})
504+ d.remoteCatalog.mu.Lock()
505+ d.remoteCatalog.refresh = 9
506+ d.remoteCatalog.mu.Unlock()
507+
508+ require.False(t, d.applyRemoteRefreshResult(8, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "late", State: "running"}}}, nil))
509+ hosts.set()
510+ require.True(t, d.applyRemoteRefreshResult(9, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "removed", State: "running"}}}, nil))
511+ require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))
512+ requireNoRemoteLockViolations(t, nil, cache)
513+ d.remoteCatalog.mu.Lock()
514+ require.NotContains(t, d.remoteCatalog.cache, "arch")
515+ require.NotContains(t, d.remoteCatalog.status, "arch")
516+ d.remoteCatalog.mu.Unlock()
517+}
518+
519+func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
520+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
521+ d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
522+ old := ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}
523+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{old})
524+ d.remoteCatalog.mu.Lock()
525+ d.remoteCatalog.refresh = 3
526+ d.remoteCatalog.mu.Unlock()
527+
528+ require.False(t, d.applyRemoteRefreshResult(3, "arch", ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{Got: 19, Want: ports.ProtocolVersion}))
529+ d.remoteCatalog.mu.Lock()
530+ require.Equal(t, old, d.remoteCatalog.cache["arch"])
531+ require.Equal(t, remoteHostVersionMismatch, d.remoteCatalog.status["arch"])
532+ d.remoteCatalog.mu.Unlock()
533+}
534+
535+func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
536+ t.Helper()
537+ tr, sends := newCapturingTransport(t)
538+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
539+ ac.initOverlays()
540+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
541+ tb.stableID = string(id) + "-tab"
542+ sess := &session{sessionCore: sessionCore{id: id, name: string(id), client: ac}, tabs: []*tab{tb}, active: 0}
543+ publishTiledPaneOwners(sess, tb)
544+ ac.setSession(sess)
545+ d.sessions[id] = sess
546+ return sess, ac, sends
547+}
548+
549+func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
550+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
551+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(450, 0))
552+ firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
553+ _, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
554+ before := make(map[*attachedClient]*picker.Model)
555+
556+ for _, owner := range []*attachedClient{first, second} {
557+ owner.overlays.pickerMu.Lock()
558+ owner.overlays.picker = d.newPickerModel(firstSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: "second", TabID: "second-tab"})
559+ owner.overlays.pickerIntent = pickerNavigate
560+ owner.overlays.pickerGeneration++
561+ instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker}
562+ before[owner] = owner.overlays.picker
563+ owner.overlays.pickerMu.Unlock()
564+ require.True(t, d.registerRemotePicker(instance))
565+ }
566+ d.remoteCatalog.mu.Lock()
567+ d.remoteCatalog.refresh = 4
568+ d.remoteCatalog.mu.Unlock()
569+
570+ require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{
571+ ProtocolVersion: ports.ProtocolVersion,
572+ Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 2}},
573+ }, nil))
574+ receiveRemotePicker(t, cache.stores, "remote cache store")
575+ requireNoRemoteLockViolations(t, catalog, cache)
576+
577+ for _, owner := range []*attachedClient{first, second} {
578+ owner.overlays.pickerMu.Lock()
579+ updated := owner.overlays.picker
580+ selected, ok := updated.Selected()
581+ owner.overlays.pickerMu.Unlock()
582+ require.NotSame(t, before[owner], updated, "every open picker must receive the refreshed model")
583+ require.True(t, ok)
584+ require.Equal(t, domain.SessionID("second"), selected.Session)
585+ }
586+}
587+
588+func TestRemotePickerTeardownLifecycle(t *testing.T) {
589+ for _, test := range []struct {
590+ name string
591+ resumeCapable bool
592+ teardown func(*Daemon, *session, *attachedClient)
593+ }{
594+ {name: "client gone parks resumable attachment", resumeCapable: true, teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), false) }},
595+ {name: "send error parks resumable attachment", resumeCapable: true, teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
596+ {name: "client gone removes non-resumable attachment", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), false) }},
597+ {name: "send error removes non-resumable attachment", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
598+ } {
599+ t.Run(test.name, func(t *testing.T) {
600+ d := newRemotePickerDaemon(nil)
601+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
602+ ac.resumeCapable = test.resumeCapable
603+ model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
604+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
605+ ac.overlays.pickerMu.Lock()
606+ generation := ac.overlays.pickerGeneration
607+ ac.overlays.pickerMu.Unlock()
608+
609+ test.teardown(d, sess, ac)
610+
611+ d.remoteCatalog.mu.Lock()
612+ _, registered := d.remoteCatalog.pickers[ac]
613+ d.remoteCatalog.mu.Unlock()
614+ ac.overlays.pickerMu.Lock()
615+ current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration
616+ ac.overlays.pickerMu.Unlock()
617+ if !test.resumeCapable {
618+ require.False(t, registered)
619+ require.Nil(t, current)
620+ return
621+ }
622+ require.True(t, ac.parked)
623+ require.True(t, registered)
624+ require.Same(t, model, current)
625+ require.Equal(t, generation, currentGeneration)
626+ d.mu.Lock()
627+ parked := d.parked[ac.resumeToken]
628+ d.mu.Unlock()
629+ require.NotNil(t, parked)
630+ require.Same(t, ac, parked.ac)
631+ })
632+ }
633+}
634+
635+func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.T) {
636+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
637+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
638+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(475, 0))
639+ d.clock = clock
640+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
641+ ac.resumeCapable = true
642+ d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
643+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
644+
645+ require.True(t, d.parkAttachment(sess, ac))
646+ timer := receiveRemotePicker(t, clock.timers, "park expiry timer")
647+ timer.ch <- clock.Now()
648+ receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
649+
650+ ac.overlays.pickerMu.Lock()
651+ require.Nil(t, ac.overlays.picker)
652+ ac.overlays.pickerMu.Unlock()
653+ d.remoteCatalog.mu.Lock()
654+ require.NotContains(t, d.remoteCatalog.pickers, ac)
655+ d.remoteCatalog.mu.Unlock()
656+}
657+
658+func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) {
659+ d := newRemotePickerDaemon(nil)
660+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
661+ ac.resumeCapable = true
662+ d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
663+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
664+ ac.overlays.pickerMu.Lock()
665+ refreshed := ac.overlays.picker
666+ ac.overlays.pickerMu.Unlock()
667+ require.NotNil(t, refreshed)
668+ require.True(t, d.parkAttachment(sess, ac))
669+ token := ac.resumeToken
670+ d.mu.Lock()
671+ parked := d.parked[token]
672+ d.mu.Unlock()
673+ require.NotNil(t, parked)
674+
675+ d.mu.Lock()
676+ retirement := d.retireParkedAttachmentLocked(token, parked)
677+ d.mu.Unlock()
678+ d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{retirement})
679+ ac.overlays.pickerMu.Lock()
680+ require.Nil(t, ac.overlays.picker, "terminal retirement closes a refreshed model in its captured generation")
681+ ac.overlays.pickerMu.Unlock()
682+}
683+
684+func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) {
685+ d := newRemotePickerDaemon(nil)
686+ firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
687+ secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
688+ first.resumeCapable, second.resumeCapable = true, true
689+ d.publishPicker(firstSession, first, d.newPickerModel(firstSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
690+ require.True(t, d.parkAttachment(firstSession, first))
691+ token := first.resumeToken
692+ d.publishPicker(secondSession, second, d.newPickerModel(secondSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
693+ second.resumeToken = token
694+ require.True(t, d.parkAttachment(secondSession, second))
695+
696+ first.overlays.pickerMu.Lock()
697+ require.Nil(t, first.overlays.picker)
698+ first.overlays.pickerMu.Unlock()
699+ second.overlays.pickerMu.Lock()
700+ require.NotNil(t, second.overlays.picker)
701+ second.overlays.pickerMu.Unlock()
702+}
703+
704+func TestParkedPickerResumePreservesGeneration(t *testing.T) {
705+ d := newRemotePickerDaemon(nil)
706+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
707+ ac.resumeCapable = true
708+ ac.clientID = [16]byte{1, 2, 3, 4}
709+ d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
710+ ac.overlays.pickerMu.Lock()
711+ model, generation := ac.overlays.picker, ac.overlays.pickerGeneration
712+ ac.overlays.pickerMu.Unlock()
713+ d.clientGone(sess, ac, ac.transport(), false)
714+ token := ac.resumeToken
715+ tr := &closeTrackingTransport{}
716+ resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, sess.name, token), tr, domain.Size{Cols: 80, Rows: 24})
717+ require.NoError(t, err)
718+ require.True(t, ok)
719+ require.Same(t, sess, resumedSess)
720+ require.Same(t, ac, resumedAC)
721+ ac.overlays.pickerMu.Lock()
722+ require.Same(t, model, ac.overlays.picker)
723+ require.Equal(t, generation, ac.overlays.pickerGeneration)
724+ ac.overlays.pickerMu.Unlock()
725+}
726+
727+func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
728+ d := newRemotePickerDaemon(nil)
729+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
730+ ac.resumeCapable = true
731+ d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
732+ require.True(t, d.parkAttachment(sess, ac))
733+ token := ac.resumeToken
734+ d.mu.Lock()
735+ parked := d.parked[token]
736+ d.mu.Unlock()
737+ require.NotNil(t, parked)
738+
739+ newer := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
740+ d.publishPicker(sess, ac, newer, pickerNavigate, moveSourceLocator{})
741+ d.expireParked(token, parked)
742+ ac.overlays.pickerMu.Lock()
743+ require.Same(t, newer, ac.overlays.picker)
744+ ac.overlays.pickerMu.Unlock()
745+}
746+
747+func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.T) {
748+ d := newRemotePickerDaemon(nil)
749+ ownerSession, owner, _ := addRemoteRefreshPickerOwner(t, d, "local")
750+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
751+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
752+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
753+ }})
754+ owner.overlays.pickerMu.Lock()
755+ owner.overlays.picker = d.newPickerModel(ownerSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
756+ owner.overlays.pickerIntent = pickerNavigate
757+ before, ok := owner.overlays.picker.Cursor()
758+ _, beforeSelectable := owner.overlays.picker.Selected()
759+ owner.overlays.pickerMu.Unlock()
760+ require.True(t, ok)
761+ require.Equal(t, &key, before.RemoteKey)
762+ require.False(t, beforeSelectable, "cache-derived remote row must stay gated until proxy activation lands")
763+
764+ proxy := &proxySession{
765+ sessionCore: sessionCore{id: key.ID(), name: key.Display()},
766+ key: key,
767+ meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "live"}}},
768+ }
769+ d.mu.Lock()
770+ d.sessions[key.ID()] = proxy
771+ d.mu.Unlock()
772+
773+ d.refreshPickerOpts(owner, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
774+
775+ owner.overlays.pickerMu.Lock()
776+ after, ok := owner.overlays.picker.Cursor()
777+ _, selectable := owner.overlays.picker.Selected()
778+ owner.overlays.pickerMu.Unlock()
779+ require.True(t, ok)
780+ require.Equal(t, before.Session, after.Session)
781+ require.Equal(t, before.RemoteKey, after.RemoteKey)
782+ require.False(t, selectable, "cache-to-live upgrade must preserve the proxy activation gate")
783+}
784+
785+func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
786+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
787+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(500, 0))
788+ newInstance := func() remotePickerInstance {
789+ ac := &attachedClient{}
790+ ac.initOverlays()
791+ model := picker.New(nil, picker.SelectionConfig{})
792+ ac.overlays.pickerMu.Lock()
793+ ac.overlays.picker = model
794+ ac.overlays.pickerGeneration++
795+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
796+ ac.overlays.pickerMu.Unlock()
797+ return instance
798+ }
799+ closeInstance := func(instance remotePickerInstance) {
800+ instance.ac.overlays.pickerMu.Lock()
801+ instance.ac.overlays.picker = nil
802+ instance.ac.overlays.pickerMu.Unlock()
803+ d.remotePickerClosed(instance)
804+ }
805+ first, second := newInstance(), newInstance()
806+ d.remotePickerOpened(first)
807+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
808+ d.remotePickerOpened(second)
809+ receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
810+ request = receiveRemotePicker(t, catalog.requests, "catalog request")
811+
812+ closeInstance(first)
813+ select {
814+ case <-request.ctx.Done():
815+ t.Fatal("refresh canceled while another picker remained open")
816+ default:
817+ }
818+ closeInstance(second)
819+ receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
820+
821+ d.remoteCatalog.mu.Lock()
822+ require.Empty(t, d.remoteCatalog.pickers)
823+ require.Nil(t, d.remoteCatalog.cancel)
824+ d.remoteCatalog.mu.Unlock()
825+}
826+
827+func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
828+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
829+ d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(525, 0))
830+ ac := &attachedClient{}
831+ ac.initOverlays()
832+ model := picker.New(nil, picker.SelectionConfig{})
833+ ac.overlays.pickerMu.Lock()
834+ ac.overlays.picker = model
835+ ac.overlays.pickerGeneration++
836+ instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
837+ ac.overlays.pickerMu.Unlock()
838+
839+ // Stop at the production seam after exact picker ownership registration but
840+ // before refresh startup, then let the last-picker close linearize first.
841+ require.True(t, d.registerRemotePicker(instance))
842+ ac.overlays.pickerMu.Lock()
843+ ac.overlays.picker = nil
844+ ac.overlays.pickerMu.Unlock()
845+ d.remotePickerClosed(instance)
846+
847+ d.remoteCatalog.mu.Lock()
848+ refreshBefore := d.remoteCatalog.refresh
849+ d.remoteCatalog.mu.Unlock()
850+ require.Zero(t, d.startRemotePickerRefresh(instance))
851+
852+ d.remoteCatalog.mu.Lock()
853+ require.Empty(t, d.remoteCatalog.pickers, "the closing picker must remain unregistered")
854+ require.Equal(t, refreshBefore, d.remoteCatalog.refresh, "a close that wins before startup must prevent a later refresh generation")
855+ require.Nil(t, d.remoteCatalog.cancel, "a close that wins before startup must prevent a later cancel installation")
856+ d.remoteCatalog.mu.Unlock()
857+ select {
858+ case request := <-catalog.requests:
859+ t.Fatalf("catalog call remained running after the last picker closed: %s", request.host)
860+ default:
861+ }
862+}
863+
864+func TestRemotePickerPublishCannotRegisterAfterSnatchClear(t *testing.T) {
865+ d := newRemotePickerDaemon(nil)
866+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
867+ ac.roleGeneration.Store(1)
868+ model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
869+ registrationReached := make(chan struct{})
870+ allowRegistration := make(chan struct{})
871+ registrationWait := make(chan error, 1)
872+ ac.overlays.beforeRemotePickerRegistration = func() {
873+ close(registrationReached)
874+ registrationWait <- waitRemotePickerClose(allowRegistration, "picker registration release")
875+ }
876+ published := make(chan struct{})
877+ go func() {
878+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
879+ close(published)
880+ }()
881+ receiveRemotePickerClose(t, registrationReached, "picker registration")
882+
883+ token := attachmentRoleToken{
884+ sess: sess, ac: ac, role: attachmentSnatched,
885+ generation: 1, transport: ac.transportSnapshot(),
886+ }
887+ require.True(t, d.clearForSnatch(token))
888+ close(allowRegistration)
889+ require.NoError(t, receiveRemotePicker(t, registrationWait, "picker registration release"))
890+ receiveRemotePickerClose(t, published, "picker publication")
891+
892+ d.remoteCatalog.mu.Lock()
893+ _, registered := d.remoteCatalog.pickers[ac]
894+ d.remoteCatalog.mu.Unlock()
895+ require.False(t, registered, "a picker cleared before delayed registration must not become a refresh owner")
896+}
897+
898+func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
899+ d := newRemotePickerDaemon(nil)
900+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
901+ first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
902+ d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
903+
904+ staleClose := d.pickerListInputState(ac)
905+ ac.overlays.pickerMu.Lock()
906+ staleClose.closeLocked()
907+ ac.overlays.pickerMu.Unlock()
908+
909+ reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
910+ d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
911+ staleClose.afterClose()
912+
913+ d.remoteCatalog.mu.Lock()
914+ _, registered := d.remoteCatalog.pickers[ac]
915+ d.remoteCatalog.mu.Unlock()
916+ require.True(t, registered, "stale close cleanup must retain the reopened picker registration")
917+ ac.overlays.pickerMu.Lock()
918+ require.Same(t, reopened, ac.overlays.picker)
919+ ac.overlays.pickerMu.Unlock()
920+}
921+
922+func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
923+ d := newRemotePickerDaemon(nil)
924+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
925+ first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
926+ d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
927+
928+ rebuildReached := make(chan struct{})
929+ allowPublication := make(chan struct{})
930+ publicationWait := make(chan error, 1)
931+ ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
932+ close(rebuildReached)
933+ publicationWait <- waitRemotePickerClose(allowPublication, "picker publication release")
934+ }
935+ refreshed := make(chan struct{})
936+ go func() {
937+ d.refreshRemoteOpenPickers()
938+ close(refreshed)
939+ }()
940+ receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
941+
942+ d.closePicker(ac)
943+ reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
944+ d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
945+ close(allowPublication)
946+ require.NoError(t, receiveRemotePicker(t, publicationWait, "picker publication release"))
947+ receiveRemotePickerClose(t, refreshed, "picker refresh")
948+
949+ ac.overlays.pickerMu.Lock()
950+ require.Same(t, reopened, ac.overlays.picker, "a rebuild for the old picker must not publish into its replacement")
951+ ac.overlays.pickerMu.Unlock()
952+}
953+
954+func TestRemoteRowsRemainNonSelectableDuringRefreshPhase(t *testing.T) {
955+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
956+ for _, availability := range []picker.RemoteAvailability{picker.RemoteCached, picker.RemoteFresh, picker.RemoteStale, picker.RemoteVersionMismatch} {
957+ model := picker.New([]picker.SessionView{{
958+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: availability,
959+ ConnectOnly: true, RemoteAttachReady: false,
960+ }}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
961+ _, selectable := model.Selected()
962+ require.False(t, selectable, "availability %d became remotely attachable in phase 5", availability)
963+ }
964+}
M internal/usecase/daemon/resume.go +19 -9
@@ -216,6 +216,12 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
216216 // cannot leave closed panes retained through its capture cache. This must
217217 // remain outside d.mu: sendMu is ordered before the daemon lock.
218218 ac.clearCaptureFrames()
219+ var pickerGeneration uint64
220+ if ac.overlays != nil {
221+ ac.overlays.pickerMu.Lock()
222+ pickerGeneration = ac.overlays.pickerGeneration
223+ ac.overlays.pickerMu.Unlock()
224+ }
219225 d.mu.Lock()
220226 if d.closing || d.sessions[sess.id] != sess {
221227 d.clearParkingInFlightLocked(ac.resumeToken, ac)
@@ -244,13 +250,13 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
244250 }
245251 grace := d.resumeParkGrace
246252 timer := d.clock.NewTimer(grace)
247- parked := &parkedAttachment{sess: sess, ac: ac, role: role, timer: timer, done: make(chan struct{})}
253+ parked := &parkedAttachment{sess: sess, ac: ac, role: role, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
248254 ac.parked = true
249255 d.parked[token] = parked
250256 d.clearParkingInFlightLocked(token, ac)
251257 d.mu.Unlock()
252258 if oldRetirement != nil {
253- finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
259+ d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
254260 }
255261 if oldSame != nil {
256262 if oldSame.timer != nil {
@@ -275,6 +281,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
275281 if d.parked[token] == parked {
276282 d.removeParkedLocked(token, parked)
277283 d.mu.Unlock()
284+ d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
278285 d.log.Warn("parked client expired", "session", parked.sess.name)
279286 return
280287 }
@@ -312,8 +319,9 @@ func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) {
312319 }
313320
314321 type parkedAttachmentRetirement struct {
315- parked *parkedAttachment
316- transport transportSnapshot
322+ parked *parkedAttachment
323+ pickerGeneration uint64
324+ transport transportSnapshot
317325 }
318326
319327 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
@@ -324,8 +332,9 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
324332 parked.ac.roleGeneration.Add(1)
325333 parked.ac.setSession(nil)
326334 return parkedAttachmentRetirement{
327- parked: parked,
328- transport: parked.ac.transportSnapshot(),
335+ parked: parked,
336+ pickerGeneration: parked.pickerGeneration,
337+ transport: parked.ac.transportSnapshot(),
329338 }
330339 }
331340
@@ -350,15 +359,16 @@ func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
350359 return retirements
351360 }
352361
353-// finishParkedAttachmentRetirements stops timers and closes transports without
354-// holding daemon, session, coordinator, or pane locks.
355-func finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {
362+// finishParkedAttachmentRetirements stops timers and retires picker ownership
363+// and transports without holding daemon, session, coordinator, or pane locks.
364+func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {
356365 for _, retirement := range retirements {
357366 if retirement.parked.timer != nil {
358367 retirement.parked.timer.Stop()
359368 }
360369 retirement.parked.closeDone()
361370 ac := retirement.parked.ac
371+ d.closePickerIfCurrent(ac, nil, retirement.pickerGeneration)
362372 _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
363373 }
364374 }
@@ -216,6 +216,12 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
216 // cannot leave closed panes retained through its capture cache. This must216 // cannot leave closed panes retained through its capture cache. This must
217 // remain outside d.mu: sendMu is ordered before the daemon lock.217 // remain outside d.mu: sendMu is ordered before the daemon lock.
218 ac.clearCaptureFrames()218 ac.clearCaptureFrames()
219+ var pickerGeneration uint64
220+ if ac.overlays != nil {
221+ ac.overlays.pickerMu.Lock()
222+ pickerGeneration = ac.overlays.pickerGeneration
223+ ac.overlays.pickerMu.Unlock()
224+ }
219 d.mu.Lock()225 d.mu.Lock()
220 if d.closing || d.sessions[sess.id] != sess {226 if d.closing || d.sessions[sess.id] != sess {
221 d.clearParkingInFlightLocked(ac.resumeToken, ac)227 d.clearParkingInFlightLocked(ac.resumeToken, ac)
@@ -244,13 +250,13 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
244 }250 }
245 grace := d.resumeParkGrace251 grace := d.resumeParkGrace
246 timer := d.clock.NewTimer(grace)252 timer := d.clock.NewTimer(grace)
247- parked := &parkedAttachment{sess: sess, ac: ac, role: role, timer: timer, done: make(chan struct{})}253+ parked := &parkedAttachment{sess: sess, ac: ac, role: role, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
248 ac.parked = true254 ac.parked = true
249 d.parked[token] = parked255 d.parked[token] = parked
250 d.clearParkingInFlightLocked(token, ac)256 d.clearParkingInFlightLocked(token, ac)
251 d.mu.Unlock()257 d.mu.Unlock()
252 if oldRetirement != nil {258 if oldRetirement != nil {
253- finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})259+ d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
254 }260 }
255 if oldSame != nil {261 if oldSame != nil {
256 if oldSame.timer != nil {262 if oldSame.timer != nil {
@@ -275,6 +281,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
275 if d.parked[token] == parked {281 if d.parked[token] == parked {
276 d.removeParkedLocked(token, parked)282 d.removeParkedLocked(token, parked)
277 d.mu.Unlock()283 d.mu.Unlock()
284+ d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
278 d.log.Warn("parked client expired", "session", parked.sess.name)285 d.log.Warn("parked client expired", "session", parked.sess.name)
279 return286 return
280 }287 }
@@ -312,8 +319,9 @@ func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) {
312 }319 }
313 320
314 type parkedAttachmentRetirement struct {321 type parkedAttachmentRetirement struct {
315- parked *parkedAttachment322+ parked *parkedAttachment
316- transport transportSnapshot323+ pickerGeneration uint64
324+ transport transportSnapshot
317 }325 }
318 326
319 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {327 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
@@ -324,8 +332,9 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
324 parked.ac.roleGeneration.Add(1)332 parked.ac.roleGeneration.Add(1)
325 parked.ac.setSession(nil)333 parked.ac.setSession(nil)
326 return parkedAttachmentRetirement{334 return parkedAttachmentRetirement{
327- parked: parked,335+ parked: parked,
328- transport: parked.ac.transportSnapshot(),336+ pickerGeneration: parked.pickerGeneration,
337+ transport: parked.ac.transportSnapshot(),
329 }338 }
330 }339 }
331 340
@@ -350,15 +359,16 @@ func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
350 return retirements359 return retirements
351 }360 }
352 361
353-// finishParkedAttachmentRetirements stops timers and closes transports without362+// finishParkedAttachmentRetirements stops timers and retires picker ownership
354-// holding daemon, session, coordinator, or pane locks.363+// and transports without holding daemon, session, coordinator, or pane locks.
355-func finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {364+func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {
356 for _, retirement := range retirements {365 for _, retirement := range retirements {
357 if retirement.parked.timer != nil {366 if retirement.parked.timer != nil {
358 retirement.parked.timer.Stop()367 retirement.parked.timer.Stop()
359 }368 }
360 retirement.parked.closeDone()369 retirement.parked.closeDone()
361 ac := retirement.parked.ac370 ac := retirement.parked.ac
371+ d.closePickerIfCurrent(ac, nil, retirement.pickerGeneration)
362 _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))372 _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
363 }373 }
364 }374 }
M internal/usecase/daemon/resume_test.go +1 -1
@@ -1702,7 +1702,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
17021702 require.NotNil(t, parked)
17031703 retirements := tc.discard(d, ac.resumeToken, parked)
17041704 d.mu.Unlock()
1705- finishParkedAttachmentRetirements(retirements)
1705+ d.finishParkedAttachmentRetirements(retirements)
17061706
17071707 require.Nil(t, ac.previousSession.Get())
17081708 })
@@ -1702,7 +1702,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1702 require.NotNil(t, parked)1702 require.NotNil(t, parked)
1703 retirements := tc.discard(d, ac.resumeToken, parked)1703 retirements := tc.discard(d, ac.resumeToken, parked)
1704 d.mu.Unlock()1704 d.mu.Unlock()
1705- finishParkedAttachmentRetirements(retirements)1705+ d.finishParkedAttachmentRetirements(retirements)
1706 1706
1707 require.Nil(t, ac.previousSession.Get())1707 require.Nil(t, ac.previousSession.Get())
1708 })1708 })
M internal/usecase/daemon/session.go +4 -1
@@ -778,6 +778,9 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
778778 }
779779 p.sessionCore.mu.Unlock()
780780 d.notices.routingMu.Unlock()
781+ if current {
782+ d.armProxyWarm(p)
783+ }
781784 return current
782785 }
783786
@@ -1605,7 +1608,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16051608 d.closing = true
16061609 }
16071610 d.mu.Unlock()
1608- finishParkedAttachmentRetirements(parkedRetirements)
1611+ d.finishParkedAttachmentRetirements(parkedRetirements)
16091612 frozen.unfreeze()
16101613 frozen = frozenRoleEffectGates{}
16111614 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
@@ -778,6 +778,9 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
778 }778 }
779 p.sessionCore.mu.Unlock()779 p.sessionCore.mu.Unlock()
780 d.notices.routingMu.Unlock()780 d.notices.routingMu.Unlock()
781+ if current {
782+ d.armProxyWarm(p)
783+ }
781 return current784 return current
782 }785 }
783 786
@@ -1605,7 +1608,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1605 d.closing = true1608 d.closing = true
1606 }1609 }
1607 d.mu.Unlock()1610 d.mu.Unlock()
1608- finishParkedAttachmentRetirements(parkedRetirements)1611+ d.finishParkedAttachmentRetirements(parkedRetirements)
1609 frozen.unfreeze()1612 frozen.unfreeze()
1610 frozen = frozenRoleEffectGates{}1613 frozen = frozenRoleEffectGates{}
1611 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)1614 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
M internal/usecase/daemon/status_test.go +6 -3
@@ -581,7 +581,8 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
581581 assertSessionColorScheme(t, sess, true)
582582
583583 tr, _ := newCapturingTransport(t)
584- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
584+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
585+ require.NoError(t, err)
585586
586587 assertSessionColorSchemeUnknown(t, sess)
587588 }
@@ -682,7 +683,8 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
682683 })
683684 tr, _ := newCapturingTransport(t)
684685
685- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
686+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
687+ require.NoError(t, err)
686688
687689 var got []byte
688690 tb := sess.activeTab()
@@ -791,7 +793,8 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
791793 d, sess, old, _ := newManualSessionWithPTYs(t, p)
792794 defer release()
793795 tr, _ := newCapturingTransport(t)
794- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
796+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
797+ require.NoError(t, err)
795798
796799 d.applyTheme(sess, old, ports.Theme{
797800 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
@@ -581,7 +581,8 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
581 assertSessionColorScheme(t, sess, true)581 assertSessionColorScheme(t, sess, true)
582 582
583 tr, _ := newCapturingTransport(t)583 tr, _ := newCapturingTransport(t)
584- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})584+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
585+ require.NoError(t, err)
585 586
586 assertSessionColorSchemeUnknown(t, sess)587 assertSessionColorSchemeUnknown(t, sess)
587 }588 }
@@ -682,7 +683,8 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
682 })683 })
683 tr, _ := newCapturingTransport(t)684 tr, _ := newCapturingTransport(t)
684 685
685- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})686+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
687+ require.NoError(t, err)
686 688
687 var got []byte689 var got []byte
688 tb := sess.activeTab()690 tb := sess.activeTab()
@@ -791,7 +793,8 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
791 d, sess, old, _ := newManualSessionWithPTYs(t, p)793 d, sess, old, _ := newManualSessionWithPTYs(t, p)
792 defer release()794 defer release()
793 tr, _ := newCapturingTransport(t)795 tr, _ := newCapturingTransport(t)
794- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})796+ _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
797+ require.NoError(t, err)
795 798
796 d.applyTheme(sess, old, ports.Theme{799 d.applyTheme(sess, old, ports.Theme{
797 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},800 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
M internal/usecase/picker/model.go +122 -22
@@ -15,6 +15,16 @@ const (
1515 MinStackHeight = 12
1616 )
1717
18+type RemoteAvailability uint8
19+
20+const (
21+ RemoteNone RemoteAvailability = iota
22+ RemoteCached
23+ RemoteFresh
24+ RemoteStale
25+ RemoteVersionMismatch
26+)
27+
1828 type SessionView struct {
1929 ID domain.SessionID
2030 Incarnation domain.IncarnationID
@@ -24,9 +34,16 @@ type SessionView struct {
2434 Active int
2535 Stopped bool
2636 ExpectedCreatedAt *int64
37+ RemoteKey *domain.RemoteSessionKey
38+ // RemoteHost marks remote host status rows that have no session key.
39+ RemoteHost string
40+ RemoteAvailability RemoteAvailability
41+ RemoteDetail string
42+ ConnectOnly bool
43+ RemoteAttachReady bool
2744 // CannotAcceptMoves reports whether this session cannot receive a moved tab
2845 // or pane. False for ordinary local (and stopped) sessions; true for
29- // restricted proxy sessions in phase 4. Populated, not yet consumed.
46+ // restricted proxy sessions.
3047 CannotAcceptMoves bool
3148 }
3249
@@ -50,6 +67,7 @@ type SourceFilter struct {
5067 Session domain.SessionID
5168 Incarnation domain.IncarnationID
5269 TabID domain.TabStableID
70+ RemoteKey *domain.RemoteSessionKey
5371 }
5472
5573 // SelectionConfig describes which rows are selectable, which stable target
@@ -95,6 +113,7 @@ type Target struct {
95113 Session domain.SessionID
96114 Incarnation domain.IncarnationID
97115 Name string
116+ RemoteKey *domain.RemoteSessionKey
98117 TabID domain.TabStableID
99118 TabIndex int
100119 Stopped bool
@@ -165,6 +184,11 @@ type row struct {
165184 stopped bool
166185 expectedCreatedAt int64
167186 hasExpectedCreatedAt bool
187+ remoteKey domain.RemoteSessionKey
188+ hasRemoteKey bool
189+ remote bool
190+ selectable bool
191+ dim bool
168192 }
169193
170194 func New(sessions []SessionView, config SelectionConfig) *Model {
@@ -175,10 +199,10 @@ func New(sessions []SessionView, config SelectionConfig) *Model {
175199 for _, pickerRow := range sessionRows {
176200 idx := len(m.rows)
177201 m.rows = append(m.rows, pickerRow)
178- if pickerRow.kind.selectable(config.Mode) && selectionMatches(pickerRow, config.Current, config.Mode) {
202+ if selectionMatches(pickerRow, config.Current, config.Mode) {
179203 m.selected = idx
180204 }
181- if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
205+ if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.selectable && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
182206 activeSelection = idx
183207 }
184208 }
@@ -201,7 +225,10 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
201225 if config.Mode != SelectNavigationTab && session.Stopped {
202226 return nil
203227 }
204- if config.Mode == SelectMoveTabSession && (sourceMatchesSession(config.Source, session) || len(session.Tabs) == 0) {
228+ if config.Mode == SelectMoveTabSession && sourceMatchesSession(config.Source, session) {
229+ return nil
230+ }
231+ if config.Mode == SelectMoveTabSession && len(session.Tabs) == 0 && !session.CannotAcceptMoves {
205232 return nil
206233 }
207234
@@ -217,24 +244,65 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
217244 stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,
218245 hasExpectedCreatedAt: hasExpectedCreatedAt,
219246 }
247+ if session.RemoteKey != nil {
248+ common.remoteKey, common.hasRemoteKey = *session.RemoteKey, true
249+ }
250+ common.remote = common.hasRemoteKey || session.RemoteHost != ""
220251 header := common
221252 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
253+ header.selectable = header.kind.selectable(config.Mode)
254+ if common.remote {
255+ header.detail = remoteRowDetail(session)
256+ header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session)
257+ header.dim = remoteDim(session)
258+ }
259+ if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
260+ header.selectable, header.dim = false, true
261+ }
222262 rows := []row{header}
223- for i, tab := range session.Tabs {
224- if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
225- continue
263+ if !session.ConnectOnly {
264+ for i, tab := range session.Tabs {
265+ if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
266+ continue
267+ }
268+ tabRow := common
269+ tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
270+ tabRow.tabID, tabRow.tabIndex = tab.TabID, i
271+ tabRow.selectable = tabRow.kind.selectable(config.Mode)
272+ if common.remote {
273+ tabRow.selectable = false
274+ tabRow.dim = remoteDim(session)
275+ }
276+ if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
277+ tabRow.selectable, tabRow.dim = false, true
278+ }
279+ rows = append(rows, tabRow)
226280 }
227- tabRow := common
228- tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
229- tabRow.tabID, tabRow.tabIndex = tab.TabID, i
230- rows = append(rows, tabRow)
231281 }
232- if config.Mode == SelectMovePaneTab && len(rows) == 1 {
282+ if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {
233283 return nil
234284 }
235285 return rows
236286 }
237287
288+func remoteSelectable(session SessionView) bool {
289+ return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch
290+}
291+
292+func remoteDim(session SessionView) bool {
293+ return !session.RemoteAttachReady || session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch
294+}
295+
296+func remoteRowDetail(session SessionView) string {
297+ if session.RemoteDetail != "" && (session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch) {
298+ return session.RemoteDetail
299+ }
300+ if !session.RemoteAttachReady {
301+ return "proxy activation pending"
302+ }
303+ return session.RemoteDetail
304+}
305+
238306 func sourceMatchesSession(source SourceFilter, session SessionView) bool {
239307 if source.Session != session.ID {
240308 return false
@@ -257,6 +325,9 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b
257325 if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) {
258326 return false
259327 }
328+ if pickerRow.hasRemoteKey && pickerRow.kind == rowSession {
329+ return current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey
330+ }
260331 if mode == SelectMoveTabSession {
261332 return pickerRow.kind == rowSession
262333 }
@@ -317,18 +388,25 @@ func (m *Model) Down() {
317388 m.move(1)
318389 }
319390
391+// Cursor reports the identity under the picker cursor independently from
392+// whether that row may be activated. Remote refreshes use it to preserve a
393+// structured remote session key while Selected continues to enforce safety.
394+func (m *Model) Cursor() (Target, bool) {
395+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
396+ return Target{}, false
397+ }
398+ return m.rows[m.selected].target(), true
399+}
400+
320401 func (m *Model) Selected() (Target, bool) {
321402 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
322403 return Target{}, false
323404 }
324405 r := m.rows[m.selected]
325- if !r.kind.selectable(m.mode) {
406+ if !r.selectable {
326407 return Target{}, false
327408 }
328- return Target{
329- Session: r.session, Incarnation: r.incarnation, Name: r.targetName, TabID: r.tabID,
330- TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
331- }, true
409+ return r.target(), true
332410 }
333411
334412 // SelectedIndex reports the raw selected row index. It is -1 only when the
@@ -351,13 +429,13 @@ func (m *Model) SelectNearestRow(idx int) {
351429 }
352430 idx = clamp(idx, 0, len(m.rows)-1)
353431 for i := idx; i < len(m.rows); i++ {
354- if m.rows[i].kind.selectable(m.mode) {
432+ if m.rows[i].selectable {
355433 m.selected = i
356434 return
357435 }
358436 }
359437 for i := idx - 1; i >= 0; i-- {
360- if m.rows[i].kind.selectable(m.mode) {
438+ if m.rows[i].selectable {
361439 m.selected = i
362440 return
363441 }
@@ -396,12 +474,12 @@ func (m *Model) move(delta int) {
396474 if m == nil || len(m.rows) == 0 {
397475 return
398476 }
399- if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].kind.selectable(m.mode) {
477+ if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].selectable {
400478 m.selected = m.firstSelectable()
401479 return
402480 }
403481 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
404- if m.rows[i].kind.selectable(m.mode) {
482+ if m.rows[i].selectable {
405483 m.selected = i
406484 return
407485 }
@@ -410,13 +488,20 @@ func (m *Model) move(delta int) {
410488
411489 func (m *Model) firstSelectable() int {
412490 for i, r := range m.rows {
413- if r.kind.selectable(m.mode) {
491+ if r.selectable {
414492 return i
415493 }
416494 }
417495 return -1
418496 }
419497
498+func (r row) target() Target {
499+ return Target{
500+ Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), TabID: r.tabID,
501+ TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
502+ }
503+}
504+
420505 func (r row) expectedCreatedAtPointer() *int64 {
421506 if !r.hasExpectedCreatedAt {
422507 return nil
@@ -425,6 +510,14 @@ func (r row) expectedCreatedAtPointer() *int64 {
425510 return &value
426511 }
427512
513+func (r row) remoteKeyPointer() *domain.RemoteSessionKey {
514+ if !r.hasRemoteKey {
515+ return nil
516+ }
517+ key := r.remoteKey
518+ return &key
519+}
520+
428521 // renderList draws each visible row as up to three segments: a name segment
429522 // (bold when styles came from a truecolor theme), a base-styled attention
430523 // marker right after the name, and a muted detail segment (tab rows only) —
@@ -450,6 +543,11 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
450543 if r.stopped && idx != m.selected {
451544 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
452545 }
546+ if r.dim {
547+ base.Attrs |= renderer.AttrDim
548+ nameStyle.Attrs |= renderer.AttrDim
549+ detailStyle.Attrs |= renderer.AttrDim
550+ }
453551 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
454552
455553 name := r.dispName
@@ -462,6 +560,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
462560 if r.kind.rendersAsHeader() {
463561 if r.stopped {
464562 ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
563+ } else {
564+ ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)
465565 }
466566 continue
467567 }
@@ -15,6 +15,16 @@ const (
15 MinStackHeight = 1215 MinStackHeight = 12
16 )16 )
17 17
18+type RemoteAvailability uint8
19+
20+const (
21+ RemoteNone RemoteAvailability = iota
22+ RemoteCached
23+ RemoteFresh
24+ RemoteStale
25+ RemoteVersionMismatch
26+)
27+
18 type SessionView struct {28 type SessionView struct {
19 ID domain.SessionID29 ID domain.SessionID
20 Incarnation domain.IncarnationID30 Incarnation domain.IncarnationID
@@ -24,9 +34,16 @@ type SessionView struct {
24 Active int34 Active int
25 Stopped bool35 Stopped bool
26 ExpectedCreatedAt *int6436 ExpectedCreatedAt *int64
37+ RemoteKey *domain.RemoteSessionKey
38+ // RemoteHost marks remote host status rows that have no session key.
39+ RemoteHost string
40+ RemoteAvailability RemoteAvailability
41+ RemoteDetail string
42+ ConnectOnly bool
43+ RemoteAttachReady bool
27 // CannotAcceptMoves reports whether this session cannot receive a moved tab44 // CannotAcceptMoves reports whether this session cannot receive a moved tab
28 // or pane. False for ordinary local (and stopped) sessions; true for45 // or pane. False for ordinary local (and stopped) sessions; true for
29- // restricted proxy sessions in phase 4. Populated, not yet consumed.46+ // restricted proxy sessions.
30 CannotAcceptMoves bool47 CannotAcceptMoves bool
31 }48 }
32 49
@@ -50,6 +67,7 @@ type SourceFilter struct {
50 Session domain.SessionID67 Session domain.SessionID
51 Incarnation domain.IncarnationID68 Incarnation domain.IncarnationID
52 TabID domain.TabStableID69 TabID domain.TabStableID
70+ RemoteKey *domain.RemoteSessionKey
53 }71 }
54 72
55 // SelectionConfig describes which rows are selectable, which stable target73 // SelectionConfig describes which rows are selectable, which stable target
@@ -95,6 +113,7 @@ type Target struct {
95 Session domain.SessionID113 Session domain.SessionID
96 Incarnation domain.IncarnationID114 Incarnation domain.IncarnationID
97 Name string115 Name string
116+ RemoteKey *domain.RemoteSessionKey
98 TabID domain.TabStableID117 TabID domain.TabStableID
99 TabIndex int118 TabIndex int
100 Stopped bool119 Stopped bool
@@ -165,6 +184,11 @@ type row struct {
165 stopped bool184 stopped bool
166 expectedCreatedAt int64185 expectedCreatedAt int64
167 hasExpectedCreatedAt bool186 hasExpectedCreatedAt bool
187+ remoteKey domain.RemoteSessionKey
188+ hasRemoteKey bool
189+ remote bool
190+ selectable bool
191+ dim bool
168 }192 }
169 193
170 func New(sessions []SessionView, config SelectionConfig) *Model {194 func New(sessions []SessionView, config SelectionConfig) *Model {
@@ -175,10 +199,10 @@ func New(sessions []SessionView, config SelectionConfig) *Model {
175 for _, pickerRow := range sessionRows {199 for _, pickerRow := range sessionRows {
176 idx := len(m.rows)200 idx := len(m.rows)
177 m.rows = append(m.rows, pickerRow)201 m.rows = append(m.rows, pickerRow)
178- if pickerRow.kind.selectable(config.Mode) && selectionMatches(pickerRow, config.Current, config.Mode) {202+ if selectionMatches(pickerRow, config.Current, config.Mode) {
179 m.selected = idx203 m.selected = idx
180 }204 }
181- if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {205+ if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.selectable && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
182 activeSelection = idx206 activeSelection = idx
183 }207 }
184 }208 }
@@ -201,7 +225,10 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
201 if config.Mode != SelectNavigationTab && session.Stopped {225 if config.Mode != SelectNavigationTab && session.Stopped {
202 return nil226 return nil
203 }227 }
204- if config.Mode == SelectMoveTabSession && (sourceMatchesSession(config.Source, session) || len(session.Tabs) == 0) {228+ if config.Mode == SelectMoveTabSession && sourceMatchesSession(config.Source, session) {
229+ return nil
230+ }
231+ if config.Mode == SelectMoveTabSession && len(session.Tabs) == 0 && !session.CannotAcceptMoves {
205 return nil232 return nil
206 }233 }
207 234
@@ -217,24 +244,65 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
217 stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,244 stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,
218 hasExpectedCreatedAt: hasExpectedCreatedAt,245 hasExpectedCreatedAt: hasExpectedCreatedAt,
219 }246 }
247+ if session.RemoteKey != nil {
248+ common.remoteKey, common.hasRemoteKey = *session.RemoteKey, true
249+ }
250+ common.remote = common.hasRemoteKey || session.RemoteHost != ""
220 header := common251 header := common
221 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1252 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
253+ header.selectable = header.kind.selectable(config.Mode)
254+ if common.remote {
255+ header.detail = remoteRowDetail(session)
256+ header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session)
257+ header.dim = remoteDim(session)
258+ }
259+ if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
260+ header.selectable, header.dim = false, true
261+ }
222 rows := []row{header}262 rows := []row{header}
223- for i, tab := range session.Tabs {263+ if !session.ConnectOnly {
224- if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {264+ for i, tab := range session.Tabs {
225- continue265+ if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
266+ continue
267+ }
268+ tabRow := common
269+ tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
270+ tabRow.tabID, tabRow.tabIndex = tab.TabID, i
271+ tabRow.selectable = tabRow.kind.selectable(config.Mode)
272+ if common.remote {
273+ tabRow.selectable = false
274+ tabRow.dim = remoteDim(session)
275+ }
276+ if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
277+ tabRow.selectable, tabRow.dim = false, true
278+ }
279+ rows = append(rows, tabRow)
226 }280 }
227- tabRow := common
228- tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
229- tabRow.tabID, tabRow.tabIndex = tab.TabID, i
230- rows = append(rows, tabRow)
231 }281 }
232- if config.Mode == SelectMovePaneTab && len(rows) == 1 {282+ if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {
233 return nil283 return nil
234 }284 }
235 return rows285 return rows
236 }286 }
237 287
288+func remoteSelectable(session SessionView) bool {
289+ return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch
290+}
291+
292+func remoteDim(session SessionView) bool {
293+ return !session.RemoteAttachReady || session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch
294+}
295+
296+func remoteRowDetail(session SessionView) string {
297+ if session.RemoteDetail != "" && (session.RemoteAvailability == RemoteStale || session.RemoteAvailability == RemoteVersionMismatch) {
298+ return session.RemoteDetail
299+ }
300+ if !session.RemoteAttachReady {
301+ return "proxy activation pending"
302+ }
303+ return session.RemoteDetail
304+}
305+
238 func sourceMatchesSession(source SourceFilter, session SessionView) bool {306 func sourceMatchesSession(source SourceFilter, session SessionView) bool {
239 if source.Session != session.ID {307 if source.Session != session.ID {
240 return false308 return false
@@ -257,6 +325,9 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b
257 if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) {325 if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) {
258 return false326 return false
259 }327 }
328+ if pickerRow.hasRemoteKey && pickerRow.kind == rowSession {
329+ return current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey
330+ }
260 if mode == SelectMoveTabSession {331 if mode == SelectMoveTabSession {
261 return pickerRow.kind == rowSession332 return pickerRow.kind == rowSession
262 }333 }
@@ -317,18 +388,25 @@ func (m *Model) Down() {
317 m.move(1)388 m.move(1)
318 }389 }
319 390
391+// Cursor reports the identity under the picker cursor independently from
392+// whether that row may be activated. Remote refreshes use it to preserve a
393+// structured remote session key while Selected continues to enforce safety.
394+func (m *Model) Cursor() (Target, bool) {
395+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
396+ return Target{}, false
397+ }
398+ return m.rows[m.selected].target(), true
399+}
400+
320 func (m *Model) Selected() (Target, bool) {401 func (m *Model) Selected() (Target, bool) {
321 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {402 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
322 return Target{}, false403 return Target{}, false
323 }404 }
324 r := m.rows[m.selected]405 r := m.rows[m.selected]
325- if !r.kind.selectable(m.mode) {406+ if !r.selectable {
326 return Target{}, false407 return Target{}, false
327 }408 }
328- return Target{409+ return r.target(), true
329- Session: r.session, Incarnation: r.incarnation, Name: r.targetName, TabID: r.tabID,
330- TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
331- }, true
332 }410 }
333 411
334 // SelectedIndex reports the raw selected row index. It is -1 only when the412 // SelectedIndex reports the raw selected row index. It is -1 only when the
@@ -351,13 +429,13 @@ func (m *Model) SelectNearestRow(idx int) {
351 }429 }
352 idx = clamp(idx, 0, len(m.rows)-1)430 idx = clamp(idx, 0, len(m.rows)-1)
353 for i := idx; i < len(m.rows); i++ {431 for i := idx; i < len(m.rows); i++ {
354- if m.rows[i].kind.selectable(m.mode) {432+ if m.rows[i].selectable {
355 m.selected = i433 m.selected = i
356 return434 return
357 }435 }
358 }436 }
359 for i := idx - 1; i >= 0; i-- {437 for i := idx - 1; i >= 0; i-- {
360- if m.rows[i].kind.selectable(m.mode) {438+ if m.rows[i].selectable {
361 m.selected = i439 m.selected = i
362 return440 return
363 }441 }
@@ -396,12 +474,12 @@ func (m *Model) move(delta int) {
396 if m == nil || len(m.rows) == 0 {474 if m == nil || len(m.rows) == 0 {
397 return475 return
398 }476 }
399- if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].kind.selectable(m.mode) {477+ if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].selectable {
400 m.selected = m.firstSelectable()478 m.selected = m.firstSelectable()
401 return479 return
402 }480 }
403 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {481 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
404- if m.rows[i].kind.selectable(m.mode) {482+ if m.rows[i].selectable {
405 m.selected = i483 m.selected = i
406 return484 return
407 }485 }
@@ -410,13 +488,20 @@ func (m *Model) move(delta int) {
410 488
411 func (m *Model) firstSelectable() int {489 func (m *Model) firstSelectable() int {
412 for i, r := range m.rows {490 for i, r := range m.rows {
413- if r.kind.selectable(m.mode) {491+ if r.selectable {
414 return i492 return i
415 }493 }
416 }494 }
417 return -1495 return -1
418 }496 }
419 497
498+func (r row) target() Target {
499+ return Target{
500+ Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), TabID: r.tabID,
501+ TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
502+ }
503+}
504+
420 func (r row) expectedCreatedAtPointer() *int64 {505 func (r row) expectedCreatedAtPointer() *int64 {
421 if !r.hasExpectedCreatedAt {506 if !r.hasExpectedCreatedAt {
422 return nil507 return nil
@@ -425,6 +510,14 @@ func (r row) expectedCreatedAtPointer() *int64 {
425 return &value510 return &value
426 }511 }
427 512
513+func (r row) remoteKeyPointer() *domain.RemoteSessionKey {
514+ if !r.hasRemoteKey {
515+ return nil
516+ }
517+ key := r.remoteKey
518+ return &key
519+}
520+
428 // renderList draws each visible row as up to three segments: a name segment521 // renderList draws each visible row as up to three segments: a name segment
429 // (bold when styles came from a truecolor theme), a base-styled attention522 // (bold when styles came from a truecolor theme), a base-styled attention
430 // marker right after the name, and a muted detail segment (tab rows only) —523 // marker right after the name, and a muted detail segment (tab rows only) —
@@ -450,6 +543,11 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
450 if r.stopped && idx != m.selected {543 if r.stopped && idx != m.selected {
451 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped544 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
452 }545 }
546+ if r.dim {
547+ base.Attrs |= renderer.AttrDim
548+ nameStyle.Attrs |= renderer.AttrDim
549+ detailStyle.Attrs |= renderer.AttrDim
550+ }
453 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})551 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
454 552
455 name := r.dispName553 name := r.dispName
@@ -462,6 +560,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
462 if r.kind.rendersAsHeader() {560 if r.kind.rendersAsHeader() {
463 if r.stopped {561 if r.stopped {
464 ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)562 ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
563+ } else {
564+ ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)
465 }565 }
466 continue566 continue
467 }567 }
M internal/usecase/picker/model_test.go +156 -0
@@ -722,3 +722,159 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
722722 })
723723 }
724724 }
725+
726+func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) {
727+ key := domain.RemoteSessionKey{Host: "build@mule", Name: "work"}
728+ availability := []RemoteAvailability{RemoteCached, RemoteFresh, RemoteStale, RemoteVersionMismatch}
729+ sessions := make([]SessionView, 0, len(availability))
730+ for _, state := range availability {
731+ sessions = append(sessions, SessionView{
732+ ID: key.ID(),
733+ Name: "presentation text is not routing data",
734+ Tabs: []TabEntry{{Name: "relayed metadata is not a picker target"}},
735+ RemoteKey: &key,
736+ RemoteAvailability: state,
737+ RemoteDetail: "remote state detail",
738+ ConnectOnly: true,
739+ RemoteAttachReady: false,
740+ })
741+ }
742+
743+ model := New(sessions, SelectionConfig{Mode: SelectNavigationTab})
744+ require.Len(t, model.rows, len(availability), "connect-only remote rows do not synthesize tab destinations")
745+ for i, pickerRow := range model.rows {
746+ require.Equal(t, rowSession, pickerRow.kind)
747+ require.Equal(t, key, pickerRow.remoteKey)
748+ require.True(t, pickerRow.hasRemoteKey)
749+ require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])
750+ require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i])
751+ wantDetail := "proxy activation pending"
752+ if availability[i] == RemoteStale || availability[i] == RemoteVersionMismatch {
753+ wantDetail = "remote state detail"
754+ }
755+ require.Equal(t, wantDetail, pickerRow.detail)
756+ }
757+ _, ok := model.Selected()
758+ require.False(t, ok)
759+ frame := model.Render(domain.Size{Cols: 80, Rows: len(availability)}, Preview{})
760+ for y := range availability {
761+ require.NotZero(t, frame.At(0, y).Style.Attrs&renderer.AttrDim)
762+ }
763+}
764+
765+func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *testing.T) {
766+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
767+ cached := New([]SessionView{{
768+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached,
769+ ConnectOnly: true, RemoteAttachReady: false,
770+ }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}})
771+
772+ cursor, ok := cached.Cursor()
773+ require.True(t, ok)
774+ require.Equal(t, key.ID(), cursor.Session)
775+ require.Equal(t, &key, cursor.RemoteKey)
776+ _, selectable := cached.Selected()
777+ require.False(t, selectable, "cursor identity must not bypass the phase 5 activation gate")
778+
779+ live := New([]SessionView{{
780+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh,
781+ ConnectOnly: true, RemoteAttachReady: false,
782+ }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{
783+ Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
784+ }})
785+
786+ upgraded, ok := live.Cursor()
787+ require.True(t, ok)
788+ require.Equal(t, cursor, upgraded)
789+ _, selectable = live.Selected()
790+ require.False(t, selectable, "cache-to-live cursor preservation must not enable attach")
791+}
792+
793+func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) {
794+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
795+ model := New([]SessionView{{
796+ ID: key.ID(),
797+ Name: key.Display(),
798+ Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
799+ RemoteKey: &key,
800+ RemoteAvailability: RemoteFresh,
801+ RemoteAttachReady: false,
802+ }}, SelectionConfig{Mode: SelectNavigationTab})
803+
804+ require.Len(t, model.rows, 2)
805+ for _, pickerRow := range model.rows {
806+ require.False(t, pickerRow.selectable)
807+ require.True(t, pickerRow.dim)
808+ }
809+}
810+
811+func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) {
812+ key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"}
813+ wantKey := key
814+ model := New([]SessionView{{
815+ ID: key.ID(),
816+ Name: "not a parseable remote display label",
817+ RemoteKey: &key,
818+ RemoteAvailability: RemoteFresh,
819+ ConnectOnly: true,
820+ RemoteAttachReady: true,
821+ }}, SelectionConfig{Mode: SelectNavigationTab})
822+ key.Host = "mutated-after-model-construction"
823+
824+ got, ok := model.Selected()
825+ require.True(t, ok)
826+ require.Equal(t, wantKey.ID(), got.Session)
827+ require.Equal(t, &wantKey, got.RemoteKey)
828+ require.Equal(t, "", got.Name, "connect-only remote routing never derives a name from display text")
829+}
830+
831+func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
832+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
833+ local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}
834+ availability := []struct {
835+ name string
836+ state RemoteAvailability
837+ }{
838+ {name: "cached", state: RemoteCached},
839+ {name: "fresh", state: RemoteFresh},
840+ {name: "stale", state: RemoteStale},
841+ {name: "version mismatch", state: RemoteVersionMismatch},
842+ }
843+ modes := []struct {
844+ name string
845+ mode SelectionMode
846+ }{
847+ {name: "move pane", mode: SelectMovePaneTab},
848+ {name: "move tab", mode: SelectMoveTabSession},
849+ }
850+
851+ for _, availabilityCase := range availability {
852+ t.Run(availabilityCase.name, func(t *testing.T) {
853+ remote := SessionView{
854+ ID: key.ID(),
855+ Name: key.Display(),
856+ Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
857+ RemoteKey: &key,
858+ RemoteAvailability: availabilityCase.state,
859+ ConnectOnly: true,
860+ RemoteAttachReady: true,
861+ CannotAcceptMoves: true,
862+ }
863+ for _, mode := range modes {
864+ t.Run(mode.name, func(t *testing.T) {
865+ model := New([]SessionView{local, remote}, SelectionConfig{Mode: mode.mode, Source: SourceFilter{Session: "source"}})
866+ var remoteRow *row
867+ for i := range model.rows {
868+ if model.rows[i].session == key.ID() {
869+ remoteRow = &model.rows[i]
870+ break
871+ }
872+ }
873+ require.NotNil(t, remoteRow)
874+ require.True(t, remoteRow.dim)
875+ require.False(t, remoteRow.selectable)
876+ })
877+ }
878+ })
879+ }
880+}
@@ -722,3 +722,159 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
722 })722 })
723 }723 }
724 }724 }
725+
726+func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) {
727+ key := domain.RemoteSessionKey{Host: "build@mule", Name: "work"}
728+ availability := []RemoteAvailability{RemoteCached, RemoteFresh, RemoteStale, RemoteVersionMismatch}
729+ sessions := make([]SessionView, 0, len(availability))
730+ for _, state := range availability {
731+ sessions = append(sessions, SessionView{
732+ ID: key.ID(),
733+ Name: "presentation text is not routing data",
734+ Tabs: []TabEntry{{Name: "relayed metadata is not a picker target"}},
735+ RemoteKey: &key,
736+ RemoteAvailability: state,
737+ RemoteDetail: "remote state detail",
738+ ConnectOnly: true,
739+ RemoteAttachReady: false,
740+ })
741+ }
742+
743+ model := New(sessions, SelectionConfig{Mode: SelectNavigationTab})
744+ require.Len(t, model.rows, len(availability), "connect-only remote rows do not synthesize tab destinations")
745+ for i, pickerRow := range model.rows {
746+ require.Equal(t, rowSession, pickerRow.kind)
747+ require.Equal(t, key, pickerRow.remoteKey)
748+ require.True(t, pickerRow.hasRemoteKey)
749+ require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])
750+ require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i])
751+ wantDetail := "proxy activation pending"
752+ if availability[i] == RemoteStale || availability[i] == RemoteVersionMismatch {
753+ wantDetail = "remote state detail"
754+ }
755+ require.Equal(t, wantDetail, pickerRow.detail)
756+ }
757+ _, ok := model.Selected()
758+ require.False(t, ok)
759+ frame := model.Render(domain.Size{Cols: 80, Rows: len(availability)}, Preview{})
760+ for y := range availability {
761+ require.NotZero(t, frame.At(0, y).Style.Attrs&renderer.AttrDim)
762+ }
763+}
764+
765+func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *testing.T) {
766+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
767+ cached := New([]SessionView{{
768+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached,
769+ ConnectOnly: true, RemoteAttachReady: false,
770+ }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}})
771+
772+ cursor, ok := cached.Cursor()
773+ require.True(t, ok)
774+ require.Equal(t, key.ID(), cursor.Session)
775+ require.Equal(t, &key, cursor.RemoteKey)
776+ _, selectable := cached.Selected()
777+ require.False(t, selectable, "cursor identity must not bypass the phase 5 activation gate")
778+
779+ live := New([]SessionView{{
780+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh,
781+ ConnectOnly: true, RemoteAttachReady: false,
782+ }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{
783+ Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
784+ }})
785+
786+ upgraded, ok := live.Cursor()
787+ require.True(t, ok)
788+ require.Equal(t, cursor, upgraded)
789+ _, selectable = live.Selected()
790+ require.False(t, selectable, "cache-to-live cursor preservation must not enable attach")
791+}
792+
793+func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) {
794+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
795+ model := New([]SessionView{{
796+ ID: key.ID(),
797+ Name: key.Display(),
798+ Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
799+ RemoteKey: &key,
800+ RemoteAvailability: RemoteFresh,
801+ RemoteAttachReady: false,
802+ }}, SelectionConfig{Mode: SelectNavigationTab})
803+
804+ require.Len(t, model.rows, 2)
805+ for _, pickerRow := range model.rows {
806+ require.False(t, pickerRow.selectable)
807+ require.True(t, pickerRow.dim)
808+ }
809+}
810+
811+func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) {
812+ key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"}
813+ wantKey := key
814+ model := New([]SessionView{{
815+ ID: key.ID(),
816+ Name: "not a parseable remote display label",
817+ RemoteKey: &key,
818+ RemoteAvailability: RemoteFresh,
819+ ConnectOnly: true,
820+ RemoteAttachReady: true,
821+ }}, SelectionConfig{Mode: SelectNavigationTab})
822+ key.Host = "mutated-after-model-construction"
823+
824+ got, ok := model.Selected()
825+ require.True(t, ok)
826+ require.Equal(t, wantKey.ID(), got.Session)
827+ require.Equal(t, &wantKey, got.RemoteKey)
828+ require.Equal(t, "", got.Name, "connect-only remote routing never derives a name from display text")
829+}
830+
831+func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
832+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
833+ local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}
834+ availability := []struct {
835+ name string
836+ state RemoteAvailability
837+ }{
838+ {name: "cached", state: RemoteCached},
839+ {name: "fresh", state: RemoteFresh},
840+ {name: "stale", state: RemoteStale},
841+ {name: "version mismatch", state: RemoteVersionMismatch},
842+ }
843+ modes := []struct {
844+ name string
845+ mode SelectionMode
846+ }{
847+ {name: "move pane", mode: SelectMovePaneTab},
848+ {name: "move tab", mode: SelectMoveTabSession},
849+ }
850+
851+ for _, availabilityCase := range availability {
852+ t.Run(availabilityCase.name, func(t *testing.T) {
853+ remote := SessionView{
854+ ID: key.ID(),
855+ Name: key.Display(),
856+ Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
857+ RemoteKey: &key,
858+ RemoteAvailability: availabilityCase.state,
859+ ConnectOnly: true,
860+ RemoteAttachReady: true,
861+ CannotAcceptMoves: true,
862+ }
863+ for _, mode := range modes {
864+ t.Run(mode.name, func(t *testing.T) {
865+ model := New([]SessionView{local, remote}, SelectionConfig{Mode: mode.mode, Source: SourceFilter{Session: "source"}})
866+ var remoteRow *row
867+ for i := range model.rows {
868+ if model.rows[i].session == key.ID() {
869+ remoteRow = &model.rows[i]
870+ break
871+ }
872+ }
873+ require.NotNil(t, remoteRow)
874+ require.True(t, remoteRow.dim)
875+ require.False(t, remoteRow.selectable)
876+ })
877+ }
878+ })
879+ }
880+}

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