detached f1b7847

feat(daemon): retain unified remote picker ownership

commit
f1b7847e29999cb69b402757e57a647101d19ea0
author
brice · · 2026-08-07 02:49 UTC
parent
8062ab3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
28 files · +1696 -366
M internal/usecase/daemon/attachment_owner.go +30 -8
@@ -35,6 +35,14 @@ type remoteViewKey struct {
3535 sessionName string
3636 }
3737
38+type remoteViewLinkState uint8
39+
40+const (
41+ remoteViewLinkHealthy remoteViewLinkState = iota
42+ remoteViewLinkReconnecting
43+ remoteViewLinkUnavailable
44+)
45+
3846 func remoteViewKeyForTarget(target domain.RemoteSessionTarget) (remoteViewKey, error) {
3947 if err := target.Validate(); err != nil {
4048 return remoteViewKey{}, err
@@ -55,19 +63,33 @@ type remoteView struct {
5563 id remoteViewID
5664 key remoteViewKey
5765
58- mu sync.Mutex
59- closed bool
60- attachments map[*attachedClient]struct{}
61- screen *vt.Screen
62- metadata ports.SessionMeta
63- displayOrigin string
66+ mu sync.Mutex
67+ // tabActivationMu serializes command/metadata activation sequences without
68+ // retaining mu while waiting for remote transport or metadata frames.
69+ tabActivationMu sync.Mutex
70+ closed bool
71+ attachments map[*attachedClient]struct{}
72+ screen *vt.Screen
73+ metadata ports.SessionMeta
74+ displayOrigin string
6475 // link and linkGeneration fence every remote transport callback. They are
6576 // owned by the view rather than the local-session registry, and neither
6677 // is ever used while view.mu is held for transport I/O.
6778 link *remoteLink
6879 linkGeneration uint64
69- output remoteOutputState
70- resetRequested bool
80+ // metadataChanged is replaced and the prior channel is closed after every
81+ // exact metadata or link-generation change. Waiters use it only after
82+ // releasing view.mu, so it never carries mutable metadata itself.
83+ metadataChanged chan struct{}
84+ linkState remoteViewLinkState
85+ // reconnectTarget is the currently active running tab route used only for
86+ // a later link replacement. It is guarded by mu with link state.
87+ reconnectTarget domain.RemoteSessionTarget
88+ // reconnectGeneration fences a single automatic reconnect attempt against
89+ // its failed link generation and any later terminal/replacement lifecycle.
90+ reconnectGeneration uint64
91+ output remoteOutputState
92+ resetRequested bool
7193 // warm and warmGeneration fence the five-minute retention timer for an
7294 // unattached view. The timer is always stopped after view.mu is released.
7395 warm *remoteViewWarm
@@ -35,6 +35,14 @@ type remoteViewKey struct {
35 sessionName string35 sessionName string
36 }36 }
37 37
38+type remoteViewLinkState uint8
39+
40+const (
41+ remoteViewLinkHealthy remoteViewLinkState = iota
42+ remoteViewLinkReconnecting
43+ remoteViewLinkUnavailable
44+)
45+
38 func remoteViewKeyForTarget(target domain.RemoteSessionTarget) (remoteViewKey, error) {46 func remoteViewKeyForTarget(target domain.RemoteSessionTarget) (remoteViewKey, error) {
39 if err := target.Validate(); err != nil {47 if err := target.Validate(); err != nil {
40 return remoteViewKey{}, err48 return remoteViewKey{}, err
@@ -55,19 +63,33 @@ type remoteView struct {
55 id remoteViewID63 id remoteViewID
56 key remoteViewKey64 key remoteViewKey
57 65
58- mu sync.Mutex66+ mu sync.Mutex
59- closed bool67+ // tabActivationMu serializes command/metadata activation sequences without
60- attachments map[*attachedClient]struct{}68+ // retaining mu while waiting for remote transport or metadata frames.
61- screen *vt.Screen69+ tabActivationMu sync.Mutex
62- metadata ports.SessionMeta70+ closed bool
63- displayOrigin string71+ attachments map[*attachedClient]struct{}
72+ screen *vt.Screen
73+ metadata ports.SessionMeta
74+ displayOrigin string
64 // link and linkGeneration fence every remote transport callback. They are75 // link and linkGeneration fence every remote transport callback. They are
65 // owned by the view rather than the local-session registry, and neither76 // owned by the view rather than the local-session registry, and neither
66 // is ever used while view.mu is held for transport I/O.77 // is ever used while view.mu is held for transport I/O.
67 link *remoteLink78 link *remoteLink
68 linkGeneration uint6479 linkGeneration uint64
69- output remoteOutputState80+ // metadataChanged is replaced and the prior channel is closed after every
70- resetRequested bool81+ // exact metadata or link-generation change. Waiters use it only after
82+ // releasing view.mu, so it never carries mutable metadata itself.
83+ metadataChanged chan struct{}
84+ linkState remoteViewLinkState
85+ // reconnectTarget is the currently active running tab route used only for
86+ // a later link replacement. It is guarded by mu with link state.
87+ reconnectTarget domain.RemoteSessionTarget
88+ // reconnectGeneration fences a single automatic reconnect attempt against
89+ // its failed link generation and any later terminal/replacement lifecycle.
90+ reconnectGeneration uint64
91+ output remoteOutputState
92+ resetRequested bool
71 // warm and warmGeneration fence the five-minute retention timer for an93 // warm and warmGeneration fence the five-minute retention timer for an
72 // unattached view. The timer is always stopped after view.mu is released.94 // unattached view. The timer is always stopped after view.mu is released.
73 warm *remoteViewWarm95 warm *remoteViewWarm
M internal/usecase/daemon/client.go +10 -0
@@ -237,6 +237,16 @@ func (ac *attachedClient) currentSession() *session {
237237 return ac.currentAttachmentSession()
238238 }
239239
240+// navigationSession returns the local session that owns local navigation UI.
241+// A remote view has no local tabs or PTYs, but the attachment retains its
242+// immediate local predecessor so picker and palette shell state stay local.
243+func (ac *attachedClient) navigationSession() *session {
244+ if current := ac.currentAttachmentSession(); current != nil {
245+ return current
246+ }
247+ return localSession(ac.previousOwner.Get())
248+}
249+
240250 func (ac *attachedClient) setAttachmentOwner(owner attachmentOwner) {
241251 if ac != nil {
242252 ac.owner.Set(normalizeAttachmentOwner(owner))
@@ -237,6 +237,16 @@ func (ac *attachedClient) currentSession() *session {
237 return ac.currentAttachmentSession()237 return ac.currentAttachmentSession()
238 }238 }
239 239
240+// navigationSession returns the local session that owns local navigation UI.
241+// A remote view has no local tabs or PTYs, but the attachment retains its
242+// immediate local predecessor so picker and palette shell state stay local.
243+func (ac *attachedClient) navigationSession() *session {
244+ if current := ac.currentAttachmentSession(); current != nil {
245+ return current
246+ }
247+ return localSession(ac.previousOwner.Get())
248+}
249+
240 func (ac *attachedClient) setAttachmentOwner(owner attachmentOwner) {250 func (ac *attachedClient) setAttachmentOwner(owner attachmentOwner) {
241 if ac != nil {251 if ac != nil {
242 ac.owner.Set(normalizeAttachmentOwner(owner))252 ac.owner.Set(normalizeAttachmentOwner(owner))
M internal/usecase/daemon/command_tracker.go +22 -0
@@ -135,6 +135,28 @@ func (t *CommandRequestTracker) Fail(requestID, generation uint64, err error) {
135135 t.finish(requestID, generation, CommandRequestOutcome{Err: err})
136136 }
137137
138+// FailGeneration completes every pending request for one retired transport
139+// generation. It releases the tracker lock before publishing outcomes so a
140+// caller can immediately wait or begin work on a replacement generation.
141+func (t *CommandRequestTracker) FailGeneration(generation uint64, err error) {
142+ if t == nil {
143+ return
144+ }
145+ t.mu.Lock()
146+ pending := make([]commandRequestPending, 0)
147+ for requestID, request := range t.pending {
148+ if request.generation != generation {
149+ continue
150+ }
151+ delete(t.pending, requestID)
152+ pending = append(pending, request)
153+ }
154+ t.mu.Unlock()
155+ for _, request := range pending {
156+ request.outcome <- CommandRequestOutcome{Err: err}
157+ }
158+}
159+
138160 func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {
139161 if t == nil {
140162 return
@@ -135,6 +135,28 @@ func (t *CommandRequestTracker) Fail(requestID, generation uint64, err error) {
135 t.finish(requestID, generation, CommandRequestOutcome{Err: err})135 t.finish(requestID, generation, CommandRequestOutcome{Err: err})
136 }136 }
137 137
138+// FailGeneration completes every pending request for one retired transport
139+// generation. It releases the tracker lock before publishing outcomes so a
140+// caller can immediately wait or begin work on a replacement generation.
141+func (t *CommandRequestTracker) FailGeneration(generation uint64, err error) {
142+ if t == nil {
143+ return
144+ }
145+ t.mu.Lock()
146+ pending := make([]commandRequestPending, 0)
147+ for requestID, request := range t.pending {
148+ if request.generation != generation {
149+ continue
150+ }
151+ delete(t.pending, requestID)
152+ pending = append(pending, request)
153+ }
154+ t.mu.Unlock()
155+ for _, request := range pending {
156+ request.outcome <- CommandRequestOutcome{Err: err}
157+ }
158+}
159+
138 func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {160 func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {
139 if t == nil {161 if t == nil {
140 return162 return
M internal/usecase/daemon/daemon.go +14 -6
@@ -813,11 +813,9 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
813813 for _, view := range d.remoteViews {
814814 remoteViews = append(remoteViews, view)
815815 }
816- constructions := make([]context.CancelFunc, 0, len(d.remoteViewConstructions))
816+ constructions := make([]*remoteViewConstruction, 0, len(d.remoteViewConstructions))
817817 for _, construction := range d.remoteViewConstructions {
818- if construction.cancel != nil {
819- constructions = append(constructions, construction.cancel)
820- }
818+ constructions = append(constructions, construction)
821819 }
822820 clear(d.remoteViews)
823821 clear(d.remoteViewsByKey)
@@ -829,8 +827,18 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
829827 // attachment after releasing d.mu so a token cannot remain valid and a
830828 // blocked local-client Recv/Send cannot outlive daemon shutdown. Candidate
831829 // construction uses the same exact-close rule, even before publication.
832- for _, cancel := range constructions {
833- cancel()
830+ for _, construction := range constructions {
831+ if construction != nil && construction.cancel != nil {
832+ construction.cancel()
833+ }
834+ }
835+ // Candidate construction owns dial and handshake I/O outside daemon locks.
836+ // Shutdown waits for each canceled constructor to close its completion gate
837+ // so no unpublished transport or callback can outlive daemon teardown.
838+ for _, construction := range constructions {
839+ if construction != nil && construction.done != nil {
840+ <-construction.done
841+ }
834842 }
835843 remoteWarms := make([]*remoteViewWarm, 0, len(remoteViews))
836844 remoteLinks := make([]*remoteLink, 0, len(remoteViews))
@@ -813,11 +813,9 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
813 for _, view := range d.remoteViews {813 for _, view := range d.remoteViews {
814 remoteViews = append(remoteViews, view)814 remoteViews = append(remoteViews, view)
815 }815 }
816- constructions := make([]context.CancelFunc, 0, len(d.remoteViewConstructions))816+ constructions := make([]*remoteViewConstruction, 0, len(d.remoteViewConstructions))
817 for _, construction := range d.remoteViewConstructions {817 for _, construction := range d.remoteViewConstructions {
818- if construction.cancel != nil {818+ constructions = append(constructions, construction)
819- constructions = append(constructions, construction.cancel)
820- }
821 }819 }
822 clear(d.remoteViews)820 clear(d.remoteViews)
823 clear(d.remoteViewsByKey)821 clear(d.remoteViewsByKey)
@@ -829,8 +827,18 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
829 // attachment after releasing d.mu so a token cannot remain valid and a827 // attachment after releasing d.mu so a token cannot remain valid and a
830 // blocked local-client Recv/Send cannot outlive daemon shutdown. Candidate828 // blocked local-client Recv/Send cannot outlive daemon shutdown. Candidate
831 // construction uses the same exact-close rule, even before publication.829 // construction uses the same exact-close rule, even before publication.
832- for _, cancel := range constructions {830+ for _, construction := range constructions {
833- cancel()831+ if construction != nil && construction.cancel != nil {
832+ construction.cancel()
833+ }
834+ }
835+ // Candidate construction owns dial and handshake I/O outside daemon locks.
836+ // Shutdown waits for each canceled constructor to close its completion gate
837+ // so no unpublished transport or callback can outlive daemon teardown.
838+ for _, construction := range constructions {
839+ if construction != nil && construction.done != nil {
840+ <-construction.done
841+ }
834 }842 }
835 remoteWarms := make([]*remoteViewWarm, 0, len(remoteViews))843 remoteWarms := make([]*remoteViewWarm, 0, len(remoteViews))
836 remoteLinks := make([]*remoteLink, 0, len(remoteViews))844 remoteLinks := make([]*remoteLink, 0, len(remoteViews))
M internal/usecase/daemon/input.go +40 -1
@@ -26,7 +26,7 @@ func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionTok
2626 return
2727 }
2828 if view, remote := token.owner.(*remoteView); remote {
29- d.handleRemoteViewInput(view, token, inputSeq, data)
29+ d.handleRemoteViewInputForAttachment(view, token, inputSeq, data)
3030 return
3131 }
3232 if token.ac.renderMode == ports.RenderModeProxiedContent {
@@ -36,6 +36,45 @@ func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionTok
3636 d.handleInputForAttachment(token, data)
3737 }
3838
39+// handleRemoteViewInputForAttachment keeps picker and palette routing local
40+// while forwarding ordinary terminal bytes unchanged to the exact remote link.
41+func (d *Daemon) handleRemoteViewInputForAttachment(view *remoteView, token attachmentConnectionToken, inputSeq uint64, data []byte) {
42+ if view == nil || token.ac == nil || !token.attachmentEffectCurrent() {
43+ return
44+ }
45+ ac := token.ac
46+ ac.initOverlays()
47+ if ac.overlays.HandleInput(d, data, token.effect) {
48+ return
49+ }
50+ if ac.keys == nil {
51+ d.handleRemoteViewInput(view, token, inputSeq, data)
52+ return
53+ }
54+ ac.keys.RouteWithHandler(data, remoteViewKeyHandler{d: d, ac: ac, view: view, token: token, inputSeq: inputSeq})
55+}
56+
57+type remoteViewKeyHandler struct {
58+ d *Daemon
59+ ac *attachedClient
60+ view *remoteView
61+ token attachmentConnectionToken
62+ inputSeq uint64
63+}
64+
65+func (h remoteViewKeyHandler) Forward(data []byte) {
66+ h.d.handleRemoteViewInput(h.view, h.token, h.inputSeq, data)
67+}
68+
69+func (h remoteViewKeyHandler) Action(action keys.Action, _ []byte) {
70+ if action != keys.ActionOpenPalette || !h.token.attachmentEffectCurrent() {
71+ return
72+ }
73+ if source := h.ac.navigationSession(); source != nil {
74+ h.d.enterPalette(source, h.ac)
75+ }
76+}
77+
3978 // handleProxiedInputForAttachment preserves remote terminal bytes exactly.
4079 // Local key routing, overlays, mouse interpretation, and copy-mode state are
4180 // deliberately unavailable on the remote daemon's content-only surface.
@@ -26,7 +26,7 @@ func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionTok
26 return26 return
27 }27 }
28 if view, remote := token.owner.(*remoteView); remote {28 if view, remote := token.owner.(*remoteView); remote {
29- d.handleRemoteViewInput(view, token, inputSeq, data)29+ d.handleRemoteViewInputForAttachment(view, token, inputSeq, data)
30 return30 return
31 }31 }
32 if token.ac.renderMode == ports.RenderModeProxiedContent {32 if token.ac.renderMode == ports.RenderModeProxiedContent {
@@ -36,6 +36,45 @@ func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionTok
36 d.handleInputForAttachment(token, data)36 d.handleInputForAttachment(token, data)
37 }37 }
38 38
39+// handleRemoteViewInputForAttachment keeps picker and palette routing local
40+// while forwarding ordinary terminal bytes unchanged to the exact remote link.
41+func (d *Daemon) handleRemoteViewInputForAttachment(view *remoteView, token attachmentConnectionToken, inputSeq uint64, data []byte) {
42+ if view == nil || token.ac == nil || !token.attachmentEffectCurrent() {
43+ return
44+ }
45+ ac := token.ac
46+ ac.initOverlays()
47+ if ac.overlays.HandleInput(d, data, token.effect) {
48+ return
49+ }
50+ if ac.keys == nil {
51+ d.handleRemoteViewInput(view, token, inputSeq, data)
52+ return
53+ }
54+ ac.keys.RouteWithHandler(data, remoteViewKeyHandler{d: d, ac: ac, view: view, token: token, inputSeq: inputSeq})
55+}
56+
57+type remoteViewKeyHandler struct {
58+ d *Daemon
59+ ac *attachedClient
60+ view *remoteView
61+ token attachmentConnectionToken
62+ inputSeq uint64
63+}
64+
65+func (h remoteViewKeyHandler) Forward(data []byte) {
66+ h.d.handleRemoteViewInput(h.view, h.token, h.inputSeq, data)
67+}
68+
69+func (h remoteViewKeyHandler) Action(action keys.Action, _ []byte) {
70+ if action != keys.ActionOpenPalette || !h.token.attachmentEffectCurrent() {
71+ return
72+ }
73+ if source := h.ac.navigationSession(); source != nil {
74+ h.d.enterPalette(source, h.ac)
75+ }
76+}
77+
39 // handleProxiedInputForAttachment preserves remote terminal bytes exactly.78 // handleProxiedInputForAttachment preserves remote terminal bytes exactly.
40 // Local key routing, overlays, mouse interpretation, and copy-mode state are79 // Local key routing, overlays, mouse interpretation, and copy-mode state are
41 // deliberately unavailable on the remote daemon's content-only surface.80 // deliberately unavailable on the remote daemon's content-only surface.
M internal/usecase/daemon/move_picker.go +1 -1
@@ -167,7 +167,7 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
167167 }
168168 rt := ac.overlays
169169 rt.pickerMu.Lock()
170- if rt.picker == nil {
170+ if rt.picker == nil || rt.pickerRemoteSelection != nil {
171171 rt.pickerMu.Unlock()
172172 return
173173 }
@@ -167,7 +167,7 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
167 }167 }
168 rt := ac.overlays168 rt := ac.overlays
169 rt.pickerMu.Lock()169 rt.pickerMu.Lock()
170- if rt.picker == nil {170+ if rt.picker == nil || rt.pickerRemoteSelection != nil {
171 rt.pickerMu.Unlock()171 rt.pickerMu.Unlock()
172 return172 return
173 }173 }
M internal/usecase/daemon/overlay_runtime.go +5 -2
@@ -31,8 +31,11 @@ type overlayRuntime struct {
3131 pickerRemotePreview picker.Preview
3232 pickerRemotePreviewCancel context.CancelFunc
3333 pickerPreviewGeneration uint64
34- pickerPending []byte
35- pickerESC pendingByteTimer
34+ // pickerRemoteSelection fences one asynchronous remote picker activation.
35+ // It is cleared before the picker accepts another remote activation.
36+ pickerRemoteSelection *remotePickerSelection
37+ pickerPending []byte
38+ pickerESC pendingByteTimer
3639
3740 // Test-only, unsynchronized lifecycle seams. Assign them before picker
3841 // publication or goroutine startup. Hooks run without pickerMu or
@@ -31,8 +31,11 @@ type overlayRuntime struct {
31 pickerRemotePreview picker.Preview31 pickerRemotePreview picker.Preview
32 pickerRemotePreviewCancel context.CancelFunc32 pickerRemotePreviewCancel context.CancelFunc
33 pickerPreviewGeneration uint6433 pickerPreviewGeneration uint64
34- pickerPending []byte34+ // pickerRemoteSelection fences one asynchronous remote picker activation.
35- pickerESC pendingByteTimer35+ // It is cleared before the picker accepts another remote activation.
36+ pickerRemoteSelection *remotePickerSelection
37+ pickerPending []byte
38+ pickerESC pendingByteTimer
36 39
37 // Test-only, unsynchronized lifecycle seams. Assign them before picker40 // Test-only, unsynchronized lifecycle seams. Assign them before picker
38 // publication or goroutine startup. Hooks run without pickerMu or41 // publication or goroutine startup. Hooks run without pickerMu or
M internal/usecase/daemon/palette.go +4 -4
@@ -49,7 +49,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
4949 ac.overlays.paletteFeedback = ""
5050 ac.overlays.palettePending = nil
5151 ac.overlays.paletteMu.Unlock()
52- d.invalidateRender(sess, ac, true, "palette.go")
52+ d.invalidateAttachedOwner(ac, true, "palette.go")
5353 }
5454
5555 // paletteResults captures eligible named sessions before paletteMu so the
@@ -146,7 +146,7 @@ func (d *Daemon) recordPaletteUse(code string) {
146146 }
147147
148148 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
149- entry := ac.currentAttachmentSession()
149+ entry := ac.navigationSession()
150150 if entry == nil {
151151 return
152152 }
@@ -253,12 +253,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
253253 }
254254 ac.overlays.paletteMu.Unlock()
255255 if cancel {
256- d.invalidateRender(entry, ac, true, "palette.go")
256+ d.invalidateAttachedOwner(ac, true, "palette.go")
257257 return
258258 }
259259 if !execute {
260260 if changed {
261- d.invalidateRender(entry, ac, true, "palette.go")
261+ d.invalidateAttachedOwner(ac, true, "palette.go")
262262 }
263263 return
264264 }
@@ -49,7 +49,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
49 ac.overlays.paletteFeedback = ""49 ac.overlays.paletteFeedback = ""
50 ac.overlays.palettePending = nil50 ac.overlays.palettePending = nil
51 ac.overlays.paletteMu.Unlock()51 ac.overlays.paletteMu.Unlock()
52- d.invalidateRender(sess, ac, true, "palette.go")52+ d.invalidateAttachedOwner(ac, true, "palette.go")
53 }53 }
54 54
55 // paletteResults captures eligible named sessions before paletteMu so the55 // paletteResults captures eligible named sessions before paletteMu so the
@@ -146,7 +146,7 @@ func (d *Daemon) recordPaletteUse(code string) {
146 }146 }
147 147
148 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {148 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
149- entry := ac.currentAttachmentSession()149+ entry := ac.navigationSession()
150 if entry == nil {150 if entry == nil {
151 return151 return
152 }152 }
@@ -253,12 +253,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
253 }253 }
254 ac.overlays.paletteMu.Unlock()254 ac.overlays.paletteMu.Unlock()
255 if cancel {255 if cancel {
256- d.invalidateRender(entry, ac, true, "palette.go")256+ d.invalidateAttachedOwner(ac, true, "palette.go")
257 return257 return
258 }258 }
259 if !execute {259 if !execute {
260 if changed {260 if changed {
261- d.invalidateRender(entry, ac, true, "palette.go")261+ d.invalidateAttachedOwner(ac, true, "palette.go")
262 }262 }
263 return263 return
264 }264 }
M internal/usecase/daemon/picker.go +53 -76
@@ -63,6 +63,8 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
6363 rt := ac.overlays
6464 rt.pickerMu.Lock()
6565 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
66+ previousSelection := rt.pickerRemoteSelection
67+ rt.pickerRemoteSelection = nil
6668 rt.pickerPreviewGeneration++
6769 if rt.pickerRemotePreviewCancel != nil {
6870 rt.pickerRemotePreviewCancel()
@@ -80,9 +82,12 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
8082 rt.pickerPending = nil
8183 rt.pickerESC.stop()
8284 rt.pickerMu.Unlock()
85+ if previousSelection != nil {
86+ previousSelection.cancel()
87+ }
8388 d.teardownPreviewSubscription(ac, previous, previousGeneration)
8489 d.registerPreviewForSelection(ac)
85- d.invalidateRender(sess, ac, true, "picker.go")
90+ d.invalidateAttachedOwner(ac, true, "picker.go")
8691 if rt.beforeRemotePickerRegistration != nil {
8792 rt.beforeRemotePickerRegistration()
8893 }
@@ -264,6 +269,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
264269 rt := ac.overlays
265270 var previewGeneration uint64
266271 var instance remotePickerInstance
272+ var closed bool
267273 return listInputState{
268274 pending: &rt.pickerPending,
269275 esc: &rt.pickerESC,
@@ -273,6 +279,13 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
273279 unlock: rt.pickerMu.Unlock,
274280 active: func() bool { return rt.picker != nil },
275281 closeLocked: func() {
282+ if selection := rt.pickerRemoteSelection; selection != nil {
283+ rt.pickerRemoteSelection = nil
284+ rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
285+ selection.cancel()
286+ return
287+ }
288+ closed = true
276289 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
277290 rt.picker = nil
278291 rt.pickerIntent = pickerNavigate
@@ -280,17 +293,17 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
280293 previewGeneration = rt.pickerPreviewGeneration
281294 },
282295 afterClose: func() {
283- d.clearPreviewGeneration(ac, previewGeneration)
284- d.remotePickerClosed(instance)
285- if sess := ac.currentAttachmentSession(); sess != nil {
286- d.invalidateRender(sess, ac, true, "picker.go")
296+ if closed {
297+ d.clearPreviewGeneration(ac, previewGeneration)
298+ d.remotePickerClosed(instance)
287299 }
300+ d.invalidateAttachedOwner(ac, true, "picker.go")
288301 },
289302 }
290303 }
291304
292305 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
293- sess := ac.currentAttachmentSession()
306+ sess := ac.navigationSession()
294307 if sess == nil {
295308 return
296309 }
@@ -397,7 +410,29 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
397410 return
398411 }
399412 var err error
400- if len(effects) != 0 && effects[0] != nil {
413+ if target.RemoteTarget != nil {
414+ // Remote picker activation is always an attachment-owned async
415+ // transition. Unit/headless callers may omit the frame effect, so
416+ // admit a short-lived local effect here rather than closing the
417+ // picker and falling through to the client handoff path.
418+ var effect *attachmentEffectTicket
419+ ownedEffect := false
420+ if len(effects) != 0 && effects[0] != nil {
421+ effect = effects[0]
422+ } else {
423+ token := sess.attachmentToken(ac, ac.transport())
424+ var admitted bool
425+ effect, admitted = ac.beginAttachmentEffect(token)
426+ if !admitted {
427+ return
428+ }
429+ ownedEffect = true
430+ }
431+ err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
432+ if ownedEffect {
433+ effect.End()
434+ }
435+ } else if len(effects) != 0 && effects[0] != nil {
401436 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
402437 } else {
403438 d.closePicker(ac)
@@ -413,7 +448,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
413448 return
414449 }
415450 if result.exit || result.changed {
416- d.invalidateRender(sess, ac, true, "picker.go")
451+ d.invalidateAttachedOwner(ac, true, "picker.go")
417452 }
418453 }
419454
@@ -707,6 +742,8 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
707742 return false
708743 }
709744 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
745+ selection := ac.overlays.pickerRemoteSelection
746+ ac.overlays.pickerRemoteSelection = nil
710747 ac.overlays.picker = nil
711748 ac.overlays.pickerTitle = ""
712749 ac.overlays.pickerIntent = pickerNavigate
@@ -715,6 +752,9 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
715752 ac.overlays.pickerESC.stop()
716753 previewGeneration := ac.overlays.pickerPreviewGeneration
717754 ac.overlays.pickerMu.Unlock()
755+ if selection != nil {
756+ selection.cancel()
757+ }
718758 d.clearPreviewGeneration(ac, previewGeneration)
719759 if instance.model != nil {
720760 d.remotePickerClosed(instance)
@@ -857,14 +897,13 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
857897 return nil
858898 }
859899 if target.RemoteTarget != nil {
860- var key domain.RemoteSessionKey
861- if target.RemoteKey != nil {
862- key = *target.RemoteKey
863- }
864- return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
900+ return d.startRemotePickerSelection(token, target, guard, action)
865901 }
866902 if target.RemoteKey != nil {
867- return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
903+ // Picker-originated remote activation requires the complete validated
904+ // lifecycle/tab target. Legacy display keys must never fall back to a
905+ // client-owned attach handoff.
906+ return errAttachmentTransition
868907 }
869908 token.effect.bindActionEnd(d, action)
870909 token.effect.End()
@@ -875,68 +914,6 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
875914 return d.switchToTargetGuardedForAttachment(source, token.ac, target, guard, &token, action)
876915 }
877916
878-// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
879-// endpoint to the thin client. The daemon owns no remote session shadow: after
880-// the target is sent, the local attachment is detached and the client opens a
881-// fresh connection to the owning daemon.
882-func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
883- var handoff ports.AttachTarget
884- if target.RemoteTarget != nil {
885- remoteTarget := *target.RemoteTarget
886- if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
887- return errAttachmentTransition
888- }
889- if target.RemoteKey != nil {
890- if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
891- return errAttachmentTransition
892- }
893- }
894- handoff = ports.AttachTarget{
895- Endpoint: remoteTarget.Endpoint,
896- Session: remoteTarget.SessionName,
897- Intent: ports.IntentAttach,
898- RemoteTarget: &remoteTarget,
899- EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
900- }
901- } else {
902- if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
903- return errAttachmentTransition
904- }
905- handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach}
906- }
907- payload := ports.MarshalAttachTarget(handoff)
908- if payload == nil {
909- return errAttachmentTransition
910- }
911- if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
912- return err
913- }
914- if guard.closePicker {
915- d.closePicker(token.ac)
916- }
917- d.clientGoneForAttachment(token, false)
918- return nil
919-}
920-
921-func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
922- if d == nil {
923- return false
924- }
925- d.remoteCatalog.mu.Lock()
926- defer d.remoteCatalog.mu.Unlock()
927- for _, entry := range d.remoteCatalog.cache {
928- if entry.Host != key.Host {
929- continue
930- }
931- for _, session := range entry.Sessions {
932- if session.Name == key.Name {
933- return d.remoteCatalog.status[key.Host] != remoteHostVersionMismatch
934- }
935- }
936- }
937- return false
938-}
939-
940917 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
941918 if d == nil || target.Validate() != nil {
942919 return false
@@ -63,6 +63,8 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
63 rt := ac.overlays63 rt := ac.overlays
64 rt.pickerMu.Lock()64 rt.pickerMu.Lock()
65 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration65 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
66+ previousSelection := rt.pickerRemoteSelection
67+ rt.pickerRemoteSelection = nil
66 rt.pickerPreviewGeneration++68 rt.pickerPreviewGeneration++
67 if rt.pickerRemotePreviewCancel != nil {69 if rt.pickerRemotePreviewCancel != nil {
68 rt.pickerRemotePreviewCancel()70 rt.pickerRemotePreviewCancel()
@@ -80,9 +82,12 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
80 rt.pickerPending = nil82 rt.pickerPending = nil
81 rt.pickerESC.stop()83 rt.pickerESC.stop()
82 rt.pickerMu.Unlock()84 rt.pickerMu.Unlock()
85+ if previousSelection != nil {
86+ previousSelection.cancel()
87+ }
83 d.teardownPreviewSubscription(ac, previous, previousGeneration)88 d.teardownPreviewSubscription(ac, previous, previousGeneration)
84 d.registerPreviewForSelection(ac)89 d.registerPreviewForSelection(ac)
85- d.invalidateRender(sess, ac, true, "picker.go")90+ d.invalidateAttachedOwner(ac, true, "picker.go")
86 if rt.beforeRemotePickerRegistration != nil {91 if rt.beforeRemotePickerRegistration != nil {
87 rt.beforeRemotePickerRegistration()92 rt.beforeRemotePickerRegistration()
88 }93 }
@@ -264,6 +269,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
264 rt := ac.overlays269 rt := ac.overlays
265 var previewGeneration uint64270 var previewGeneration uint64
266 var instance remotePickerInstance271 var instance remotePickerInstance
272+ var closed bool
267 return listInputState{273 return listInputState{
268 pending: &rt.pickerPending,274 pending: &rt.pickerPending,
269 esc: &rt.pickerESC,275 esc: &rt.pickerESC,
@@ -273,6 +279,13 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
273 unlock: rt.pickerMu.Unlock,279 unlock: rt.pickerMu.Unlock,
274 active: func() bool { return rt.picker != nil },280 active: func() bool { return rt.picker != nil },
275 closeLocked: func() {281 closeLocked: func() {
282+ if selection := rt.pickerRemoteSelection; selection != nil {
283+ rt.pickerRemoteSelection = nil
284+ rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
285+ selection.cancel()
286+ return
287+ }
288+ closed = true
276 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}289 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
277 rt.picker = nil290 rt.picker = nil
278 rt.pickerIntent = pickerNavigate291 rt.pickerIntent = pickerNavigate
@@ -280,17 +293,17 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
280 previewGeneration = rt.pickerPreviewGeneration293 previewGeneration = rt.pickerPreviewGeneration
281 },294 },
282 afterClose: func() {295 afterClose: func() {
283- d.clearPreviewGeneration(ac, previewGeneration)296+ if closed {
284- d.remotePickerClosed(instance)297+ d.clearPreviewGeneration(ac, previewGeneration)
285- if sess := ac.currentAttachmentSession(); sess != nil {298+ d.remotePickerClosed(instance)
286- d.invalidateRender(sess, ac, true, "picker.go")
287 }299 }
300+ d.invalidateAttachedOwner(ac, true, "picker.go")
288 },301 },
289 }302 }
290 }303 }
291 304
292 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {305 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
293- sess := ac.currentAttachmentSession()306+ sess := ac.navigationSession()
294 if sess == nil {307 if sess == nil {
295 return308 return
296 }309 }
@@ -397,7 +410,29 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
397 return410 return
398 }411 }
399 var err error412 var err error
400- if len(effects) != 0 && effects[0] != nil {413+ if target.RemoteTarget != nil {
414+ // Remote picker activation is always an attachment-owned async
415+ // transition. Unit/headless callers may omit the frame effect, so
416+ // admit a short-lived local effect here rather than closing the
417+ // picker and falling through to the client handoff path.
418+ var effect *attachmentEffectTicket
419+ ownedEffect := false
420+ if len(effects) != 0 && effects[0] != nil {
421+ effect = effects[0]
422+ } else {
423+ token := sess.attachmentToken(ac, ac.transport())
424+ var admitted bool
425+ effect, admitted = ac.beginAttachmentEffect(token)
426+ if !admitted {
427+ return
428+ }
429+ ownedEffect = true
430+ }
431+ err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
432+ if ownedEffect {
433+ effect.End()
434+ }
435+ } else if len(effects) != 0 && effects[0] != nil {
401 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")436 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
402 } else {437 } else {
403 d.closePicker(ac)438 d.closePicker(ac)
@@ -413,7 +448,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
413 return448 return
414 }449 }
415 if result.exit || result.changed {450 if result.exit || result.changed {
416- d.invalidateRender(sess, ac, true, "picker.go")451+ d.invalidateAttachedOwner(ac, true, "picker.go")
417 }452 }
418 }453 }
419 454
@@ -707,6 +742,8 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
707 return false742 return false
708 }743 }
709 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}744 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
745+ selection := ac.overlays.pickerRemoteSelection
746+ ac.overlays.pickerRemoteSelection = nil
710 ac.overlays.picker = nil747 ac.overlays.picker = nil
711 ac.overlays.pickerTitle = ""748 ac.overlays.pickerTitle = ""
712 ac.overlays.pickerIntent = pickerNavigate749 ac.overlays.pickerIntent = pickerNavigate
@@ -715,6 +752,9 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
715 ac.overlays.pickerESC.stop()752 ac.overlays.pickerESC.stop()
716 previewGeneration := ac.overlays.pickerPreviewGeneration753 previewGeneration := ac.overlays.pickerPreviewGeneration
717 ac.overlays.pickerMu.Unlock()754 ac.overlays.pickerMu.Unlock()
755+ if selection != nil {
756+ selection.cancel()
757+ }
718 d.clearPreviewGeneration(ac, previewGeneration)758 d.clearPreviewGeneration(ac, previewGeneration)
719 if instance.model != nil {759 if instance.model != nil {
720 d.remotePickerClosed(instance)760 d.remotePickerClosed(instance)
@@ -857,14 +897,13 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
857 return nil897 return nil
858 }898 }
859 if target.RemoteTarget != nil {899 if target.RemoteTarget != nil {
860- var key domain.RemoteSessionKey900+ return d.startRemotePickerSelection(token, target, guard, action)
861- if target.RemoteKey != nil {
862- key = *target.RemoteKey
863- }
864- return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
865 }901 }
866 if target.RemoteKey != nil {902 if target.RemoteKey != nil {
867- return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)903+ // Picker-originated remote activation requires the complete validated
904+ // lifecycle/tab target. Legacy display keys must never fall back to a
905+ // client-owned attach handoff.
906+ return errAttachmentTransition
868 }907 }
869 token.effect.bindActionEnd(d, action)908 token.effect.bindActionEnd(d, action)
870 token.effect.End()909 token.effect.End()
@@ -875,68 +914,6 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
875 return d.switchToTargetGuardedForAttachment(source, token.ac, target, guard, &token, action)914 return d.switchToTargetGuardedForAttachment(source, token.ac, target, guard, &token, action)
876 }915 }
877 916
878-// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
879-// endpoint to the thin client. The daemon owns no remote session shadow: after
880-// the target is sent, the local attachment is detached and the client opens a
881-// fresh connection to the owning daemon.
882-func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
883- var handoff ports.AttachTarget
884- if target.RemoteTarget != nil {
885- remoteTarget := *target.RemoteTarget
886- if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
887- return errAttachmentTransition
888- }
889- if target.RemoteKey != nil {
890- if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
891- return errAttachmentTransition
892- }
893- }
894- handoff = ports.AttachTarget{
895- Endpoint: remoteTarget.Endpoint,
896- Session: remoteTarget.SessionName,
897- Intent: ports.IntentAttach,
898- RemoteTarget: &remoteTarget,
899- EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
900- }
901- } else {
902- if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
903- return errAttachmentTransition
904- }
905- handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach}
906- }
907- payload := ports.MarshalAttachTarget(handoff)
908- if payload == nil {
909- return errAttachmentTransition
910- }
911- if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
912- return err
913- }
914- if guard.closePicker {
915- d.closePicker(token.ac)
916- }
917- d.clientGoneForAttachment(token, false)
918- return nil
919-}
920-
921-func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
922- if d == nil {
923- return false
924- }
925- d.remoteCatalog.mu.Lock()
926- defer d.remoteCatalog.mu.Unlock()
927- for _, entry := range d.remoteCatalog.cache {
928- if entry.Host != key.Host {
929- continue
930- }
931- for _, session := range entry.Sessions {
932- if session.Name == key.Name {
933- return d.remoteCatalog.status[key.Host] != remoteHostVersionMismatch
934- }
935- }
936- }
937- return false
938-}
939-
940 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {917 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
941 if d == nil || target.Validate() != nil {918 if d == nil || target.Validate() != nil {
942 return false919 return false
M internal/usecase/daemon/remote_attach_test.go +15 -32
@@ -17,36 +17,7 @@ func remoteLifecycleForTest() domain.SessionLifecycleID {
1717 return id
1818 }
1919
20-func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
21- d := newRemotePickerDaemon(nil)
22- lifecycle := remoteLifecycleForTest()
23- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
24- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
25- LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
26- }},
27- }})
28- d.remoteCatalog.mu.Lock()
29- d.remoteCatalog.status["arch"] = remoteHostFresh
30- d.remoteCatalog.mu.Unlock()
31- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
32- token := sess.attachmentToken(ac, ac.transport())
33- effect, admitted := ac.beginAttachmentEffect(token)
34- require.True(t, admitted)
35- token.effect = effect
36- defer effect.End()
37- key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
38- remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
39- target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
40- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
41- frame := receiveRemotePicker(t, sends, "rich attach target")
42- got, err := ports.UnmarshalAttachTarget(frame.Payload)
43- require.NoError(t, err)
44- require.NotNil(t, got.RemoteTarget)
45- require.Equal(t, remoteTarget, *got.RemoteTarget)
46- require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy)
47-}
48-
49-func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
20+func TestRemotePickerSelectionRejectsReplacedLifecycle(t *testing.T) {
5021 d := newRemotePickerDaemon(nil)
5122 lifecycle := remoteLifecycleForTest()
5223 cachedLifecycle := lifecycle
@@ -59,7 +30,11 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
5930 d.remoteCatalog.mu.Lock()
6031 d.remoteCatalog.status["arch"] = remoteHostFresh
6132 d.remoteCatalog.mu.Unlock()
62- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
33+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
34+ ac.overlays.pickerMu.Lock()
35+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
36+ ac.overlays.pickerGeneration++
37+ ac.overlays.pickerMu.Unlock()
6338 token := sess.attachmentToken(ac, ac.transport())
6439 effect, admitted := ac.beginAttachmentEffect(token)
6540 require.True(t, admitted)
@@ -68,5 +43,13 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
6843 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
6944 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
7045 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
71- require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
46+
47+ require.ErrorIs(t, d.switchToTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select"), errAttachmentTransition)
48+ require.Same(t, sess, ac.currentAttachmentSession())
49+ require.True(t, ac.overlays.pickerActive())
50+ select {
51+ case frame := <-sends:
52+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
53+ default:
54+ }
7255 }
@@ -17,36 +17,7 @@ func remoteLifecycleForTest() domain.SessionLifecycleID {
17 return id17 return id
18 }18 }
19 19
20-func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {20+func TestRemotePickerSelectionRejectsReplacedLifecycle(t *testing.T) {
21- d := newRemotePickerDaemon(nil)
22- lifecycle := remoteLifecycleForTest()
23- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
24- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
25- LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
26- }},
27- }})
28- d.remoteCatalog.mu.Lock()
29- d.remoteCatalog.status["arch"] = remoteHostFresh
30- d.remoteCatalog.mu.Unlock()
31- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
32- token := sess.attachmentToken(ac, ac.transport())
33- effect, admitted := ac.beginAttachmentEffect(token)
34- require.True(t, admitted)
35- token.effect = effect
36- defer effect.End()
37- key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
38- remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
39- target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
40- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
41- frame := receiveRemotePicker(t, sends, "rich attach target")
42- got, err := ports.UnmarshalAttachTarget(frame.Payload)
43- require.NoError(t, err)
44- require.NotNil(t, got.RemoteTarget)
45- require.Equal(t, remoteTarget, *got.RemoteTarget)
46- require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy)
47-}
48-
49-func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
50 d := newRemotePickerDaemon(nil)21 d := newRemotePickerDaemon(nil)
51 lifecycle := remoteLifecycleForTest()22 lifecycle := remoteLifecycleForTest()
52 cachedLifecycle := lifecycle23 cachedLifecycle := lifecycle
@@ -59,7 +30,11 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
59 d.remoteCatalog.mu.Lock()30 d.remoteCatalog.mu.Lock()
60 d.remoteCatalog.status["arch"] = remoteHostFresh31 d.remoteCatalog.status["arch"] = remoteHostFresh
61 d.remoteCatalog.mu.Unlock()32 d.remoteCatalog.mu.Unlock()
62- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")33+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
34+ ac.overlays.pickerMu.Lock()
35+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
36+ ac.overlays.pickerGeneration++
37+ ac.overlays.pickerMu.Unlock()
63 token := sess.attachmentToken(ac, ac.transport())38 token := sess.attachmentToken(ac, ac.transport())
64 effect, admitted := ac.beginAttachmentEffect(token)39 effect, admitted := ac.beginAttachmentEffect(token)
65 require.True(t, admitted)40 require.True(t, admitted)
@@ -68,5 +43,13 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
68 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}43 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
69 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}44 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
70 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}45 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
71- require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)46+
47+ require.ErrorIs(t, d.switchToTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select"), errAttachmentTransition)
48+ require.Same(t, sess, ac.currentAttachmentSession())
49+ require.True(t, ac.overlays.pickerActive())
50+ select {
51+ case frame := <-sends:
52+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
53+ default:
54+ }
72 }55 }
M internal/usecase/daemon/remote_attachment_transition.go +29 -0
@@ -5,6 +5,28 @@ package daemon
55 // I/O deliberately happen before this function; this function only performs
66 // the frozen, exact local ownership hand-off.
77 func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target *remoteView) (attachmentConnectionToken, error) {
8+ return d.transitionToRemoteViewGuarded(token, target, nil, nil, 0)
9+}
10+
11+// transitionToRemoteViewForPicker carries the picker lifecycle fence through
12+// the attachment freeze boundary. A completed remote handshake is insufficient
13+// to move the client if Escape, picker replacement, or source transport loss
14+// won while it was in flight.
15+func (d *Daemon) transitionToRemoteViewForPicker(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection) (attachmentConnectionToken, error) {
16+ if target == nil {
17+ return attachmentConnectionToken{}, errAttachmentTransition
18+ }
19+ target.mu.Lock()
20+ link, generation := target.link, target.linkGeneration
21+ healthy := remoteViewLinkReusableLocked(target)
22+ target.mu.Unlock()
23+ if !healthy {
24+ return attachmentConnectionToken{}, errAttachmentTransition
25+ }
26+ return d.transitionToRemoteViewGuarded(token, target, selection, link, generation)
27+}
28+
29+func (d *Daemon) transitionToRemoteViewGuarded(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection, expectedLink *remoteLink, expectedLinkGeneration uint64) (attachmentConnectionToken, error) {
830 source := token.localSession()
931 if d == nil || source == nil || token.ac == nil || target == nil {
1032 return attachmentConnectionToken{}, errAttachmentTransition
@@ -17,6 +39,9 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target
1739 if !frozen.acquired || !frozen.drained {
1840 return attachmentConnectionToken{}, errAttachmentTransition
1941 }
42+ if selection != nil && !selection.current() {
43+ return attachmentConnectionToken{}, errAttachmentTransition
44+ }
2045
2146 d.mu.Lock()
2247 if d.closing || d.sessions[source.id] != source || !d.attachmentOwnerRegisteredByDaemonLocked(target) {
@@ -32,6 +57,10 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target
3257 token.ac.transportSnapshotCurrent(token.transport) &&
3358 attachmentRegisteredLocked(source, token.ac) &&
3459 !target.closed
60+ if selection != nil {
61+ current = current && expectedLink != nil && target.link == expectedLink &&
62+ target.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(target)
63+ }
3564 if !current {
3665 target.mu.Unlock()
3766 source.mu.Unlock()
@@ -5,6 +5,28 @@ package daemon
5 // I/O deliberately happen before this function; this function only performs5 // I/O deliberately happen before this function; this function only performs
6 // the frozen, exact local ownership hand-off.6 // the frozen, exact local ownership hand-off.
7 func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target *remoteView) (attachmentConnectionToken, error) {7 func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target *remoteView) (attachmentConnectionToken, error) {
8+ return d.transitionToRemoteViewGuarded(token, target, nil, nil, 0)
9+}
10+
11+// transitionToRemoteViewForPicker carries the picker lifecycle fence through
12+// the attachment freeze boundary. A completed remote handshake is insufficient
13+// to move the client if Escape, picker replacement, or source transport loss
14+// won while it was in flight.
15+func (d *Daemon) transitionToRemoteViewForPicker(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection) (attachmentConnectionToken, error) {
16+ if target == nil {
17+ return attachmentConnectionToken{}, errAttachmentTransition
18+ }
19+ target.mu.Lock()
20+ link, generation := target.link, target.linkGeneration
21+ healthy := remoteViewLinkReusableLocked(target)
22+ target.mu.Unlock()
23+ if !healthy {
24+ return attachmentConnectionToken{}, errAttachmentTransition
25+ }
26+ return d.transitionToRemoteViewGuarded(token, target, selection, link, generation)
27+}
28+
29+func (d *Daemon) transitionToRemoteViewGuarded(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection, expectedLink *remoteLink, expectedLinkGeneration uint64) (attachmentConnectionToken, error) {
8 source := token.localSession()30 source := token.localSession()
9 if d == nil || source == nil || token.ac == nil || target == nil {31 if d == nil || source == nil || token.ac == nil || target == nil {
10 return attachmentConnectionToken{}, errAttachmentTransition32 return attachmentConnectionToken{}, errAttachmentTransition
@@ -17,6 +39,9 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target
17 if !frozen.acquired || !frozen.drained {39 if !frozen.acquired || !frozen.drained {
18 return attachmentConnectionToken{}, errAttachmentTransition40 return attachmentConnectionToken{}, errAttachmentTransition
19 }41 }
42+ if selection != nil && !selection.current() {
43+ return attachmentConnectionToken{}, errAttachmentTransition
44+ }
20 45
21 d.mu.Lock()46 d.mu.Lock()
22 if d.closing || d.sessions[source.id] != source || !d.attachmentOwnerRegisteredByDaemonLocked(target) {47 if d.closing || d.sessions[source.id] != source || !d.attachmentOwnerRegisteredByDaemonLocked(target) {
@@ -32,6 +57,10 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target
32 token.ac.transportSnapshotCurrent(token.transport) &&57 token.ac.transportSnapshotCurrent(token.transport) &&
33 attachmentRegisteredLocked(source, token.ac) &&58 attachmentRegisteredLocked(source, token.ac) &&
34 !target.closed59 !target.closed
60+ if selection != nil {
61+ current = current && expectedLink != nil && target.link == expectedLink &&
62+ target.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(target)
63+ }
35 if !current {64 if !current {
36 target.mu.Unlock()65 target.mu.Unlock()
37 source.mu.Unlock()66 source.mu.Unlock()
A internal/usecase/daemon/remote_attachment_transition_picker_test.go +36 -0
@@ -0,0 +1,36 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/picker"
10+)
11+
12+func TestPickerTransitionRejectsInactiveRemoteLink(t *testing.T) {
13+ d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
14+ view := &remoteView{key: remoteViewKey{
15+ endpoint: "remote.example", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote",
16+ }}
17+ link := &remoteLink{view: view, generation: 1, transport: newRemoteLinkTestTransport()}
18+ view.link, view.linkGeneration = link, 1
19+ d.mu.Lock()
20+ require.NoError(t, d.registerRemoteViewLocked(view))
21+ d.mu.Unlock()
22+
23+ model := picker.New(nil, picker.SelectionConfig{})
24+ ac.overlays.pickerMu.Lock()
25+ ac.overlays.picker = model
26+ ac.overlays.pickerGeneration++
27+ selection := &remotePickerSelection{model: model, generation: ac.overlays.pickerGeneration, token: source.attachmentToken(ac, ac.transport())}
28+ ac.overlays.pickerRemoteSelection = selection
29+ ac.overlays.pickerMu.Unlock()
30+
31+ _, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)
32+ require.ErrorIs(t, err, errAttachmentTransition)
33+ require.Same(t, source, ac.currentAttachmentSession())
34+ require.True(t, source.attachmentRegistered(ac))
35+ require.False(t, view.attachmentRegistered(ac))
36+}
@@ -0,0 +1,36 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/picker"
10+)
11+
12+func TestPickerTransitionRejectsInactiveRemoteLink(t *testing.T) {
13+ d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
14+ view := &remoteView{key: remoteViewKey{
15+ endpoint: "remote.example", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote",
16+ }}
17+ link := &remoteLink{view: view, generation: 1, transport: newRemoteLinkTestTransport()}
18+ view.link, view.linkGeneration = link, 1
19+ d.mu.Lock()
20+ require.NoError(t, d.registerRemoteViewLocked(view))
21+ d.mu.Unlock()
22+
23+ model := picker.New(nil, picker.SelectionConfig{})
24+ ac.overlays.pickerMu.Lock()
25+ ac.overlays.picker = model
26+ ac.overlays.pickerGeneration++
27+ selection := &remotePickerSelection{model: model, generation: ac.overlays.pickerGeneration, token: source.attachmentToken(ac, ac.transport())}
28+ ac.overlays.pickerRemoteSelection = selection
29+ ac.overlays.pickerMu.Unlock()
30+
31+ _, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)
32+ require.ErrorIs(t, err, errAttachmentTransition)
33+ require.Same(t, source, ac.currentAttachmentSession())
34+ require.True(t, source.attachmentRegistered(ac))
35+ require.False(t, view.attachmentRegistered(ac))
36+}
M internal/usecase/daemon/remote_frame_routing_test.go +86 -0
@@ -6,6 +6,7 @@ import (
66
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/keys"
910 "github.com/bnema/vev/pkg/renderer"
1011 "github.com/bnema/vev/pkg/vt"
1112 "github.com/stretchr/testify/require"
@@ -39,6 +40,91 @@ func newRemoteFrameRoutingFixture(t *testing.T) (*Daemon, *remoteView, *attached
3940 return d, view, ac, token, sends
4041 }
4142
43+func TestRemoteViewRenderSnapshotDoesNotResizeSharedScreen(t *testing.T) {
44+ view := &remoteView{screen: vt.NewScreen(80, 22)}
45+
46+ snapshot, ok := view.renderSnapshot(domain.Size{Cols: 100, Rows: 28})
47+
48+ require.True(t, ok)
49+ require.Equal(t, 80, snapshot.frame.Width)
50+ require.Equal(t, 22, snapshot.frame.Height)
51+ view.mu.Lock()
52+ require.Equal(t, 80, view.screen.Frame.Width)
53+ require.Equal(t, 22, view.screen.Frame.Height)
54+ view.mu.Unlock()
55+}
56+
57+func TestRemoteFrameRoutingKeepsPickerNavigationLocal(t *testing.T) {
58+ d, source, ac, view, token := remoteBackFixture(t)
59+ remoteTransport := newRemoteLinkTestTransport()
60+ view.mu.Lock()
61+ view.linkGeneration = 1
62+ view.link = &remoteLink{view: view, generation: 1, transport: remoteTransport, active: true}
63+ view.mu.Unlock()
64+
65+ otherTab := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
66+ otherTab.stableID = "other-tab"
67+ other := &session{sessionCore: sessionCore{id: "other", name: "other", attachments: make(map[*attachedClient]struct{})}, tabs: []*tab{otherTab}}
68+ publishTiledPaneOwners(other, otherTab)
69+ d.mu.Lock()
70+ d.sessions[other.id] = other
71+ d.mu.Unlock()
72+
73+ d.enterPicker(source, ac)
74+ ac.overlays.pickerMu.Lock()
75+ before, ok := ac.overlays.picker.Selected()
76+ ac.overlays.pickerMu.Unlock()
77+ require.True(t, ok)
78+ d.handleAttachmentClientFrame(token, frameInput([]byte("j")))
79+
80+ ac.overlays.pickerMu.Lock()
81+ selected, ok := ac.overlays.picker.Selected()
82+ ac.overlays.pickerMu.Unlock()
83+ require.True(t, ok)
84+ require.NotEqual(t, before.Session, selected.Session, "picker navigation remains local while viewing remote content")
85+ select {
86+ case frame := <-remoteTransport.sent:
87+ t.Fatalf("picker navigation was forwarded to remote link as %v", frame.Type)
88+ default:
89+ }
90+}
91+
92+func TestRemoteFrameRoutingForwardsOrdinaryTerminalInput(t *testing.T) {
93+ d, _, _, view, token := remoteBackFixture(t)
94+ remoteTransport := newRemoteLinkTestTransport()
95+ view.mu.Lock()
96+ view.linkGeneration = 1
97+ view.link = &remoteLink{view: view, generation: 1, transport: remoteTransport, active: true}
98+ view.mu.Unlock()
99+
100+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
101+
102+ frame := awaitFrame(t, remoteTransport.sent, ports.MsgInput)
103+ input, err := ports.UnmarshalInput(frame.Payload)
104+ require.NoError(t, err)
105+ require.Equal(t, []byte("x"), input.Data)
106+}
107+
108+func TestRemoteFrameRoutingOpensPaletteLocally(t *testing.T) {
109+ d, _, ac, view, token := remoteBackFixture(t)
110+ remoteTransport := newRemoteLinkTestTransport()
111+ view.mu.Lock()
112+ view.linkGeneration = 1
113+ view.link = &remoteLink{view: view, generation: 1, transport: remoteTransport, active: true}
114+ view.mu.Unlock()
115+
116+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
117+
118+ require.True(t, ac.overlays.paletteActive(), "the local palette remains reachable while remote content is active")
119+ d.handleAttachmentClientFrame(token, frameInput([]byte("SSP\r")))
120+ require.True(t, ac.overlays.pickerActive(), "the local session picker reopens from the remote view")
121+ select {
122+ case frame := <-remoteTransport.sent:
123+ t.Fatalf("palette shortcut was forwarded to remote link as %v", frame.Type)
124+ default:
125+ }
126+}
127+
42128 func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) {
43129 d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
44130
@@ -6,6 +6,7 @@ import (
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/keys"
9 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
10 "github.com/bnema/vev/pkg/vt"11 "github.com/bnema/vev/pkg/vt"
11 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
@@ -39,6 +40,91 @@ func newRemoteFrameRoutingFixture(t *testing.T) (*Daemon, *remoteView, *attached
39 return d, view, ac, token, sends40 return d, view, ac, token, sends
40 }41 }
41 42
43+func TestRemoteViewRenderSnapshotDoesNotResizeSharedScreen(t *testing.T) {
44+ view := &remoteView{screen: vt.NewScreen(80, 22)}
45+
46+ snapshot, ok := view.renderSnapshot(domain.Size{Cols: 100, Rows: 28})
47+
48+ require.True(t, ok)
49+ require.Equal(t, 80, snapshot.frame.Width)
50+ require.Equal(t, 22, snapshot.frame.Height)
51+ view.mu.Lock()
52+ require.Equal(t, 80, view.screen.Frame.Width)
53+ require.Equal(t, 22, view.screen.Frame.Height)
54+ view.mu.Unlock()
55+}
56+
57+func TestRemoteFrameRoutingKeepsPickerNavigationLocal(t *testing.T) {
58+ d, source, ac, view, token := remoteBackFixture(t)
59+ remoteTransport := newRemoteLinkTestTransport()
60+ view.mu.Lock()
61+ view.linkGeneration = 1
62+ view.link = &remoteLink{view: view, generation: 1, transport: remoteTransport, active: true}
63+ view.mu.Unlock()
64+
65+ otherTab := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
66+ otherTab.stableID = "other-tab"
67+ other := &session{sessionCore: sessionCore{id: "other", name: "other", attachments: make(map[*attachedClient]struct{})}, tabs: []*tab{otherTab}}
68+ publishTiledPaneOwners(other, otherTab)
69+ d.mu.Lock()
70+ d.sessions[other.id] = other
71+ d.mu.Unlock()
72+
73+ d.enterPicker(source, ac)
74+ ac.overlays.pickerMu.Lock()
75+ before, ok := ac.overlays.picker.Selected()
76+ ac.overlays.pickerMu.Unlock()
77+ require.True(t, ok)
78+ d.handleAttachmentClientFrame(token, frameInput([]byte("j")))
79+
80+ ac.overlays.pickerMu.Lock()
81+ selected, ok := ac.overlays.picker.Selected()
82+ ac.overlays.pickerMu.Unlock()
83+ require.True(t, ok)
84+ require.NotEqual(t, before.Session, selected.Session, "picker navigation remains local while viewing remote content")
85+ select {
86+ case frame := <-remoteTransport.sent:
87+ t.Fatalf("picker navigation was forwarded to remote link as %v", frame.Type)
88+ default:
89+ }
90+}
91+
92+func TestRemoteFrameRoutingForwardsOrdinaryTerminalInput(t *testing.T) {
93+ d, _, _, view, token := remoteBackFixture(t)
94+ remoteTransport := newRemoteLinkTestTransport()
95+ view.mu.Lock()
96+ view.linkGeneration = 1
97+ view.link = &remoteLink{view: view, generation: 1, transport: remoteTransport, active: true}
98+ view.mu.Unlock()
99+
100+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
101+
102+ frame := awaitFrame(t, remoteTransport.sent, ports.MsgInput)
103+ input, err := ports.UnmarshalInput(frame.Payload)
104+ require.NoError(t, err)
105+ require.Equal(t, []byte("x"), input.Data)
106+}
107+
108+func TestRemoteFrameRoutingOpensPaletteLocally(t *testing.T) {
109+ d, _, ac, view, token := remoteBackFixture(t)
110+ remoteTransport := newRemoteLinkTestTransport()
111+ view.mu.Lock()
112+ view.linkGeneration = 1
113+ view.link = &remoteLink{view: view, generation: 1, transport: remoteTransport, active: true}
114+ view.mu.Unlock()
115+
116+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
117+
118+ require.True(t, ac.overlays.paletteActive(), "the local palette remains reachable while remote content is active")
119+ d.handleAttachmentClientFrame(token, frameInput([]byte("SSP\r")))
120+ require.True(t, ac.overlays.pickerActive(), "the local session picker reopens from the remote view")
121+ select {
122+ case frame := <-remoteTransport.sent:
123+ t.Fatalf("palette shortcut was forwarded to remote link as %v", frame.Type)
124+ default:
125+ }
126+}
127+
42 func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) {128 func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) {
43 d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)129 d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
44 130
M internal/usecase/daemon/remote_link.go +110 -8
@@ -45,6 +45,7 @@ type remoteLink struct {
4545 cancel context.CancelFunc
4646
4747 sendMu sync.Mutex
48+ commands *CommandRequestTracker
4849 startOnce sync.Once
4950 workerStarted atomic.Bool
5051 done chan struct{}
@@ -301,8 +302,12 @@ func installRemoteViewCandidateLocked(view, candidate *remoteView) (*remoteLink,
301302 view.screen = candidate.screen
302303 view.metadata = cloneRemoteSessionMeta(candidate.metadata)
303304 view.displayOrigin = candidate.displayOrigin
305+ view.reconnectTarget = candidate.reconnectTarget
304306 view.output = candidate.output
305307 view.resetRequested = candidate.resetRequested
308+ view.linkState = remoteViewLinkHealthy
309+ view.reconnectGeneration++
310+ signalRemoteViewMetadataChangedLocked(view)
306311 if replaced != nil {
307312 replaced.active = false
308313 }
@@ -314,6 +319,7 @@ func stopAndJoinRemoteLink(link *remoteLink) {
314319 if link == nil {
315320 return
316321 }
322+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
317323 if link.cancel != nil {
318324 link.cancel()
319325 }
@@ -359,6 +365,7 @@ func (d *Daemon) constructRemoteView(ctx context.Context, target domain.RemoteSe
359365 target: target,
360366 clientID: clientID,
361367 cancel: cancelLink,
368+ commands: NewCommandRequestTracker(),
362369 done: make(chan struct{}),
363370 active: true,
364371 }
@@ -507,17 +514,56 @@ func validateInitialRemoteLinkMetadata(frame ports.Frame, target domain.RemoteSe
507514 if metadata.LifecycleID != target.LifecycleID || metadata.SessionName != target.SessionName || metadata.Revision != 1 {
508515 return ports.SessionMeta{}, errors.New("remote view: metadata identity mismatch")
509516 }
510- if !target.Stopped && metadata.ActiveTabID != target.LiveTabID {
511- return ports.SessionMeta{}, errors.New("remote view: metadata active tab mismatch")
517+ if err := validateInitialRemoteLinkTarget(metadata, target); err != nil {
518+ return ports.SessionMeta{}, err
512519 }
513520 return metadata, nil
514521 }
515522
523+func validateInitialRemoteLinkTarget(metadata ports.SessionMeta, target domain.RemoteSessionTarget) error {
524+ if err := ports.ValidateSessionMeta(metadata); err != nil {
525+ return fmt.Errorf("remote view: invalid initial metadata: %w", err)
526+ }
527+ if !target.Stopped {
528+ if metadata.ActiveTabID != target.LiveTabID {
529+ return errors.New("remote view: metadata active tab mismatch")
530+ }
531+ return nil
532+ }
533+
534+ tabs := make([]domain.TabSelectorTab, len(metadata.Tabs))
535+ for i, tab := range metadata.Tabs {
536+ tabs[i] = domain.TabSelectorTab{ID: tab.ID, Name: tab.Name}
537+ }
538+ selected, ok := target.StoppedTab.Resolve(tabs)
539+ if !ok {
540+ return errors.New("remote view: stopped tab selector mismatch")
541+ }
542+ if metadata.ActiveTabID != tabs[selected].ID {
543+ return errors.New("remote view: metadata active tab mismatch")
544+ }
545+ return nil
546+}
547+
516548 func cloneRemoteSessionMeta(metadata ports.SessionMeta) ports.SessionMeta {
517549 metadata.Tabs = append([]ports.SessionTabMeta(nil), metadata.Tabs...)
518550 return metadata
519551 }
520552
553+// signalRemoteViewMetadataChangedLocked wakes waiters after an exact remote
554+// metadata or link-generation change. The caller holds view.mu and must not
555+// perform rendering or transport work before releasing it.
556+func signalRemoteViewMetadataChangedLocked(view *remoteView) {
557+ if view == nil {
558+ return
559+ }
560+ previous := view.metadataChanged
561+ view.metadataChanged = make(chan struct{})
562+ if previous != nil {
563+ close(previous)
564+ }
565+}
566+
521567 type remoteMetadataResult uint8
522568
523569 const (
@@ -558,6 +604,12 @@ func (v *remoteView) applyRemoteMetadataWithAttachments(link *remoteLink, metada
558604 return remoteMetadataStale, nil
559605 }
560606 v.metadata = cloneRemoteSessionMeta(metadata)
607+ reconnectTarget := v.reconnectTarget
608+ if reconnectTarget.Validate() != nil {
609+ reconnectTarget = link.target
610+ }
611+ v.reconnectTarget = runningRemoteTarget(reconnectTarget, metadata.ActiveTabID)
612+ signalRemoteViewMetadataChangedLocked(v)
561613 if initial {
562614 return remoteMetadataAccepted, nil
563615 }
@@ -681,6 +733,15 @@ func (d *Daemon) handleRemoteLinkFrame(link *remoteLink, frame ports.Frame) erro
681733 default:
682734 return errors.New("remote view: invalid metadata update")
683735 }
736+ case ports.MsgCommandResult:
737+ result, err := ports.UnmarshalCommandResult(frame.Payload)
738+ if err != nil {
739+ return fmt.Errorf("remote view: decode command result: %w", err)
740+ }
741+ if link.commands != nil {
742+ link.commands.Complete(link.generation, result)
743+ }
744+ return nil
684745 case ports.MsgPing:
685746 if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
686747 return fmt.Errorf("remote view: decode ping: %w", err)
@@ -690,11 +751,11 @@ func (d *Daemon) handleRemoteLinkFrame(link *remoteLink, frame ports.Frame) erro
690751 _, err := ports.UnmarshalPong(frame.Payload)
691752 return err
692753 case ports.MsgDetached:
693- _, err := ports.UnmarshalDetached(frame.Payload)
754+ detached, err := ports.UnmarshalDetached(frame.Payload)
694755 if err != nil {
695756 return fmt.Errorf("remote view: decode detached: %w", err)
696757 }
697- d.retireTerminalRemoteView(link, ports.ReasonSessionKilled)
758+ d.retireTerminalRemoteView(link, detached.Reason)
698759 return nil
699760 case ports.MsgError:
700761 remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
@@ -743,8 +804,10 @@ func (link *remoteLink) send(frame ports.Frame) error {
743804 view.mu.Lock()
744805 if view.link == link && view.linkGeneration == link.generation {
745806 link.active = false
807+ signalRemoteViewMetadataChangedLocked(view)
746808 }
747809 view.mu.Unlock()
810+ link.commands.FailGeneration(link.generation, err)
748811 link.cancel()
749812 _ = transport.Close()
750813 }
@@ -776,24 +839,59 @@ func (d *Daemon) sendRemoteViewFrame(view *remoteView, frame ports.Frame) error
776839 if link == nil {
777840 return errRemoteViewUnavailable
778841 }
779- return link.send(frame)
842+ err := link.send(frame)
843+ if err != nil {
844+ d.markRemoteLinkUnavailable(link)
845+ }
846+ return err
780847 }
781848
782849 func (d *Daemon) markRemoteLinkUnavailable(link *remoteLink) {
783- if link == nil || link.view == nil {
850+ if d == nil || link == nil || link.view == nil {
784851 return
785852 }
786853 view := link.view
787854 view.mu.Lock()
788855 current := !view.closed && view.link == link && view.linkGeneration == link.generation
856+ var (
857+ attachments []*attachedClient
858+ startReconnect bool
859+ reconnectGeneration uint64
860+ reconnectSize domain.Size
861+ reconnectTarget domain.RemoteSessionTarget
862+ )
789863 if current {
790864 link.active = false
865+ signalRemoteViewMetadataChangedLocked(view)
866+ attachments = make([]*attachedClient, 0, len(view.attachments))
867+ for attachment := range view.attachments {
868+ attachments = append(attachments, attachment)
869+ }
870+ if view.linkState == remoteViewLinkHealthy {
871+ view.linkState = remoteViewLinkReconnecting
872+ view.reconnectGeneration++
873+ reconnectGeneration = view.reconnectGeneration
874+ startReconnect = len(attachments) != 0
875+ reconnectSize = reconnectSizeLocked(view)
876+ reconnectTarget = view.reconnectTarget
877+ if reconnectTarget.Validate() != nil {
878+ reconnectTarget = link.target
879+ }
880+ }
791881 }
792882 view.mu.Unlock()
793- if current {
794- link.cancel()
883+ if !current {
884+ return
885+ }
886+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
887+ link.cancel()
888+ if link.transport != nil {
795889 _ = link.transport.Close()
796890 }
891+ d.repaintRemoteViewAttachments(view, attachments)
892+ if startReconnect {
893+ go d.reconnectRemoteView(view, link, reconnectGeneration, reconnectTarget, reconnectSize)
894+ }
797895 }
798896
799897 // stopUnpublishedRemoteView is used for candidate failure and lost-election
@@ -808,8 +906,10 @@ func (d *Daemon) stopUnpublishedRemoteView(view *remoteView) {
808906 link := view.link
809907 view.link = nil
810908 view.linkGeneration++
909+ signalRemoteViewMetadataChangedLocked(view)
811910 view.mu.Unlock()
812911 if link != nil {
912+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
813913 link.cancel()
814914 if link.transport != nil {
815915 _ = link.transport.Close()
@@ -831,9 +931,11 @@ func (d *Daemon) stopRemoteViewLink(view *remoteView) *remoteLink {
831931 view.link = nil
832932 view.linkGeneration++
833933 link.active = false
934+ signalRemoteViewMetadataChangedLocked(view)
834935 }
835936 view.mu.Unlock()
836937 if link != nil {
938+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
837939 link.cancel()
838940 if link.transport != nil {
839941 _ = link.transport.Close()
@@ -45,6 +45,7 @@ type remoteLink struct {
45 cancel context.CancelFunc45 cancel context.CancelFunc
46 46
47 sendMu sync.Mutex47 sendMu sync.Mutex
48+ commands *CommandRequestTracker
48 startOnce sync.Once49 startOnce sync.Once
49 workerStarted atomic.Bool50 workerStarted atomic.Bool
50 done chan struct{}51 done chan struct{}
@@ -301,8 +302,12 @@ func installRemoteViewCandidateLocked(view, candidate *remoteView) (*remoteLink,
301 view.screen = candidate.screen302 view.screen = candidate.screen
302 view.metadata = cloneRemoteSessionMeta(candidate.metadata)303 view.metadata = cloneRemoteSessionMeta(candidate.metadata)
303 view.displayOrigin = candidate.displayOrigin304 view.displayOrigin = candidate.displayOrigin
305+ view.reconnectTarget = candidate.reconnectTarget
304 view.output = candidate.output306 view.output = candidate.output
305 view.resetRequested = candidate.resetRequested307 view.resetRequested = candidate.resetRequested
308+ view.linkState = remoteViewLinkHealthy
309+ view.reconnectGeneration++
310+ signalRemoteViewMetadataChangedLocked(view)
306 if replaced != nil {311 if replaced != nil {
307 replaced.active = false312 replaced.active = false
308 }313 }
@@ -314,6 +319,7 @@ func stopAndJoinRemoteLink(link *remoteLink) {
314 if link == nil {319 if link == nil {
315 return320 return
316 }321 }
322+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
317 if link.cancel != nil {323 if link.cancel != nil {
318 link.cancel()324 link.cancel()
319 }325 }
@@ -359,6 +365,7 @@ func (d *Daemon) constructRemoteView(ctx context.Context, target domain.RemoteSe
359 target: target,365 target: target,
360 clientID: clientID,366 clientID: clientID,
361 cancel: cancelLink,367 cancel: cancelLink,
368+ commands: NewCommandRequestTracker(),
362 done: make(chan struct{}),369 done: make(chan struct{}),
363 active: true,370 active: true,
364 }371 }
@@ -507,17 +514,56 @@ func validateInitialRemoteLinkMetadata(frame ports.Frame, target domain.RemoteSe
507 if metadata.LifecycleID != target.LifecycleID || metadata.SessionName != target.SessionName || metadata.Revision != 1 {514 if metadata.LifecycleID != target.LifecycleID || metadata.SessionName != target.SessionName || metadata.Revision != 1 {
508 return ports.SessionMeta{}, errors.New("remote view: metadata identity mismatch")515 return ports.SessionMeta{}, errors.New("remote view: metadata identity mismatch")
509 }516 }
510- if !target.Stopped && metadata.ActiveTabID != target.LiveTabID {517+ if err := validateInitialRemoteLinkTarget(metadata, target); err != nil {
511- return ports.SessionMeta{}, errors.New("remote view: metadata active tab mismatch")518+ return ports.SessionMeta{}, err
512 }519 }
513 return metadata, nil520 return metadata, nil
514 }521 }
515 522
523+func validateInitialRemoteLinkTarget(metadata ports.SessionMeta, target domain.RemoteSessionTarget) error {
524+ if err := ports.ValidateSessionMeta(metadata); err != nil {
525+ return fmt.Errorf("remote view: invalid initial metadata: %w", err)
526+ }
527+ if !target.Stopped {
528+ if metadata.ActiveTabID != target.LiveTabID {
529+ return errors.New("remote view: metadata active tab mismatch")
530+ }
531+ return nil
532+ }
533+
534+ tabs := make([]domain.TabSelectorTab, len(metadata.Tabs))
535+ for i, tab := range metadata.Tabs {
536+ tabs[i] = domain.TabSelectorTab{ID: tab.ID, Name: tab.Name}
537+ }
538+ selected, ok := target.StoppedTab.Resolve(tabs)
539+ if !ok {
540+ return errors.New("remote view: stopped tab selector mismatch")
541+ }
542+ if metadata.ActiveTabID != tabs[selected].ID {
543+ return errors.New("remote view: metadata active tab mismatch")
544+ }
545+ return nil
546+}
547+
516 func cloneRemoteSessionMeta(metadata ports.SessionMeta) ports.SessionMeta {548 func cloneRemoteSessionMeta(metadata ports.SessionMeta) ports.SessionMeta {
517 metadata.Tabs = append([]ports.SessionTabMeta(nil), metadata.Tabs...)549 metadata.Tabs = append([]ports.SessionTabMeta(nil), metadata.Tabs...)
518 return metadata550 return metadata
519 }551 }
520 552
553+// signalRemoteViewMetadataChangedLocked wakes waiters after an exact remote
554+// metadata or link-generation change. The caller holds view.mu and must not
555+// perform rendering or transport work before releasing it.
556+func signalRemoteViewMetadataChangedLocked(view *remoteView) {
557+ if view == nil {
558+ return
559+ }
560+ previous := view.metadataChanged
561+ view.metadataChanged = make(chan struct{})
562+ if previous != nil {
563+ close(previous)
564+ }
565+}
566+
521 type remoteMetadataResult uint8567 type remoteMetadataResult uint8
522 568
523 const (569 const (
@@ -558,6 +604,12 @@ func (v *remoteView) applyRemoteMetadataWithAttachments(link *remoteLink, metada
558 return remoteMetadataStale, nil604 return remoteMetadataStale, nil
559 }605 }
560 v.metadata = cloneRemoteSessionMeta(metadata)606 v.metadata = cloneRemoteSessionMeta(metadata)
607+ reconnectTarget := v.reconnectTarget
608+ if reconnectTarget.Validate() != nil {
609+ reconnectTarget = link.target
610+ }
611+ v.reconnectTarget = runningRemoteTarget(reconnectTarget, metadata.ActiveTabID)
612+ signalRemoteViewMetadataChangedLocked(v)
561 if initial {613 if initial {
562 return remoteMetadataAccepted, nil614 return remoteMetadataAccepted, nil
563 }615 }
@@ -681,6 +733,15 @@ func (d *Daemon) handleRemoteLinkFrame(link *remoteLink, frame ports.Frame) erro
681 default:733 default:
682 return errors.New("remote view: invalid metadata update")734 return errors.New("remote view: invalid metadata update")
683 }735 }
736+ case ports.MsgCommandResult:
737+ result, err := ports.UnmarshalCommandResult(frame.Payload)
738+ if err != nil {
739+ return fmt.Errorf("remote view: decode command result: %w", err)
740+ }
741+ if link.commands != nil {
742+ link.commands.Complete(link.generation, result)
743+ }
744+ return nil
684 case ports.MsgPing:745 case ports.MsgPing:
685 if _, err := ports.UnmarshalPing(frame.Payload); err != nil {746 if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
686 return fmt.Errorf("remote view: decode ping: %w", err)747 return fmt.Errorf("remote view: decode ping: %w", err)
@@ -690,11 +751,11 @@ func (d *Daemon) handleRemoteLinkFrame(link *remoteLink, frame ports.Frame) erro
690 _, err := ports.UnmarshalPong(frame.Payload)751 _, err := ports.UnmarshalPong(frame.Payload)
691 return err752 return err
692 case ports.MsgDetached:753 case ports.MsgDetached:
693- _, err := ports.UnmarshalDetached(frame.Payload)754+ detached, err := ports.UnmarshalDetached(frame.Payload)
694 if err != nil {755 if err != nil {
695 return fmt.Errorf("remote view: decode detached: %w", err)756 return fmt.Errorf("remote view: decode detached: %w", err)
696 }757 }
697- d.retireTerminalRemoteView(link, ports.ReasonSessionKilled)758+ d.retireTerminalRemoteView(link, detached.Reason)
698 return nil759 return nil
699 case ports.MsgError:760 case ports.MsgError:
700 remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)761 remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
@@ -743,8 +804,10 @@ func (link *remoteLink) send(frame ports.Frame) error {
743 view.mu.Lock()804 view.mu.Lock()
744 if view.link == link && view.linkGeneration == link.generation {805 if view.link == link && view.linkGeneration == link.generation {
745 link.active = false806 link.active = false
807+ signalRemoteViewMetadataChangedLocked(view)
746 }808 }
747 view.mu.Unlock()809 view.mu.Unlock()
810+ link.commands.FailGeneration(link.generation, err)
748 link.cancel()811 link.cancel()
749 _ = transport.Close()812 _ = transport.Close()
750 }813 }
@@ -776,24 +839,59 @@ func (d *Daemon) sendRemoteViewFrame(view *remoteView, frame ports.Frame) error
776 if link == nil {839 if link == nil {
777 return errRemoteViewUnavailable840 return errRemoteViewUnavailable
778 }841 }
779- return link.send(frame)842+ err := link.send(frame)
843+ if err != nil {
844+ d.markRemoteLinkUnavailable(link)
845+ }
846+ return err
780 }847 }
781 848
782 func (d *Daemon) markRemoteLinkUnavailable(link *remoteLink) {849 func (d *Daemon) markRemoteLinkUnavailable(link *remoteLink) {
783- if link == nil || link.view == nil {850+ if d == nil || link == nil || link.view == nil {
784 return851 return
785 }852 }
786 view := link.view853 view := link.view
787 view.mu.Lock()854 view.mu.Lock()
788 current := !view.closed && view.link == link && view.linkGeneration == link.generation855 current := !view.closed && view.link == link && view.linkGeneration == link.generation
856+ var (
857+ attachments []*attachedClient
858+ startReconnect bool
859+ reconnectGeneration uint64
860+ reconnectSize domain.Size
861+ reconnectTarget domain.RemoteSessionTarget
862+ )
789 if current {863 if current {
790 link.active = false864 link.active = false
865+ signalRemoteViewMetadataChangedLocked(view)
866+ attachments = make([]*attachedClient, 0, len(view.attachments))
867+ for attachment := range view.attachments {
868+ attachments = append(attachments, attachment)
869+ }
870+ if view.linkState == remoteViewLinkHealthy {
871+ view.linkState = remoteViewLinkReconnecting
872+ view.reconnectGeneration++
873+ reconnectGeneration = view.reconnectGeneration
874+ startReconnect = len(attachments) != 0
875+ reconnectSize = reconnectSizeLocked(view)
876+ reconnectTarget = view.reconnectTarget
877+ if reconnectTarget.Validate() != nil {
878+ reconnectTarget = link.target
879+ }
880+ }
791 }881 }
792 view.mu.Unlock()882 view.mu.Unlock()
793- if current {883+ if !current {
794- link.cancel()884+ return
885+ }
886+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
887+ link.cancel()
888+ if link.transport != nil {
795 _ = link.transport.Close()889 _ = link.transport.Close()
796 }890 }
891+ d.repaintRemoteViewAttachments(view, attachments)
892+ if startReconnect {
893+ go d.reconnectRemoteView(view, link, reconnectGeneration, reconnectTarget, reconnectSize)
894+ }
797 }895 }
798 896
799 // stopUnpublishedRemoteView is used for candidate failure and lost-election897 // stopUnpublishedRemoteView is used for candidate failure and lost-election
@@ -808,8 +906,10 @@ func (d *Daemon) stopUnpublishedRemoteView(view *remoteView) {
808 link := view.link906 link := view.link
809 view.link = nil907 view.link = nil
810 view.linkGeneration++908 view.linkGeneration++
909+ signalRemoteViewMetadataChangedLocked(view)
811 view.mu.Unlock()910 view.mu.Unlock()
812 if link != nil {911 if link != nil {
912+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
813 link.cancel()913 link.cancel()
814 if link.transport != nil {914 if link.transport != nil {
815 _ = link.transport.Close()915 _ = link.transport.Close()
@@ -831,9 +931,11 @@ func (d *Daemon) stopRemoteViewLink(view *remoteView) *remoteLink {
831 view.link = nil931 view.link = nil
832 view.linkGeneration++932 view.linkGeneration++
833 link.active = false933 link.active = false
934+ signalRemoteViewMetadataChangedLocked(view)
834 }935 }
835 view.mu.Unlock()936 view.mu.Unlock()
836 if link != nil {937 if link != nil {
938+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
837 link.cancel()939 link.cancel()
838 if link.transport != nil {940 if link.transport != nil {
839 _ = link.transport.Close()941 _ = link.transport.Close()
M internal/usecase/daemon/remote_link_test.go +256 -3
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "sync/atomic"
99 "testing"
10+ "time"
1011
1112 "github.com/bnema/vev/internal/domain"
1213 "github.com/bnema/vev/internal/ports"
@@ -121,14 +122,19 @@ func enqueueRemoteLinkHandshake(t *testing.T, transport *remoteLinkTestTransport
121122
122123 func enqueueRemoteLinkHandshakeWithContent(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size, contentMarker string) {
123124 t.Helper()
124- content := contentSize(size)
125- metaPayload, err := ports.MarshalSessionMeta(ports.SessionMeta{
125+ enqueueRemoteLinkHandshakeWithMetadata(t, transport, target, size, ports.SessionMeta{
126126 LifecycleID: target.LifecycleID,
127127 Revision: 1,
128128 SessionName: target.SessionName,
129129 ActiveTabID: target.LiveTabID,
130130 Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}},
131- })
131+ }, contentMarker)
132+}
133+
134+func enqueueRemoteLinkHandshakeWithMetadata(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size, metadata ports.SessionMeta, contentMarker string) {
135+ t.Helper()
136+ content := contentSize(size)
137+ metaPayload, err := ports.MarshalSessionMeta(metadata)
132138 require.NoError(t, err)
133139 outputPayload, err := ports.MarshalOutput(ports.Output{
134140 Epoch: 1, New: 1, Size: content, Full: true, Data: []byte(contentMarker),
@@ -197,6 +203,21 @@ func remoteMetadataFrame(t *testing.T, metadata ports.SessionMeta) ports.Frame {
197203 return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}
198204 }
199205
206+func TestRemoteLinkDetachedPropagatesItsExactReason(t *testing.T) {
207+ d, view, link, _ := newRemoteMetadataLinkFixture(t)
208+ attachment, sends := attachRemoteMetadataClient(t, view)
209+
210+ require.NoError(t, d.handleRemoteLinkFrame(link, ports.Frame{
211+ Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}),
212+ }))
213+
214+ frame := awaitFrame(t, sends, ports.MsgDetached)
215+ detached, err := ports.UnmarshalDetached(frame.Payload)
216+ require.NoError(t, err)
217+ require.Equal(t, ports.ReasonDetach, detached.Reason)
218+ require.Nil(t, attachment.currentAttachmentOwner())
219+}
220+
200221 func TestRemoteLinkAcceptedMetadataRepaintsEveryAttachedClientUnderLocalChrome(t *testing.T) {
201222 d, view, link, _ := newRemoteMetadataLinkFixture(t)
202223 first, firstSends := attachRemoteMetadataClient(t, view)
@@ -318,6 +339,153 @@ func TestRemoteLinkRejectsInvalidMetadataIdentityAndOrdering(t *testing.T) {
318339 }
319340 }
320341
342+func TestValidateInitialRemoteLinkTargetResolvesStoppedSelectorsStrictly(t *testing.T) {
343+ baseTarget := remoteLinkTestTarget()
344+ baseTarget.LiveTabID = ""
345+ baseTarget.Stopped = true
346+ baseMetadata := ports.SessionMeta{
347+ LifecycleID: baseTarget.LifecycleID,
348+ Revision: 1,
349+ SessionName: baseTarget.SessionName,
350+ ActiveTabID: "tab-b",
351+ Tabs: []ports.SessionTabMeta{
352+ {ID: "tab-a", Name: "alpha"},
353+ {ID: "tab-b", Name: "beta"},
354+ },
355+ }
356+ tests := []struct {
357+ name string
358+ mutate func(*domain.RemoteSessionTarget, *ports.SessionMeta)
359+ wantErr string
360+ }{
361+ {
362+ name: "valid stable selector",
363+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
364+ target.StoppedTab = domain.NewStableTabSelector("tab-b")
365+ },
366+ },
367+ {
368+ name: "valid ordinal selector",
369+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
370+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2)
371+ },
372+ },
373+ {
374+ name: "wrong stable ID",
375+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
376+ target.StoppedTab = domain.NewStableTabSelector("tab-missing")
377+ },
378+ wantErr: "stopped tab selector mismatch",
379+ },
380+ {
381+ name: "missing stable ID",
382+ mutate: func(_ *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
383+ metadata.Tabs[0].ID = ""
384+ },
385+ wantErr: "invalid initial metadata",
386+ },
387+ {
388+ name: "duplicate stable IDs",
389+ mutate: func(_ *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
390+ metadata.Tabs[0].ID = metadata.Tabs[1].ID
391+ },
392+ wantErr: "invalid initial metadata",
393+ },
394+ {
395+ name: "reordered ordinal selector",
396+ mutate: func(target *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
397+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2)
398+ metadata.Tabs[0], metadata.Tabs[1] = metadata.Tabs[1], metadata.Tabs[0]
399+ },
400+ wantErr: "stopped tab selector mismatch",
401+ },
402+ {
403+ name: "ordinal name mismatch",
404+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
405+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "renamed", 2)
406+ },
407+ wantErr: "stopped tab selector mismatch",
408+ },
409+ {
410+ name: "ordinal count mismatch",
411+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
412+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 3)
413+ },
414+ wantErr: "stopped tab selector mismatch",
415+ },
416+ {
417+ name: "active tab mismatch",
418+ mutate: func(target *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
419+ target.StoppedTab = domain.NewStableTabSelector("tab-b")
420+ metadata.ActiveTabID = "tab-a"
421+ },
422+ wantErr: "metadata active tab mismatch",
423+ },
424+ {
425+ name: "running target validation remains strict",
426+ mutate: func(target *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
427+ *target = remoteLinkTestTarget()
428+ metadata.ActiveTabID = "tab-a"
429+ metadata.Tabs[1].ID = target.LiveTabID
430+ },
431+ wantErr: "metadata active tab mismatch",
432+ },
433+ }
434+ for _, test := range tests {
435+ t.Run(test.name, func(t *testing.T) {
436+ target := baseTarget
437+ metadata := baseMetadata
438+ metadata.Tabs = append([]ports.SessionTabMeta(nil), baseMetadata.Tabs...)
439+ test.mutate(&target, &metadata)
440+
441+ err := validateInitialRemoteLinkTarget(metadata, target)
442+ if test.wantErr == "" {
443+ require.NoError(t, err)
444+ return
445+ }
446+ require.ErrorContains(t, err, test.wantErr)
447+ })
448+ }
449+}
450+
451+func TestOpenRemoteViewRejectsStoppedTargetBeforeCandidatePublication(t *testing.T) {
452+ d := newTestDaemon(t, nil, stubClock{})
453+ target := remoteLinkTestTarget()
454+ target.LiveTabID = ""
455+ target.Stopped = true
456+ target.StoppedTab = domain.NewStableTabSelector("tab-b")
457+ transport := newRemoteLinkTestTransport()
458+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
459+ SessionID: "remote-id", SessionName: target.SessionName, RenderMode: ports.RenderModeProxiedContent,
460+ })}}
461+ metadataPayload, err := ports.MarshalSessionMeta(ports.SessionMeta{
462+ LifecycleID: target.LifecycleID,
463+ Revision: 1,
464+ SessionName: target.SessionName,
465+ ActiveTabID: "tab-a",
466+ Tabs: []ports.SessionTabMeta{
467+ {ID: "tab-a", Name: "alpha"},
468+ {ID: "tab-b", Name: "beta"},
469+ },
470+ })
471+ require.NoError(t, err)
472+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: metadataPayload}}
473+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
474+
475+ view, err := d.openRemoteView(context.Background(), target, domain.Size{Cols: 80, Rows: 24})
476+ require.Nil(t, view)
477+ require.ErrorContains(t, err, "metadata active tab mismatch")
478+ select {
479+ case <-transport.closed:
480+ default:
481+ t.Fatal("rejected stopped-target candidate transport was not closed")
482+ }
483+ d.mu.Lock()
484+ require.Empty(t, d.remoteViews)
485+ require.Empty(t, d.remoteViewConstructions)
486+ d.mu.Unlock()
487+}
488+
321489 func TestOpenRemoteViewPublishesHandshakeReadyCandidate(t *testing.T) {
322490 d := newTestDaemon(t, nil, stubClock{})
323491 target := remoteLinkTestTarget()
@@ -416,6 +584,91 @@ func TestOpenRemoteViewReconnectsInactiveExactView(t *testing.T) {
416584 d.shutdownAll(ports.ReasonServerShutdown)
417585 }
418586
587+func TestRemoteLinkFailureReconnectsAttachedViewWithoutChangingLocalOwner(t *testing.T) {
588+ d := newTestDaemon(t, nil, stubClock{})
589+ target := remoteLinkTestTarget()
590+ size := domain.Size{Cols: 80, Rows: 24}
591+ firstTransport := newRemoteLinkTestTransport()
592+ secondTransport := newRemoteLinkTestTransport()
593+ enqueueRemoteLinkHandshake(t, firstTransport, target, size)
594+ enqueueRemoteLinkHandshake(t, secondTransport, target, size)
595+ reconnectStarted := make(chan struct{})
596+ releaseReconnect := make(chan struct{})
597+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
598+ &remoteLinkTestDialer{transport: firstTransport},
599+ &remoteLinkTestDialer{transport: secondTransport, started: reconnectStarted, release: releaseReconnect},
600+ }}
601+ d.remoteDialerFactory = factory
602+
603+ view, err := d.openRemoteView(context.Background(), target, size)
604+ require.NoError(t, err)
605+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
606+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
607+ attachment := &attachedClient{tr: &closeTrackingTransport{}}
608+ attachment.setAttachmentOwner(view)
609+ view.mu.Lock()
610+ require.True(t, view.registerAttachmentLocked(attachment))
611+ oldLink := view.link
612+ oldScreen := view.screen
613+ view.mu.Unlock()
614+
615+ firstTransport.recv <- remoteLinkTestReceive{err: io.EOF}
616+ awaitTestCompletion(t, reconnectStarted, "remote link failure did not begin reconnect")
617+ view.mu.Lock()
618+ require.Equal(t, remoteViewLinkReconnecting, view.linkState)
619+ require.Same(t, oldLink, view.link)
620+ require.Same(t, oldScreen, view.screen)
621+ view.mu.Unlock()
622+ require.Same(t, view, attachment.currentAttachmentOwner())
623+
624+ close(releaseReconnect)
625+ require.Eventually(t, func() bool {
626+ view.mu.Lock()
627+ defer view.mu.Unlock()
628+ return view.link != oldLink && view.linkState == remoteViewLinkHealthy && view.link != nil && view.link.active
629+ }, time.Second, 5*time.Millisecond, "validated reconnect should restore the same local remote view")
630+ require.Same(t, view, attachment.currentAttachmentOwner())
631+ require.Equal(t, int32(2), factory.calls.Load())
632+ d.shutdownAll(ports.ReasonServerShutdown)
633+}
634+
635+func TestRemoteLinkFailedAutoReconnectMarksViewUnavailableWithoutDetachingLocalOwner(t *testing.T) {
636+ d := newTestDaemon(t, nil, stubClock{})
637+ target := remoteLinkTestTarget()
638+ size := domain.Size{Cols: 80, Rows: 24}
639+ firstTransport := newRemoteLinkTestTransport()
640+ enqueueRemoteLinkHandshake(t, firstTransport, target, size)
641+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
642+ &remoteLinkTestDialer{transport: firstTransport},
643+ &remoteLinkTestDialer{},
644+ }}
645+ d.remoteDialerFactory = factory
646+
647+ view, err := d.openRemoteView(context.Background(), target, size)
648+ require.NoError(t, err)
649+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
650+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
651+ attachment := &attachedClient{tr: &closeTrackingTransport{}}
652+ attachment.setAttachmentOwner(view)
653+ view.mu.Lock()
654+ require.True(t, view.registerAttachmentLocked(attachment))
655+ oldLink := view.link
656+ view.mu.Unlock()
657+
658+ firstTransport.recv <- remoteLinkTestReceive{err: io.EOF}
659+ require.Eventually(t, func() bool {
660+ view.mu.Lock()
661+ defer view.mu.Unlock()
662+ return view.link == oldLink && view.linkState == remoteViewLinkUnavailable && !oldLink.active
663+ }, time.Second, 5*time.Millisecond, "failed automatic reconnect should leave the local view available for navigation")
664+ snapshot, ok := view.renderSnapshot(size)
665+ require.True(t, ok)
666+ require.Contains(t, remoteStatusSnapshot(view, snapshot).session, "unavailable")
667+ require.Same(t, view, attachment.currentAttachmentOwner())
668+ require.Equal(t, int32(2), factory.calls.Load())
669+ d.shutdownAll(ports.ReasonServerShutdown)
670+}
671+
419672 func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) {
420673 d := newTestDaemon(t, nil, stubClock{})
421674 target := remoteLinkTestTarget()
@@ -7,6 +7,7 @@ import (
7 "sync"7 "sync"
8 "sync/atomic"8 "sync/atomic"
9 "testing"9 "testing"
10+ "time"
10 11
11 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
@@ -121,14 +122,19 @@ func enqueueRemoteLinkHandshake(t *testing.T, transport *remoteLinkTestTransport
121 122
122 func enqueueRemoteLinkHandshakeWithContent(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size, contentMarker string) {123 func enqueueRemoteLinkHandshakeWithContent(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size, contentMarker string) {
123 t.Helper()124 t.Helper()
124- content := contentSize(size)125+ enqueueRemoteLinkHandshakeWithMetadata(t, transport, target, size, ports.SessionMeta{
125- metaPayload, err := ports.MarshalSessionMeta(ports.SessionMeta{
126 LifecycleID: target.LifecycleID,126 LifecycleID: target.LifecycleID,
127 Revision: 1,127 Revision: 1,
128 SessionName: target.SessionName,128 SessionName: target.SessionName,
129 ActiveTabID: target.LiveTabID,129 ActiveTabID: target.LiveTabID,
130 Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}},130 Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}},
131- })131+ }, contentMarker)
132+}
133+
134+func enqueueRemoteLinkHandshakeWithMetadata(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size, metadata ports.SessionMeta, contentMarker string) {
135+ t.Helper()
136+ content := contentSize(size)
137+ metaPayload, err := ports.MarshalSessionMeta(metadata)
132 require.NoError(t, err)138 require.NoError(t, err)
133 outputPayload, err := ports.MarshalOutput(ports.Output{139 outputPayload, err := ports.MarshalOutput(ports.Output{
134 Epoch: 1, New: 1, Size: content, Full: true, Data: []byte(contentMarker),140 Epoch: 1, New: 1, Size: content, Full: true, Data: []byte(contentMarker),
@@ -197,6 +203,21 @@ func remoteMetadataFrame(t *testing.T, metadata ports.SessionMeta) ports.Frame {
197 return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}203 return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}
198 }204 }
199 205
206+func TestRemoteLinkDetachedPropagatesItsExactReason(t *testing.T) {
207+ d, view, link, _ := newRemoteMetadataLinkFixture(t)
208+ attachment, sends := attachRemoteMetadataClient(t, view)
209+
210+ require.NoError(t, d.handleRemoteLinkFrame(link, ports.Frame{
211+ Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}),
212+ }))
213+
214+ frame := awaitFrame(t, sends, ports.MsgDetached)
215+ detached, err := ports.UnmarshalDetached(frame.Payload)
216+ require.NoError(t, err)
217+ require.Equal(t, ports.ReasonDetach, detached.Reason)
218+ require.Nil(t, attachment.currentAttachmentOwner())
219+}
220+
200 func TestRemoteLinkAcceptedMetadataRepaintsEveryAttachedClientUnderLocalChrome(t *testing.T) {221 func TestRemoteLinkAcceptedMetadataRepaintsEveryAttachedClientUnderLocalChrome(t *testing.T) {
201 d, view, link, _ := newRemoteMetadataLinkFixture(t)222 d, view, link, _ := newRemoteMetadataLinkFixture(t)
202 first, firstSends := attachRemoteMetadataClient(t, view)223 first, firstSends := attachRemoteMetadataClient(t, view)
@@ -318,6 +339,153 @@ func TestRemoteLinkRejectsInvalidMetadataIdentityAndOrdering(t *testing.T) {
318 }339 }
319 }340 }
320 341
342+func TestValidateInitialRemoteLinkTargetResolvesStoppedSelectorsStrictly(t *testing.T) {
343+ baseTarget := remoteLinkTestTarget()
344+ baseTarget.LiveTabID = ""
345+ baseTarget.Stopped = true
346+ baseMetadata := ports.SessionMeta{
347+ LifecycleID: baseTarget.LifecycleID,
348+ Revision: 1,
349+ SessionName: baseTarget.SessionName,
350+ ActiveTabID: "tab-b",
351+ Tabs: []ports.SessionTabMeta{
352+ {ID: "tab-a", Name: "alpha"},
353+ {ID: "tab-b", Name: "beta"},
354+ },
355+ }
356+ tests := []struct {
357+ name string
358+ mutate func(*domain.RemoteSessionTarget, *ports.SessionMeta)
359+ wantErr string
360+ }{
361+ {
362+ name: "valid stable selector",
363+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
364+ target.StoppedTab = domain.NewStableTabSelector("tab-b")
365+ },
366+ },
367+ {
368+ name: "valid ordinal selector",
369+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
370+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2)
371+ },
372+ },
373+ {
374+ name: "wrong stable ID",
375+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
376+ target.StoppedTab = domain.NewStableTabSelector("tab-missing")
377+ },
378+ wantErr: "stopped tab selector mismatch",
379+ },
380+ {
381+ name: "missing stable ID",
382+ mutate: func(_ *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
383+ metadata.Tabs[0].ID = ""
384+ },
385+ wantErr: "invalid initial metadata",
386+ },
387+ {
388+ name: "duplicate stable IDs",
389+ mutate: func(_ *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
390+ metadata.Tabs[0].ID = metadata.Tabs[1].ID
391+ },
392+ wantErr: "invalid initial metadata",
393+ },
394+ {
395+ name: "reordered ordinal selector",
396+ mutate: func(target *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
397+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2)
398+ metadata.Tabs[0], metadata.Tabs[1] = metadata.Tabs[1], metadata.Tabs[0]
399+ },
400+ wantErr: "stopped tab selector mismatch",
401+ },
402+ {
403+ name: "ordinal name mismatch",
404+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
405+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "renamed", 2)
406+ },
407+ wantErr: "stopped tab selector mismatch",
408+ },
409+ {
410+ name: "ordinal count mismatch",
411+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
412+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 3)
413+ },
414+ wantErr: "stopped tab selector mismatch",
415+ },
416+ {
417+ name: "active tab mismatch",
418+ mutate: func(target *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
419+ target.StoppedTab = domain.NewStableTabSelector("tab-b")
420+ metadata.ActiveTabID = "tab-a"
421+ },
422+ wantErr: "metadata active tab mismatch",
423+ },
424+ {
425+ name: "running target validation remains strict",
426+ mutate: func(target *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
427+ *target = remoteLinkTestTarget()
428+ metadata.ActiveTabID = "tab-a"
429+ metadata.Tabs[1].ID = target.LiveTabID
430+ },
431+ wantErr: "metadata active tab mismatch",
432+ },
433+ }
434+ for _, test := range tests {
435+ t.Run(test.name, func(t *testing.T) {
436+ target := baseTarget
437+ metadata := baseMetadata
438+ metadata.Tabs = append([]ports.SessionTabMeta(nil), baseMetadata.Tabs...)
439+ test.mutate(&target, &metadata)
440+
441+ err := validateInitialRemoteLinkTarget(metadata, target)
442+ if test.wantErr == "" {
443+ require.NoError(t, err)
444+ return
445+ }
446+ require.ErrorContains(t, err, test.wantErr)
447+ })
448+ }
449+}
450+
451+func TestOpenRemoteViewRejectsStoppedTargetBeforeCandidatePublication(t *testing.T) {
452+ d := newTestDaemon(t, nil, stubClock{})
453+ target := remoteLinkTestTarget()
454+ target.LiveTabID = ""
455+ target.Stopped = true
456+ target.StoppedTab = domain.NewStableTabSelector("tab-b")
457+ transport := newRemoteLinkTestTransport()
458+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
459+ SessionID: "remote-id", SessionName: target.SessionName, RenderMode: ports.RenderModeProxiedContent,
460+ })}}
461+ metadataPayload, err := ports.MarshalSessionMeta(ports.SessionMeta{
462+ LifecycleID: target.LifecycleID,
463+ Revision: 1,
464+ SessionName: target.SessionName,
465+ ActiveTabID: "tab-a",
466+ Tabs: []ports.SessionTabMeta{
467+ {ID: "tab-a", Name: "alpha"},
468+ {ID: "tab-b", Name: "beta"},
469+ },
470+ })
471+ require.NoError(t, err)
472+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: metadataPayload}}
473+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
474+
475+ view, err := d.openRemoteView(context.Background(), target, domain.Size{Cols: 80, Rows: 24})
476+ require.Nil(t, view)
477+ require.ErrorContains(t, err, "metadata active tab mismatch")
478+ select {
479+ case <-transport.closed:
480+ default:
481+ t.Fatal("rejected stopped-target candidate transport was not closed")
482+ }
483+ d.mu.Lock()
484+ require.Empty(t, d.remoteViews)
485+ require.Empty(t, d.remoteViewConstructions)
486+ d.mu.Unlock()
487+}
488+
321 func TestOpenRemoteViewPublishesHandshakeReadyCandidate(t *testing.T) {489 func TestOpenRemoteViewPublishesHandshakeReadyCandidate(t *testing.T) {
322 d := newTestDaemon(t, nil, stubClock{})490 d := newTestDaemon(t, nil, stubClock{})
323 target := remoteLinkTestTarget()491 target := remoteLinkTestTarget()
@@ -416,6 +584,91 @@ func TestOpenRemoteViewReconnectsInactiveExactView(t *testing.T) {
416 d.shutdownAll(ports.ReasonServerShutdown)584 d.shutdownAll(ports.ReasonServerShutdown)
417 }585 }
418 586
587+func TestRemoteLinkFailureReconnectsAttachedViewWithoutChangingLocalOwner(t *testing.T) {
588+ d := newTestDaemon(t, nil, stubClock{})
589+ target := remoteLinkTestTarget()
590+ size := domain.Size{Cols: 80, Rows: 24}
591+ firstTransport := newRemoteLinkTestTransport()
592+ secondTransport := newRemoteLinkTestTransport()
593+ enqueueRemoteLinkHandshake(t, firstTransport, target, size)
594+ enqueueRemoteLinkHandshake(t, secondTransport, target, size)
595+ reconnectStarted := make(chan struct{})
596+ releaseReconnect := make(chan struct{})
597+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
598+ &remoteLinkTestDialer{transport: firstTransport},
599+ &remoteLinkTestDialer{transport: secondTransport, started: reconnectStarted, release: releaseReconnect},
600+ }}
601+ d.remoteDialerFactory = factory
602+
603+ view, err := d.openRemoteView(context.Background(), target, size)
604+ require.NoError(t, err)
605+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
606+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
607+ attachment := &attachedClient{tr: &closeTrackingTransport{}}
608+ attachment.setAttachmentOwner(view)
609+ view.mu.Lock()
610+ require.True(t, view.registerAttachmentLocked(attachment))
611+ oldLink := view.link
612+ oldScreen := view.screen
613+ view.mu.Unlock()
614+
615+ firstTransport.recv <- remoteLinkTestReceive{err: io.EOF}
616+ awaitTestCompletion(t, reconnectStarted, "remote link failure did not begin reconnect")
617+ view.mu.Lock()
618+ require.Equal(t, remoteViewLinkReconnecting, view.linkState)
619+ require.Same(t, oldLink, view.link)
620+ require.Same(t, oldScreen, view.screen)
621+ view.mu.Unlock()
622+ require.Same(t, view, attachment.currentAttachmentOwner())
623+
624+ close(releaseReconnect)
625+ require.Eventually(t, func() bool {
626+ view.mu.Lock()
627+ defer view.mu.Unlock()
628+ return view.link != oldLink && view.linkState == remoteViewLinkHealthy && view.link != nil && view.link.active
629+ }, time.Second, 5*time.Millisecond, "validated reconnect should restore the same local remote view")
630+ require.Same(t, view, attachment.currentAttachmentOwner())
631+ require.Equal(t, int32(2), factory.calls.Load())
632+ d.shutdownAll(ports.ReasonServerShutdown)
633+}
634+
635+func TestRemoteLinkFailedAutoReconnectMarksViewUnavailableWithoutDetachingLocalOwner(t *testing.T) {
636+ d := newTestDaemon(t, nil, stubClock{})
637+ target := remoteLinkTestTarget()
638+ size := domain.Size{Cols: 80, Rows: 24}
639+ firstTransport := newRemoteLinkTestTransport()
640+ enqueueRemoteLinkHandshake(t, firstTransport, target, size)
641+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
642+ &remoteLinkTestDialer{transport: firstTransport},
643+ &remoteLinkTestDialer{},
644+ }}
645+ d.remoteDialerFactory = factory
646+
647+ view, err := d.openRemoteView(context.Background(), target, size)
648+ require.NoError(t, err)
649+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
650+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
651+ attachment := &attachedClient{tr: &closeTrackingTransport{}}
652+ attachment.setAttachmentOwner(view)
653+ view.mu.Lock()
654+ require.True(t, view.registerAttachmentLocked(attachment))
655+ oldLink := view.link
656+ view.mu.Unlock()
657+
658+ firstTransport.recv <- remoteLinkTestReceive{err: io.EOF}
659+ require.Eventually(t, func() bool {
660+ view.mu.Lock()
661+ defer view.mu.Unlock()
662+ return view.link == oldLink && view.linkState == remoteViewLinkUnavailable && !oldLink.active
663+ }, time.Second, 5*time.Millisecond, "failed automatic reconnect should leave the local view available for navigation")
664+ snapshot, ok := view.renderSnapshot(size)
665+ require.True(t, ok)
666+ require.Contains(t, remoteStatusSnapshot(view, snapshot).session, "unavailable")
667+ require.Same(t, view, attachment.currentAttachmentOwner())
668+ require.Equal(t, int32(2), factory.calls.Load())
669+ d.shutdownAll(ports.ReasonServerShutdown)
670+}
671+
419 func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) {672 func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) {
420 d := newTestDaemon(t, nil, stubClock{})673 d := newTestDaemon(t, nil, stubClock{})
421 target := remoteLinkTestTarget()674 target := remoteLinkTestTarget()
M internal/usecase/daemon/remote_picker.go +5 -9
@@ -178,8 +178,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
178178 }
179179 // Keep the structured session identity on rows even when one tab ID is
180180 // malformed or a broken session cannot be activated. Cursor navigation
181- // must remain possible, while sendRemoteAttachTargetForAttachment will
182- // fail closed on the invalid target.
181+ // remains possible for diagnostics, but activation fails closed.
183182 remoteTarget = &target
184183 }
185184 reason := ""
@@ -194,13 +193,10 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
194193 }
195194 ready := status == remoteHostFresh && targetValid && !broken
196195 if legacy {
197- // Count-only peers cannot prove lifecycle/tab identity. Preserve the
198- // historical direct-connect row for compatibility, while all current
199- // schema rows remain gated by exact identity and fresh ownership.
200- ready = status != remoteHostVersionMismatch && !broken
201- if status == remoteHostVersionMismatch {
202- reason = remoteReasonForStatus(status)
203- }
196+ // Proxied picker activation requires exact lifecycle and stable-tab
197+ // identity. A count-only cached row cannot fall back to client-owned
198+ // direct attachment under the v25 protocol contract.
199+ reason = "identity_changed"
204200 }
205201 detail := remotePickerStatusDetail(status, fetchedAt)
206202 if detail == "" {
@@ -178,8 +178,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
178 }178 }
179 // Keep the structured session identity on rows even when one tab ID is179 // Keep the structured session identity on rows even when one tab ID is
180 // malformed or a broken session cannot be activated. Cursor navigation180 // malformed or a broken session cannot be activated. Cursor navigation
181- // must remain possible, while sendRemoteAttachTargetForAttachment will181+ // remains possible for diagnostics, but activation fails closed.
182- // fail closed on the invalid target.
183 remoteTarget = &target182 remoteTarget = &target
184 }183 }
185 reason := ""184 reason := ""
@@ -194,13 +193,10 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
194 }193 }
195 ready := status == remoteHostFresh && targetValid && !broken194 ready := status == remoteHostFresh && targetValid && !broken
196 if legacy {195 if legacy {
197- // Count-only peers cannot prove lifecycle/tab identity. Preserve the196+ // Proxied picker activation requires exact lifecycle and stable-tab
198- // historical direct-connect row for compatibility, while all current197+ // identity. A count-only cached row cannot fall back to client-owned
199- // schema rows remain gated by exact identity and fresh ownership.198+ // direct attachment under the v25 protocol contract.
200- ready = status != remoteHostVersionMismatch && !broken199+ reason = "identity_changed"
201- if status == remoteHostVersionMismatch {
202- reason = remoteReasonForStatus(status)
203- }
204 }200 }
205 detail := remotePickerStatusDetail(status, fetchedAt)201 detail := remotePickerStatusDetail(status, fetchedAt)
206 if detail == "" {202 if detail == "" {
A internal/usecase/daemon/remote_picker_selection.go +141 -0
@@ -0,0 +1,141 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+)
10+
11+// remotePickerSelection is one generation-fenced picker activation. It keeps
12+// the local attachment capability until the remote link has completed its
13+// validated proxied handshake; no remote endpoint is ever published to the
14+// thin client from this path.
15+type remotePickerSelection struct {
16+ model *picker.Model
17+ generation uint64
18+ token attachmentConnectionToken
19+ target domain.RemoteSessionTarget
20+ ctx context.Context
21+ cancel context.CancelFunc
22+}
23+
24+func (selection *remotePickerSelection) current() bool {
25+ if selection == nil || selection.token.ac == nil || selection.token.ac.overlays == nil {
26+ return false
27+ }
28+ rt := selection.token.ac.overlays
29+ rt.pickerMu.Lock()
30+ current := rt.pickerRemoteSelection == selection &&
31+ rt.picker == selection.model &&
32+ rt.pickerGeneration == selection.generation
33+ rt.pickerMu.Unlock()
34+ return current
35+}
36+
37+func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
38+ if d == nil || token.ac == nil || token.localSession() == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() {
39+ return errAttachmentTransition
40+ }
41+ if target.RemoteTarget == nil {
42+ return errAttachmentTransition
43+ }
44+ remoteTarget := *target.RemoteTarget
45+ if remoteTarget.Validate() != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
46+ return errAttachmentTransition
47+ }
48+ if target.RemoteKey != nil && (target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID) {
49+ return errAttachmentTransition
50+ }
51+
52+ ctx, cancel := context.WithCancel(context.Background())
53+ selection := &remotePickerSelection{
54+ token: token,
55+ target: remoteTarget,
56+ ctx: ctx,
57+ cancel: cancel,
58+ }
59+ rt := token.ac.overlays
60+ rt.pickerMu.Lock()
61+ if rt.picker == nil || rt.pickerRemoteSelection != nil {
62+ rt.pickerMu.Unlock()
63+ cancel()
64+ return errAttachmentTransition
65+ }
66+ selection.model = rt.picker
67+ selection.generation = rt.pickerGeneration
68+ rt.pickerRemoteSelection = selection
69+ rt.pickerTitle = " Sessions · connecting (Esc cancels) "
70+ rt.pickerPending = nil
71+ rt.pickerESC.stop()
72+ rt.pickerMu.Unlock()
73+
74+ if token.effect != nil {
75+ token.effect.bindActionEnd(d, action)
76+ }
77+ d.invalidateRender(token.localSession(), token.ac, true, "remote_picker_selection.go")
78+ go d.completeRemotePickerSelection(selection)
79+ return nil
80+}
81+
82+func (d *Daemon) completeRemotePickerSelection(selection *remotePickerSelection) {
83+ if selection == nil || selection.token.ac == nil {
84+ return
85+ }
86+ view, err := d.openRemoteView(selection.ctx, selection.target, selection.token.ac.sizeSnapshot())
87+ if err != nil {
88+ d.failRemotePickerSelection(selection, err)
89+ return
90+ }
91+ if err := d.activateRemoteViewTab(selection.ctx, view, selection.target); err != nil {
92+ d.parkRemoteViewWarm(view)
93+ d.failRemotePickerSelection(selection, err)
94+ return
95+ }
96+ if !selection.current() {
97+ d.parkRemoteViewWarm(view)
98+ return
99+ }
100+
101+ published, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)
102+ if err != nil {
103+ d.parkRemoteViewWarm(view)
104+ d.failRemotePickerSelection(selection, err)
105+ return
106+ }
107+ // The transition freezes the initiating attachment before its final picker
108+ // generation check. Closing this exact picker afterwards cannot hand a
109+ // canceled or replacement picker to the remote owner.
110+ d.closePickerIfCurrent(selection.token.ac, selection.model, selection.generation)
111+ d.firstPaintForTransition(published)
112+}
113+
114+func (d *Daemon) failRemotePickerSelection(selection *remotePickerSelection, err error) {
115+ if selection == nil || selection.token.ac == nil || selection.token.ac.overlays == nil {
116+ return
117+ }
118+ rt := selection.token.ac.overlays
119+ rt.pickerMu.Lock()
120+ current := rt.pickerRemoteSelection == selection &&
121+ rt.picker == selection.model &&
122+ rt.pickerGeneration == selection.generation
123+ if current {
124+ rt.pickerRemoteSelection = nil
125+ rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
126+ }
127+ rt.pickerMu.Unlock()
128+ if !current {
129+ return
130+ }
131+ selection.cancel()
132+ if !errors.Is(err, context.Canceled) {
133+ d.log.Warn("remote picker selection failed", "endpoint", selection.target.Endpoint, "session", selection.target.SessionName, "err", err)
134+ if selection.token.current() {
135+ d.notify(selection.token.localSession(), domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil)
136+ }
137+ }
138+ if source := selection.token.localSession(); source != nil && selection.token.current() {
139+ d.invalidateRender(source, selection.token.ac, true, "remote_picker_selection.go")
140+ }
141+}
@@ -0,0 +1,141 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+)
10+
11+// remotePickerSelection is one generation-fenced picker activation. It keeps
12+// the local attachment capability until the remote link has completed its
13+// validated proxied handshake; no remote endpoint is ever published to the
14+// thin client from this path.
15+type remotePickerSelection struct {
16+ model *picker.Model
17+ generation uint64
18+ token attachmentConnectionToken
19+ target domain.RemoteSessionTarget
20+ ctx context.Context
21+ cancel context.CancelFunc
22+}
23+
24+func (selection *remotePickerSelection) current() bool {
25+ if selection == nil || selection.token.ac == nil || selection.token.ac.overlays == nil {
26+ return false
27+ }
28+ rt := selection.token.ac.overlays
29+ rt.pickerMu.Lock()
30+ current := rt.pickerRemoteSelection == selection &&
31+ rt.picker == selection.model &&
32+ rt.pickerGeneration == selection.generation
33+ rt.pickerMu.Unlock()
34+ return current
35+}
36+
37+func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
38+ if d == nil || token.ac == nil || token.localSession() == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() {
39+ return errAttachmentTransition
40+ }
41+ if target.RemoteTarget == nil {
42+ return errAttachmentTransition
43+ }
44+ remoteTarget := *target.RemoteTarget
45+ if remoteTarget.Validate() != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
46+ return errAttachmentTransition
47+ }
48+ if target.RemoteKey != nil && (target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID) {
49+ return errAttachmentTransition
50+ }
51+
52+ ctx, cancel := context.WithCancel(context.Background())
53+ selection := &remotePickerSelection{
54+ token: token,
55+ target: remoteTarget,
56+ ctx: ctx,
57+ cancel: cancel,
58+ }
59+ rt := token.ac.overlays
60+ rt.pickerMu.Lock()
61+ if rt.picker == nil || rt.pickerRemoteSelection != nil {
62+ rt.pickerMu.Unlock()
63+ cancel()
64+ return errAttachmentTransition
65+ }
66+ selection.model = rt.picker
67+ selection.generation = rt.pickerGeneration
68+ rt.pickerRemoteSelection = selection
69+ rt.pickerTitle = " Sessions · connecting (Esc cancels) "
70+ rt.pickerPending = nil
71+ rt.pickerESC.stop()
72+ rt.pickerMu.Unlock()
73+
74+ if token.effect != nil {
75+ token.effect.bindActionEnd(d, action)
76+ }
77+ d.invalidateRender(token.localSession(), token.ac, true, "remote_picker_selection.go")
78+ go d.completeRemotePickerSelection(selection)
79+ return nil
80+}
81+
82+func (d *Daemon) completeRemotePickerSelection(selection *remotePickerSelection) {
83+ if selection == nil || selection.token.ac == nil {
84+ return
85+ }
86+ view, err := d.openRemoteView(selection.ctx, selection.target, selection.token.ac.sizeSnapshot())
87+ if err != nil {
88+ d.failRemotePickerSelection(selection, err)
89+ return
90+ }
91+ if err := d.activateRemoteViewTab(selection.ctx, view, selection.target); err != nil {
92+ d.parkRemoteViewWarm(view)
93+ d.failRemotePickerSelection(selection, err)
94+ return
95+ }
96+ if !selection.current() {
97+ d.parkRemoteViewWarm(view)
98+ return
99+ }
100+
101+ published, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)
102+ if err != nil {
103+ d.parkRemoteViewWarm(view)
104+ d.failRemotePickerSelection(selection, err)
105+ return
106+ }
107+ // The transition freezes the initiating attachment before its final picker
108+ // generation check. Closing this exact picker afterwards cannot hand a
109+ // canceled or replacement picker to the remote owner.
110+ d.closePickerIfCurrent(selection.token.ac, selection.model, selection.generation)
111+ d.firstPaintForTransition(published)
112+}
113+
114+func (d *Daemon) failRemotePickerSelection(selection *remotePickerSelection, err error) {
115+ if selection == nil || selection.token.ac == nil || selection.token.ac.overlays == nil {
116+ return
117+ }
118+ rt := selection.token.ac.overlays
119+ rt.pickerMu.Lock()
120+ current := rt.pickerRemoteSelection == selection &&
121+ rt.picker == selection.model &&
122+ rt.pickerGeneration == selection.generation
123+ if current {
124+ rt.pickerRemoteSelection = nil
125+ rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
126+ }
127+ rt.pickerMu.Unlock()
128+ if !current {
129+ return
130+ }
131+ selection.cancel()
132+ if !errors.Is(err, context.Canceled) {
133+ d.log.Warn("remote picker selection failed", "endpoint", selection.target.Endpoint, "session", selection.target.SessionName, "err", err)
134+ if selection.token.current() {
135+ d.notify(selection.token.localSession(), domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil)
136+ }
137+ }
138+ if source := selection.token.localSession(); source != nil && selection.token.current() {
139+ d.invalidateRender(source, selection.token.ac, true, "remote_picker_selection.go")
140+ }
141+}
M internal/usecase/daemon/remote_picker_test.go +351 -52
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "io"
88 "log/slog"
9+ "strings"
910 "sync"
1011 "testing"
1112 "time"
@@ -14,6 +15,7 @@ import (
1415
1516 "github.com/bnema/vev/internal/domain"
1617 "github.com/bnema/vev/internal/ports"
18+ "github.com/bnema/vev/internal/usecase/keys"
1719 "github.com/bnema/vev/internal/usecase/picker"
1820 )
1921
@@ -560,77 +562,360 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
560562 d.remoteCatalog.mu.Unlock()
561563 }
562564
563-func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
565+func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *testing.T) {
564566 d := newRemotePickerDaemon(nil)
567+ target := remoteLinkTestTarget()
568+ require.NoError(t, target.Validate())
565569 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
566- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
570+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
571+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
572+ }},
567573 }})
574+ d.remoteCatalog.mu.Lock()
575+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
576+ d.remoteCatalog.mu.Unlock()
577+ require.True(t, d.remotePickerTargetReadyTarget(target))
578+
579+ remoteTransport := newRemoteLinkTestTransport()
580+ remoteDialStarted := make(chan struct{})
581+ remoteDialRelease := make(chan struct{})
582+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{
583+ transport: remoteTransport,
584+ started: remoteDialStarted,
585+ release: remoteDialRelease,
586+ }}
568587 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
588+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
589+ ac.overlays.pickerMu.Lock()
590+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
591+ ac.overlays.pickerGeneration++
592+ ac.overlays.pickerMu.Unlock()
593+
569594 token := sess.attachmentToken(ac, ac.transport())
570595 effect, admitted := ac.beginAttachmentEffect(token)
571596 require.True(t, admitted)
572597 token.effect = effect
573- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
574- target := picker.Target{Session: key.ID(), RemoteKey: &key}
575- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
598+ pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
599+ require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
600+ effect.End()
601+
602+ receiveRemotePickerClose(t, remoteDialStarted, "remote picker dial")
603+ require.Same(t, sess, ac.currentAttachmentSession(), "the source remains attached while the remote handshake is pending")
604+ require.True(t, ac.overlays.pickerActive(), "the local picker stays available until publication")
605+ require.Never(t, func() bool {
606+ select {
607+ case frame := <-sends:
608+ return frame.Type == ports.MsgAttachTarget
609+ default:
610+ return false
611+ }
612+ }, 100*time.Millisecond, 5*time.Millisecond, "picker selection must not hand the client to the remote daemon")
613+
614+ enqueueRemoteLinkHandshake(t, remoteTransport, target, ac.sizeSnapshot())
615+ close(remoteDialRelease)
616+ require.Eventually(t, func() bool {
617+ _, remote := ac.currentAttachmentOwner().(*remoteView)
618+ return remote
619+ }, time.Second, 5*time.Millisecond, "the ready remote surface is atomically published to the same client")
620+ require.False(t, ac.overlays.pickerActive())
621+ require.Same(t, sess, ac.previousOwner.Get(), "return navigation retains the local source owner")
622+ view, ok := ac.currentAttachmentOwner().(*remoteView)
623+ require.True(t, ok)
624+ remoteToken := attachmentOwnerToken(view, ac, ac.transport())
625+ require.True(t, remoteToken.current())
626+ d.handleAttachmentClientFrame(remoteToken, frameInput([]byte{keys.ESC, ' '}))
627+ require.True(t, ac.overlays.paletteActive(), "the same local connection can reopen its local palette")
628+ d.handleAttachmentClientFrame(remoteToken, frameInput([]byte("SSP\r")))
629+ ac.overlays.pickerMu.Lock()
630+ model := ac.overlays.picker.Clone()
631+ ac.overlays.pickerMu.Unlock()
632+ require.NotNil(t, model)
633+ frame := model.Render(domain.Size{Cols: 80, Rows: 20}, picker.Preview{})
634+ var rendered strings.Builder
635+ for y := range frame.Height {
636+ rendered.WriteString(rowText(frame.Row(y)))
637+ }
638+ require.Contains(t, rendered.String(), sess.name)
639+ require.Contains(t, rendered.String(), target.SessionName, "the reopened picker retains local and remote rows")
576640
577- frame := receiveRemotePicker(t, sends, "attach target")
578- require.Equal(t, ports.MsgAttachTarget, frame.Type)
579- got, err := ports.UnmarshalAttachTarget(frame.Payload)
580- require.NoError(t, err)
581- require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach}, got)
582- require.Nil(t, ac.currentAttachmentSession())
583641 d.mu.Lock()
584- require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow")
642+ require.Len(t, d.remoteViews, 1)
585643 d.mu.Unlock()
644+ require.Never(t, func() bool {
645+ select {
646+ case frame := <-sends:
647+ return frame.Type == ports.MsgAttachTarget
648+ default:
649+ return false
650+ }
651+ }, 100*time.Millisecond, 5*time.Millisecond)
652+ d.shutdownAll(ports.ReasonServerShutdown)
586653 }
587654
588-func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
655+func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) {
589656 d := newRemotePickerDaemon(nil)
657+ target := remoteLinkTestTarget()
590658 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
591- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
659+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
660+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
661+ }},
592662 }})
593- const sendErr = "remote attach send failed"
594- tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}
595- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
596- ac.initOverlays()
597- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
598- sess := &session{sessionCore: sessionCore{id: "local", name: "local", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}
599- publishTiledPaneOwners(sess, tb)
600- ac.setSession(sess)
601- d.sessions[sess.id] = sess
663+ d.remoteCatalog.mu.Lock()
664+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
665+ d.remoteCatalog.mu.Unlock()
666+
667+ remoteTransport := newRemoteLinkTestTransport()
668+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: remoteTransport}}
669+ d.remoteDialerFactory = factory
670+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
671+ pickerKey := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
672+ pickerTarget := picker.Target{Session: pickerKey.ID(), RemoteKey: &pickerKey, RemoteTarget: &target}
673+ model := picker.New([]picker.SessionView{{
674+ ID: pickerTarget.Session,
675+ Name: target.SessionName,
676+ RemoteKey: &pickerKey,
677+ RemoteTarget: &target,
678+ RemoteAttachReady: true,
679+ Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}},
680+ }}, picker.SelectionConfig{})
602681 ac.overlays.pickerMu.Lock()
603- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
682+ ac.overlays.picker = model
604683 ac.overlays.pickerGeneration++
605684 ac.overlays.pickerMu.Unlock()
606685
607- gone := make(chan struct{})
608- d.afterClientGoneDetach = func() { close(gone) }
609- token := sess.attachmentToken(ac, tr)
610- effect, admitted := ac.beginAttachmentEffect(token)
686+ selectRemote := func() {
687+ // Exercise the picker input boundary without a frame effect. The daemon
688+ // must admit a short-lived local effect rather than taking the legacy
689+ // client handoff path or closing the picker before the async selection.
690+ d.handlePickerInput(ac, []byte("\r"))
691+ }
692+ selectRemote()
693+ enqueueRemoteLinkHandshake(t, remoteTransport, target, ac.sizeSnapshot())
694+ require.Eventually(t, func() bool {
695+ _, remote := ac.currentAttachmentOwner().(*remoteView)
696+ return remote
697+ }, time.Second, 5*time.Millisecond, "the first remote picker selection should publish the remote view")
698+
699+ view, ok := ac.currentAttachmentOwner().(*remoteView)
700+ require.True(t, ok)
701+ backToken := attachmentOwnerToken(view, ac, ac.transport())
702+ require.NotNil(t, backToken.ac)
703+ backEffect, admitted := ac.beginAttachmentEffect(backToken)
611704 require.True(t, admitted)
612- token.effect = effect
613- defer effect.End()
614- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
615- target := picker.Target{Session: key.ID(), RemoteKey: &key}
616- err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")
617- require.EqualError(t, err, sendErr)
618- require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")
619- select {
620- case <-gone:
621- t.Fatal("failed control send reached clientGoneForAttachment")
622- default:
705+ backToken.effect = backEffect
706+ require.NoError(t, d.backSessionForAttachment(backToken))
707+ backEffect.End()
708+ require.Eventually(t, func() bool {
709+ return ac.currentAttachmentSession() == sess
710+ }, time.Second, 5*time.Millisecond, "back navigation should return to the local source")
711+ view.mu.Lock()
712+ require.NotNil(t, view.warm, "the detached remote view should be retained warm")
713+ view.mu.Unlock()
714+
715+ ac.overlays.pickerMu.Lock()
716+ ac.overlays.picker = model
717+ ac.overlays.pickerGeneration++
718+ ac.overlays.pickerMu.Unlock()
719+ selectRemote()
720+ require.Eventually(t, func() bool {
721+ owner, remote := ac.currentAttachmentOwner().(*remoteView)
722+ return remote && owner == view
723+ }, time.Second, 5*time.Millisecond, "reselecting should publish the warm remote view")
724+ require.Equal(t, int32(1), factory.calls.Load(), "reselecting a warm remote view must not dial again")
725+ view.mu.Lock()
726+ require.Nil(t, view.warm, "publishing the warm view should cancel its retention timer")
727+ view.mu.Unlock()
728+ d.shutdownAll(ports.ReasonServerShutdown)
729+}
730+
731+func TestRemotePickerWarmReselectActivatesSelectedAlternateTabWithoutSecondDial(t *testing.T) {
732+ d := newRemotePickerDaemon(nil)
733+ target := remoteLinkTestTarget()
734+ alternate := target
735+ alternate.LiveTabID = "tab-2"
736+ const alternateName = "alternate"
737+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
738+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
739+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "running",
740+ Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}, {ID: string(alternate.LiveTabID), Index: 1, Name: alternateName}},
741+ }},
742+ }})
743+ d.remoteCatalog.mu.Lock()
744+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
745+ d.remoteCatalog.mu.Unlock()
746+
747+ remoteTransport := newRemoteLinkTestTransport()
748+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: remoteTransport}}
749+ d.remoteDialerFactory = factory
750+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
751+ pickerKey := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
752+ openPicker := func() {
753+ ac.overlays.pickerMu.Lock()
754+ ac.overlays.picker = picker.New([]picker.SessionView{{
755+ ID: pickerKey.ID(), Name: target.SessionName, RemoteKey: &pickerKey, RemoteTarget: &target, RemoteAttachReady: true,
756+ Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}, {TabID: alternate.LiveTabID, Name: alternateName}},
757+ }}, picker.SelectionConfig{})
758+ ac.overlays.pickerGeneration++
759+ ac.overlays.pickerMu.Unlock()
623760 }
624- require.Same(t, sess, ac.currentAttachmentSession())
761+ selectTarget := func(selected domain.RemoteSessionTarget) {
762+ token := sess.attachmentToken(ac, ac.transport())
763+ effect, admitted := ac.beginAttachmentEffect(token)
764+ require.True(t, admitted)
765+ token.effect = effect
766+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: pickerKey.ID(), RemoteKey: &pickerKey, RemoteTarget: &selected}, sessionHandoffGuard{closePicker: true}, "picker-select"))
767+ effect.End()
768+ }
769+
770+ openPicker()
771+ selectTarget(target)
772+ enqueueRemoteLinkHandshakeWithMetadata(t, remoteTransport, target, ac.sizeSnapshot(), ports.SessionMeta{
773+ LifecycleID: target.LifecycleID, Revision: 1, SessionName: target.SessionName, ActiveTabID: target.LiveTabID,
774+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}, {ID: alternate.LiveTabID, Name: alternateName}},
775+ }, "first tab")
776+ require.Eventually(t, func() bool {
777+ _, remote := ac.currentAttachmentOwner().(*remoteView)
778+ return remote
779+ }, time.Second, 5*time.Millisecond)
780+ view, ok := ac.currentAttachmentOwner().(*remoteView)
781+ require.True(t, ok)
782+
783+ backToken := attachmentOwnerToken(view, ac, ac.transport())
784+ backEffect, admitted := ac.beginAttachmentEffect(backToken)
785+ require.True(t, admitted)
786+ backToken.effect = backEffect
787+ require.NoError(t, d.backSessionForAttachment(backToken))
788+ backEffect.End()
789+ require.Eventually(t, func() bool { return ac.currentAttachmentSession() == sess }, time.Second, 5*time.Millisecond)
790+
791+ openPicker()
792+ selectTarget(alternate)
793+ commandFrame := awaitFrame(t, remoteTransport.sent, ports.MsgCommand)
794+ command, err := ports.UnmarshalCommandRequest(commandFrame.Payload)
795+ require.NoError(t, err)
796+ require.Equal(t, "next-tab", command.Slug)
797+ require.True(t, command.Attached)
798+ remoteTransport.recv <- remoteLinkTestReceive{frame: remoteMetadataFrame(t, ports.SessionMeta{
799+ LifecycleID: target.LifecycleID, Revision: 2, SessionName: target.SessionName, ActiveTabID: alternate.LiveTabID,
800+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}, {ID: alternate.LiveTabID, Name: alternateName}},
801+ })}
802+ resultPayload := ports.MarshalCommandResult(ports.CommandResult{RequestID: command.RequestID, OK: true})
803+ remoteTransport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgCommandResult, Payload: resultPayload}}
804+
805+ require.Eventually(t, func() bool {
806+ owner, remote := ac.currentAttachmentOwner().(*remoteView)
807+ if !remote || owner != view {
808+ return false
809+ }
810+ view.mu.Lock()
811+ defer view.mu.Unlock()
812+ return view.metadata.ActiveTabID == alternate.LiveTabID
813+ }, time.Second, 5*time.Millisecond)
814+ require.Equal(t, int32(1), factory.calls.Load(), "alternate tab warm reuse must not dial again")
815+ d.shutdownAll(ports.ReasonServerShutdown)
625816 }
626817
627-type remotePickerSendErrorTransport struct {
628- err error
818+func TestRemotePickerSelectionFailureKeepsSourceAndPickerOpen(t *testing.T) {
819+ d := newRemotePickerDaemon(nil)
820+ target := remoteLinkTestTarget()
821+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
822+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
823+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
824+ }},
825+ }})
826+ d.remoteCatalog.mu.Lock()
827+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
828+ d.remoteCatalog.mu.Unlock()
829+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{}}
830+
831+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
832+ ac.overlays.pickerMu.Lock()
833+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
834+ ac.overlays.pickerGeneration++
835+ ac.overlays.pickerMu.Unlock()
836+ token := sess.attachmentToken(ac, ac.transport())
837+ effect, admitted := ac.beginAttachmentEffect(token)
838+ require.True(t, admitted)
839+ token.effect = effect
840+ pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
841+ require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
842+ effect.End()
843+
844+ require.Eventually(t, func() bool {
845+ ac.overlays.pickerMu.Lock()
846+ defer ac.overlays.pickerMu.Unlock()
847+ return ac.overlays.pickerRemoteSelection == nil
848+ }, time.Second, 5*time.Millisecond)
849+ require.True(t, ac.overlays.pickerActive(), "a failed remote link leaves the local picker usable")
850+ require.Same(t, sess, ac.currentAttachmentSession(), "a failed remote link leaves the source owner untouched")
851+ require.Never(t, func() bool {
852+ select {
853+ case frame := <-sends:
854+ return frame.Type == ports.MsgAttachTarget
855+ default:
856+ return false
857+ }
858+ }, 100*time.Millisecond, 5*time.Millisecond)
859+ d.mu.Lock()
860+ require.Empty(t, d.remoteViews)
861+ d.mu.Unlock()
862+ d.shutdownAll(ports.ReasonServerShutdown)
629863 }
630864
631-func (t *remotePickerSendErrorTransport) Send(ports.Frame) error { return t.err }
632-func (*remotePickerSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
633-func (*remotePickerSendErrorTransport) Close() error { return nil }
865+func TestRemotePickerSelectionEscapeCancelsWithoutDetachingSource(t *testing.T) {
866+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
867+ d := newRemotePickerDaemon(nil)
868+ d.clock = clock
869+ target := remoteLinkTestTarget()
870+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
871+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
872+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
873+ }},
874+ }})
875+ d.remoteCatalog.mu.Lock()
876+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
877+ d.remoteCatalog.mu.Unlock()
878+ remoteTransport := newRemoteLinkTestTransport()
879+ dialStarted := make(chan struct{})
880+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: remoteTransport, started: dialStarted, release: make(chan struct{})}}
881+
882+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
883+ ac.overlays.pickerMu.Lock()
884+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
885+ ac.overlays.pickerGeneration++
886+ ac.overlays.pickerMu.Unlock()
887+ token := sess.attachmentToken(ac, ac.transport())
888+ effect, admitted := ac.beginAttachmentEffect(token)
889+ require.True(t, admitted)
890+ token.effect = effect
891+ pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
892+ require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
893+ effect.End()
894+ receiveRemotePickerClose(t, dialStarted, "remote picker dial")
895+
896+ d.handlePickerInput(ac, []byte{keys.ESC})
897+ escTimer := receiveRemotePicker(t, clock.timers, "picker escape timer")
898+ escTimer.ch <- time.Now()
899+ require.Eventually(t, func() bool {
900+ ac.overlays.pickerMu.Lock()
901+ defer ac.overlays.pickerMu.Unlock()
902+ return ac.overlays.pickerRemoteSelection == nil
903+ }, time.Second, 5*time.Millisecond)
904+ require.True(t, ac.overlays.pickerActive(), "Escape cancels the pending remote activation but keeps the picker open")
905+ require.Same(t, sess, ac.currentAttachmentSession())
906+ require.Never(t, func() bool {
907+ select {
908+ case frame := <-sends:
909+ return frame.Type == ports.MsgAttachTarget
910+ default:
911+ return false
912+ }
913+ }, 100*time.Millisecond, 5*time.Millisecond)
914+ d.mu.Lock()
915+ require.Empty(t, d.remoteViews)
916+ d.mu.Unlock()
917+ d.shutdownAll(ports.ReasonServerShutdown)
918+}
634919
635920 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
636921 t.Helper()
@@ -979,15 +1264,29 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
9791264 ac.overlays.pickerMu.Unlock()
9801265 }
9811266
982-func TestRemoteRowsBecomeSelectableOnlyAfterOwnershipCompletion(t *testing.T) {
1267+func TestRemoteRowsRequireFreshRichIdentityForPickerActivation(t *testing.T) {
9831268 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
984- for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} {
985- view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, status, time.Unix(1, 0))
1269+ session := ports.RemoteCatalogSession{
1270+ LifecycleID: remoteLifecycleForTest(), Name: key.Name, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Name: "main"}},
1271+ }
1272+ for _, test := range []struct {
1273+ status remoteHostStatus
1274+ want bool
1275+ }{
1276+ {status: remoteHostCached},
1277+ {status: remoteHostRefreshing},
1278+ {status: remoteHostFresh, want: true},
1279+ {status: remoteHostUnreachable},
1280+ {status: remoteHostVersionMismatch},
1281+ } {
1282+ view := remotePickerView(key, session, test.status, time.Unix(1, 0))
9861283 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
987- require.True(t, selectable, "status %d is ready after ownership completion", status)
1284+ require.Equal(t, test.want, selectable, "status %d", test.status)
1285+ require.Equal(t, test.want, view.RemoteAttachReady, "status %d", test.status)
9881286 }
989- mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{})
990- require.False(t, mismatch.RemoteAttachReady)
991- _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1287+
1288+ legacy := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostFresh, time.Unix(1, 0))
1289+ require.False(t, legacy.RemoteAttachReady, "count-only cached rows cannot initiate proxied picker activation")
1290+ _, selectable := picker.New([]picker.SessionView{legacy}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
9921291 require.False(t, selectable)
9931292 }
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9+ "strings"
9 "sync"10 "sync"
10 "testing"11 "testing"
11 "time"12 "time"
@@ -14,6 +15,7 @@ import (
14 15
15 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18+ "github.com/bnema/vev/internal/usecase/keys"
17 "github.com/bnema/vev/internal/usecase/picker"19 "github.com/bnema/vev/internal/usecase/picker"
18 )20 )
19 21
@@ -560,77 +562,360 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
560 d.remoteCatalog.mu.Unlock()562 d.remoteCatalog.mu.Unlock()
561 }563 }
562 564
563-func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {565+func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *testing.T) {
564 d := newRemotePickerDaemon(nil)566 d := newRemotePickerDaemon(nil)
567+ target := remoteLinkTestTarget()
568+ require.NoError(t, target.Validate())
565 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{569 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
566- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},570+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
571+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
572+ }},
567 }})573 }})
574+ d.remoteCatalog.mu.Lock()
575+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
576+ d.remoteCatalog.mu.Unlock()
577+ require.True(t, d.remotePickerTargetReadyTarget(target))
578+
579+ remoteTransport := newRemoteLinkTestTransport()
580+ remoteDialStarted := make(chan struct{})
581+ remoteDialRelease := make(chan struct{})
582+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{
583+ transport: remoteTransport,
584+ started: remoteDialStarted,
585+ release: remoteDialRelease,
586+ }}
568 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")587 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
588+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
589+ ac.overlays.pickerMu.Lock()
590+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
591+ ac.overlays.pickerGeneration++
592+ ac.overlays.pickerMu.Unlock()
593+
569 token := sess.attachmentToken(ac, ac.transport())594 token := sess.attachmentToken(ac, ac.transport())
570 effect, admitted := ac.beginAttachmentEffect(token)595 effect, admitted := ac.beginAttachmentEffect(token)
571 require.True(t, admitted)596 require.True(t, admitted)
572 token.effect = effect597 token.effect = effect
573- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}598+ pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
574- target := picker.Target{Session: key.ID(), RemoteKey: &key}599+ require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
575- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))600+ effect.End()
601+
602+ receiveRemotePickerClose(t, remoteDialStarted, "remote picker dial")
603+ require.Same(t, sess, ac.currentAttachmentSession(), "the source remains attached while the remote handshake is pending")
604+ require.True(t, ac.overlays.pickerActive(), "the local picker stays available until publication")
605+ require.Never(t, func() bool {
606+ select {
607+ case frame := <-sends:
608+ return frame.Type == ports.MsgAttachTarget
609+ default:
610+ return false
611+ }
612+ }, 100*time.Millisecond, 5*time.Millisecond, "picker selection must not hand the client to the remote daemon")
613+
614+ enqueueRemoteLinkHandshake(t, remoteTransport, target, ac.sizeSnapshot())
615+ close(remoteDialRelease)
616+ require.Eventually(t, func() bool {
617+ _, remote := ac.currentAttachmentOwner().(*remoteView)
618+ return remote
619+ }, time.Second, 5*time.Millisecond, "the ready remote surface is atomically published to the same client")
620+ require.False(t, ac.overlays.pickerActive())
621+ require.Same(t, sess, ac.previousOwner.Get(), "return navigation retains the local source owner")
622+ view, ok := ac.currentAttachmentOwner().(*remoteView)
623+ require.True(t, ok)
624+ remoteToken := attachmentOwnerToken(view, ac, ac.transport())
625+ require.True(t, remoteToken.current())
626+ d.handleAttachmentClientFrame(remoteToken, frameInput([]byte{keys.ESC, ' '}))
627+ require.True(t, ac.overlays.paletteActive(), "the same local connection can reopen its local palette")
628+ d.handleAttachmentClientFrame(remoteToken, frameInput([]byte("SSP\r")))
629+ ac.overlays.pickerMu.Lock()
630+ model := ac.overlays.picker.Clone()
631+ ac.overlays.pickerMu.Unlock()
632+ require.NotNil(t, model)
633+ frame := model.Render(domain.Size{Cols: 80, Rows: 20}, picker.Preview{})
634+ var rendered strings.Builder
635+ for y := range frame.Height {
636+ rendered.WriteString(rowText(frame.Row(y)))
637+ }
638+ require.Contains(t, rendered.String(), sess.name)
639+ require.Contains(t, rendered.String(), target.SessionName, "the reopened picker retains local and remote rows")
576 640
577- frame := receiveRemotePicker(t, sends, "attach target")
578- require.Equal(t, ports.MsgAttachTarget, frame.Type)
579- got, err := ports.UnmarshalAttachTarget(frame.Payload)
580- require.NoError(t, err)
581- require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach}, got)
582- require.Nil(t, ac.currentAttachmentSession())
583 d.mu.Lock()641 d.mu.Lock()
584- require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow")642+ require.Len(t, d.remoteViews, 1)
585 d.mu.Unlock()643 d.mu.Unlock()
644+ require.Never(t, func() bool {
645+ select {
646+ case frame := <-sends:
647+ return frame.Type == ports.MsgAttachTarget
648+ default:
649+ return false
650+ }
651+ }, 100*time.Millisecond, 5*time.Millisecond)
652+ d.shutdownAll(ports.ReasonServerShutdown)
586 }653 }
587 654
588-func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {655+func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) {
589 d := newRemotePickerDaemon(nil)656 d := newRemotePickerDaemon(nil)
657+ target := remoteLinkTestTarget()
590 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{658 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
591- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},659+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
660+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
661+ }},
592 }})662 }})
593- const sendErr = "remote attach send failed"663+ d.remoteCatalog.mu.Lock()
594- tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}664+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
595- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}665+ d.remoteCatalog.mu.Unlock()
596- ac.initOverlays()666+
597- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})667+ remoteTransport := newRemoteLinkTestTransport()
598- sess := &session{sessionCore: sessionCore{id: "local", name: "local", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}668+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: remoteTransport}}
599- publishTiledPaneOwners(sess, tb)669+ d.remoteDialerFactory = factory
600- ac.setSession(sess)670+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
601- d.sessions[sess.id] = sess671+ pickerKey := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
672+ pickerTarget := picker.Target{Session: pickerKey.ID(), RemoteKey: &pickerKey, RemoteTarget: &target}
673+ model := picker.New([]picker.SessionView{{
674+ ID: pickerTarget.Session,
675+ Name: target.SessionName,
676+ RemoteKey: &pickerKey,
677+ RemoteTarget: &target,
678+ RemoteAttachReady: true,
679+ Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}},
680+ }}, picker.SelectionConfig{})
602 ac.overlays.pickerMu.Lock()681 ac.overlays.pickerMu.Lock()
603- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})682+ ac.overlays.picker = model
604 ac.overlays.pickerGeneration++683 ac.overlays.pickerGeneration++
605 ac.overlays.pickerMu.Unlock()684 ac.overlays.pickerMu.Unlock()
606 685
607- gone := make(chan struct{})686+ selectRemote := func() {
608- d.afterClientGoneDetach = func() { close(gone) }687+ // Exercise the picker input boundary without a frame effect. The daemon
609- token := sess.attachmentToken(ac, tr)688+ // must admit a short-lived local effect rather than taking the legacy
610- effect, admitted := ac.beginAttachmentEffect(token)689+ // client handoff path or closing the picker before the async selection.
690+ d.handlePickerInput(ac, []byte("\r"))
691+ }
692+ selectRemote()
693+ enqueueRemoteLinkHandshake(t, remoteTransport, target, ac.sizeSnapshot())
694+ require.Eventually(t, func() bool {
695+ _, remote := ac.currentAttachmentOwner().(*remoteView)
696+ return remote
697+ }, time.Second, 5*time.Millisecond, "the first remote picker selection should publish the remote view")
698+
699+ view, ok := ac.currentAttachmentOwner().(*remoteView)
700+ require.True(t, ok)
701+ backToken := attachmentOwnerToken(view, ac, ac.transport())
702+ require.NotNil(t, backToken.ac)
703+ backEffect, admitted := ac.beginAttachmentEffect(backToken)
611 require.True(t, admitted)704 require.True(t, admitted)
612- token.effect = effect705+ backToken.effect = backEffect
613- defer effect.End()706+ require.NoError(t, d.backSessionForAttachment(backToken))
614- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}707+ backEffect.End()
615- target := picker.Target{Session: key.ID(), RemoteKey: &key}708+ require.Eventually(t, func() bool {
616- err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")709+ return ac.currentAttachmentSession() == sess
617- require.EqualError(t, err, sendErr)710+ }, time.Second, 5*time.Millisecond, "back navigation should return to the local source")
618- require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")711+ view.mu.Lock()
619- select {712+ require.NotNil(t, view.warm, "the detached remote view should be retained warm")
620- case <-gone:713+ view.mu.Unlock()
621- t.Fatal("failed control send reached clientGoneForAttachment")714+
622- default:715+ ac.overlays.pickerMu.Lock()
716+ ac.overlays.picker = model
717+ ac.overlays.pickerGeneration++
718+ ac.overlays.pickerMu.Unlock()
719+ selectRemote()
720+ require.Eventually(t, func() bool {
721+ owner, remote := ac.currentAttachmentOwner().(*remoteView)
722+ return remote && owner == view
723+ }, time.Second, 5*time.Millisecond, "reselecting should publish the warm remote view")
724+ require.Equal(t, int32(1), factory.calls.Load(), "reselecting a warm remote view must not dial again")
725+ view.mu.Lock()
726+ require.Nil(t, view.warm, "publishing the warm view should cancel its retention timer")
727+ view.mu.Unlock()
728+ d.shutdownAll(ports.ReasonServerShutdown)
729+}
730+
731+func TestRemotePickerWarmReselectActivatesSelectedAlternateTabWithoutSecondDial(t *testing.T) {
732+ d := newRemotePickerDaemon(nil)
733+ target := remoteLinkTestTarget()
734+ alternate := target
735+ alternate.LiveTabID = "tab-2"
736+ const alternateName = "alternate"
737+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
738+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
739+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "running",
740+ Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}, {ID: string(alternate.LiveTabID), Index: 1, Name: alternateName}},
741+ }},
742+ }})
743+ d.remoteCatalog.mu.Lock()
744+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
745+ d.remoteCatalog.mu.Unlock()
746+
747+ remoteTransport := newRemoteLinkTestTransport()
748+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: remoteTransport}}
749+ d.remoteDialerFactory = factory
750+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
751+ pickerKey := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
752+ openPicker := func() {
753+ ac.overlays.pickerMu.Lock()
754+ ac.overlays.picker = picker.New([]picker.SessionView{{
755+ ID: pickerKey.ID(), Name: target.SessionName, RemoteKey: &pickerKey, RemoteTarget: &target, RemoteAttachReady: true,
756+ Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}, {TabID: alternate.LiveTabID, Name: alternateName}},
757+ }}, picker.SelectionConfig{})
758+ ac.overlays.pickerGeneration++
759+ ac.overlays.pickerMu.Unlock()
623 }760 }
624- require.Same(t, sess, ac.currentAttachmentSession())761+ selectTarget := func(selected domain.RemoteSessionTarget) {
762+ token := sess.attachmentToken(ac, ac.transport())
763+ effect, admitted := ac.beginAttachmentEffect(token)
764+ require.True(t, admitted)
765+ token.effect = effect
766+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: pickerKey.ID(), RemoteKey: &pickerKey, RemoteTarget: &selected}, sessionHandoffGuard{closePicker: true}, "picker-select"))
767+ effect.End()
768+ }
769+
770+ openPicker()
771+ selectTarget(target)
772+ enqueueRemoteLinkHandshakeWithMetadata(t, remoteTransport, target, ac.sizeSnapshot(), ports.SessionMeta{
773+ LifecycleID: target.LifecycleID, Revision: 1, SessionName: target.SessionName, ActiveTabID: target.LiveTabID,
774+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}, {ID: alternate.LiveTabID, Name: alternateName}},
775+ }, "first tab")
776+ require.Eventually(t, func() bool {
777+ _, remote := ac.currentAttachmentOwner().(*remoteView)
778+ return remote
779+ }, time.Second, 5*time.Millisecond)
780+ view, ok := ac.currentAttachmentOwner().(*remoteView)
781+ require.True(t, ok)
782+
783+ backToken := attachmentOwnerToken(view, ac, ac.transport())
784+ backEffect, admitted := ac.beginAttachmentEffect(backToken)
785+ require.True(t, admitted)
786+ backToken.effect = backEffect
787+ require.NoError(t, d.backSessionForAttachment(backToken))
788+ backEffect.End()
789+ require.Eventually(t, func() bool { return ac.currentAttachmentSession() == sess }, time.Second, 5*time.Millisecond)
790+
791+ openPicker()
792+ selectTarget(alternate)
793+ commandFrame := awaitFrame(t, remoteTransport.sent, ports.MsgCommand)
794+ command, err := ports.UnmarshalCommandRequest(commandFrame.Payload)
795+ require.NoError(t, err)
796+ require.Equal(t, "next-tab", command.Slug)
797+ require.True(t, command.Attached)
798+ remoteTransport.recv <- remoteLinkTestReceive{frame: remoteMetadataFrame(t, ports.SessionMeta{
799+ LifecycleID: target.LifecycleID, Revision: 2, SessionName: target.SessionName, ActiveTabID: alternate.LiveTabID,
800+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}, {ID: alternate.LiveTabID, Name: alternateName}},
801+ })}
802+ resultPayload := ports.MarshalCommandResult(ports.CommandResult{RequestID: command.RequestID, OK: true})
803+ remoteTransport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgCommandResult, Payload: resultPayload}}
804+
805+ require.Eventually(t, func() bool {
806+ owner, remote := ac.currentAttachmentOwner().(*remoteView)
807+ if !remote || owner != view {
808+ return false
809+ }
810+ view.mu.Lock()
811+ defer view.mu.Unlock()
812+ return view.metadata.ActiveTabID == alternate.LiveTabID
813+ }, time.Second, 5*time.Millisecond)
814+ require.Equal(t, int32(1), factory.calls.Load(), "alternate tab warm reuse must not dial again")
815+ d.shutdownAll(ports.ReasonServerShutdown)
625 }816 }
626 817
627-type remotePickerSendErrorTransport struct {818+func TestRemotePickerSelectionFailureKeepsSourceAndPickerOpen(t *testing.T) {
628- err error819+ d := newRemotePickerDaemon(nil)
820+ target := remoteLinkTestTarget()
821+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
822+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
823+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
824+ }},
825+ }})
826+ d.remoteCatalog.mu.Lock()
827+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
828+ d.remoteCatalog.mu.Unlock()
829+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{}}
830+
831+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
832+ ac.overlays.pickerMu.Lock()
833+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
834+ ac.overlays.pickerGeneration++
835+ ac.overlays.pickerMu.Unlock()
836+ token := sess.attachmentToken(ac, ac.transport())
837+ effect, admitted := ac.beginAttachmentEffect(token)
838+ require.True(t, admitted)
839+ token.effect = effect
840+ pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
841+ require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
842+ effect.End()
843+
844+ require.Eventually(t, func() bool {
845+ ac.overlays.pickerMu.Lock()
846+ defer ac.overlays.pickerMu.Unlock()
847+ return ac.overlays.pickerRemoteSelection == nil
848+ }, time.Second, 5*time.Millisecond)
849+ require.True(t, ac.overlays.pickerActive(), "a failed remote link leaves the local picker usable")
850+ require.Same(t, sess, ac.currentAttachmentSession(), "a failed remote link leaves the source owner untouched")
851+ require.Never(t, func() bool {
852+ select {
853+ case frame := <-sends:
854+ return frame.Type == ports.MsgAttachTarget
855+ default:
856+ return false
857+ }
858+ }, 100*time.Millisecond, 5*time.Millisecond)
859+ d.mu.Lock()
860+ require.Empty(t, d.remoteViews)
861+ d.mu.Unlock()
862+ d.shutdownAll(ports.ReasonServerShutdown)
629 }863 }
630 864
631-func (t *remotePickerSendErrorTransport) Send(ports.Frame) error { return t.err }865+func TestRemotePickerSelectionEscapeCancelsWithoutDetachingSource(t *testing.T) {
632-func (*remotePickerSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }866+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
633-func (*remotePickerSendErrorTransport) Close() error { return nil }867+ d := newRemotePickerDaemon(nil)
868+ d.clock = clock
869+ target := remoteLinkTestTarget()
870+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
871+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
872+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
873+ }},
874+ }})
875+ d.remoteCatalog.mu.Lock()
876+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
877+ d.remoteCatalog.mu.Unlock()
878+ remoteTransport := newRemoteLinkTestTransport()
879+ dialStarted := make(chan struct{})
880+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: remoteTransport, started: dialStarted, release: make(chan struct{})}}
881+
882+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
883+ ac.overlays.pickerMu.Lock()
884+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
885+ ac.overlays.pickerGeneration++
886+ ac.overlays.pickerMu.Unlock()
887+ token := sess.attachmentToken(ac, ac.transport())
888+ effect, admitted := ac.beginAttachmentEffect(token)
889+ require.True(t, admitted)
890+ token.effect = effect
891+ pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
892+ require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
893+ effect.End()
894+ receiveRemotePickerClose(t, dialStarted, "remote picker dial")
895+
896+ d.handlePickerInput(ac, []byte{keys.ESC})
897+ escTimer := receiveRemotePicker(t, clock.timers, "picker escape timer")
898+ escTimer.ch <- time.Now()
899+ require.Eventually(t, func() bool {
900+ ac.overlays.pickerMu.Lock()
901+ defer ac.overlays.pickerMu.Unlock()
902+ return ac.overlays.pickerRemoteSelection == nil
903+ }, time.Second, 5*time.Millisecond)
904+ require.True(t, ac.overlays.pickerActive(), "Escape cancels the pending remote activation but keeps the picker open")
905+ require.Same(t, sess, ac.currentAttachmentSession())
906+ require.Never(t, func() bool {
907+ select {
908+ case frame := <-sends:
909+ return frame.Type == ports.MsgAttachTarget
910+ default:
911+ return false
912+ }
913+ }, 100*time.Millisecond, 5*time.Millisecond)
914+ d.mu.Lock()
915+ require.Empty(t, d.remoteViews)
916+ d.mu.Unlock()
917+ d.shutdownAll(ports.ReasonServerShutdown)
918+}
634 919
635 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {920 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
636 t.Helper()921 t.Helper()
@@ -979,15 +1264,29 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
979 ac.overlays.pickerMu.Unlock()1264 ac.overlays.pickerMu.Unlock()
980 }1265 }
981 1266
982-func TestRemoteRowsBecomeSelectableOnlyAfterOwnershipCompletion(t *testing.T) {1267+func TestRemoteRowsRequireFreshRichIdentityForPickerActivation(t *testing.T) {
983 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}1268 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
984- for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} {1269+ session := ports.RemoteCatalogSession{
985- view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, status, time.Unix(1, 0))1270+ LifecycleID: remoteLifecycleForTest(), Name: key.Name, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Name: "main"}},
1271+ }
1272+ for _, test := range []struct {
1273+ status remoteHostStatus
1274+ want bool
1275+ }{
1276+ {status: remoteHostCached},
1277+ {status: remoteHostRefreshing},
1278+ {status: remoteHostFresh, want: true},
1279+ {status: remoteHostUnreachable},
1280+ {status: remoteHostVersionMismatch},
1281+ } {
1282+ view := remotePickerView(key, session, test.status, time.Unix(1, 0))
986 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()1283 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
987- require.True(t, selectable, "status %d is ready after ownership completion", status)1284+ require.Equal(t, test.want, selectable, "status %d", test.status)
1285+ require.Equal(t, test.want, view.RemoteAttachReady, "status %d", test.status)
988 }1286 }
989- mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{})1287+
990- require.False(t, mismatch.RemoteAttachReady)1288+ legacy := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostFresh, time.Unix(1, 0))
991- _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()1289+ require.False(t, legacy.RemoteAttachReady, "count-only cached rows cannot initiate proxied picker activation")
1290+ _, selectable := picker.New([]picker.SessionView{legacy}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
992 require.False(t, selectable)1291 require.False(t, selectable)
993 }1292 }
A internal/usecase/daemon/remote_reconnect.go +57 -0
@@ -0,0 +1,57 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// reconnectRemoteView preserves the registered view and its local attachments
10+// while openRemoteView verifies a replacement link. It changes presentation
11+// state only when the failed exact link and reconnect generation are still
12+// current, so a later picker open, terminal retirement, or shutdown wins.
13+func (d *Daemon) reconnectRemoteView(view *remoteView, failed *remoteLink, reconnectGeneration uint64, target domain.RemoteSessionTarget, size domain.Size) {
14+ if d == nil || view == nil || failed == nil {
15+ return
16+ }
17+ ctx := d.serveCtx
18+ if ctx == nil {
19+ ctx = context.Background()
20+ }
21+ _, err := d.openRemoteView(ctx, target, size)
22+ if err == nil {
23+ return
24+ }
25+
26+ view.mu.Lock()
27+ current := !view.closed &&
28+ view.link == failed &&
29+ view.linkGeneration == failed.generation &&
30+ view.linkState == remoteViewLinkReconnecting &&
31+ view.reconnectGeneration == reconnectGeneration
32+ if !current {
33+ view.mu.Unlock()
34+ return
35+ }
36+ view.linkState = remoteViewLinkUnavailable
37+ view.reconnectGeneration++
38+ attachments := make([]*attachedClient, 0, len(view.attachments))
39+ for attachment := range view.attachments {
40+ attachments = append(attachments, attachment)
41+ }
42+ view.mu.Unlock()
43+ d.repaintRemoteViewAttachments(view, attachments)
44+}
45+
46+// reconnectSizeLocked derives an outer geometry matching the retained remote
47+// content surface. The caller holds view.mu. Existing content is authoritative
48+// until a validated replacement publication adopts a newer attachment claim.
49+func reconnectSizeLocked(view *remoteView) domain.Size {
50+ if view != nil && view.screen != nil {
51+ cols, rows := view.screen.Frame.Width, view.screen.Frame.Height
52+ if cols > 0 && rows > 0 {
53+ return domain.Size{Cols: cols, Rows: rows + tabChromeRows}
54+ }
55+ }
56+ return defaultSize
57+}
@@ -0,0 +1,57 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// reconnectRemoteView preserves the registered view and its local attachments
10+// while openRemoteView verifies a replacement link. It changes presentation
11+// state only when the failed exact link and reconnect generation are still
12+// current, so a later picker open, terminal retirement, or shutdown wins.
13+func (d *Daemon) reconnectRemoteView(view *remoteView, failed *remoteLink, reconnectGeneration uint64, target domain.RemoteSessionTarget, size domain.Size) {
14+ if d == nil || view == nil || failed == nil {
15+ return
16+ }
17+ ctx := d.serveCtx
18+ if ctx == nil {
19+ ctx = context.Background()
20+ }
21+ _, err := d.openRemoteView(ctx, target, size)
22+ if err == nil {
23+ return
24+ }
25+
26+ view.mu.Lock()
27+ current := !view.closed &&
28+ view.link == failed &&
29+ view.linkGeneration == failed.generation &&
30+ view.linkState == remoteViewLinkReconnecting &&
31+ view.reconnectGeneration == reconnectGeneration
32+ if !current {
33+ view.mu.Unlock()
34+ return
35+ }
36+ view.linkState = remoteViewLinkUnavailable
37+ view.reconnectGeneration++
38+ attachments := make([]*attachedClient, 0, len(view.attachments))
39+ for attachment := range view.attachments {
40+ attachments = append(attachments, attachment)
41+ }
42+ view.mu.Unlock()
43+ d.repaintRemoteViewAttachments(view, attachments)
44+}
45+
46+// reconnectSizeLocked derives an outer geometry matching the retained remote
47+// content surface. The caller holds view.mu. Existing content is authoritative
48+// until a validated replacement publication adopts a newer attachment claim.
49+func reconnectSizeLocked(view *remoteView) domain.Size {
50+ if view != nil && view.screen != nil {
51+ cols, rows := view.screen.Frame.Width, view.screen.Frame.Height
52+ if cols > 0 && rows > 0 {
53+ return domain.Size{Cols: cols, Rows: rows + tabChromeRows}
54+ }
55+ }
56+ return defaultSize
57+}
M internal/usecase/daemon/remote_render.go +45 -14
@@ -11,12 +11,13 @@ import (
1111 const remoteContentPaneID = "remote-content"
1212
1313 type remoteViewRenderSnapshot struct {
14- frame renderer.Frame
15- cursor vt.CursorSnapshot
16- title string
17- tabs []ports.SessionTabMeta
18- activeID domain.TabStableID
19- origin string
14+ frame renderer.Frame
15+ cursor vt.CursorSnapshot
16+ title string
17+ tabs []ports.SessionTabMeta
18+ activeID domain.TabStableID
19+ origin string
20+ linkState remoteViewLinkState
2021 }
2122
2223 // resizeScreen updates only the private remote VT surface. The attachment
@@ -54,24 +55,48 @@ func (v *remoteView) renderSnapshot(size domain.Size) (remoteViewRenderSnapshot,
5455 }
5556 if v.screen == nil {
5657 v.screen = vt.NewScreen(size.Cols, size.Rows)
57- } else if v.screen.Frame.Width != size.Cols || v.screen.Frame.Height != size.Rows {
58- v.screen.Resize(size.Cols, size.Rows)
5958 }
59+ // The shared remote screen follows the latest valid content-size claim via
60+ // resizeScreen. Rendering another local attachment must only capture it;
61+ // resizing here would let a non-authoritative window corrupt the remote
62+ // output chain without sending a matching remote resize.
6063 screen := v.screen.Snapshot()
6164 frame := renderer.NewFrame(screen.Columns(), screen.Rows())
6265 for y := range frame.Height {
6366 copy(frame.Row(y), screen.BorrowedRow(y))
6467 }
6568 return remoteViewRenderSnapshot{
66- frame: frame,
67- cursor: screen.Cursor(),
68- title: screen.Title(),
69- tabs: append([]ports.SessionTabMeta(nil), v.metadata.Tabs...),
70- activeID: v.metadata.ActiveTabID,
71- origin: v.displayOrigin,
69+ frame: frame,
70+ cursor: screen.Cursor(),
71+ title: screen.Title(),
72+ tabs: append([]ports.SessionTabMeta(nil), v.metadata.Tabs...),
73+ activeID: v.metadata.ActiveTabID,
74+ origin: v.displayOrigin,
75+ linkState: v.linkState,
7276 }, true
7377 }
7478
79+// invalidateAttachedOwner repaints an attachment through its actual owner.
80+// Local UI overlays remain attachment-local while remote content is active,
81+// so they must never enqueue work on the source session's coordinator after
82+// that attachment has moved to a remote view.
83+func (d *Daemon) invalidateAttachedOwner(ac *attachedClient, reset bool, producer string) {
84+ if d == nil || ac == nil {
85+ return
86+ }
87+ owner := ac.currentAttachmentOwner()
88+ if view, remote := owner.(*remoteView); remote {
89+ token := attachmentOwnerToken(view, ac, ac.transport())
90+ if token.ac != nil {
91+ go d.paintRemoteView(view, ac, reset, token)
92+ }
93+ return
94+ }
95+ if sess := localSession(owner); sess != nil {
96+ d.invalidateRender(sess, ac, reset, producer)
97+ }
98+}
99+
75100 func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statusSnapshot {
76101 name := ""
77102 if view != nil {
@@ -83,6 +108,12 @@ func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statu
83108 if snap.origin != "" {
84109 name += " at " + snap.origin
85110 }
111+ switch snap.linkState {
112+ case remoteViewLinkReconnecting:
113+ name += " · reconnecting"
114+ case remoteViewLinkUnavailable:
115+ name += " · unavailable"
116+ }
86117 status := statusSnapshot{session: name, tabs: make([]statusTab, 0, max(1, len(snap.tabs)))}
87118 for _, tab := range snap.tabs {
88119 status.tabs = append(status.tabs, statusTab{
@@ -11,12 +11,13 @@ import (
11 const remoteContentPaneID = "remote-content"11 const remoteContentPaneID = "remote-content"
12 12
13 type remoteViewRenderSnapshot struct {13 type remoteViewRenderSnapshot struct {
14- frame renderer.Frame14+ frame renderer.Frame
15- cursor vt.CursorSnapshot15+ cursor vt.CursorSnapshot
16- title string16+ title string
17- tabs []ports.SessionTabMeta17+ tabs []ports.SessionTabMeta
18- activeID domain.TabStableID18+ activeID domain.TabStableID
19- origin string19+ origin string
20+ linkState remoteViewLinkState
20 }21 }
21 22
22 // resizeScreen updates only the private remote VT surface. The attachment23 // resizeScreen updates only the private remote VT surface. The attachment
@@ -54,24 +55,48 @@ func (v *remoteView) renderSnapshot(size domain.Size) (remoteViewRenderSnapshot,
54 }55 }
55 if v.screen == nil {56 if v.screen == nil {
56 v.screen = vt.NewScreen(size.Cols, size.Rows)57 v.screen = vt.NewScreen(size.Cols, size.Rows)
57- } else if v.screen.Frame.Width != size.Cols || v.screen.Frame.Height != size.Rows {
58- v.screen.Resize(size.Cols, size.Rows)
59 }58 }
59+ // The shared remote screen follows the latest valid content-size claim via
60+ // resizeScreen. Rendering another local attachment must only capture it;
61+ // resizing here would let a non-authoritative window corrupt the remote
62+ // output chain without sending a matching remote resize.
60 screen := v.screen.Snapshot()63 screen := v.screen.Snapshot()
61 frame := renderer.NewFrame(screen.Columns(), screen.Rows())64 frame := renderer.NewFrame(screen.Columns(), screen.Rows())
62 for y := range frame.Height {65 for y := range frame.Height {
63 copy(frame.Row(y), screen.BorrowedRow(y))66 copy(frame.Row(y), screen.BorrowedRow(y))
64 }67 }
65 return remoteViewRenderSnapshot{68 return remoteViewRenderSnapshot{
66- frame: frame,69+ frame: frame,
67- cursor: screen.Cursor(),70+ cursor: screen.Cursor(),
68- title: screen.Title(),71+ title: screen.Title(),
69- tabs: append([]ports.SessionTabMeta(nil), v.metadata.Tabs...),72+ tabs: append([]ports.SessionTabMeta(nil), v.metadata.Tabs...),
70- activeID: v.metadata.ActiveTabID,73+ activeID: v.metadata.ActiveTabID,
71- origin: v.displayOrigin,74+ origin: v.displayOrigin,
75+ linkState: v.linkState,
72 }, true76 }, true
73 }77 }
74 78
79+// invalidateAttachedOwner repaints an attachment through its actual owner.
80+// Local UI overlays remain attachment-local while remote content is active,
81+// so they must never enqueue work on the source session's coordinator after
82+// that attachment has moved to a remote view.
83+func (d *Daemon) invalidateAttachedOwner(ac *attachedClient, reset bool, producer string) {
84+ if d == nil || ac == nil {
85+ return
86+ }
87+ owner := ac.currentAttachmentOwner()
88+ if view, remote := owner.(*remoteView); remote {
89+ token := attachmentOwnerToken(view, ac, ac.transport())
90+ if token.ac != nil {
91+ go d.paintRemoteView(view, ac, reset, token)
92+ }
93+ return
94+ }
95+ if sess := localSession(owner); sess != nil {
96+ d.invalidateRender(sess, ac, reset, producer)
97+ }
98+}
99+
75 func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statusSnapshot {100 func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statusSnapshot {
76 name := ""101 name := ""
77 if view != nil {102 if view != nil {
@@ -83,6 +108,12 @@ func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statu
83 if snap.origin != "" {108 if snap.origin != "" {
84 name += " at " + snap.origin109 name += " at " + snap.origin
85 }110 }
111+ switch snap.linkState {
112+ case remoteViewLinkReconnecting:
113+ name += " · reconnecting"
114+ case remoteViewLinkUnavailable:
115+ name += " · unavailable"
116+ }
86 status := statusSnapshot{session: name, tabs: make([]statusTab, 0, max(1, len(snap.tabs)))}117 status := statusSnapshot{session: name, tabs: make([]statusTab, 0, max(1, len(snap.tabs)))}
87 for _, tab := range snap.tabs {118 for _, tab := range snap.tabs {
88 status.tabs = append(status.tabs, statusTab{119 status.tabs = append(status.tabs, statusTab{
M internal/usecase/daemon/remote_terminal.go +2 -0
@@ -34,6 +34,7 @@ func (d *Daemon) retireTerminalRemoteView(link *remoteLink, reason uint8) {
3434 view.link = nil
3535 view.linkGeneration++
3636 link.active = false
37+ signalRemoteViewMetadataChangedLocked(view)
3738 _ = d.unregisterRemoteViewLocked(view)
3839 view.mu.Unlock()
3940 d.mu.Unlock()
@@ -43,6 +44,7 @@ func (d *Daemon) retireTerminalRemoteView(link *remoteLink, reason uint8) {
4344 for _, attachment := range attachments {
4445 d.retireShutdownRemoteAttachment(view, attachment, reason)
4546 }
47+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
4648 link.cancel()
4749 if link.transport != nil {
4850 _ = link.transport.Close()
@@ -34,6 +34,7 @@ func (d *Daemon) retireTerminalRemoteView(link *remoteLink, reason uint8) {
34 view.link = nil34 view.link = nil
35 view.linkGeneration++35 view.linkGeneration++
36 link.active = false36 link.active = false
37+ signalRemoteViewMetadataChangedLocked(view)
37 _ = d.unregisterRemoteViewLocked(view)38 _ = d.unregisterRemoteViewLocked(view)
38 view.mu.Unlock()39 view.mu.Unlock()
39 d.mu.Unlock()40 d.mu.Unlock()
@@ -43,6 +44,7 @@ func (d *Daemon) retireTerminalRemoteView(link *remoteLink, reason uint8) {
43 for _, attachment := range attachments {44 for _, attachment := range attachments {
44 d.retireShutdownRemoteAttachment(view, attachment, reason)45 d.retireShutdownRemoteAttachment(view, attachment, reason)
45 }46 }
47+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
46 link.cancel()48 link.cancel()
47 if link.transport != nil {49 if link.transport != nil {
48 _ = link.transport.Close()50 _ = link.transport.Close()
A internal/usecase/daemon/remote_view_tabs.go +215 -0
@@ -0,0 +1,215 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+var errRemoteViewTabUnavailable = errors.New("remote view: selected tab is unavailable")
13+
14+func runningRemoteTarget(target domain.RemoteSessionTarget, active domain.TabStableID) domain.RemoteSessionTarget {
15+ target.Stopped = false
16+ target.StoppedTab = domain.TabSelector{}
17+ target.LiveTabID = active
18+ return target
19+}
20+
21+// activateRemoteViewTab moves an already-handshaken remote view to the exact
22+// stable tab selected in the local picker. The remote protocol presently
23+// exposes relative tab commands, so each command waits for the metadata
24+// revision it causes and recalculates from the authoritative remote order.
25+// The caller remains locally attached until this succeeds.
26+func (d *Daemon) activateRemoteViewTab(ctx context.Context, view *remoteView, target domain.RemoteSessionTarget) error {
27+ if d == nil || view == nil {
28+ return errRemoteViewUnavailable
29+ }
30+ if ctx == nil {
31+ ctx = context.Background()
32+ }
33+ if err := target.Validate(); err != nil {
34+ return fmt.Errorf("remote view: invalid tab target: %w", err)
35+ }
36+ view.tabActivationMu.Lock()
37+ defer view.tabActivationMu.Unlock()
38+
39+ for attempts := 0; ; attempts++ {
40+ link, generation, revision, active, selected, tabCount, err := remoteViewTabActivationSnapshot(view, target)
41+ if err != nil {
42+ return err
43+ }
44+ if active == selected {
45+ view.mu.Lock()
46+ if !view.closed && view.link == link && view.linkGeneration == generation && view.metadata.ActiveTabID == selected {
47+ view.reconnectTarget = runningRemoteTarget(target, selected)
48+ }
49+ view.mu.Unlock()
50+ return nil
51+ }
52+ // Each successful relative command must advance the metadata revision.
53+ // A remote peer that continually races us cannot make this selection
54+ // converge indefinitely.
55+ if attempts >= tabCount {
56+ return errRemoteViewTabUnavailable
57+ }
58+ slug, err := remoteViewRelativeTabCommand(view, link, generation, selected)
59+ if err != nil {
60+ return err
61+ }
62+ if err := d.sendRemoteViewCommand(ctx, link, generation, slug); err != nil {
63+ return err
64+ }
65+ if err := d.waitRemoteViewMetadataRevision(ctx, view, link, generation, revision); err != nil {
66+ return err
67+ }
68+ }
69+}
70+
71+func remoteViewTabActivationSnapshot(view *remoteView, target domain.RemoteSessionTarget) (link *remoteLink, generation, revision uint64, active, selected domain.TabStableID, tabCount int, err error) {
72+ view.mu.Lock()
73+ defer view.mu.Unlock()
74+ if !remoteViewLinkReusableLocked(view) {
75+ return nil, 0, 0, "", "", 0, errRemoteViewUnavailable
76+ }
77+ selected, err = remoteTargetTabID(target, view.metadata)
78+ if err != nil {
79+ return nil, 0, 0, "", "", 0, err
80+ }
81+ if _, ok := remoteSessionTabIndex(view.metadata.Tabs, selected); !ok {
82+ return nil, 0, 0, "", "", 0, errRemoteViewTabUnavailable
83+ }
84+ return view.link, view.linkGeneration, view.metadata.Revision, view.metadata.ActiveTabID, selected, len(view.metadata.Tabs), nil
85+}
86+
87+func remoteTargetTabID(target domain.RemoteSessionTarget, metadata ports.SessionMeta) (domain.TabStableID, error) {
88+ if !target.Stopped {
89+ return target.LiveTabID, nil
90+ }
91+ tabs := make([]domain.TabSelectorTab, len(metadata.Tabs))
92+ for i, tab := range metadata.Tabs {
93+ tabs[i] = domain.TabSelectorTab{ID: tab.ID, Name: tab.Name}
94+ }
95+ index, ok := target.StoppedTab.Resolve(tabs)
96+ if !ok {
97+ return "", errRemoteViewTabUnavailable
98+ }
99+ return tabs[index].ID, nil
100+}
101+
102+func remoteSessionTabIndex(tabs []ports.SessionTabMeta, id domain.TabStableID) (int, bool) {
103+ found := -1
104+ for index, tab := range tabs {
105+ if tab.ID != id {
106+ continue
107+ }
108+ if found != -1 {
109+ return 0, false
110+ }
111+ found = index
112+ }
113+ return found, found >= 0
114+}
115+
116+func remoteViewRelativeTabCommand(view *remoteView, link *remoteLink, generation uint64, selected domain.TabStableID) (string, error) {
117+ view.mu.Lock()
118+ defer view.mu.Unlock()
119+ if view.closed || view.link != link || view.linkGeneration != generation || !link.active {
120+ return "", errRemoteViewStale
121+ }
122+ activeIndex, activeOK := remoteSessionTabIndex(view.metadata.Tabs, view.metadata.ActiveTabID)
123+ selectedIndex, selectedOK := remoteSessionTabIndex(view.metadata.Tabs, selected)
124+ if !activeOK || !selectedOK || len(view.metadata.Tabs) < 2 {
125+ return "", errRemoteViewTabUnavailable
126+ }
127+ forward := (selectedIndex - activeIndex + len(view.metadata.Tabs)) % len(view.metadata.Tabs)
128+ backward := (activeIndex - selectedIndex + len(view.metadata.Tabs)) % len(view.metadata.Tabs)
129+ if forward == 0 || backward == 0 {
130+ return "", errRemoteViewTabUnavailable
131+ }
132+ if forward <= backward {
133+ return "next-tab", nil
134+ }
135+ return "previous-tab", nil
136+}
137+
138+func (d *Daemon) sendRemoteViewCommand(ctx context.Context, link *remoteLink, generation uint64, slug string) error {
139+ if d == nil || link == nil || link.commands == nil {
140+ return errRemoteViewUnavailable
141+ }
142+ if slug != "next-tab" && slug != "previous-tab" {
143+ return fmt.Errorf("remote view: command %q is not allowed", slug)
144+ }
145+ requestID, outcome := link.commands.Publish(generation)
146+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
147+ Version: ports.ProtocolVersion, RequestID: requestID, Attached: true, Slug: slug,
148+ })
149+ if err != nil {
150+ link.commands.Fail(requestID, generation, err)
151+ return fmt.Errorf("remote view: encode command: %w", err)
152+ }
153+ if err := link.send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
154+ link.commands.Fail(requestID, generation, err)
155+ d.markRemoteLinkUnavailable(link)
156+ return fmt.Errorf("remote view: send command: %w", err)
157+ }
158+ clock := d.clock
159+ if clock == nil {
160+ clock = systemClock{}
161+ }
162+ result, err := link.commands.Wait(ctx, clock, requestID, generation, outcome)
163+ if err != nil {
164+ return fmt.Errorf("remote view: wait for command: %w", err)
165+ }
166+ if !result.OK {
167+ return fmt.Errorf("remote view: command %q failed (%d): %s", slug, result.Code, result.Text)
168+ }
169+ return nil
170+}
171+
172+func (d *Daemon) waitRemoteViewMetadataRevision(ctx context.Context, view *remoteView, link *remoteLink, generation, revision uint64) error {
173+ if d == nil || view == nil || link == nil {
174+ return errRemoteViewUnavailable
175+ }
176+ if ctx == nil {
177+ ctx = context.Background()
178+ }
179+ clock := d.clock
180+ if clock == nil {
181+ clock = systemClock{}
182+ }
183+ timer := clock.NewTimer(CommandRequestTimeout)
184+ if timer == nil {
185+ return ErrCommandRequestTimeout
186+ }
187+ defer timer.Stop()
188+
189+ for {
190+ view.mu.Lock()
191+ current := !view.closed && view.link == link && view.linkGeneration == generation && link.active
192+ if !current {
193+ view.mu.Unlock()
194+ return errRemoteViewStale
195+ }
196+ if view.metadata.Revision > revision {
197+ view.mu.Unlock()
198+ return nil
199+ }
200+ changed := view.metadataChanged
201+ if changed == nil {
202+ changed = make(chan struct{})
203+ view.metadataChanged = changed
204+ }
205+ view.mu.Unlock()
206+
207+ select {
208+ case <-ctx.Done():
209+ return ctx.Err()
210+ case <-timer.C():
211+ return ErrCommandRequestTimeout
212+ case <-changed:
213+ }
214+ }
215+}
@@ -0,0 +1,215 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+var errRemoteViewTabUnavailable = errors.New("remote view: selected tab is unavailable")
13+
14+func runningRemoteTarget(target domain.RemoteSessionTarget, active domain.TabStableID) domain.RemoteSessionTarget {
15+ target.Stopped = false
16+ target.StoppedTab = domain.TabSelector{}
17+ target.LiveTabID = active
18+ return target
19+}
20+
21+// activateRemoteViewTab moves an already-handshaken remote view to the exact
22+// stable tab selected in the local picker. The remote protocol presently
23+// exposes relative tab commands, so each command waits for the metadata
24+// revision it causes and recalculates from the authoritative remote order.
25+// The caller remains locally attached until this succeeds.
26+func (d *Daemon) activateRemoteViewTab(ctx context.Context, view *remoteView, target domain.RemoteSessionTarget) error {
27+ if d == nil || view == nil {
28+ return errRemoteViewUnavailable
29+ }
30+ if ctx == nil {
31+ ctx = context.Background()
32+ }
33+ if err := target.Validate(); err != nil {
34+ return fmt.Errorf("remote view: invalid tab target: %w", err)
35+ }
36+ view.tabActivationMu.Lock()
37+ defer view.tabActivationMu.Unlock()
38+
39+ for attempts := 0; ; attempts++ {
40+ link, generation, revision, active, selected, tabCount, err := remoteViewTabActivationSnapshot(view, target)
41+ if err != nil {
42+ return err
43+ }
44+ if active == selected {
45+ view.mu.Lock()
46+ if !view.closed && view.link == link && view.linkGeneration == generation && view.metadata.ActiveTabID == selected {
47+ view.reconnectTarget = runningRemoteTarget(target, selected)
48+ }
49+ view.mu.Unlock()
50+ return nil
51+ }
52+ // Each successful relative command must advance the metadata revision.
53+ // A remote peer that continually races us cannot make this selection
54+ // converge indefinitely.
55+ if attempts >= tabCount {
56+ return errRemoteViewTabUnavailable
57+ }
58+ slug, err := remoteViewRelativeTabCommand(view, link, generation, selected)
59+ if err != nil {
60+ return err
61+ }
62+ if err := d.sendRemoteViewCommand(ctx, link, generation, slug); err != nil {
63+ return err
64+ }
65+ if err := d.waitRemoteViewMetadataRevision(ctx, view, link, generation, revision); err != nil {
66+ return err
67+ }
68+ }
69+}
70+
71+func remoteViewTabActivationSnapshot(view *remoteView, target domain.RemoteSessionTarget) (link *remoteLink, generation, revision uint64, active, selected domain.TabStableID, tabCount int, err error) {
72+ view.mu.Lock()
73+ defer view.mu.Unlock()
74+ if !remoteViewLinkReusableLocked(view) {
75+ return nil, 0, 0, "", "", 0, errRemoteViewUnavailable
76+ }
77+ selected, err = remoteTargetTabID(target, view.metadata)
78+ if err != nil {
79+ return nil, 0, 0, "", "", 0, err
80+ }
81+ if _, ok := remoteSessionTabIndex(view.metadata.Tabs, selected); !ok {
82+ return nil, 0, 0, "", "", 0, errRemoteViewTabUnavailable
83+ }
84+ return view.link, view.linkGeneration, view.metadata.Revision, view.metadata.ActiveTabID, selected, len(view.metadata.Tabs), nil
85+}
86+
87+func remoteTargetTabID(target domain.RemoteSessionTarget, metadata ports.SessionMeta) (domain.TabStableID, error) {
88+ if !target.Stopped {
89+ return target.LiveTabID, nil
90+ }
91+ tabs := make([]domain.TabSelectorTab, len(metadata.Tabs))
92+ for i, tab := range metadata.Tabs {
93+ tabs[i] = domain.TabSelectorTab{ID: tab.ID, Name: tab.Name}
94+ }
95+ index, ok := target.StoppedTab.Resolve(tabs)
96+ if !ok {
97+ return "", errRemoteViewTabUnavailable
98+ }
99+ return tabs[index].ID, nil
100+}
101+
102+func remoteSessionTabIndex(tabs []ports.SessionTabMeta, id domain.TabStableID) (int, bool) {
103+ found := -1
104+ for index, tab := range tabs {
105+ if tab.ID != id {
106+ continue
107+ }
108+ if found != -1 {
109+ return 0, false
110+ }
111+ found = index
112+ }
113+ return found, found >= 0
114+}
115+
116+func remoteViewRelativeTabCommand(view *remoteView, link *remoteLink, generation uint64, selected domain.TabStableID) (string, error) {
117+ view.mu.Lock()
118+ defer view.mu.Unlock()
119+ if view.closed || view.link != link || view.linkGeneration != generation || !link.active {
120+ return "", errRemoteViewStale
121+ }
122+ activeIndex, activeOK := remoteSessionTabIndex(view.metadata.Tabs, view.metadata.ActiveTabID)
123+ selectedIndex, selectedOK := remoteSessionTabIndex(view.metadata.Tabs, selected)
124+ if !activeOK || !selectedOK || len(view.metadata.Tabs) < 2 {
125+ return "", errRemoteViewTabUnavailable
126+ }
127+ forward := (selectedIndex - activeIndex + len(view.metadata.Tabs)) % len(view.metadata.Tabs)
128+ backward := (activeIndex - selectedIndex + len(view.metadata.Tabs)) % len(view.metadata.Tabs)
129+ if forward == 0 || backward == 0 {
130+ return "", errRemoteViewTabUnavailable
131+ }
132+ if forward <= backward {
133+ return "next-tab", nil
134+ }
135+ return "previous-tab", nil
136+}
137+
138+func (d *Daemon) sendRemoteViewCommand(ctx context.Context, link *remoteLink, generation uint64, slug string) error {
139+ if d == nil || link == nil || link.commands == nil {
140+ return errRemoteViewUnavailable
141+ }
142+ if slug != "next-tab" && slug != "previous-tab" {
143+ return fmt.Errorf("remote view: command %q is not allowed", slug)
144+ }
145+ requestID, outcome := link.commands.Publish(generation)
146+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
147+ Version: ports.ProtocolVersion, RequestID: requestID, Attached: true, Slug: slug,
148+ })
149+ if err != nil {
150+ link.commands.Fail(requestID, generation, err)
151+ return fmt.Errorf("remote view: encode command: %w", err)
152+ }
153+ if err := link.send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
154+ link.commands.Fail(requestID, generation, err)
155+ d.markRemoteLinkUnavailable(link)
156+ return fmt.Errorf("remote view: send command: %w", err)
157+ }
158+ clock := d.clock
159+ if clock == nil {
160+ clock = systemClock{}
161+ }
162+ result, err := link.commands.Wait(ctx, clock, requestID, generation, outcome)
163+ if err != nil {
164+ return fmt.Errorf("remote view: wait for command: %w", err)
165+ }
166+ if !result.OK {
167+ return fmt.Errorf("remote view: command %q failed (%d): %s", slug, result.Code, result.Text)
168+ }
169+ return nil
170+}
171+
172+func (d *Daemon) waitRemoteViewMetadataRevision(ctx context.Context, view *remoteView, link *remoteLink, generation, revision uint64) error {
173+ if d == nil || view == nil || link == nil {
174+ return errRemoteViewUnavailable
175+ }
176+ if ctx == nil {
177+ ctx = context.Background()
178+ }
179+ clock := d.clock
180+ if clock == nil {
181+ clock = systemClock{}
182+ }
183+ timer := clock.NewTimer(CommandRequestTimeout)
184+ if timer == nil {
185+ return ErrCommandRequestTimeout
186+ }
187+ defer timer.Stop()
188+
189+ for {
190+ view.mu.Lock()
191+ current := !view.closed && view.link == link && view.linkGeneration == generation && link.active
192+ if !current {
193+ view.mu.Unlock()
194+ return errRemoteViewStale
195+ }
196+ if view.metadata.Revision > revision {
197+ view.mu.Unlock()
198+ return nil
199+ }
200+ changed := view.metadataChanged
201+ if changed == nil {
202+ changed = make(chan struct{})
203+ view.metadataChanged = changed
204+ }
205+ view.mu.Unlock()
206+
207+ select {
208+ case <-ctx.Done():
209+ return ctx.Err()
210+ case <-timer.C():
211+ return ErrCommandRequestTimeout
212+ case <-changed:
213+ }
214+ }
215+}
M internal/usecase/daemon/remote_warm.go +2 -0
@@ -129,6 +129,7 @@ func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
129129 link := view.link
130130 view.link = nil
131131 view.linkGeneration++
132+ signalRemoteViewMetadataChangedLocked(view)
132133 if link != nil {
133134 link.active = false
134135 }
@@ -141,6 +142,7 @@ func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
141142 if link == nil {
142143 return
143144 }
145+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
144146 link.cancel()
145147 if link.transport != nil {
146148 _ = link.transport.Close()
@@ -129,6 +129,7 @@ func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
129 link := view.link129 link := view.link
130 view.link = nil130 view.link = nil
131 view.linkGeneration++131 view.linkGeneration++
132+ signalRemoteViewMetadataChangedLocked(view)
132 if link != nil {133 if link != nil {
133 link.active = false134 link.active = false
134 }135 }
@@ -141,6 +142,7 @@ func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
141 if link == nil {142 if link == nil {
142 return143 return
143 }144 }
145+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
144 link.cancel()146 link.cancel()
145 if link.transport != nil {147 if link.transport != nil {
146 _ = link.transport.Close()148 _ = link.transport.Close()
M scripts/remote-picker-harness/README.md +4 -2
@@ -28,8 +28,10 @@ Each logical transport has one persistent `vt.Screen` and a client-equivalent
2828 output-state chain. The probe writes accepted full, incremental, and side-effect
2929 frames to that screen, ACKs only accepted state-bearing frames, and retains a
3030 bounded set of screen checkpoints plus non-sensitive event metadata. The local
31-picker phase uses separate local-picker and selected-remote probes to
32-characterize the existing `MsgAttachTarget` direct handoff and its event order.
31+picker phase captures unified local/remote rows before selection, keeps the same
32+local client transport while the daemon publishes proxied remote content,
33+rejects direct `MsgAttachTarget` handoff and remote daemon chrome, verifies
34+continued input, and captures the unified rows again from the remote view.
3335
3436 The run exercises real SSH-stdio and UDP attachment, typed live catalog and
3537 preview requests, tab creation/removal fencing, exact lifecycle fencing after
@@ -28,8 +28,10 @@ Each logical transport has one persistent `vt.Screen` and a client-equivalent
28 output-state chain. The probe writes accepted full, incremental, and side-effect28 output-state chain. The probe writes accepted full, incremental, and side-effect
29 frames to that screen, ACKs only accepted state-bearing frames, and retains a29 frames to that screen, ACKs only accepted state-bearing frames, and retains a
30 bounded set of screen checkpoints plus non-sensitive event metadata. The local30 bounded set of screen checkpoints plus non-sensitive event metadata. The local
31-picker phase uses separate local-picker and selected-remote probes to31+picker phase captures unified local/remote rows before selection, keeps the same
32-characterize the existing `MsgAttachTarget` direct handoff and its event order.32+local client transport while the daemon publishes proxied remote content,
33+rejects direct `MsgAttachTarget` handoff and remote daemon chrome, verifies
34+continued input, and captures the unified rows again from the remote view.
33 35
34 The run exercises real SSH-stdio and UDP attachment, typed live catalog and36 The run exercises real SSH-stdio and UDP attachment, typed live catalog and
35 preview requests, tab creation/removal fencing, exact lifecycle fencing after37 preview requests, tab creation/removal fencing, exact lifecycle fencing after
M scripts/remote-picker-harness/main.go +58 -58
@@ -148,19 +148,17 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
148148
149149 // runLocalPickerUnitedPhase drives the actual local picker against the SSH
150150 // daemon container. It validates the seam that individual catalog, preview,
151-// and attach tests cannot: catalog active-tab selection, live remote preview,
152-// emitted rich handoff, and the remote attachment's visible provenance.
151+// and attach tests cannot: unified rows before and after selection, remote
152+// content publication on the same local client transport, local chrome
153+// composition, and continued input forwarding.
153154 func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) {
154155 const (
155156 localSession = "local-picker"
156157 remoteSession = "picker"
157158 previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
159+ inputMarker = "VEV_REMOTE_PICKER_INPUT_OK"
158160 )
159161
160- trace := &probeTrace{}
161- if artifact := harnessArtifactFromContext(ctx); artifact != nil {
162- artifact.registerTrace(trace)
163- }
164162 remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
165163 if err != nil {
166164 return fmt.Errorf("open remote picker source: %w", err)
@@ -243,7 +241,10 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
243241 if err != nil {
244242 return fmt.Errorf("open local picker transport: %w", err)
245243 }
246- local.probe.configure("local-picker", trace)
244+ // This is the one client-equivalent connection for the proof. Remote
245+ // selection must replace its daemon-side owner without sending an endpoint
246+ // or MsgAttachTarget to this transport.
247+ local.probe.configure("local-picker", nil)
247248 defer func() { _ = local.Close() }()
248249 if err := attachAndCheck(ctx, local, ports.Hello{
249250 Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
@@ -263,6 +264,10 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
263264 if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
264265 return fmt.Errorf("await remote picker catalog row: %w", err)
265266 }
267+ beforeRows := capturePickerRows(local.probe)
268+ if err := assertUnifiedPickerRows(beforeRows, localSession, remoteSession); err != nil {
269+ return fmt.Errorf("capture unified picker rows before remote selection: %w", err)
270+ }
266271 // The local selected tab is first and the remote session header is not
267272 // selectable, so one down reaches the first remote tab. Derive the rest
268273 // from the catalog's stable active tab identity instead of assuming an
@@ -276,45 +281,49 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
276281 if err := sendRawInput(local, "\r"); err != nil {
277282 return fmt.Errorf("commit remote picker target: %w", err)
278283 }
279- handoffFrame, err := receiveFrame(ctx, local, ports.MsgAttachTarget)
280- if err != nil {
281- return fmt.Errorf("await remote picker handoff: %w", err)
284+ if err := awaitOutputContains(ctx, local, remoteSession+" at remote"); err != nil {
285+ return fmt.Errorf("publish remote content on local transport: %w", err)
282286 }
283- handoff, err := ports.UnmarshalAttachTarget(handoffFrame.Payload)
284- if err != nil {
285- return fmt.Errorf("decode remote picker handoff: %w", err)
287+ if !local.probe.contains(previewMarker) {
288+ return errors.New("published remote content omitted preview marker")
286289 }
287- if handoff.RemoteTarget == nil {
288- return errors.New("remote picker handoff omitted the remote target")
290+ if err := assertNoDirectHandoff(local); err != nil {
291+ return err
289292 }
290- if handoff.RemoteTarget.LiveTabID != selectedTarget.LiveTabID {
291- return fmt.Errorf("remote picker handoff tab = %q, want %q", handoff.RemoteTarget.LiveTabID, selectedTarget.LiveTabID)
293+ if err := assertNoRemoteChrome(local.probe, remoteSession); err != nil {
294+ return fmt.Errorf("remote content chrome: %w", err)
292295 }
293- if handoff.RemoteTarget.DisplayOrigin != "remote" {
294- return fmt.Errorf("remote picker handoff origin = %q, want remote", handoff.RemoteTarget.DisplayOrigin)
296+ if err := sendInputAndAwait(ctx, local, "printf '"+inputMarker+"'", inputMarker); err != nil {
297+ return fmt.Errorf("continued input on local transport: %w", err)
295298 }
296- if handoff.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
297- return fmt.Errorf("remote picker handoff policy = %d, want daemon-owned", handoff.EnvironmentPolicy)
299+ if err := assertNoDirectHandoff(local); err != nil {
300+ return err
298301 }
299302
300- selected, err := openTransport(ctx, ports.RemoteTransportStdio, target)
301- if err != nil {
302- return fmt.Errorf("open selected remote transport: %w", err)
303+ // Reopen the picker through the same local connection while its owner is a
304+ // remote view. The navigation shell must remain local and retain both sides
305+ // of the unified session list.
306+ if err := sendRawInput(local, "\x1b "); err != nil {
307+ return fmt.Errorf("reopen command palette from remote content: %w", err)
303308 }
304- selected.probe.configure("selected-remote", trace)
305- defer func() { _ = selected.Close() }()
306- if err := attachAndCheck(ctx, selected, ports.Hello{
307- Intent: ports.IntentAttach, Name: handoff.Session, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
308- TermEnv: "xterm-256color", TrueColor: true,
309- RemoteTarget: handoff.RemoteTarget, EnvironmentPolicy: handoff.EnvironmentPolicy,
310- }); err != nil {
311- return fmt.Errorf("attach selected remote target: %w", err)
309+ if err := awaitOutputContains(ctx, local, "Commands"); err != nil {
310+ return fmt.Errorf("await reopened command palette: %w", err)
311+ }
312+ if err := sendRawInput(local, "SSP\r"); err != nil {
313+ return fmt.Errorf("reopen session picker from remote content: %w", err)
314+ }
315+ if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
316+ return fmt.Errorf("await reopened remote picker row: %w", err)
317+ }
318+ afterRows := capturePickerRows(local.probe)
319+ if err := assertUnifiedPickerRows(afterRows, localSession, remoteSession); err != nil {
320+ return fmt.Errorf("capture unified picker rows after remote selection: %w", err)
312321 }
313- if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {
314- return fmt.Errorf("remote provenance status bar: %w", err)
322+ if err := assertNoDirectHandoff(local); err != nil {
323+ return err
315324 }
316- if err := validateHandoffEventOrdering(trace.events); err != nil {
317- return fmt.Errorf("characterize remote picker handoff: %w", err)
325+ if err := sendRawInput(local, "\x1b"); err != nil {
326+ return fmt.Errorf("close reopened session picker: %w", err)
318327 }
319328 return nil
320329 }
@@ -751,28 +760,6 @@ func remotePreviewContains(preview ports.RemotePreview, want string) bool {
751760 return strings.Contains(text.String(), want)
752761 }
753762
754-func receiveFrame(ctx context.Context, tr *harnessTransport, typ ports.MsgType) (ports.Frame, error) {
755- deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
756- defer cancel()
757- for {
758- frame, err := receiveWithTimeout(deadline, tr)
759- if err != nil {
760- return ports.Frame{}, err
761- }
762- if frame.Type == typ {
763- if typ == ports.MsgAttachTarget {
764- tr.probe.recordControl(probeEventAttachTarget)
765- }
766- return frame, nil
767- }
768- if frame.Type == ports.MsgOutput {
769- if err := processOutputFrame(tr, frame); err != nil {
770- return ports.Frame{}, err
771- }
772- }
773- }
774-}
775-
776763 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {
777764 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
778765 return err
@@ -999,11 +986,24 @@ func runRemoteCommand(ctx context.Context, target string, command ...string) err
999986 return runCapturedCommand(ctx, spec.Path, spec.Args...)
1000987 }
1001988
989+func assertNoDirectHandoff(tr *harnessTransport) error {
990+ if tr == nil || tr.probe == nil {
991+ return errors.New("local picker transport has no visual probe")
992+ }
993+ if tr.probe.unexpectedHandoffs != 0 {
994+ return fmt.Errorf("picker emitted %d direct MsgAttachTarget handoff frame(s)", tr.probe.unexpectedHandoffs)
995+ }
996+ return nil
997+}
998+
1002999 func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (ports.Frame, error) {
10031000 select {
10041001 case <-ctx.Done():
10051002 return ports.Frame{}, ctx.Err()
10061003 case outcome := <-tr.frames:
1004+ if outcome.err == nil {
1005+ tr.probe.recordIncoming(outcome.frame)
1006+ }
10071007 return outcome.frame, outcome.err
10081008 }
10091009 }
@@ -148,19 +148,17 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
148 148
149 // runLocalPickerUnitedPhase drives the actual local picker against the SSH149 // runLocalPickerUnitedPhase drives the actual local picker against the SSH
150 // daemon container. It validates the seam that individual catalog, preview,150 // daemon container. It validates the seam that individual catalog, preview,
151-// and attach tests cannot: catalog active-tab selection, live remote preview,151+// and attach tests cannot: unified rows before and after selection, remote
152-// emitted rich handoff, and the remote attachment's visible provenance.152+// content publication on the same local client transport, local chrome
153+// composition, and continued input forwarding.
153 func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) {154 func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) {
154 const (155 const (
155 localSession = "local-picker"156 localSession = "local-picker"
156 remoteSession = "picker"157 remoteSession = "picker"
157 previewMarker = "VEV_REMOTE_PICKER_ACTIVE"158 previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
159+ inputMarker = "VEV_REMOTE_PICKER_INPUT_OK"
158 )160 )
159 161
160- trace := &probeTrace{}
161- if artifact := harnessArtifactFromContext(ctx); artifact != nil {
162- artifact.registerTrace(trace)
163- }
164 remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)162 remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
165 if err != nil {163 if err != nil {
166 return fmt.Errorf("open remote picker source: %w", err)164 return fmt.Errorf("open remote picker source: %w", err)
@@ -243,7 +241,10 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
243 if err != nil {241 if err != nil {
244 return fmt.Errorf("open local picker transport: %w", err)242 return fmt.Errorf("open local picker transport: %w", err)
245 }243 }
246- local.probe.configure("local-picker", trace)244+ // This is the one client-equivalent connection for the proof. Remote
245+ // selection must replace its daemon-side owner without sending an endpoint
246+ // or MsgAttachTarget to this transport.
247+ local.probe.configure("local-picker", nil)
247 defer func() { _ = local.Close() }()248 defer func() { _ = local.Close() }()
248 if err := attachAndCheck(ctx, local, ports.Hello{249 if err := attachAndCheck(ctx, local, ports.Hello{
249 Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",250 Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
@@ -263,6 +264,10 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
263 if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {264 if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
264 return fmt.Errorf("await remote picker catalog row: %w", err)265 return fmt.Errorf("await remote picker catalog row: %w", err)
265 }266 }
267+ beforeRows := capturePickerRows(local.probe)
268+ if err := assertUnifiedPickerRows(beforeRows, localSession, remoteSession); err != nil {
269+ return fmt.Errorf("capture unified picker rows before remote selection: %w", err)
270+ }
266 // The local selected tab is first and the remote session header is not271 // The local selected tab is first and the remote session header is not
267 // selectable, so one down reaches the first remote tab. Derive the rest272 // selectable, so one down reaches the first remote tab. Derive the rest
268 // from the catalog's stable active tab identity instead of assuming an273 // from the catalog's stable active tab identity instead of assuming an
@@ -276,45 +281,49 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
276 if err := sendRawInput(local, "\r"); err != nil {281 if err := sendRawInput(local, "\r"); err != nil {
277 return fmt.Errorf("commit remote picker target: %w", err)282 return fmt.Errorf("commit remote picker target: %w", err)
278 }283 }
279- handoffFrame, err := receiveFrame(ctx, local, ports.MsgAttachTarget)284+ if err := awaitOutputContains(ctx, local, remoteSession+" at remote"); err != nil {
280- if err != nil {285+ return fmt.Errorf("publish remote content on local transport: %w", err)
281- return fmt.Errorf("await remote picker handoff: %w", err)
282 }286 }
283- handoff, err := ports.UnmarshalAttachTarget(handoffFrame.Payload)287+ if !local.probe.contains(previewMarker) {
284- if err != nil {288+ return errors.New("published remote content omitted preview marker")
285- return fmt.Errorf("decode remote picker handoff: %w", err)
286 }289 }
287- if handoff.RemoteTarget == nil {290+ if err := assertNoDirectHandoff(local); err != nil {
288- return errors.New("remote picker handoff omitted the remote target")291+ return err
289 }292 }
290- if handoff.RemoteTarget.LiveTabID != selectedTarget.LiveTabID {293+ if err := assertNoRemoteChrome(local.probe, remoteSession); err != nil {
291- return fmt.Errorf("remote picker handoff tab = %q, want %q", handoff.RemoteTarget.LiveTabID, selectedTarget.LiveTabID)294+ return fmt.Errorf("remote content chrome: %w", err)
292 }295 }
293- if handoff.RemoteTarget.DisplayOrigin != "remote" {296+ if err := sendInputAndAwait(ctx, local, "printf '"+inputMarker+"'", inputMarker); err != nil {
294- return fmt.Errorf("remote picker handoff origin = %q, want remote", handoff.RemoteTarget.DisplayOrigin)297+ return fmt.Errorf("continued input on local transport: %w", err)
295 }298 }
296- if handoff.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {299+ if err := assertNoDirectHandoff(local); err != nil {
297- return fmt.Errorf("remote picker handoff policy = %d, want daemon-owned", handoff.EnvironmentPolicy)300+ return err
298 }301 }
299 302
300- selected, err := openTransport(ctx, ports.RemoteTransportStdio, target)303+ // Reopen the picker through the same local connection while its owner is a
301- if err != nil {304+ // remote view. The navigation shell must remain local and retain both sides
302- return fmt.Errorf("open selected remote transport: %w", err)305+ // of the unified session list.
306+ if err := sendRawInput(local, "\x1b "); err != nil {
307+ return fmt.Errorf("reopen command palette from remote content: %w", err)
303 }308 }
304- selected.probe.configure("selected-remote", trace)309+ if err := awaitOutputContains(ctx, local, "Commands"); err != nil {
305- defer func() { _ = selected.Close() }()310+ return fmt.Errorf("await reopened command palette: %w", err)
306- if err := attachAndCheck(ctx, selected, ports.Hello{311+ }
307- Intent: ports.IntentAttach, Name: handoff.Session, Env: localEnvironment(), Cwd: "/tmp/local-cwd",312+ if err := sendRawInput(local, "SSP\r"); err != nil {
308- TermEnv: "xterm-256color", TrueColor: true,313+ return fmt.Errorf("reopen session picker from remote content: %w", err)
309- RemoteTarget: handoff.RemoteTarget, EnvironmentPolicy: handoff.EnvironmentPolicy,314+ }
310- }); err != nil {315+ if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
311- return fmt.Errorf("attach selected remote target: %w", err)316+ return fmt.Errorf("await reopened remote picker row: %w", err)
317+ }
318+ afterRows := capturePickerRows(local.probe)
319+ if err := assertUnifiedPickerRows(afterRows, localSession, remoteSession); err != nil {
320+ return fmt.Errorf("capture unified picker rows after remote selection: %w", err)
312 }321 }
313- if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {322+ if err := assertNoDirectHandoff(local); err != nil {
314- return fmt.Errorf("remote provenance status bar: %w", err)323+ return err
315 }324 }
316- if err := validateHandoffEventOrdering(trace.events); err != nil {325+ if err := sendRawInput(local, "\x1b"); err != nil {
317- return fmt.Errorf("characterize remote picker handoff: %w", err)326+ return fmt.Errorf("close reopened session picker: %w", err)
318 }327 }
319 return nil328 return nil
320 }329 }
@@ -751,28 +760,6 @@ func remotePreviewContains(preview ports.RemotePreview, want string) bool {
751 return strings.Contains(text.String(), want)760 return strings.Contains(text.String(), want)
752 }761 }
753 762
754-func receiveFrame(ctx context.Context, tr *harnessTransport, typ ports.MsgType) (ports.Frame, error) {
755- deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
756- defer cancel()
757- for {
758- frame, err := receiveWithTimeout(deadline, tr)
759- if err != nil {
760- return ports.Frame{}, err
761- }
762- if frame.Type == typ {
763- if typ == ports.MsgAttachTarget {
764- tr.probe.recordControl(probeEventAttachTarget)
765- }
766- return frame, nil
767- }
768- if frame.Type == ports.MsgOutput {
769- if err := processOutputFrame(tr, frame); err != nil {
770- return ports.Frame{}, err
771- }
772- }
773- }
774-}
775-
776 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {763 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {
777 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {764 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
778 return err765 return err
@@ -999,11 +986,24 @@ func runRemoteCommand(ctx context.Context, target string, command ...string) err
999 return runCapturedCommand(ctx, spec.Path, spec.Args...)986 return runCapturedCommand(ctx, spec.Path, spec.Args...)
1000 }987 }
1001 988
989+func assertNoDirectHandoff(tr *harnessTransport) error {
990+ if tr == nil || tr.probe == nil {
991+ return errors.New("local picker transport has no visual probe")
992+ }
993+ if tr.probe.unexpectedHandoffs != 0 {
994+ return fmt.Errorf("picker emitted %d direct MsgAttachTarget handoff frame(s)", tr.probe.unexpectedHandoffs)
995+ }
996+ return nil
997+}
998+
1002 func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (ports.Frame, error) {999 func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (ports.Frame, error) {
1003 select {1000 select {
1004 case <-ctx.Done():1001 case <-ctx.Done():
1005 return ports.Frame{}, ctx.Err()1002 return ports.Frame{}, ctx.Err()
1006 case outcome := <-tr.frames:1003 case outcome := <-tr.frames:
1004+ if outcome.err == nil {
1005+ tr.probe.recordIncoming(outcome.frame)
1006+ }
1007 return outcome.frame, outcome.err1007 return outcome.frame, outcome.err
1008 }1008 }
1009 }1009 }
M scripts/remote-picker-harness/run.sh +3 -2
@@ -6,8 +6,9 @@ usage() {
66 Usage: scripts/remote-picker-harness/run.sh
77
88 Builds the current vev binary into a disposable two-container environment and
9-runs local-picker rich-handoff plus SSH-stdio, UDP, catalog, preview,
10-lifecycle-fence, and environment acceptance checks. Docker is invoked normally,
9+runs the unified same-connection local-picker proof plus SSH-stdio, UDP,
10+catalog, preview, lifecycle-fence, and environment acceptance checks. Docker is
11+invoked normally,
1112 so DOCKER_HOST and the active
1213 Docker context select the daemon; no socket path is assumed.
1314
@@ -6,8 +6,9 @@ usage() {
6 Usage: scripts/remote-picker-harness/run.sh6 Usage: scripts/remote-picker-harness/run.sh
7 7
8 Builds the current vev binary into a disposable two-container environment and8 Builds the current vev binary into a disposable two-container environment and
9-runs local-picker rich-handoff plus SSH-stdio, UDP, catalog, preview,9+runs the unified same-connection local-picker proof plus SSH-stdio, UDP,
10-lifecycle-fence, and environment acceptance checks. Docker is invoked normally,10+catalog, preview, lifecycle-fence, and environment acceptance checks. Docker is
11+invoked normally,
11 so DOCKER_HOST and the active12 so DOCKER_HOST and the active
12 Docker context select the daemon; no socket path is assumed.13 Docker context select the daemon; no socket path is assumed.
13 14
M scripts/remote-picker-harness/visual_probe.go +70 -34
@@ -3,6 +3,7 @@ package main
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "os"
89 "path/filepath"
@@ -22,10 +23,10 @@ const (
2223 maxProbeTraces = 128
2324 maxArtifactBytes = 64 << 10
2425
25- probeEventOutput = "output"
26- probeEventWelcome = "welcome"
27- probeEventAttachTarget = "attach_target"
28- probeEventOutputReset = "output_reset_request"
26+ probeEventOutput = "output"
27+ probeEventWelcome = "welcome"
28+ probeEventOutputReset = "output_reset_request"
29+ probeEventUnexpectedHandoff = "unexpected_attach_target"
2930 )
3031
3132 // visualOutputState mirrors the client's output dependency chain. A probe may
@@ -134,6 +135,8 @@ type visualProbe struct {
134135 outputFrames int
135136 outputBytes int
136137 resetPending bool
138+
139+ unexpectedHandoffs int
137140 }
138141
139142 type harnessArtifactContextKey struct{}
@@ -247,62 +250,95 @@ func (p *visualProbe) contains(want string) bool {
247250 return strings.Contains(p.text(), want)
248251 }
249252
253+func (p *visualProbe) recordIncoming(frame ports.Frame) {
254+ if p == nil || frame.Type != ports.MsgAttachTarget {
255+ return
256+ }
257+ p.unexpectedHandoffs++
258+ p.record(probeEvent{Kind: probeEventUnexpectedHandoff})
259+}
260+
250261 func (p *visualProbe) markAcked(result visualOutputResult) {
251262 if result.event != nil && result.StateBearing && result.Accepted {
252263 result.event.Acked = true
253264 }
254265 }
255266
256-func visualScreenText(screen *vt.Screen) string {
267+func visualScreenLines(screen *vt.Screen) []string {
257268 if screen == nil {
258- return ""
269+ return nil
259270 }
260- var text strings.Builder
271+ lines := make([]string, screen.Frame.Height)
261272 for y := range screen.Frame.Height {
273+ var line strings.Builder
262274 for x := range screen.Frame.Width {
263275 r := screen.Frame.At(x, y).Rune
264276 if r == 0 {
265277 r = ' '
266278 }
267- text.WriteRune(r)
279+ line.WriteRune(r)
268280 }
269- text.WriteByte('\n')
281+ lines[y] = line.String()
270282 }
271- return text.String()
283+ return lines
272284 }
273285
274-func validateHandoffEventOrdering(events []*probeEvent) error {
275- acceptedState := func(event *probeEvent) bool {
276- return event != nil && event.Kind == probeEventOutput && event.Accepted && event.StateBearing && event.Acked
286+func visualScreenText(screen *vt.Screen) string {
287+ lines := visualScreenLines(screen)
288+ if len(lines) == 0 {
289+ return ""
277290 }
278- localOutput := eventIndex(events, func(event *probeEvent) bool {
279- return event.Transport == "local-picker" && acceptedState(event)
280- })
281- handoff := eventIndex(events, func(event *probeEvent) bool {
282- return event.Transport == "local-picker" && event.Kind == probeEventAttachTarget
283- })
284- selectedWelcome := eventIndex(events, func(event *probeEvent) bool {
285- return event.Transport == "selected-remote" && event.Kind == probeEventWelcome
286- })
287- selectedOutput := eventIndex(events, func(event *probeEvent) bool {
288- return event.Transport == "selected-remote" && acceptedState(event)
289- })
290- if localOutput < 0 || handoff < 0 || selectedWelcome < 0 || selectedOutput < 0 {
291- return fmt.Errorf("handoff trace omitted local output, direct handoff, selected welcome, or selected output")
291+ return strings.Join(lines, "\n") + "\n"
292+}
293+
294+func capturePickerRows(probe *visualProbe) []string {
295+ if probe == nil {
296+ return nil
297+ }
298+ var rows []string
299+ for _, line := range visualScreenLines(probe.screen) {
300+ if line = strings.TrimSpace(line); line != "" {
301+ rows = append(rows, line)
302+ }
303+ }
304+ return rows
305+}
306+
307+func assertUnifiedPickerRows(rows []string, localSession, remoteSession string) error {
308+ if len(rows) == 0 {
309+ return errors.New("picker screen had no visible rows")
310+ }
311+ contains := func(want string) bool {
312+ for _, row := range rows {
313+ if strings.Contains(row, want) {
314+ return true
315+ }
316+ }
317+ return false
292318 }
293- if !(localOutput < handoff && handoff < selectedWelcome && selectedWelcome < selectedOutput) {
294- return fmt.Errorf("handoff trace order was not local output, direct handoff, selected welcome, selected output")
319+ if !contains(localSession) {
320+ return fmt.Errorf("unified picker rows omitted local session %q", localSession)
321+ }
322+ remoteRow := remoteSession + "@remote"
323+ if !contains(remoteRow) {
324+ return fmt.Errorf("unified picker rows omitted remote session %q", remoteRow)
295325 }
296326 return nil
297327 }
298328
299-func eventIndex(events []*probeEvent, match func(*probeEvent) bool) int {
300- for i, event := range events {
301- if event != nil && match(event) {
302- return i
329+func assertNoRemoteChrome(probe *visualProbe, remoteSession string) error {
330+ lines := visualScreenLines(probe.screen)
331+ if len(lines) < 3 {
332+ return errors.New("remote view did not expose a content region")
333+ }
334+ // The first and last rows belong to the local attachment's bars. Remote
335+ // daemon chrome must not be replayed into the content rows.
336+ for _, line := range lines[1 : len(lines)-1] {
337+ if strings.Contains(line, remoteSession+" at remote") || strings.Contains(line, " Sessions · ") {
338+ return fmt.Errorf("remote chrome leaked into content row %q", strings.TrimSpace(line))
303339 }
304340 }
305- return -1
341+ return nil
306342 }
307343
308344 type harnessArtifact struct {
@@ -3,6 +3,7 @@ package main
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "errors"
6 "fmt"7 "fmt"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
@@ -22,10 +23,10 @@ const (
22 maxProbeTraces = 12823 maxProbeTraces = 128
23 maxArtifactBytes = 64 << 1024 maxArtifactBytes = 64 << 10
24 25
25- probeEventOutput = "output"26+ probeEventOutput = "output"
26- probeEventWelcome = "welcome"27+ probeEventWelcome = "welcome"
27- probeEventAttachTarget = "attach_target"28+ probeEventOutputReset = "output_reset_request"
28- probeEventOutputReset = "output_reset_request"29+ probeEventUnexpectedHandoff = "unexpected_attach_target"
29 )30 )
30 31
31 // visualOutputState mirrors the client's output dependency chain. A probe may32 // visualOutputState mirrors the client's output dependency chain. A probe may
@@ -134,6 +135,8 @@ type visualProbe struct {
134 outputFrames int135 outputFrames int
135 outputBytes int136 outputBytes int
136 resetPending bool137 resetPending bool
138+
139+ unexpectedHandoffs int
137 }140 }
138 141
139 type harnessArtifactContextKey struct{}142 type harnessArtifactContextKey struct{}
@@ -247,62 +250,95 @@ func (p *visualProbe) contains(want string) bool {
247 return strings.Contains(p.text(), want)250 return strings.Contains(p.text(), want)
248 }251 }
249 252
253+func (p *visualProbe) recordIncoming(frame ports.Frame) {
254+ if p == nil || frame.Type != ports.MsgAttachTarget {
255+ return
256+ }
257+ p.unexpectedHandoffs++
258+ p.record(probeEvent{Kind: probeEventUnexpectedHandoff})
259+}
260+
250 func (p *visualProbe) markAcked(result visualOutputResult) {261 func (p *visualProbe) markAcked(result visualOutputResult) {
251 if result.event != nil && result.StateBearing && result.Accepted {262 if result.event != nil && result.StateBearing && result.Accepted {
252 result.event.Acked = true263 result.event.Acked = true
253 }264 }
254 }265 }
255 266
256-func visualScreenText(screen *vt.Screen) string {267+func visualScreenLines(screen *vt.Screen) []string {
257 if screen == nil {268 if screen == nil {
258- return ""269+ return nil
259 }270 }
260- var text strings.Builder271+ lines := make([]string, screen.Frame.Height)
261 for y := range screen.Frame.Height {272 for y := range screen.Frame.Height {
273+ var line strings.Builder
262 for x := range screen.Frame.Width {274 for x := range screen.Frame.Width {
263 r := screen.Frame.At(x, y).Rune275 r := screen.Frame.At(x, y).Rune
264 if r == 0 {276 if r == 0 {
265 r = ' '277 r = ' '
266 }278 }
267- text.WriteRune(r)279+ line.WriteRune(r)
268 }280 }
269- text.WriteByte('\n')281+ lines[y] = line.String()
270 }282 }
271- return text.String()283+ return lines
272 }284 }
273 285
274-func validateHandoffEventOrdering(events []*probeEvent) error {286+func visualScreenText(screen *vt.Screen) string {
275- acceptedState := func(event *probeEvent) bool {287+ lines := visualScreenLines(screen)
276- return event != nil && event.Kind == probeEventOutput && event.Accepted && event.StateBearing && event.Acked288+ if len(lines) == 0 {
289+ return ""
277 }290 }
278- localOutput := eventIndex(events, func(event *probeEvent) bool {291+ return strings.Join(lines, "\n") + "\n"
279- return event.Transport == "local-picker" && acceptedState(event)292+}
280- })293+
281- handoff := eventIndex(events, func(event *probeEvent) bool {294+func capturePickerRows(probe *visualProbe) []string {
282- return event.Transport == "local-picker" && event.Kind == probeEventAttachTarget295+ if probe == nil {
283- })296+ return nil
284- selectedWelcome := eventIndex(events, func(event *probeEvent) bool {297+ }
285- return event.Transport == "selected-remote" && event.Kind == probeEventWelcome298+ var rows []string
286- })299+ for _, line := range visualScreenLines(probe.screen) {
287- selectedOutput := eventIndex(events, func(event *probeEvent) bool {300+ if line = strings.TrimSpace(line); line != "" {
288- return event.Transport == "selected-remote" && acceptedState(event)301+ rows = append(rows, line)
289- })302+ }
290- if localOutput < 0 || handoff < 0 || selectedWelcome < 0 || selectedOutput < 0 {303+ }
291- return fmt.Errorf("handoff trace omitted local output, direct handoff, selected welcome, or selected output")304+ return rows
305+}
306+
307+func assertUnifiedPickerRows(rows []string, localSession, remoteSession string) error {
308+ if len(rows) == 0 {
309+ return errors.New("picker screen had no visible rows")
310+ }
311+ contains := func(want string) bool {
312+ for _, row := range rows {
313+ if strings.Contains(row, want) {
314+ return true
315+ }
316+ }
317+ return false
292 }318 }
293- if !(localOutput < handoff && handoff < selectedWelcome && selectedWelcome < selectedOutput) {319+ if !contains(localSession) {
294- return fmt.Errorf("handoff trace order was not local output, direct handoff, selected welcome, selected output")320+ return fmt.Errorf("unified picker rows omitted local session %q", localSession)
321+ }
322+ remoteRow := remoteSession + "@remote"
323+ if !contains(remoteRow) {
324+ return fmt.Errorf("unified picker rows omitted remote session %q", remoteRow)
295 }325 }
296 return nil326 return nil
297 }327 }
298 328
299-func eventIndex(events []*probeEvent, match func(*probeEvent) bool) int {329+func assertNoRemoteChrome(probe *visualProbe, remoteSession string) error {
300- for i, event := range events {330+ lines := visualScreenLines(probe.screen)
301- if event != nil && match(event) {331+ if len(lines) < 3 {
302- return i332+ return errors.New("remote view did not expose a content region")
333+ }
334+ // The first and last rows belong to the local attachment's bars. Remote
335+ // daemon chrome must not be replayed into the content rows.
336+ for _, line := range lines[1 : len(lines)-1] {
337+ if strings.Contains(line, remoteSession+" at remote") || strings.Contains(line, " Sessions · ") {
338+ return fmt.Errorf("remote chrome leaked into content row %q", strings.TrimSpace(line))
303 }339 }
304 }340 }
305- return -1341+ return nil
306 }342 }
307 343
308 type harnessArtifact struct {344 type harnessArtifact struct {
M scripts/remote-picker-harness/visual_probe_test.go +32 -54
@@ -221,65 +221,43 @@ func TestProcessOutputFrameACKsOnlyAcceptedStateBearingFrames(t *testing.T) {
221221 }
222222 }
223223
224-func TestHandoffEventOrdering(t *testing.T) {
225- event := func(transport, kind string, accepted bool) *probeEvent {
226- result := &probeEvent{Transport: transport, Kind: kind, Accepted: accepted}
227- if kind == probeEventOutput && accepted {
228- result.StateBearing = true
229- result.Acked = true
230- }
231- return result
224+func TestCaptureUnifiedPickerRowsRequiresLocalAndRemoteEntries(t *testing.T) {
225+ probe := newVisualProbe(domain.Size{Cols: 48, Rows: 4})
226+ probe.screen.Write([]byte("local-picker\npicker@remote"))
227+
228+ rows := capturePickerRows(probe)
229+ if err := assertUnifiedPickerRows(rows, "local-picker", "picker"); err != nil {
230+ t.Fatalf("assert unified picker rows: %v; rows = %#v", err, rows)
232231 }
233- for _, tt := range []struct {
232+}
233+
234+func TestVisualProbeRecordsUnexpectedAttachTarget(t *testing.T) {
235+ probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
236+ probe.recordIncoming(ports.Frame{Type: ports.MsgAttachTarget})
237+
238+ if probe.unexpectedHandoffs != 1 {
239+ t.Fatalf("unexpected handoffs = %d, want 1", probe.unexpectedHandoffs)
240+ }
241+ if len(probe.events) != 1 || probe.events[0].Kind != probeEventUnexpectedHandoff {
242+ t.Fatalf("events = %#v, want one unexpected handoff event", probe.events)
243+ }
244+}
245+
246+func TestAssertNoRemoteChromeIgnoresLocalBarsButRejectsContentLeak(t *testing.T) {
247+ for _, test := range []struct {
234248 name string
235- events []*probeEvent
249+ content string
236250 wantErr bool
237251 }{
238- {
239- name: "direct handoff order",
240- events: []*probeEvent{
241- event("local-picker", probeEventWelcome, true),
242- event("local-picker", probeEventOutput, true),
243- event("local-picker", probeEventAttachTarget, true),
244- event("selected-remote", probeEventWelcome, true),
245- event("selected-remote", probeEventOutput, true),
246- },
247- },
248- {
249- name: "selected output arrived before welcome",
250- events: []*probeEvent{
251- event("local-picker", probeEventOutput, true),
252- event("local-picker", probeEventAttachTarget, true),
253- event("selected-remote", probeEventOutput, true),
254- event("selected-remote", probeEventWelcome, true),
255- },
256- wantErr: true,
257- },
258- {
259- name: "rejected local output does not characterize handoff",
260- events: []*probeEvent{
261- event("local-picker", probeEventOutput, false),
262- event("local-picker", probeEventAttachTarget, true),
263- event("selected-remote", probeEventWelcome, true),
264- event("selected-remote", probeEventOutput, true),
265- },
266- wantErr: true,
267- },
268- {
269- name: "unacknowledged local output does not characterize handoff",
270- events: []*probeEvent{
271- {Transport: "local-picker", Kind: probeEventOutput, Accepted: true},
272- event("local-picker", probeEventAttachTarget, true),
273- event("selected-remote", probeEventWelcome, true),
274- event("selected-remote", probeEventOutput, true),
275- },
276- wantErr: true,
277- },
252+ {name: "local bars", content: "picker at remote\nremote content\nlocal bottom", wantErr: false},
253+ {name: "remote status in content", content: "local top\npicker at remote\nlocal bottom", wantErr: true},
278254 } {
279- t.Run(tt.name, func(t *testing.T) {
280- err := validateHandoffEventOrdering(tt.events)
281- if (err != nil) != tt.wantErr {
282- t.Fatalf("error = %v, want error = %t", err, tt.wantErr)
255+ t.Run(test.name, func(t *testing.T) {
256+ probe := newVisualProbe(domain.Size{Cols: 48, Rows: 3})
257+ probe.screen.Write([]byte(test.content))
258+ err := assertNoRemoteChrome(probe, "picker")
259+ if (err != nil) != test.wantErr {
260+ t.Fatalf("error = %v, want error = %t", err, test.wantErr)
283261 }
284262 })
285263 }
@@ -221,65 +221,43 @@ func TestProcessOutputFrameACKsOnlyAcceptedStateBearingFrames(t *testing.T) {
221 }221 }
222 }222 }
223 223
224-func TestHandoffEventOrdering(t *testing.T) {224+func TestCaptureUnifiedPickerRowsRequiresLocalAndRemoteEntries(t *testing.T) {
225- event := func(transport, kind string, accepted bool) *probeEvent {225+ probe := newVisualProbe(domain.Size{Cols: 48, Rows: 4})
226- result := &probeEvent{Transport: transport, Kind: kind, Accepted: accepted}226+ probe.screen.Write([]byte("local-picker\npicker@remote"))
227- if kind == probeEventOutput && accepted {227+
228- result.StateBearing = true228+ rows := capturePickerRows(probe)
229- result.Acked = true229+ if err := assertUnifiedPickerRows(rows, "local-picker", "picker"); err != nil {
230- }230+ t.Fatalf("assert unified picker rows: %v; rows = %#v", err, rows)
231- return result
232 }231 }
233- for _, tt := range []struct {232+}
233+
234+func TestVisualProbeRecordsUnexpectedAttachTarget(t *testing.T) {
235+ probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
236+ probe.recordIncoming(ports.Frame{Type: ports.MsgAttachTarget})
237+
238+ if probe.unexpectedHandoffs != 1 {
239+ t.Fatalf("unexpected handoffs = %d, want 1", probe.unexpectedHandoffs)
240+ }
241+ if len(probe.events) != 1 || probe.events[0].Kind != probeEventUnexpectedHandoff {
242+ t.Fatalf("events = %#v, want one unexpected handoff event", probe.events)
243+ }
244+}
245+
246+func TestAssertNoRemoteChromeIgnoresLocalBarsButRejectsContentLeak(t *testing.T) {
247+ for _, test := range []struct {
234 name string248 name string
235- events []*probeEvent249+ content string
236 wantErr bool250 wantErr bool
237 }{251 }{
238- {252+ {name: "local bars", content: "picker at remote\nremote content\nlocal bottom", wantErr: false},
239- name: "direct handoff order",253+ {name: "remote status in content", content: "local top\npicker at remote\nlocal bottom", wantErr: true},
240- events: []*probeEvent{
241- event("local-picker", probeEventWelcome, true),
242- event("local-picker", probeEventOutput, true),
243- event("local-picker", probeEventAttachTarget, true),
244- event("selected-remote", probeEventWelcome, true),
245- event("selected-remote", probeEventOutput, true),
246- },
247- },
248- {
249- name: "selected output arrived before welcome",
250- events: []*probeEvent{
251- event("local-picker", probeEventOutput, true),
252- event("local-picker", probeEventAttachTarget, true),
253- event("selected-remote", probeEventOutput, true),
254- event("selected-remote", probeEventWelcome, true),
255- },
256- wantErr: true,
257- },
258- {
259- name: "rejected local output does not characterize handoff",
260- events: []*probeEvent{
261- event("local-picker", probeEventOutput, false),
262- event("local-picker", probeEventAttachTarget, true),
263- event("selected-remote", probeEventWelcome, true),
264- event("selected-remote", probeEventOutput, true),
265- },
266- wantErr: true,
267- },
268- {
269- name: "unacknowledged local output does not characterize handoff",
270- events: []*probeEvent{
271- {Transport: "local-picker", Kind: probeEventOutput, Accepted: true},
272- event("local-picker", probeEventAttachTarget, true),
273- event("selected-remote", probeEventWelcome, true),
274- event("selected-remote", probeEventOutput, true),
275- },
276- wantErr: true,
277- },
278 } {254 } {
279- t.Run(tt.name, func(t *testing.T) {255+ t.Run(test.name, func(t *testing.T) {
280- err := validateHandoffEventOrdering(tt.events)256+ probe := newVisualProbe(domain.Size{Cols: 48, Rows: 3})
281- if (err != nil) != tt.wantErr {257+ probe.screen.Write([]byte(test.content))
282- t.Fatalf("error = %v, want error = %t", err, tt.wantErr)258+ err := assertNoRemoteChrome(probe, "picker")
259+ if (err != nil) != test.wantErr {
260+ t.Fatalf("error = %v, want error = %t", err, test.wantErr)
283 }261 }
284 })262 })
285 }263 }

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 2

Tags 13

↑↓ moveenter openesc close