feat(daemon): retain unified remote picker ownership
- commit
- f1b7847e29999cb69b402757e57a647101d19ea0
- author
- brice · · 2026-08-07 02:49 UTC
- parent
- 8062ab3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 35 | 35 | sessionName string | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | + | type remoteViewLinkState uint8 | |
| 39 | + | ||
| 40 | + | const ( | |
| 41 | + | remoteViewLinkHealthy remoteViewLinkState = iota | |
| 42 | + | remoteViewLinkReconnecting | |
| 43 | + | remoteViewLinkUnavailable | |
| 44 | + | ) | |
| 45 | + | ||
| 38 | 46 | func remoteViewKeyForTarget(target domain.RemoteSessionTarget) (remoteViewKey, error) { | |
| 39 | 47 | if err := target.Validate(); err != nil { | |
| 40 | 48 | return remoteViewKey{}, err | |
| @@ -55,19 +63,33 @@ type remoteView struct { | |||
| 55 | 63 | id remoteViewID | |
| 56 | 64 | key remoteViewKey | |
| 57 | 65 | ||
| 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 | |
| 64 | 75 | // link and linkGeneration fence every remote transport callback. They are | |
| 65 | 76 | // owned by the view rather than the local-session registry, and neither | |
| 66 | 77 | // is ever used while view.mu is held for transport I/O. | |
| 67 | 78 | link *remoteLink | |
| 68 | 79 | 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 | |
| 71 | 93 | // warm and warmGeneration fence the five-minute retention timer for an | |
| 72 | 94 | // unattached view. The timer is always stopped after view.mu is released. | |
| 73 | 95 | warm *remoteViewWarm | |
| @@ -35,6 +35,14 @@ type remoteViewKey struct { | |||||
| 35 | sessionName string | 35 | 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{}, err | 48 | return remoteViewKey{}, err | ||
| @@ -55,19 +63,33 @@ type remoteView struct { | |||||
| 55 | id remoteViewID | 63 | id remoteViewID | ||
| 56 | key remoteViewKey | 64 | key remoteViewKey | ||
| 57 | 65 | ||||
| 58 | - | mu sync.Mutex | 66 | + | mu sync.Mutex |
| 59 | - | closed bool | 67 | + | // 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.Screen | 69 | + | tabActivationMu sync.Mutex |
| 62 | - | metadata ports.SessionMeta | 70 | + | closed bool |
| 63 | - | displayOrigin string | 71 | + | 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 are | 75 | // link and linkGeneration fence every remote transport callback. They are | ||
| 65 | // owned by the view rather than the local-session registry, and neither | 76 | // 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 *remoteLink | 78 | link *remoteLink | ||
| 68 | linkGeneration uint64 | 79 | linkGeneration uint64 | ||
| 69 | - | output remoteOutputState | 80 | + | // metadataChanged is replaced and the prior channel is closed after every |
| 70 | - | resetRequested bool | 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 | |||
| 71 | // warm and warmGeneration fence the five-minute retention timer for an | 93 | // 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 *remoteViewWarm | 95 | warm *remoteViewWarm | ||
M internal/usecase/daemon/client.go +10 -0
| @@ -237,6 +237,16 @@ func (ac *attachedClient) currentSession() *session { | |||
| 237 | 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 | 250 | func (ac *attachedClient) setAttachmentOwner(owner attachmentOwner) { | |
| 241 | 251 | if ac != nil { | |
| 242 | 252 | 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) { | |||
| 135 | 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 | 160 | func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) { | |
| 139 | 161 | if t == nil { | |
| 140 | 162 | 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 | return | 162 | return | ||
M internal/usecase/daemon/daemon.go +14 -6
| @@ -813,11 +813,9 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho | |||
| 813 | 813 | for _, view := range d.remoteViews { | |
| 814 | 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 | 817 | for _, construction := range d.remoteViewConstructions { | |
| 818 | - | if construction.cancel != nil { | |
| 819 | - | constructions = append(constructions, construction.cancel) | |
| 820 | - | } | |
| 818 | + | constructions = append(constructions, construction) | |
| 821 | 819 | } | |
| 822 | 820 | clear(d.remoteViews) | |
| 823 | 821 | clear(d.remoteViewsByKey) | |
| @@ -829,8 +827,18 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho | |||
| 829 | 827 | // attachment after releasing d.mu so a token cannot remain valid and a | |
| 830 | 828 | // blocked local-client Recv/Send cannot outlive daemon shutdown. Candidate | |
| 831 | 829 | // 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 | + | } | |
| 834 | 842 | } | |
| 835 | 843 | remoteWarms := make([]*remoteViewWarm, 0, len(remoteViews)) | |
| 836 | 844 | 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 a | 827 | // attachment after releasing d.mu so a token cannot remain valid and a | ||
| 830 | // blocked local-client Recv/Send cannot outlive daemon shutdown. Candidate | 828 | // 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 | |||
| 26 | 26 | return | |
| 27 | 27 | } | |
| 28 | 28 | if view, remote := token.owner.(*remoteView); remote { | |
| 29 | - | d.handleRemoteViewInput(view, token, inputSeq, data) | |
| 29 | + | d.handleRemoteViewInputForAttachment(view, token, inputSeq, data) | |
| 30 | 30 | return | |
| 31 | 31 | } | |
| 32 | 32 | if token.ac.renderMode == ports.RenderModeProxiedContent { | |
| @@ -36,6 +36,45 @@ func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionTok | |||
| 36 | 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 | 78 | // handleProxiedInputForAttachment preserves remote terminal bytes exactly. | |
| 40 | 79 | // Local key routing, overlays, mouse interpretation, and copy-mode state are | |
| 41 | 80 | // deliberately unavailable on the remote daemon's content-only surface. | |
| @@ -26,7 +26,7 @@ func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionTok | |||||
| 26 | return | 26 | 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 | return | 30 | 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 are | 79 | // 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 | |||
| 167 | 167 | } | |
| 168 | 168 | rt := ac.overlays | |
| 169 | 169 | rt.pickerMu.Lock() | |
| 170 | - | if rt.picker == nil { | |
| 170 | + | if rt.picker == nil || rt.pickerRemoteSelection != nil { | |
| 171 | 171 | rt.pickerMu.Unlock() | |
| 172 | 172 | return | |
| 173 | 173 | } | |
| @@ -167,7 +167,7 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions | |||||
| 167 | } | 167 | } | ||
| 168 | rt := ac.overlays | 168 | 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 | return | 172 | return | ||
| 173 | } | 173 | } | ||
M internal/usecase/daemon/overlay_runtime.go +5 -2
| @@ -31,8 +31,11 @@ type overlayRuntime struct { | |||
| 31 | 31 | pickerRemotePreview picker.Preview | |
| 32 | 32 | pickerRemotePreviewCancel context.CancelFunc | |
| 33 | 33 | 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 | |
| 36 | 39 | ||
| 37 | 40 | // Test-only, unsynchronized lifecycle seams. Assign them before picker | |
| 38 | 41 | // publication or goroutine startup. Hooks run without pickerMu or | |
| @@ -31,8 +31,11 @@ type overlayRuntime struct { | |||||
| 31 | pickerRemotePreview picker.Preview | 31 | pickerRemotePreview picker.Preview | ||
| 32 | pickerRemotePreviewCancel context.CancelFunc | 32 | pickerRemotePreviewCancel context.CancelFunc | ||
| 33 | pickerPreviewGeneration uint64 | 33 | pickerPreviewGeneration uint64 | ||
| 34 | - | pickerPending []byte | 34 | + | // pickerRemoteSelection fences one asynchronous remote picker activation. |
| 35 | - | pickerESC pendingByteTimer | 35 | + | // 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 picker | 40 | // Test-only, unsynchronized lifecycle seams. Assign them before picker | ||
| 38 | // publication or goroutine startup. Hooks run without pickerMu or | 41 | // 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) { | |||
| 49 | 49 | ac.overlays.paletteFeedback = "" | |
| 50 | 50 | ac.overlays.palettePending = nil | |
| 51 | 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 | 55 | // paletteResults captures eligible named sessions before paletteMu so the | |
| @@ -146,7 +146,7 @@ func (d *Daemon) recordPaletteUse(code string) { | |||
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | 148 | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) { | |
| 149 | - | entry := ac.currentAttachmentSession() | |
| 149 | + | entry := ac.navigationSession() | |
| 150 | 150 | if entry == nil { | |
| 151 | 151 | return | |
| 152 | 152 | } | |
| @@ -253,12 +253,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 253 | 253 | } | |
| 254 | 254 | ac.overlays.paletteMu.Unlock() | |
| 255 | 255 | if cancel { | |
| 256 | - | d.invalidateRender(entry, ac, true, "palette.go") | |
| 256 | + | d.invalidateAttachedOwner(ac, true, "palette.go") | |
| 257 | 257 | return | |
| 258 | 258 | } | |
| 259 | 259 | if !execute { | |
| 260 | 260 | if changed { | |
| 261 | - | d.invalidateRender(entry, ac, true, "palette.go") | |
| 261 | + | d.invalidateAttachedOwner(ac, true, "palette.go") | |
| 262 | 262 | } | |
| 263 | 263 | return | |
| 264 | 264 | } | |
| @@ -49,7 +49,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||||
| 49 | ac.overlays.paletteFeedback = "" | 49 | ac.overlays.paletteFeedback = "" | ||
| 50 | ac.overlays.palettePending = nil | 50 | 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 the | 55 | // 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 | return | 151 | 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 | return | 257 | 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 | return | 263 | 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. | |||
| 63 | 63 | rt := ac.overlays | |
| 64 | 64 | rt.pickerMu.Lock() | |
| 65 | 65 | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | |
| 66 | + | previousSelection := rt.pickerRemoteSelection | |
| 67 | + | rt.pickerRemoteSelection = nil | |
| 66 | 68 | rt.pickerPreviewGeneration++ | |
| 67 | 69 | if rt.pickerRemotePreviewCancel != nil { | |
| 68 | 70 | rt.pickerRemotePreviewCancel() | |
| @@ -80,9 +82,12 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||
| 80 | 82 | rt.pickerPending = nil | |
| 81 | 83 | rt.pickerESC.stop() | |
| 82 | 84 | rt.pickerMu.Unlock() | |
| 85 | + | if previousSelection != nil { | |
| 86 | + | previousSelection.cancel() | |
| 87 | + | } | |
| 83 | 88 | d.teardownPreviewSubscription(ac, previous, previousGeneration) | |
| 84 | 89 | d.registerPreviewForSelection(ac) | |
| 85 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 90 | + | d.invalidateAttachedOwner(ac, true, "picker.go") | |
| 86 | 91 | if rt.beforeRemotePickerRegistration != nil { | |
| 87 | 92 | rt.beforeRemotePickerRegistration() | |
| 88 | 93 | } | |
| @@ -264,6 +269,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 264 | 269 | rt := ac.overlays | |
| 265 | 270 | var previewGeneration uint64 | |
| 266 | 271 | var instance remotePickerInstance | |
| 272 | + | var closed bool | |
| 267 | 273 | return listInputState{ | |
| 268 | 274 | pending: &rt.pickerPending, | |
| 269 | 275 | esc: &rt.pickerESC, | |
| @@ -273,6 +279,13 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 273 | 279 | unlock: rt.pickerMu.Unlock, | |
| 274 | 280 | active: func() bool { return rt.picker != nil }, | |
| 275 | 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 | 289 | instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker} | |
| 277 | 290 | rt.picker = nil | |
| 278 | 291 | rt.pickerIntent = pickerNavigate | |
| @@ -280,17 +293,17 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 280 | 293 | previewGeneration = rt.pickerPreviewGeneration | |
| 281 | 294 | }, | |
| 282 | 295 | 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) | |
| 287 | 299 | } | |
| 300 | + | d.invalidateAttachedOwner(ac, true, "picker.go") | |
| 288 | 301 | }, | |
| 289 | 302 | } | |
| 290 | 303 | } | |
| 291 | 304 | ||
| 292 | 305 | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) { | |
| 293 | - | sess := ac.currentAttachmentSession() | |
| 306 | + | sess := ac.navigationSession() | |
| 294 | 307 | if sess == nil { | |
| 295 | 308 | return | |
| 296 | 309 | } | |
| @@ -397,7 +410,29 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 397 | 410 | return | |
| 398 | 411 | } | |
| 399 | 412 | 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 | 436 | err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | |
| 402 | 437 | } else { | |
| 403 | 438 | d.closePicker(ac) | |
| @@ -413,7 +448,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 413 | 448 | return | |
| 414 | 449 | } | |
| 415 | 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 | 742 | return false | |
| 708 | 743 | } | |
| 709 | 744 | instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker} | |
| 745 | + | selection := ac.overlays.pickerRemoteSelection | |
| 746 | + | ac.overlays.pickerRemoteSelection = nil | |
| 710 | 747 | ac.overlays.picker = nil | |
| 711 | 748 | ac.overlays.pickerTitle = "" | |
| 712 | 749 | ac.overlays.pickerIntent = pickerNavigate | |
| @@ -715,6 +752,9 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g | |||
| 715 | 752 | ac.overlays.pickerESC.stop() | |
| 716 | 753 | previewGeneration := ac.overlays.pickerPreviewGeneration | |
| 717 | 754 | ac.overlays.pickerMu.Unlock() | |
| 755 | + | if selection != nil { | |
| 756 | + | selection.cancel() | |
| 757 | + | } | |
| 718 | 758 | d.clearPreviewGeneration(ac, previewGeneration) | |
| 719 | 759 | if instance.model != nil { | |
| 720 | 760 | d.remotePickerClosed(instance) | |
| @@ -857,14 +897,13 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||
| 857 | 897 | return nil | |
| 858 | 898 | } | |
| 859 | 899 | 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) | |
| 865 | 901 | } | |
| 866 | 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 | 908 | token.effect.bindActionEnd(d, action) | |
| 870 | 909 | token.effect.End() | |
| @@ -875,68 +914,6 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||
| 875 | 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 | 917 | func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool { | |
| 941 | 918 | if d == nil || target.Validate() != nil { | |
| 942 | 919 | return false | |
| @@ -63,6 +63,8 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||||
| 63 | rt := ac.overlays | 63 | rt := ac.overlays | ||
| 64 | rt.pickerMu.Lock() | 64 | rt.pickerMu.Lock() | ||
| 65 | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | 65 | 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 = nil | 82 | 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.overlays | 269 | rt := ac.overlays | ||
| 265 | var previewGeneration uint64 | 270 | var previewGeneration uint64 | ||
| 266 | var instance remotePickerInstance | 271 | 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 = nil | 290 | rt.picker = nil | ||
| 278 | rt.pickerIntent = pickerNavigate | 291 | rt.pickerIntent = pickerNavigate | ||
| @@ -280,17 +293,17 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||||
| 280 | previewGeneration = rt.pickerPreviewGeneration | 293 | 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 | return | 308 | return | ||
| 296 | } | 309 | } | ||
| @@ -397,7 +410,29 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 397 | return | 410 | return | ||
| 398 | } | 411 | } | ||
| 399 | var err error | 412 | 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 | return | 448 | 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 false | 742 | 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 = nil | 747 | ac.overlays.picker = nil | ||
| 711 | ac.overlays.pickerTitle = "" | 748 | ac.overlays.pickerTitle = "" | ||
| 712 | ac.overlays.pickerIntent = pickerNavigate | 749 | 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.pickerPreviewGeneration | 753 | 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 nil | 897 | return nil | ||
| 858 | } | 898 | } | ||
| 859 | if target.RemoteTarget != nil { | 899 | if target.RemoteTarget != nil { | ||
| 860 | - | var key domain.RemoteSessionKey | 900 | + | 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 false | 919 | return false | ||
M internal/usecase/daemon/remote_attach_test.go +15 -32
| @@ -17,36 +17,7 @@ func remoteLifecycleForTest() domain.SessionLifecycleID { | |||
| 17 | 17 | return id | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 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) { | |
| 50 | 21 | d := newRemotePickerDaemon(nil) | |
| 51 | 22 | lifecycle := remoteLifecycleForTest() | |
| 52 | 23 | cachedLifecycle := lifecycle | |
| @@ -59,7 +30,11 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) { | |||
| 59 | 30 | d.remoteCatalog.mu.Lock() | |
| 60 | 31 | d.remoteCatalog.status["arch"] = remoteHostFresh | |
| 61 | 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 | 38 | token := sess.attachmentToken(ac, ac.transport()) | |
| 64 | 39 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 65 | 40 | require.True(t, admitted) | |
| @@ -68,5 +43,13 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) { | |||
| 68 | 43 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 69 | 44 | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 70 | 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 | } | |
| @@ -17,36 +17,7 @@ func remoteLifecycleForTest() domain.SessionLifecycleID { | |||||
| 17 | return id | 17 | 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 := lifecycle | 23 | 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"] = remoteHostFresh | 31 | 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 | |||
| 5 | 5 | // I/O deliberately happen before this function; this function only performs | |
| 6 | 6 | // the frozen, exact local ownership hand-off. | |
| 7 | 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 | 30 | source := token.localSession() | |
| 9 | 31 | if d == nil || source == nil || token.ac == nil || target == nil { | |
| 10 | 32 | return attachmentConnectionToken{}, errAttachmentTransition | |
| @@ -17,6 +39,9 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target | |||
| 17 | 39 | if !frozen.acquired || !frozen.drained { | |
| 18 | 40 | return attachmentConnectionToken{}, errAttachmentTransition | |
| 19 | 41 | } | |
| 42 | + | if selection != nil && !selection.current() { | |
| 43 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 44 | + | } | |
| 20 | 45 | ||
| 21 | 46 | d.mu.Lock() | |
| 22 | 47 | if d.closing || d.sessions[source.id] != source || !d.attachmentOwnerRegisteredByDaemonLocked(target) { | |
| @@ -32,6 +57,10 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target | |||
| 32 | 57 | token.ac.transportSnapshotCurrent(token.transport) && | |
| 33 | 58 | attachmentRegisteredLocked(source, token.ac) && | |
| 34 | 59 | !target.closed | |
| 60 | + | if selection != nil { | |
| 61 | + | current = current && expectedLink != nil && target.link == expectedLink && | |
| 62 | + | target.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(target) | |
| 63 | + | } | |
| 35 | 64 | if !current { | |
| 36 | 65 | target.mu.Unlock() | |
| 37 | 66 | source.mu.Unlock() | |
| @@ -5,6 +5,28 @@ package daemon | |||||
| 5 | // I/O deliberately happen before this function; this function only performs | 5 | // 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{}, errAttachmentTransition | 32 | 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{}, errAttachmentTransition | 40 | 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.closed | 59 | !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 ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 9 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 11 | "github.com/bnema/vev/pkg/vt" | |
| 11 | 12 | "github.com/stretchr/testify/require" | |
| @@ -39,6 +40,91 @@ func newRemoteFrameRoutingFixture(t *testing.T) (*Daemon, *remoteView, *attached | |||
| 39 | 40 | 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 | 128 | func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) { | |
| 43 | 129 | d, _, ac, token, sends := newRemoteFrameRoutingFixture(t) | |
| 44 | 130 | ||
| @@ -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, sends | 40 | 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 { | |||
| 45 | 45 | cancel context.CancelFunc | |
| 46 | 46 | ||
| 47 | 47 | sendMu sync.Mutex | |
| 48 | + | commands *CommandRequestTracker | |
| 48 | 49 | startOnce sync.Once | |
| 49 | 50 | workerStarted atomic.Bool | |
| 50 | 51 | done chan struct{} | |
| @@ -301,8 +302,12 @@ func installRemoteViewCandidateLocked(view, candidate *remoteView) (*remoteLink, | |||
| 301 | 302 | view.screen = candidate.screen | |
| 302 | 303 | view.metadata = cloneRemoteSessionMeta(candidate.metadata) | |
| 303 | 304 | view.displayOrigin = candidate.displayOrigin | |
| 305 | + | view.reconnectTarget = candidate.reconnectTarget | |
| 304 | 306 | view.output = candidate.output | |
| 305 | 307 | view.resetRequested = candidate.resetRequested | |
| 308 | + | view.linkState = remoteViewLinkHealthy | |
| 309 | + | view.reconnectGeneration++ | |
| 310 | + | signalRemoteViewMetadataChangedLocked(view) | |
| 306 | 311 | if replaced != nil { | |
| 307 | 312 | replaced.active = false | |
| 308 | 313 | } | |
| @@ -314,6 +319,7 @@ func stopAndJoinRemoteLink(link *remoteLink) { | |||
| 314 | 319 | if link == nil { | |
| 315 | 320 | return | |
| 316 | 321 | } | |
| 322 | + | link.commands.FailGeneration(link.generation, errRemoteViewUnavailable) | |
| 317 | 323 | if link.cancel != nil { | |
| 318 | 324 | link.cancel() | |
| 319 | 325 | } | |
| @@ -359,6 +365,7 @@ func (d *Daemon) constructRemoteView(ctx context.Context, target domain.RemoteSe | |||
| 359 | 365 | target: target, | |
| 360 | 366 | clientID: clientID, | |
| 361 | 367 | cancel: cancelLink, | |
| 368 | + | commands: NewCommandRequestTracker(), | |
| 362 | 369 | done: make(chan struct{}), | |
| 363 | 370 | active: true, | |
| 364 | 371 | } | |
| @@ -507,17 +514,56 @@ func validateInitialRemoteLinkMetadata(frame ports.Frame, target domain.RemoteSe | |||
| 507 | 514 | if metadata.LifecycleID != target.LifecycleID || metadata.SessionName != target.SessionName || metadata.Revision != 1 { | |
| 508 | 515 | return ports.SessionMeta{}, errors.New("remote view: metadata identity mismatch") | |
| 509 | 516 | } | |
| 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 | |
| 512 | 519 | } | |
| 513 | 520 | 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 | 548 | func cloneRemoteSessionMeta(metadata ports.SessionMeta) ports.SessionMeta { | |
| 517 | 549 | metadata.Tabs = append([]ports.SessionTabMeta(nil), metadata.Tabs...) | |
| 518 | 550 | 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 | 567 | type remoteMetadataResult uint8 | |
| 522 | 568 | ||
| 523 | 569 | const ( | |
| @@ -558,6 +604,12 @@ func (v *remoteView) applyRemoteMetadataWithAttachments(link *remoteLink, metada | |||
| 558 | 604 | return remoteMetadataStale, nil | |
| 559 | 605 | } | |
| 560 | 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 | 613 | if initial { | |
| 562 | 614 | return remoteMetadataAccepted, nil | |
| 563 | 615 | } | |
| @@ -681,6 +733,15 @@ func (d *Daemon) handleRemoteLinkFrame(link *remoteLink, frame ports.Frame) erro | |||
| 681 | 733 | default: | |
| 682 | 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 | 745 | case ports.MsgPing: | |
| 685 | 746 | if _, err := ports.UnmarshalPing(frame.Payload); err != nil { | |
| 686 | 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 | 751 | _, err := ports.UnmarshalPong(frame.Payload) | |
| 691 | 752 | return err | |
| 692 | 753 | case ports.MsgDetached: | |
| 693 | - | _, err := ports.UnmarshalDetached(frame.Payload) | |
| 754 | + | detached, err := ports.UnmarshalDetached(frame.Payload) | |
| 694 | 755 | if err != nil { | |
| 695 | 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 | 759 | return nil | |
| 699 | 760 | case ports.MsgError: | |
| 700 | 761 | remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload) | |
| @@ -743,8 +804,10 @@ func (link *remoteLink) send(frame ports.Frame) error { | |||
| 743 | 804 | view.mu.Lock() | |
| 744 | 805 | if view.link == link && view.linkGeneration == link.generation { | |
| 745 | 806 | link.active = false | |
| 807 | + | signalRemoteViewMetadataChangedLocked(view) | |
| 746 | 808 | } | |
| 747 | 809 | view.mu.Unlock() | |
| 810 | + | link.commands.FailGeneration(link.generation, err) | |
| 748 | 811 | link.cancel() | |
| 749 | 812 | _ = transport.Close() | |
| 750 | 813 | } | |
| @@ -776,24 +839,59 @@ func (d *Daemon) sendRemoteViewFrame(view *remoteView, frame ports.Frame) error | |||
| 776 | 839 | if link == nil { | |
| 777 | 840 | 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 | 849 | func (d *Daemon) markRemoteLinkUnavailable(link *remoteLink) { | |
| 783 | - | if link == nil || link.view == nil { | |
| 850 | + | if d == nil || link == nil || link.view == nil { | |
| 784 | 851 | return | |
| 785 | 852 | } | |
| 786 | 853 | view := link.view | |
| 787 | 854 | view.mu.Lock() | |
| 788 | 855 | 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 | 863 | if current { | |
| 790 | 864 | 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 | 882 | 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 { | |
| 795 | 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 | 897 | // stopUnpublishedRemoteView is used for candidate failure and lost-election | |
| @@ -808,8 +906,10 @@ func (d *Daemon) stopUnpublishedRemoteView(view *remoteView) { | |||
| 808 | 906 | link := view.link | |
| 809 | 907 | view.link = nil | |
| 810 | 908 | view.linkGeneration++ | |
| 909 | + | signalRemoteViewMetadataChangedLocked(view) | |
| 811 | 910 | view.mu.Unlock() | |
| 812 | 911 | if link != nil { | |
| 912 | + | link.commands.FailGeneration(link.generation, errRemoteViewUnavailable) | |
| 813 | 913 | link.cancel() | |
| 814 | 914 | if link.transport != nil { | |
| 815 | 915 | _ = link.transport.Close() | |
| @@ -831,9 +931,11 @@ func (d *Daemon) stopRemoteViewLink(view *remoteView) *remoteLink { | |||
| 831 | 931 | view.link = nil | |
| 832 | 932 | view.linkGeneration++ | |
| 833 | 933 | link.active = false | |
| 934 | + | signalRemoteViewMetadataChangedLocked(view) | |
| 834 | 935 | } | |
| 835 | 936 | view.mu.Unlock() | |
| 836 | 937 | if link != nil { | |
| 938 | + | link.commands.FailGeneration(link.generation, errRemoteViewUnavailable) | |
| 837 | 939 | link.cancel() | |
| 838 | 940 | if link.transport != nil { | |
| 839 | 941 | _ = link.transport.Close() | |
| @@ -45,6 +45,7 @@ type remoteLink struct { | |||||
| 45 | cancel context.CancelFunc | 45 | cancel context.CancelFunc | ||
| 46 | 46 | ||||
| 47 | sendMu sync.Mutex | 47 | sendMu sync.Mutex | ||
| 48 | + | commands *CommandRequestTracker | |||
| 48 | startOnce sync.Once | 49 | startOnce sync.Once | ||
| 49 | workerStarted atomic.Bool | 50 | 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.screen | 302 | view.screen = candidate.screen | ||
| 302 | view.metadata = cloneRemoteSessionMeta(candidate.metadata) | 303 | view.metadata = cloneRemoteSessionMeta(candidate.metadata) | ||
| 303 | view.displayOrigin = candidate.displayOrigin | 304 | view.displayOrigin = candidate.displayOrigin | ||
| 305 | + | view.reconnectTarget = candidate.reconnectTarget | |||
| 304 | view.output = candidate.output | 306 | view.output = candidate.output | ||
| 305 | view.resetRequested = candidate.resetRequested | 307 | 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 = false | 312 | replaced.active = false | ||
| 308 | } | 313 | } | ||
| @@ -314,6 +319,7 @@ func stopAndJoinRemoteLink(link *remoteLink) { | |||||
| 314 | if link == nil { | 319 | if link == nil { | ||
| 315 | return | 320 | 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, nil | 520 | 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 metadata | 550 | 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 uint8 | 567 | type remoteMetadataResult uint8 | ||
| 522 | 568 | ||||
| 523 | const ( | 569 | const ( | ||
| @@ -558,6 +604,12 @@ func (v *remoteView) applyRemoteMetadataWithAttachments(link *remoteLink, metada | |||||
| 558 | return remoteMetadataStale, nil | 604 | 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, nil | 614 | 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 err | 752 | 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 nil | 759 | 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 = false | 806 | 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 errRemoteViewUnavailable | 840 | 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 | return | 851 | return | ||
| 785 | } | 852 | } | ||
| 786 | view := link.view | 853 | view := link.view | ||
| 787 | view.mu.Lock() | 854 | view.mu.Lock() | ||
| 788 | current := !view.closed && view.link == link && view.linkGeneration == link.generation | 855 | 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 = false | 864 | 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-election | 897 | // stopUnpublishedRemoteView is used for candidate failure and lost-election | ||
| @@ -808,8 +906,10 @@ func (d *Daemon) stopUnpublishedRemoteView(view *remoteView) { | |||||
| 808 | link := view.link | 906 | link := view.link | ||
| 809 | view.link = nil | 907 | 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 = nil | 931 | view.link = nil | ||
| 832 | view.linkGeneration++ | 932 | view.linkGeneration++ | ||
| 833 | link.active = false | 933 | 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 ( | |||
| 7 | 7 | "sync" | |
| 8 | 8 | "sync/atomic" | |
| 9 | 9 | "testing" | |
| 10 | + | "time" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/vev/internal/domain" | |
| 12 | 13 | "github.com/bnema/vev/internal/ports" | |
| @@ -121,14 +122,19 @@ func enqueueRemoteLinkHandshake(t *testing.T, transport *remoteLinkTestTransport | |||
| 121 | 122 | ||
| 122 | 123 | func enqueueRemoteLinkHandshakeWithContent(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size, contentMarker string) { | |
| 123 | 124 | t.Helper() | |
| 124 | - | content := contentSize(size) | |
| 125 | - | metaPayload, err := ports.MarshalSessionMeta(ports.SessionMeta{ | |
| 125 | + | enqueueRemoteLinkHandshakeWithMetadata(t, transport, target, size, ports.SessionMeta{ | |
| 126 | 126 | LifecycleID: target.LifecycleID, | |
| 127 | 127 | Revision: 1, | |
| 128 | 128 | SessionName: target.SessionName, | |
| 129 | 129 | ActiveTabID: target.LiveTabID, | |
| 130 | 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 | 138 | require.NoError(t, err) | |
| 133 | 139 | outputPayload, err := ports.MarshalOutput(ports.Output{ | |
| 134 | 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 | 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 | 221 | func TestRemoteLinkAcceptedMetadataRepaintsEveryAttachedClientUnderLocalChrome(t *testing.T) { | |
| 201 | 222 | d, view, link, _ := newRemoteMetadataLinkFixture(t) | |
| 202 | 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 | 489 | func TestOpenRemoteViewPublishesHandshakeReadyCandidate(t *testing.T) { | |
| 322 | 490 | d := newTestDaemon(t, nil, stubClock{}) | |
| 323 | 491 | target := remoteLinkTestTarget() | |
| @@ -416,6 +584,91 @@ func TestOpenRemoteViewReconnectsInactiveExactView(t *testing.T) { | |||
| 416 | 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 | 672 | func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) { | |
| 420 | 673 | d := newTestDaemon(t, nil, stubClock{}) | |
| 421 | 674 | 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 | |||
| 178 | 178 | } | |
| 179 | 179 | // Keep the structured session identity on rows even when one tab ID is | |
| 180 | 180 | // 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. | |
| 183 | 182 | remoteTarget = &target | |
| 184 | 183 | } | |
| 185 | 184 | reason := "" | |
| @@ -194,13 +193,10 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||
| 194 | 193 | } | |
| 195 | 194 | ready := status == remoteHostFresh && targetValid && !broken | |
| 196 | 195 | 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" | |
| 204 | 200 | } | |
| 205 | 201 | detail := remotePickerStatusDetail(status, fetchedAt) | |
| 206 | 202 | 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 is | 179 | // Keep the structured session identity on rows even when one tab ID is | ||
| 180 | // malformed or a broken session cannot be activated. Cursor navigation | 180 | // malformed or a broken session cannot be activated. Cursor navigation | ||
| 181 | - | // must remain possible, while sendRemoteAttachTargetForAttachment will | 181 | + | // remains possible for diagnostics, but activation fails closed. |
| 182 | - | // fail closed on the invalid target. | |||
| 183 | remoteTarget = &target | 182 | 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 && !broken | 194 | ready := status == remoteHostFresh && targetValid && !broken | ||
| 196 | if legacy { | 195 | if legacy { | ||
| 197 | - | // Count-only peers cannot prove lifecycle/tab identity. Preserve the | 196 | + | // Proxied picker activation requires exact lifecycle and stable-tab |
| 198 | - | // historical direct-connect row for compatibility, while all current | 197 | + | // 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 && !broken | 199 | + | 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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | + | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "testing" | |
| 11 | 12 | "time" | |
| @@ -14,6 +15,7 @@ import ( | |||
| 14 | 15 | ||
| 15 | 16 | "github.com/bnema/vev/internal/domain" | |
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| 18 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 17 | 19 | "github.com/bnema/vev/internal/usecase/picker" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| @@ -560,77 +562,360 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) { | |||
| 560 | 562 | d.remoteCatalog.mu.Unlock() | |
| 561 | 563 | } | |
| 562 | 564 | ||
| 563 | - | func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |
| 565 | + | func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *testing.T) { | |
| 564 | 566 | d := newRemotePickerDaemon(nil) | |
| 567 | + | target := remoteLinkTestTarget() | |
| 568 | + | require.NoError(t, target.Validate()) | |
| 565 | 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 | 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 | 594 | token := sess.attachmentToken(ac, ac.transport()) | |
| 570 | 595 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 571 | 596 | require.True(t, admitted) | |
| 572 | 597 | 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") | |
| 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 | 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 | 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 | 656 | d := newRemotePickerDaemon(nil) | |
| 657 | + | target := remoteLinkTestTarget() | |
| 590 | 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" | |
| 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{}) | |
| 602 | 681 | ac.overlays.pickerMu.Lock() | |
| 603 | - | ac.overlays.picker = picker.New(nil, picker.SelectionConfig{}) | |
| 682 | + | ac.overlays.picker = model | |
| 604 | 683 | ac.overlays.pickerGeneration++ | |
| 605 | 684 | ac.overlays.pickerMu.Unlock() | |
| 606 | 685 | ||
| 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) | |
| 611 | 704 | 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() | |
| 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 { | |
| 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) | |
| 629 | 863 | } | |
| 630 | 864 | ||
| 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 | + | } | |
| 634 | 919 | ||
| 635 | 920 | func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) { | |
| 636 | 921 | t.Helper() | |
| @@ -979,15 +1264,29 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | |||
| 979 | 1264 | ac.overlays.pickerMu.Unlock() | |
| 980 | 1265 | } | |
| 981 | 1266 | ||
| 982 | - | func TestRemoteRowsBecomeSelectableOnlyAfterOwnershipCompletion(t *testing.T) { | |
| 1267 | + | func TestRemoteRowsRequireFreshRichIdentityForPickerActivation(t *testing.T) { | |
| 983 | 1268 | 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)) | |
| 986 | 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{}) | |
| 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() | |
| 992 | 1291 | require.False(t, selectable) | |
| 993 | 1292 | } | |
| @@ -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 = effect | 597 | 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] = sess | 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{}) | |||
| 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 = effect | 705 | + | 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 error | 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) | |||
| 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 ( | |||
| 11 | 11 | const remoteContentPaneID = "remote-content" | |
| 12 | 12 | ||
| 13 | 13 | 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 | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | // 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 | 56 | if v.screen == nil { | |
| 56 | 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 | 63 | screen := v.screen.Snapshot() | |
| 61 | 64 | frame := renderer.NewFrame(screen.Columns(), screen.Rows()) | |
| 62 | 65 | for y := range frame.Height { | |
| 63 | 66 | copy(frame.Row(y), screen.BorrowedRow(y)) | |
| 64 | 67 | } | |
| 65 | 68 | 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, | |
| 72 | 76 | }, 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 | 100 | func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statusSnapshot { | |
| 76 | 101 | name := "" | |
| 77 | 102 | if view != nil { | |
| @@ -83,6 +108,12 @@ func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statu | |||
| 83 | 108 | if snap.origin != "" { | |
| 84 | 109 | name += " at " + snap.origin | |
| 85 | 110 | } | |
| 111 | + | switch snap.linkState { | |
| 112 | + | case remoteViewLinkReconnecting: | |
| 113 | + | name += " · reconnecting" | |
| 114 | + | case remoteViewLinkUnavailable: | |
| 115 | + | name += " · unavailable" | |
| 116 | + | } | |
| 86 | 117 | status := statusSnapshot{session: name, tabs: make([]statusTab, 0, max(1, len(snap.tabs)))} | |
| 87 | 118 | for _, tab := range snap.tabs { | |
| 88 | 119 | 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.Frame | 14 | + | frame renderer.Frame |
| 15 | - | cursor vt.CursorSnapshot | 15 | + | cursor vt.CursorSnapshot |
| 16 | - | title string | 16 | + | title string |
| 17 | - | tabs []ports.SessionTabMeta | 17 | + | tabs []ports.SessionTabMeta |
| 18 | - | activeID domain.TabStableID | 18 | + | activeID domain.TabStableID |
| 19 | - | origin string | 19 | + | origin string |
| 20 | + | linkState remoteViewLinkState | |||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 22 | // resizeScreen updates only the private remote VT surface. The attachment | 23 | // 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 | }, true | 76 | }, 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.origin | 109 | 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) { | |||
| 34 | 34 | view.link = nil | |
| 35 | 35 | view.linkGeneration++ | |
| 36 | 36 | link.active = false | |
| 37 | + | signalRemoteViewMetadataChangedLocked(view) | |
| 37 | 38 | _ = d.unregisterRemoteViewLocked(view) | |
| 38 | 39 | view.mu.Unlock() | |
| 39 | 40 | d.mu.Unlock() | |
| @@ -43,6 +44,7 @@ func (d *Daemon) retireTerminalRemoteView(link *remoteLink, reason uint8) { | |||
| 43 | 44 | for _, attachment := range attachments { | |
| 44 | 45 | d.retireShutdownRemoteAttachment(view, attachment, reason) | |
| 45 | 46 | } | |
| 47 | + | link.commands.FailGeneration(link.generation, errRemoteViewUnavailable) | |
| 46 | 48 | link.cancel() | |
| 47 | 49 | if link.transport != nil { | |
| 48 | 50 | _ = link.transport.Close() | |
| @@ -34,6 +34,7 @@ func (d *Daemon) retireTerminalRemoteView(link *remoteLink, reason uint8) { | |||||
| 34 | view.link = nil | 34 | view.link = nil | ||
| 35 | view.linkGeneration++ | 35 | view.linkGeneration++ | ||
| 36 | link.active = false | 36 | 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) { | |||
| 129 | 129 | link := view.link | |
| 130 | 130 | view.link = nil | |
| 131 | 131 | view.linkGeneration++ | |
| 132 | + | signalRemoteViewMetadataChangedLocked(view) | |
| 132 | 133 | if link != nil { | |
| 133 | 134 | link.active = false | |
| 134 | 135 | } | |
| @@ -141,6 +142,7 @@ func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) { | |||
| 141 | 142 | if link == nil { | |
| 142 | 143 | return | |
| 143 | 144 | } | |
| 145 | + | link.commands.FailGeneration(link.generation, errRemoteViewUnavailable) | |
| 144 | 146 | link.cancel() | |
| 145 | 147 | if link.transport != nil { | |
| 146 | 148 | _ = link.transport.Close() | |
| @@ -129,6 +129,7 @@ func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) { | |||||
| 129 | link := view.link | 129 | link := view.link | ||
| 130 | view.link = nil | 130 | view.link = nil | ||
| 131 | view.linkGeneration++ | 131 | view.linkGeneration++ | ||
| 132 | + | signalRemoteViewMetadataChangedLocked(view) | |||
| 132 | if link != nil { | 133 | if link != nil { | ||
| 133 | link.active = false | 134 | 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 | return | 143 | 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 | |||
| 28 | 28 | output-state chain. The probe writes accepted full, incremental, and side-effect | |
| 29 | 29 | frames to that screen, ACKs only accepted state-bearing frames, and retains a | |
| 30 | 30 | 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. | |
| 33 | 35 | ||
| 34 | 36 | The run exercises real SSH-stdio and UDP attachment, typed live catalog and | |
| 35 | 37 | 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-effect | 28 | 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 a | 29 | 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 local | 30 | bounded set of screen checkpoints plus non-sensitive event metadata. The local | ||
| 31 | - | picker phase uses separate local-picker and selected-remote probes to | 31 | + | 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 and | 36 | The run exercises real SSH-stdio and UDP attachment, typed live catalog and | ||
| 35 | preview requests, tab creation/removal fencing, exact lifecycle fencing after | 37 | 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 | |||
| 148 | 148 | ||
| 149 | 149 | // runLocalPickerUnitedPhase drives the actual local picker against the SSH | |
| 150 | 150 | // 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. | |
| 153 | 154 | func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) { | |
| 154 | 155 | const ( | |
| 155 | 156 | localSession = "local-picker" | |
| 156 | 157 | remoteSession = "picker" | |
| 157 | 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 | 162 | remote, err := openTransport(ctx, ports.RemoteTransportStdio, target) | |
| 165 | 163 | if err != nil { | |
| 166 | 164 | return fmt.Errorf("open remote picker source: %w", err) | |
| @@ -243,7 +241,10 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports | |||
| 243 | 241 | if err != nil { | |
| 244 | 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 | 248 | defer func() { _ = local.Close() }() | |
| 248 | 249 | if err := attachAndCheck(ctx, local, ports.Hello{ | |
| 249 | 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 | 264 | if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil { | |
| 264 | 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 | 271 | // The local selected tab is first and the remote session header is not | |
| 267 | 272 | // selectable, so one down reaches the first remote tab. Derive the rest | |
| 268 | 273 | // 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 | 281 | if err := sendRawInput(local, "\r"); err != nil { | |
| 277 | 282 | return fmt.Errorf("commit remote picker target: %w", err) | |
| 278 | 283 | } | |
| 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) | |
| 282 | 286 | } | |
| 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") | |
| 286 | 289 | } | |
| 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 | |
| 289 | 292 | } | |
| 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) | |
| 292 | 295 | } | |
| 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) | |
| 295 | 298 | } | |
| 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 | |
| 298 | 301 | } | |
| 299 | 302 | ||
| 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) | |
| 303 | 308 | } | |
| 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) | |
| 312 | 321 | } | |
| 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 | |
| 315 | 324 | } | |
| 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) | |
| 318 | 327 | } | |
| 319 | 328 | return nil | |
| 320 | 329 | } | |
| @@ -751,28 +760,6 @@ func remotePreviewContains(preview ports.RemotePreview, want string) bool { | |||
| 751 | 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 | 763 | func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error { | |
| 777 | 764 | if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil { | |
| 778 | 765 | return err | |
| @@ -999,11 +986,24 @@ func runRemoteCommand(ctx context.Context, target string, command ...string) err | |||
| 999 | 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 | 999 | func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (ports.Frame, error) { | |
| 1003 | 1000 | select { | |
| 1004 | 1001 | case <-ctx.Done(): | |
| 1005 | 1002 | return ports.Frame{}, ctx.Err() | |
| 1006 | 1003 | case outcome := <-tr.frames: | |
| 1004 | + | if outcome.err == nil { | |
| 1005 | + | tr.probe.recordIncoming(outcome.frame) | |
| 1006 | + | } | |
| 1007 | 1007 | return outcome.frame, outcome.err | |
| 1008 | 1008 | } | |
| 1009 | 1009 | } | |
| @@ -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 SSH | 149 | // 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 not | 271 | // 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 rest | 272 | // selectable, so one down reaches the first remote tab. Derive the rest | ||
| 268 | // from the catalog's stable active tab identity instead of assuming an | 273 | // 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 nil | 328 | 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 err | 765 | 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.err | 1007 | return outcome.frame, outcome.err | ||
| 1008 | } | 1008 | } | ||
| 1009 | } | 1009 | } | ||
M scripts/remote-picker-harness/run.sh +3 -2
| @@ -6,8 +6,9 @@ usage() { | |||
| 6 | 6 | Usage: scripts/remote-picker-harness/run.sh | |
| 7 | 7 | ||
| 8 | 8 | 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, | |
| 11 | 12 | so DOCKER_HOST and the active | |
| 12 | 13 | Docker context select the daemon; no socket path is assumed. | |
| 13 | 14 | ||
| @@ -6,8 +6,9 @@ usage() { | |||||
| 6 | Usage: scripts/remote-picker-harness/run.sh | 6 | Usage: scripts/remote-picker-harness/run.sh | ||
| 7 | 7 | ||||
| 8 | Builds the current vev binary into a disposable two-container environment and | 8 | 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 active | 12 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| @@ -22,10 +23,10 @@ const ( | |||
| 22 | 23 | maxProbeTraces = 128 | |
| 23 | 24 | maxArtifactBytes = 64 << 10 | |
| 24 | 25 | ||
| 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" | |
| 29 | 30 | ) | |
| 30 | 31 | ||
| 31 | 32 | // visualOutputState mirrors the client's output dependency chain. A probe may | |
| @@ -134,6 +135,8 @@ type visualProbe struct { | |||
| 134 | 135 | outputFrames int | |
| 135 | 136 | outputBytes int | |
| 136 | 137 | resetPending bool | |
| 138 | + | ||
| 139 | + | unexpectedHandoffs int | |
| 137 | 140 | } | |
| 138 | 141 | ||
| 139 | 142 | type harnessArtifactContextKey struct{} | |
| @@ -247,62 +250,95 @@ func (p *visualProbe) contains(want string) bool { | |||
| 247 | 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 | 261 | func (p *visualProbe) markAcked(result visualOutputResult) { | |
| 251 | 262 | if result.event != nil && result.StateBearing && result.Accepted { | |
| 252 | 263 | 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 | 268 | if screen == nil { | |
| 258 | - | return "" | |
| 269 | + | return nil | |
| 259 | 270 | } | |
| 260 | - | var text strings.Builder | |
| 271 | + | lines := make([]string, screen.Frame.Height) | |
| 261 | 272 | for y := range screen.Frame.Height { | |
| 273 | + | var line strings.Builder | |
| 262 | 274 | for x := range screen.Frame.Width { | |
| 263 | 275 | r := screen.Frame.At(x, y).Rune | |
| 264 | 276 | if r == 0 { | |
| 265 | 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 { | |
| 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 "" | |
| 277 | 290 | } | |
| 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 | |
| 292 | 318 | } | |
| 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) | |
| 295 | 325 | } | |
| 296 | 326 | return nil | |
| 297 | 327 | } | |
| 298 | 328 | ||
| 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)) | |
| 303 | 339 | } | |
| 304 | 340 | } | |
| 305 | - | return -1 | |
| 341 | + | return nil | |
| 306 | 342 | } | |
| 307 | 343 | ||
| 308 | 344 | 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 = 128 | 23 | maxProbeTraces = 128 | ||
| 23 | maxArtifactBytes = 64 << 10 | 24 | 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 may | 32 | // visualOutputState mirrors the client's output dependency chain. A probe may | ||
| @@ -134,6 +135,8 @@ type visualProbe struct { | |||||
| 134 | outputFrames int | 135 | outputFrames int | ||
| 135 | outputBytes int | 136 | outputBytes int | ||
| 136 | resetPending bool | 137 | 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 = true | 263 | 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.Builder | 271 | + | 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).Rune | 275 | 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.Acked | 288 | + | 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 == probeEventAttachTarget | 295 | + | if probe == nil { |
| 283 | - | }) | 296 | + | return nil |
| 284 | - | selectedWelcome := eventIndex(events, func(event *probeEvent) bool { | 297 | + | } |
| 285 | - | return event.Transport == "selected-remote" && event.Kind == probeEventWelcome | 298 | + | 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 nil | 326 | 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 i | 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)) | |||
| 303 | } | 339 | } | ||
| 304 | } | 340 | } | ||
| 305 | - | return -1 | 341 | + | 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) { | |||
| 221 | 221 | } | |
| 222 | 222 | } | |
| 223 | 223 | ||
| 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) | |
| 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 | 248 | name string | |
| 235 | - | events []*probeEvent | |
| 249 | + | content string | |
| 236 | 250 | wantErr bool | |
| 237 | 251 | }{ | |
| 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}, | |
| 278 | 254 | } { | |
| 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) | |
| 283 | 261 | } | |
| 284 | 262 | }) | |
| 285 | 263 | } | |
| @@ -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 = true | 228 | + | rows := capturePickerRows(probe) |
| 229 | - | result.Acked = true | 229 | + | 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 string | 248 | name string | ||
| 235 | - | events []*probeEvent | 249 | + | content string |
| 236 | wantErr bool | 250 | 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 | } | ||