detached 919bcd1

Merge pull request #163 from bnema/test/remote-attach-e2e

fix(remote): preserve proxy picker handoffs
commit
919bcd18c428ef90abfda6c2381ba1cfbb06b0dd
author
bnema · · 2026-08-01 04:11 UTC
committer
GitHub · · 2026-08-01 04:11 UTC
merge of
c2951b3 + 55169b2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +522 -39
M internal/domain/remote_session.go +9 -2
@@ -1,6 +1,9 @@
11 package domain
22
3-import "encoding/base64"
3+import (
4+ "encoding/base64"
5+ "strings"
6+)
47
58 // RemoteSessionKey identifies a discovered remote session independently from
69 // its display label.
@@ -27,5 +30,9 @@ func (k RemoteSessionKey) ID() SessionID {
2730
2831 // Display returns the presentation label for this remote session.
2932 func (k RemoteSessionKey) Display() string {
30- return k.Name + "@" + k.Host
33+ host := k.Host
34+ if i := strings.LastIndexByte(host, '@'); i > 0 && i < len(host)-1 {
35+ host = host[i+1:]
36+ }
37+ return k.Name + "@" + host
3138 }
@@ -1,6 +1,9 @@
1 package domain1 package domain
2 2
3-import "encoding/base64"3+import (
4+ "encoding/base64"
5+ "strings"
6+)
4 7
5 // RemoteSessionKey identifies a discovered remote session independently from8 // RemoteSessionKey identifies a discovered remote session independently from
6 // its display label.9 // its display label.
@@ -27,5 +30,9 @@ func (k RemoteSessionKey) ID() SessionID {
27 30
28 // Display returns the presentation label for this remote session.31 // Display returns the presentation label for this remote session.
29 func (k RemoteSessionKey) Display() string {32 func (k RemoteSessionKey) Display() string {
30- return k.Name + "@" + k.Host33+ host := k.Host
34+ if i := strings.LastIndexByte(host, '@'); i > 0 && i < len(host)-1 {
35+ host = host[i+1:]
36+ }
37+ return k.Name + "@" + host
31 }38 }
M internal/domain/remote_session_test.go +20 -6
@@ -89,12 +89,26 @@ func TestRemoteSessionKeyValidate(t *testing.T) {
8989 }
9090
9191 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {
92- key := RemoteSessionKey{Host: "arch", Name: "hello"}
93-
94- if got, want := key.Display(), "hello@arch"; got != want {
95- t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, want)
92+ tests := []struct {
93+ host string
94+ want string
95+ }{
96+ {host: "arch", want: "hello@arch"},
97+ {host: "test@arch", want: "hello@arch"},
98+ {host: "test@arch:2222", want: "hello@arch:2222"},
99+ {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"},
100+ {host: "test@", want: "hello@test@"},
101+ {host: "@arch", want: "hello@@arch"},
96102 }
97- if key.Host != "arch" || key.Name != "hello" {
98- t.Fatalf("Display mutated routing key: %#v", key)
103+ for _, test := range tests {
104+ t.Run(test.host, func(t *testing.T) {
105+ key := RemoteSessionKey{Host: test.host, Name: "hello"}
106+ if got := key.Display(); got != test.want {
107+ t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want)
108+ }
109+ if key.Host != test.host || key.Name != "hello" {
110+ t.Fatalf("Display mutated routing key: %#v", key)
111+ }
112+ })
99113 }
100114 }
@@ -89,12 +89,26 @@ func TestRemoteSessionKeyValidate(t *testing.T) {
89 }89 }
90 90
91 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {91 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {
92- key := RemoteSessionKey{Host: "arch", Name: "hello"}92+ tests := []struct {
93-93+ host string
94- if got, want := key.Display(), "hello@arch"; got != want {94+ want string
95- t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, want)95+ }{
96+ {host: "arch", want: "hello@arch"},
97+ {host: "test@arch", want: "hello@arch"},
98+ {host: "test@arch:2222", want: "hello@arch:2222"},
99+ {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"},
100+ {host: "test@", want: "hello@test@"},
101+ {host: "@arch", want: "hello@@arch"},
96 }102 }
97- if key.Host != "arch" || key.Name != "hello" {103+ for _, test := range tests {
98- t.Fatalf("Display mutated routing key: %#v", key)104+ t.Run(test.host, func(t *testing.T) {
105+ key := RemoteSessionKey{Host: test.host, Name: "hello"}
106+ if got := key.Display(); got != test.want {
107+ t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want)
108+ }
109+ if key.Host != test.host || key.Name != "hello" {
110+ t.Fatalf("Display mutated routing key: %#v", key)
111+ }
112+ })
99 }113 }
100 }114 }
M internal/usecase/daemon/attachment_transition.go +17 -1
@@ -1,9 +1,24 @@
11 package daemon
22
3-import "errors"
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
48
59 var errAttachmentTransition = errors.New("attachment transition is no longer valid")
610
11+type attachmentLifecycleFence struct {
12+ name string
13+ createdAt int64
14+ checkCreatedAt bool
15+ incarnation domain.IncarnationID
16+ checkIncarnation bool
17+ tabID domain.TabStableID
18+ tabIndex int
19+ checkTab bool
20+}
21+
722 type attachmentTransitionRequest struct {
823 source attachmentSession
924 target attachmentSession
@@ -29,6 +44,7 @@ type attachmentTransitionRequest struct {
2944 ready bool
3045 expectedTargetCurrent *attachedClient
3146 expectedTargetTransport transportSnapshot
47+ expectedTargetLifecycle *attachmentLifecycleFence
3248 preflighted bool
3349 roleEffectsFrozen bool
3450 expectedTargetTransportInterrupted bool
@@ -1,9 +1,24 @@
1 package daemon1 package daemon
2 2
3-import "errors"3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
4 8
5 var errAttachmentTransition = errors.New("attachment transition is no longer valid")9 var errAttachmentTransition = errors.New("attachment transition is no longer valid")
6 10
11+type attachmentLifecycleFence struct {
12+ name string
13+ createdAt int64
14+ checkCreatedAt bool
15+ incarnation domain.IncarnationID
16+ checkIncarnation bool
17+ tabID domain.TabStableID
18+ tabIndex int
19+ checkTab bool
20+}
21+
7 type attachmentTransitionRequest struct {22 type attachmentTransitionRequest struct {
8 source attachmentSession23 source attachmentSession
9 target attachmentSession24 target attachmentSession
@@ -29,6 +44,7 @@ type attachmentTransitionRequest struct {
29 ready bool44 ready bool
30 expectedTargetCurrent *attachedClient45 expectedTargetCurrent *attachedClient
31 expectedTargetTransport transportSnapshot46 expectedTargetTransport transportSnapshot
47+ expectedTargetLifecycle *attachmentLifecycleFence
32 preflighted bool48 preflighted bool
33 roleEffectsFrozen bool49 roleEffectsFrozen bool
34 expectedTargetTransportInterrupted bool50 expectedTargetTransportInterrupted bool
M internal/usecase/daemon/attachment_transition_publish.go +21 -0
@@ -1,5 +1,7 @@
11 package daemon
22
3+import "github.com/bnema/vev/internal/domain"
4+
35 // transitionSourcePreflightLocked validates an initiating role while its gate
46 // is frozen and d.mu is held. No caller may create or delete a target before
57 // this succeeds.
@@ -94,6 +96,24 @@ func (p *attachmentPublication) unlockCoordinators() {
9496 p.releaseCoordinators()
9597 }
9698
99+func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachmentLifecycleFence) bool {
100+ if fence == nil {
101+ return true
102+ }
103+ target, ok := localSession(entry)
104+ if !ok {
105+ return false
106+ }
107+ if fence.checkCreatedAt && (target.name != fence.name || target.createdAt != fence.createdAt) {
108+ return false
109+ }
110+ if fence.checkIncarnation && target.incarnation != fence.incarnation {
111+ return false
112+ }
113+ return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(target.tabs) &&
114+ domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID
115+}
116+
97117 // validateAttachmentTransitionPrelocked performs every fallible transition
98118 // check without acquiring d.mu, notices.routingMu, or either session lock.
99119 // The caller holds those locks in that order, with session locks ordered by ID.
@@ -143,6 +163,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
143163 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
144164 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
145165 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
166+ invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle)
146167 invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex)
147168 if invalid {
148169 publication.unlockCoordinators()
@@ -1,5 +1,7 @@
1 package daemon1 package daemon
2 2
3+import "github.com/bnema/vev/internal/domain"
4+
3 // transitionSourcePreflightLocked validates an initiating role while its gate5 // transitionSourcePreflightLocked validates an initiating role while its gate
4 // is frozen and d.mu is held. No caller may create or delete a target before6 // is frozen and d.mu is held. No caller may create or delete a target before
5 // this succeeds.7 // this succeeds.
@@ -94,6 +96,24 @@ func (p *attachmentPublication) unlockCoordinators() {
94 p.releaseCoordinators()96 p.releaseCoordinators()
95 }97 }
96 98
99+func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachmentLifecycleFence) bool {
100+ if fence == nil {
101+ return true
102+ }
103+ target, ok := localSession(entry)
104+ if !ok {
105+ return false
106+ }
107+ if fence.checkCreatedAt && (target.name != fence.name || target.createdAt != fence.createdAt) {
108+ return false
109+ }
110+ if fence.checkIncarnation && target.incarnation != fence.incarnation {
111+ return false
112+ }
113+ return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(target.tabs) &&
114+ domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID
115+}
116+
97 // validateAttachmentTransitionPrelocked performs every fallible transition117 // validateAttachmentTransitionPrelocked performs every fallible transition
98 // check without acquiring d.mu, notices.routingMu, or either session lock.118 // check without acquiring d.mu, notices.routingMu, or either session lock.
99 // The caller holds those locks in that order, with session locks ordered by ID.119 // The caller holds those locks in that order, with session locks ordered by ID.
@@ -143,6 +163,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
143 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)163 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
144 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&164 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
145 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)165 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
166+ invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle)
146 invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex)167 invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex)
147 if invalid {168 if invalid {
148 publication.unlockCoordinators()169 publication.unlockCoordinators()
M internal/usecase/daemon/client_frame_routing.go +49 -18
@@ -2,11 +2,39 @@ package daemon
22
33 import (
44 "errors"
5+ "runtime"
56
67 "github.com/bnema/vev/internal/ports"
78 "github.com/bnema/vev/internal/usecase/command"
89 )
910
11+const connectionRoleAttempts = 8
12+
13+// currentConnectionRole retries the lock-free session-to-role snapshot when a
14+// handoff lands between those reads. The role gate still performs the final
15+// mutation admission after this function returns.
16+func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentRoleToken, bool) {
17+ for range connectionRoleAttempts {
18+ if !ac.currentTransportIs(tr) {
19+ break
20+ }
21+ sess := ac.currentAttachmentSession()
22+ if sess == nil {
23+ return nil, attachmentRoleToken{}, false
24+ }
25+ if d.afterConnectionSessionSnapshot != nil {
26+ d.afterConnectionSessionSnapshot(sess)
27+ }
28+ token := attachmentToken(sess, ac, tr)
29+ if token.role == attachmentDetached || ac.currentAttachmentSession() != sess {
30+ runtime.Gosched()
31+ continue
32+ }
33+ return sess, token, true
34+ }
35+ return nil, attachmentRoleToken{}, false
36+}
37+
1038 // runConnLoop is the per-connection input router: it pumps client messages
1139 // until detach, EOF, or a transport error. Role is resolved after every Recv so
1240 // a transport displaced while blocked in Recv immediately adopts its restricted
@@ -17,31 +45,36 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
1745 return
1846 }
1947 for {
20- if !ac.currentTransportIs(tr) {
21- return
22- }
23- sess := ac.currentSession()
24- if sess == nil {
48+ if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil {
2549 return
2650 }
2751 f, err := tr.Recv()
2852 if err != nil {
29- token := sess.attachmentToken(ac, tr)
30- if token.role == attachmentSnatched {
31- d.parkOrDropSnatchedAttachment(token)
32- } else {
33- d.clientGone(sess, ac, tr, false)
53+ for range connectionRoleAttempts {
54+ sess, token, ok := d.currentConnectionRole(ac, tr)
55+ if !ok {
56+ return
57+ }
58+ if token.role == attachmentSnatched {
59+ d.parkOrDropSnatchedAttachment(token)
60+ } else if proxy, ok := sess.(*proxySession); ok {
61+ d.detachProxyOnSendError(proxy, ac, tr)
62+ } else if local, ok := localSession(sess); ok {
63+ d.clientGone(local, ac, tr, false)
64+ } else {
65+ return
66+ }
67+ current := ac.currentAttachmentSession()
68+ if current == sess || !ac.currentTransportIs(tr) || current == nil {
69+ return
70+ }
3471 }
3572 return
3673 }
37- if !ac.currentTransportIs(tr) {
74+ _, token, ok := d.currentConnectionRole(ac, tr)
75+ if !ok {
3876 return
3977 }
40- sess = ac.currentSession()
41- if sess == nil {
42- return
43- }
44- token := sess.attachmentToken(ac, tr)
4578 switch token.role {
4679 case attachmentActive:
4780 if d.handleActiveClientFrame(token, f) {
@@ -51,8 +84,6 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
5184 if d.handleSnatchedClientFrame(token, f) {
5285 return
5386 }
54- default:
55- return
5687 }
5788 }
5889 }
@@ -2,11 +2,39 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "runtime"
5 6
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
8 )9 )
9 10
11+const connectionRoleAttempts = 8
12+
13+// currentConnectionRole retries the lock-free session-to-role snapshot when a
14+// handoff lands between those reads. The role gate still performs the final
15+// mutation admission after this function returns.
16+func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentRoleToken, bool) {
17+ for range connectionRoleAttempts {
18+ if !ac.currentTransportIs(tr) {
19+ break
20+ }
21+ sess := ac.currentAttachmentSession()
22+ if sess == nil {
23+ return nil, attachmentRoleToken{}, false
24+ }
25+ if d.afterConnectionSessionSnapshot != nil {
26+ d.afterConnectionSessionSnapshot(sess)
27+ }
28+ token := attachmentToken(sess, ac, tr)
29+ if token.role == attachmentDetached || ac.currentAttachmentSession() != sess {
30+ runtime.Gosched()
31+ continue
32+ }
33+ return sess, token, true
34+ }
35+ return nil, attachmentRoleToken{}, false
36+}
37+
10 // runConnLoop is the per-connection input router: it pumps client messages38 // runConnLoop is the per-connection input router: it pumps client messages
11 // until detach, EOF, or a transport error. Role is resolved after every Recv so39 // until detach, EOF, or a transport error. Role is resolved after every Recv so
12 // a transport displaced while blocked in Recv immediately adopts its restricted40 // a transport displaced while blocked in Recv immediately adopts its restricted
@@ -17,31 +45,36 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
17 return45 return
18 }46 }
19 for {47 for {
20- if !ac.currentTransportIs(tr) {48+ if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil {
21- return
22- }
23- sess := ac.currentSession()
24- if sess == nil {
25 return49 return
26 }50 }
27 f, err := tr.Recv()51 f, err := tr.Recv()
28 if err != nil {52 if err != nil {
29- token := sess.attachmentToken(ac, tr)53+ for range connectionRoleAttempts {
30- if token.role == attachmentSnatched {54+ sess, token, ok := d.currentConnectionRole(ac, tr)
31- d.parkOrDropSnatchedAttachment(token)55+ if !ok {
32- } else {56+ return
33- d.clientGone(sess, ac, tr, false)57+ }
58+ if token.role == attachmentSnatched {
59+ d.parkOrDropSnatchedAttachment(token)
60+ } else if proxy, ok := sess.(*proxySession); ok {
61+ d.detachProxyOnSendError(proxy, ac, tr)
62+ } else if local, ok := localSession(sess); ok {
63+ d.clientGone(local, ac, tr, false)
64+ } else {
65+ return
66+ }
67+ current := ac.currentAttachmentSession()
68+ if current == sess || !ac.currentTransportIs(tr) || current == nil {
69+ return
70+ }
34 }71 }
35 return72 return
36 }73 }
37- if !ac.currentTransportIs(tr) {74+ _, token, ok := d.currentConnectionRole(ac, tr)
75+ if !ok {
38 return76 return
39 }77 }
40- sess = ac.currentSession()
41- if sess == nil {
42- return
43- }
44- token := sess.attachmentToken(ac, tr)
45 switch token.role {78 switch token.role {
46 case attachmentActive:79 case attachmentActive:
47 if d.handleActiveClientFrame(token, f) {80 if d.handleActiveClientFrame(token, f) {
@@ -51,8 +84,6 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
51 if d.handleSnatchedClientFrame(token, f) {84 if d.handleSnatchedClientFrame(token, f) {
52 return85 return
53 }86 }
54- default:
55- return
56 }87 }
57 }88 }
58 }89 }
M internal/usecase/daemon/daemon.go +3 -0
@@ -153,6 +153,9 @@ type Daemon struct {
153153 // afterSnatchedEscapeAttempt reports whether that callback acquired its exact
154154 // role capability after revalidation.
155155 afterSnatchedEscapeAttempt func(bool)
156+ // afterConnectionSessionSnapshot is a deterministic test seam after the
157+ // connection loop reads its current session and before it captures that role.
158+ afterConnectionSessionSnapshot func(attachmentSession)
156159 // afterActiveFrameDispatch is a deterministic test seam after the connection
157160 // loop's first role check and before a decoded active frame takes effect.
158161 afterActiveFrameDispatch func(attachmentRoleToken)
@@ -153,6 +153,9 @@ type Daemon struct {
153 // afterSnatchedEscapeAttempt reports whether that callback acquired its exact153 // afterSnatchedEscapeAttempt reports whether that callback acquired its exact
154 // role capability after revalidation.154 // role capability after revalidation.
155 afterSnatchedEscapeAttempt func(bool)155 afterSnatchedEscapeAttempt func(bool)
156+ // afterConnectionSessionSnapshot is a deterministic test seam after the
157+ // connection loop reads its current session and before it captures that role.
158+ afterConnectionSessionSnapshot func(attachmentSession)
156 // afterActiveFrameDispatch is a deterministic test seam after the connection159 // afterActiveFrameDispatch is a deterministic test seam after the connection
157 // loop's first role check and before a decoded active frame takes effect.160 // loop's first role check and before a decoded active frame takes effect.
158 afterActiveFrameDispatch func(attachmentRoleToken)161 afterActiveFrameDispatch func(attachmentRoleToken)
M internal/usecase/daemon/picker.go +49 -9
@@ -578,21 +578,55 @@ type sessionHandoffGuard struct {
578578 // and revalidates the exact initiating token before changing target focus or
579579 // attachment ownership.
580580 func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {
581+ return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
582+}
583+
584+func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
585+ fence := &attachmentLifecycleFence{}
586+ if target.ExpectedCreatedAt != nil {
587+ fence.name = target.Name
588+ fence.createdAt = *target.ExpectedCreatedAt
589+ fence.checkCreatedAt = true
590+ }
591+ if target.Incarnation != (domain.IncarnationID{}) {
592+ fence.incarnation = target.Incarnation
593+ fence.checkIncarnation = true
594+ }
595+ if target.TabID != "" {
596+ fence.tabID = target.TabID
597+ fence.tabIndex = target.TabIndex
598+ fence.checkTab = true
599+ }
600+ if !fence.checkCreatedAt && !fence.checkIncarnation && !fence.checkTab {
601+ return nil
602+ }
603+ return fence
604+}
605+
606+func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
581607 if token.sess == nil || token.ac == nil || token.role != attachmentActive {
582608 return nil
583609 }
584610 d.mu.Lock()
585- targetSess := d.sessions[target.Session]
611+ targetSess, ok := localSession(d.sessions[target.Session])
586612 d.mu.Unlock()
587- if targetSess == nil || targetSess == token.sess {
613+ if !ok {
614+ if !token.activeCurrent() {
615+ return nil
616+ }
617+ d.invalidateRender(token.sess, token.ac, true, "picker.go")
618+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
619+ }
620+ if targetSess == token.sess {
588621 return nil
589622 }
590623
591624 transition, err := d.transitionAttachment(attachmentTransitionRequest{
592625 source: token.sess, target: targetSess, next: token.ac,
593626 expectedRole: attachmentActive, targetRole: attachmentActive,
594- expectedTransport: token.transport, sourceToken: &token, action: "jump-attention",
595- activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
627+ expectedTransport: token.transport, sourceToken: &token, action: action,
628+ expectedTargetLifecycle: pickerTargetLifecycleFence(target),
629+ activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
596630 })
597631 if err != nil {
598632 // Losing the exact source role is a benign stale action, not a notice for
@@ -602,12 +636,15 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic
602636 }
603637 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
604638 }
605- if target, ok := localSession(targetSess); ok {
606- d.touchMRU(target)
607- }
608- if source, ok := localSession(token.sess); ok {
609- token.ac.recordPreviousSession(source)
639+ if guard.closePicker {
640+ fresh, admitted := token.ac.beginRoleEffect(transition.published)
641+ if admitted {
642+ d.closePicker(token.ac)
643+ fresh.End()
644+ }
610645 }
646+ d.touchMRU(targetSess)
647+ token.ac.recordPreviousSession(token.sess)
611648 d.deferAttachmentTransitionCleanups(transition)
612649 d.firstPaintForTransition(transition.published)
613650 return nil
@@ -633,6 +670,9 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.
633670 if target.RemoteKey != nil {
634671 return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)
635672 }
673+ if token.sess.isProxy() && !target.Stopped {
674+ return d.switchActiveTargetForRoleGuarded(token, target, guard, action)
675+ }
636676 sess, ok := localSession(token.sess)
637677 if !ok {
638678 return errAttachmentTransition
@@ -578,21 +578,55 @@ type sessionHandoffGuard struct {
578 // and revalidates the exact initiating token before changing target focus or578 // and revalidates the exact initiating token before changing target focus or
579 // attachment ownership.579 // attachment ownership.
580 func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {580 func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {
581+ return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
582+}
583+
584+func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
585+ fence := &attachmentLifecycleFence{}
586+ if target.ExpectedCreatedAt != nil {
587+ fence.name = target.Name
588+ fence.createdAt = *target.ExpectedCreatedAt
589+ fence.checkCreatedAt = true
590+ }
591+ if target.Incarnation != (domain.IncarnationID{}) {
592+ fence.incarnation = target.Incarnation
593+ fence.checkIncarnation = true
594+ }
595+ if target.TabID != "" {
596+ fence.tabID = target.TabID
597+ fence.tabIndex = target.TabIndex
598+ fence.checkTab = true
599+ }
600+ if !fence.checkCreatedAt && !fence.checkIncarnation && !fence.checkTab {
601+ return nil
602+ }
603+ return fence
604+}
605+
606+func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
581 if token.sess == nil || token.ac == nil || token.role != attachmentActive {607 if token.sess == nil || token.ac == nil || token.role != attachmentActive {
582 return nil608 return nil
583 }609 }
584 d.mu.Lock()610 d.mu.Lock()
585- targetSess := d.sessions[target.Session]611+ targetSess, ok := localSession(d.sessions[target.Session])
586 d.mu.Unlock()612 d.mu.Unlock()
587- if targetSess == nil || targetSess == token.sess {613+ if !ok {
614+ if !token.activeCurrent() {
615+ return nil
616+ }
617+ d.invalidateRender(token.sess, token.ac, true, "picker.go")
618+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
619+ }
620+ if targetSess == token.sess {
588 return nil621 return nil
589 }622 }
590 623
591 transition, err := d.transitionAttachment(attachmentTransitionRequest{624 transition, err := d.transitionAttachment(attachmentTransitionRequest{
592 source: token.sess, target: targetSess, next: token.ac,625 source: token.sess, target: targetSess, next: token.ac,
593 expectedRole: attachmentActive, targetRole: attachmentActive,626 expectedRole: attachmentActive, targetRole: attachmentActive,
594- expectedTransport: token.transport, sourceToken: &token, action: "jump-attention",627+ expectedTransport: token.transport, sourceToken: &token, action: action,
595- activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,628+ expectedTargetLifecycle: pickerTargetLifecycleFence(target),
629+ activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
596 })630 })
597 if err != nil {631 if err != nil {
598 // Losing the exact source role is a benign stale action, not a notice for632 // Losing the exact source role is a benign stale action, not a notice for
@@ -602,12 +636,15 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic
602 }636 }
603 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)637 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
604 }638 }
605- if target, ok := localSession(targetSess); ok {639+ if guard.closePicker {
606- d.touchMRU(target)640+ fresh, admitted := token.ac.beginRoleEffect(transition.published)
607- }641+ if admitted {
608- if source, ok := localSession(token.sess); ok {642+ d.closePicker(token.ac)
609- token.ac.recordPreviousSession(source)643+ fresh.End()
644+ }
610 }645 }
646+ d.touchMRU(targetSess)
647+ token.ac.recordPreviousSession(token.sess)
611 d.deferAttachmentTransitionCleanups(transition)648 d.deferAttachmentTransitionCleanups(transition)
612 d.firstPaintForTransition(transition.published)649 d.firstPaintForTransition(transition.published)
613 return nil650 return nil
@@ -633,6 +670,9 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.
633 if target.RemoteKey != nil {670 if target.RemoteKey != nil {
634 return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)671 return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)
635 }672 }
673+ if token.sess.isProxy() && !target.Stopped {
674+ return d.switchActiveTargetForRoleGuarded(token, target, guard, action)
675+ }
636 sess, ok := localSession(token.sess)676 sess, ok := localSession(token.sess)
637 if !ok {677 if !ok {
638 return errAttachmentTransition678 return errAttachmentTransition
M internal/usecase/daemon/proxy_input_test.go +334 -0
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "errors"
6+ "io"
67 "log/slog"
78 "sync"
89 "sync/atomic"
@@ -681,3 +682,336 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
681682 require.Equal(t, key, proxy.key)
682683 t.Cleanup(func() { stopProxy(t, proxy) })
683684 }
685+
686+func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
687+ d, proxy, ac, _, handler := newProxyInputHarness(t)
688+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
689+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
690+ publishTiledPaneOwners(local, tb)
691+ d.mu.Lock()
692+ require.True(t, d.registerSessionLocked(local))
693+ d.mu.Unlock()
694+
695+ model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
696+ d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{})
697+ target, selectable := model.Selected()
698+ require.True(t, selectable)
699+ require.Equal(t, local.id, target.Session)
700+ require.Equal(t, local.name, target.Name)
701+ require.False(t, target.Stopped)
702+
703+ require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
704+ require.Same(t, local, ac.currentSession())
705+ require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")
706+
707+ d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t)
708+ localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
709+ require.NoError(t, d.backSessionForRole(localToken))
710+ require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")
711+
712+ proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
713+ require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
714+ require.Same(t, local, ac.currentSession())
715+ d.backSession(local, ac)
716+ require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")
717+}
718+
719+func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {
720+ d, proxy, ac, _, handler := newProxyInputHarness(t)
721+ err := d.switchToTargetForRole(handler.roleToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select")
722+ require.Error(t, err)
723+ require.Same(t, proxy, ac.currentAttachmentSession())
724+}
725+
726+func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
727+ d, proxy, ac, _, handler := newProxyInputHarness(t)
728+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
729+ local := &session{sessionCore: sessionCore{id: "local", name: "local", createdAt: 1}, tabs: []*tab{tb}}
730+ publishTiledPaneOwners(local, tb)
731+ d.mu.Lock()
732+ require.True(t, d.registerSessionLocked(local))
733+ d.mu.Unlock()
734+
735+ model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
736+ target, selectable := model.Selected()
737+ require.True(t, selectable)
738+ d.afterRoleEffectsFrozen = func() {
739+ d.afterRoleEffectsFrozen = nil
740+ local.mu.Lock()
741+ local.name = "replacement"
742+ local.createdAt++
743+ local.mu.Unlock()
744+ }
745+
746+ require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
747+ require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")
748+}
749+
750+func TestRemotePickerRejectsReplacedLocalIncarnation(t *testing.T) {
751+ d, proxy, ac, _, handler := newProxyInputHarness(t)
752+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
753+ tb.stableID = "local-tab"
754+ local := &session{sessionCore: sessionCore{id: "local", name: "local", ephemeral: true, incarnation: domain.IncarnationID{1}}, tabs: []*tab{tb}}
755+ publishTiledPaneOwners(local, tb)
756+ d.mu.Lock()
757+ require.True(t, d.registerSessionLocked(local))
758+ d.mu.Unlock()
759+
760+ model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)})
761+ target, selectable := model.Selected()
762+ require.True(t, selectable)
763+ d.afterRoleEffectsFrozen = func() {
764+ d.afterRoleEffectsFrozen = nil
765+ local.mu.Lock()
766+ local.incarnation = domain.IncarnationID{2}
767+ local.mu.Unlock()
768+ }
769+
770+ require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
771+ require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment")
772+}
773+
774+func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) {
775+ d, proxy, ac, _, handler := newProxyInputHarness(t)
776+ first := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
777+ first.stableID = "first"
778+ second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
779+ second.stableID = "second"
780+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{first, second}}
781+ publishTiledPaneOwners(local, first)
782+ publishTiledPaneOwners(local, second)
783+ d.mu.Lock()
784+ require.True(t, d.registerSessionLocked(local))
785+ d.mu.Unlock()
786+
787+ model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(second.stableID)})
788+ target, selectable := model.Selected()
789+ require.True(t, selectable)
790+ require.Equal(t, domain.TabStableID(second.stableID), target.TabID)
791+ d.afterRoleEffectsFrozen = func() {
792+ d.afterRoleEffectsFrozen = nil
793+ local.mu.Lock()
794+ local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0]
795+ local.mu.Unlock()
796+ }
797+
798+ require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
799+ require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment")
800+}
801+
802+func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
803+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
804+ d.attachCoordinator(local, nil, ac, true)
805+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
806+ remote := newProxyTestTransport()
807+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
808+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
809+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
810+ d.remoteTransportMode = ports.RemoteTransportUDP
811+
812+ localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
813+ require.NoError(t, d.switchToTargetForRole(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
814+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
815+ require.True(t, ok)
816+ t.Cleanup(func() { stopProxy(t, proxy) })
817+
818+ proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
819+ require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
820+ require.Same(t, local, ac.currentSession())
821+
822+ model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
823+ d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
824+ target, selectable := model.Selected()
825+ require.True(t, selectable)
826+ require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key")
827+ localToken = roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
828+ require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
829+ require.Same(t, proxy, ac.currentAttachmentSession())
830+ require.False(t, ac.overlays.pickerActive())
831+}
832+
833+func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
834+ d := newTestDaemon(t, nil, stubClock{})
835+ client := newProxyTestTransport()
836+ ac := &attachedClient{}
837+ ac.replaceTransport(client)
838+ first := &session{sessionCore: sessionCore{client: ac}}
839+ second := &session{sessionCore: sessionCore{client: ac}}
840+ ac.setSession(first)
841+
842+ const unstableSnapshots = 100
843+ attempts := 0
844+ d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
845+ attempts++
846+ if attempts >= unstableSnapshots {
847+ return
848+ }
849+ if snapshot == first {
850+ ac.setSession(second)
851+ } else {
852+ ac.setSession(first)
853+ }
854+ }
855+
856+ _, _, ok := d.currentConnectionRole(ac, client)
857+ require.False(t, ok)
858+ require.Less(t, attempts, unstableSnapshots)
859+}
860+
861+func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T) {
862+ d := newTestDaemon(t, nil, stubClock{})
863+ client := newProxyTestTransport()
864+ ac := &attachedClient{}
865+ ac.replaceTransport(client)
866+ local := &session{sessionCore: sessionCore{client: ac}}
867+ ac.setSession(local)
868+
869+ cleanupAttempts := 0
870+ d.beforeClientGoneDetach = func() {
871+ cleanupAttempts++
872+ local.mu.Lock()
873+ local.client = &attachedClient{}
874+ local.mu.Unlock()
875+ }
876+ snapshots := 0
877+ d.afterConnectionSessionSnapshot = func(attachmentSession) {
878+ snapshots++
879+ if snapshots == 100 {
880+ ac.setSession(nil)
881+ }
882+ }
883+ client.recv <- proxyRecv{err: io.EOF}
884+
885+ d.runConnLoop(ac)
886+
887+ require.Equal(t, 1, cleanupAttempts)
888+ require.Equal(t, 1, snapshots)
889+}
890+
891+func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
892+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
893+ client := newProxyTestTransport()
894+ ac.replaceTransport(client)
895+ d.attachCoordinator(local, nil, ac, true)
896+
897+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
898+ remote := newProxyTestTransport()
899+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
900+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
901+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
902+ d.remoteTransportMode = ports.RemoteTransportUDP
903+ token := roleEffectForTest(t, local.attachmentToken(ac, client))
904+ snapshots := make(chan attachmentSession, 1)
905+ transitionErr := make(chan error, 1)
906+ d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
907+ d.afterConnectionSessionSnapshot = nil
908+ snapshots <- snapshot
909+ transitionErr <- d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
910+ }
911+
912+ loopDone := make(chan struct{})
913+ go func() {
914+ d.runConnLoop(ac)
915+ close(loopDone)
916+ }()
917+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
918+ require.Same(t, local, awaitTestValue(t, snapshots, "connection role was not snapshotted"))
919+ require.NoError(t, awaitTestValue(t, transitionErr, "handoff did not run during role resolution"))
920+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
921+ require.True(t, ok)
922+ t.Cleanup(func() { stopProxy(t, proxy) })
923+ _ = awaitFrame(t, client.sent, ports.MsgPong)
924+
925+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
926+ awaitTestCompletion(t, loopDone, "connection loop did not continue after retrying role resolution")
927+}
928+
929+func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) {
930+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
931+ client := newProxyTestTransport()
932+ ac.replaceTransport(client)
933+ d.attachCoordinator(local, nil, ac, true)
934+
935+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
936+ remote := newProxyTestTransport()
937+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
938+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
939+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
940+ d.remoteTransportMode = ports.RemoteTransportUDP
941+
942+ loopDone := make(chan struct{})
943+ go func() {
944+ d.runConnLoop(ac)
945+ close(loopDone)
946+ }()
947+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
948+
949+ token := roleEffectForTest(t, local.attachmentToken(ac, client))
950+ require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
951+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
952+ require.True(t, ok)
953+ t.Cleanup(func() { stopProxy(t, proxy) })
954+
955+ client.recv <- proxyRecv{err: io.EOF}
956+ awaitTestCompletion(t, loopDone, "connection loop did not return after receive failure")
957+ require.Nil(t, ac.currentAttachmentSession(), "receive failure cleaned the stale local role instead of the current proxy role")
958+}
959+
960+func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
961+ d, _, ac, _, handler := newProxyInputHarness(t)
962+ client, ok := ac.transport().(*proxyTestTransport)
963+ require.True(t, ok)
964+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
965+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
966+ publishTiledPaneOwners(local, tb)
967+ d.mu.Lock()
968+ require.True(t, d.registerSessionLocked(local))
969+ d.mu.Unlock()
970+
971+ loopDone := make(chan struct{})
972+ go func() {
973+ d.runConnLoop(ac)
974+ close(loopDone)
975+ }()
976+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
977+
978+ require.NoError(t, d.switchToTargetForRole(handler.roleToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
979+ require.Same(t, local, ac.currentSession())
980+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
981+ awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")
982+ require.Nil(t, ac.currentAttachmentSession())
983+}
984+
985+func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
986+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
987+ client := newProxyTestTransport()
988+ ac.replaceTransport(client)
989+ d.attachCoordinator(local, nil, ac, true)
990+
991+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
992+ remote := newProxyTestTransport()
993+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
994+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
995+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
996+ d.remoteTransportMode = ports.RemoteTransportUDP
997+
998+ loopDone := make(chan struct{})
999+ go func() {
1000+ d.runConnLoop(ac)
1001+ close(loopDone)
1002+ }()
1003+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
1004+
1005+ token := local.attachmentToken(ac, client)
1006+ effect, admitted := ac.beginRoleEffect(token)
1007+ require.True(t, admitted)
1008+ token.effect = effect
1009+ require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
1010+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
1011+ require.True(t, ok)
1012+ t.Cleanup(func() { stopProxy(t, proxy) })
1013+
1014+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
1015+ awaitTestCompletion(t, loopDone, "connection loop did not handle input after the proxy handoff")
1016+ require.Nil(t, ac.currentAttachmentSession(), "connection loop abandoned the client after the proxy handoff")
1017+}
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "io"
6 "log/slog"7 "log/slog"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
@@ -681,3 +682,336 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
681 require.Equal(t, key, proxy.key)682 require.Equal(t, key, proxy.key)
682 t.Cleanup(func() { stopProxy(t, proxy) })683 t.Cleanup(func() { stopProxy(t, proxy) })
683 }684 }
685+
686+func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
687+ d, proxy, ac, _, handler := newProxyInputHarness(t)
688+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
689+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
690+ publishTiledPaneOwners(local, tb)
691+ d.mu.Lock()
692+ require.True(t, d.registerSessionLocked(local))
693+ d.mu.Unlock()
694+
695+ model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
696+ d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{})
697+ target, selectable := model.Selected()
698+ require.True(t, selectable)
699+ require.Equal(t, local.id, target.Session)
700+ require.Equal(t, local.name, target.Name)
701+ require.False(t, target.Stopped)
702+
703+ require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
704+ require.Same(t, local, ac.currentSession())
705+ require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")
706+
707+ d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t)
708+ localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
709+ require.NoError(t, d.backSessionForRole(localToken))
710+ require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")
711+
712+ proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
713+ require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
714+ require.Same(t, local, ac.currentSession())
715+ d.backSession(local, ac)
716+ require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")
717+}
718+
719+func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {
720+ d, proxy, ac, _, handler := newProxyInputHarness(t)
721+ err := d.switchToTargetForRole(handler.roleToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select")
722+ require.Error(t, err)
723+ require.Same(t, proxy, ac.currentAttachmentSession())
724+}
725+
726+func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
727+ d, proxy, ac, _, handler := newProxyInputHarness(t)
728+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
729+ local := &session{sessionCore: sessionCore{id: "local", name: "local", createdAt: 1}, tabs: []*tab{tb}}
730+ publishTiledPaneOwners(local, tb)
731+ d.mu.Lock()
732+ require.True(t, d.registerSessionLocked(local))
733+ d.mu.Unlock()
734+
735+ model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
736+ target, selectable := model.Selected()
737+ require.True(t, selectable)
738+ d.afterRoleEffectsFrozen = func() {
739+ d.afterRoleEffectsFrozen = nil
740+ local.mu.Lock()
741+ local.name = "replacement"
742+ local.createdAt++
743+ local.mu.Unlock()
744+ }
745+
746+ require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
747+ require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")
748+}
749+
750+func TestRemotePickerRejectsReplacedLocalIncarnation(t *testing.T) {
751+ d, proxy, ac, _, handler := newProxyInputHarness(t)
752+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
753+ tb.stableID = "local-tab"
754+ local := &session{sessionCore: sessionCore{id: "local", name: "local", ephemeral: true, incarnation: domain.IncarnationID{1}}, tabs: []*tab{tb}}
755+ publishTiledPaneOwners(local, tb)
756+ d.mu.Lock()
757+ require.True(t, d.registerSessionLocked(local))
758+ d.mu.Unlock()
759+
760+ model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)})
761+ target, selectable := model.Selected()
762+ require.True(t, selectable)
763+ d.afterRoleEffectsFrozen = func() {
764+ d.afterRoleEffectsFrozen = nil
765+ local.mu.Lock()
766+ local.incarnation = domain.IncarnationID{2}
767+ local.mu.Unlock()
768+ }
769+
770+ require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
771+ require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment")
772+}
773+
774+func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) {
775+ d, proxy, ac, _, handler := newProxyInputHarness(t)
776+ first := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
777+ first.stableID = "first"
778+ second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
779+ second.stableID = "second"
780+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{first, second}}
781+ publishTiledPaneOwners(local, first)
782+ publishTiledPaneOwners(local, second)
783+ d.mu.Lock()
784+ require.True(t, d.registerSessionLocked(local))
785+ d.mu.Unlock()
786+
787+ model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(second.stableID)})
788+ target, selectable := model.Selected()
789+ require.True(t, selectable)
790+ require.Equal(t, domain.TabStableID(second.stableID), target.TabID)
791+ d.afterRoleEffectsFrozen = func() {
792+ d.afterRoleEffectsFrozen = nil
793+ local.mu.Lock()
794+ local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0]
795+ local.mu.Unlock()
796+ }
797+
798+ require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
799+ require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment")
800+}
801+
802+func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
803+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
804+ d.attachCoordinator(local, nil, ac, true)
805+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
806+ remote := newProxyTestTransport()
807+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
808+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
809+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
810+ d.remoteTransportMode = ports.RemoteTransportUDP
811+
812+ localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
813+ require.NoError(t, d.switchToTargetForRole(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
814+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
815+ require.True(t, ok)
816+ t.Cleanup(func() { stopProxy(t, proxy) })
817+
818+ proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
819+ require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
820+ require.Same(t, local, ac.currentSession())
821+
822+ model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
823+ d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
824+ target, selectable := model.Selected()
825+ require.True(t, selectable)
826+ require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key")
827+ localToken = roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
828+ require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
829+ require.Same(t, proxy, ac.currentAttachmentSession())
830+ require.False(t, ac.overlays.pickerActive())
831+}
832+
833+func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
834+ d := newTestDaemon(t, nil, stubClock{})
835+ client := newProxyTestTransport()
836+ ac := &attachedClient{}
837+ ac.replaceTransport(client)
838+ first := &session{sessionCore: sessionCore{client: ac}}
839+ second := &session{sessionCore: sessionCore{client: ac}}
840+ ac.setSession(first)
841+
842+ const unstableSnapshots = 100
843+ attempts := 0
844+ d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
845+ attempts++
846+ if attempts >= unstableSnapshots {
847+ return
848+ }
849+ if snapshot == first {
850+ ac.setSession(second)
851+ } else {
852+ ac.setSession(first)
853+ }
854+ }
855+
856+ _, _, ok := d.currentConnectionRole(ac, client)
857+ require.False(t, ok)
858+ require.Less(t, attempts, unstableSnapshots)
859+}
860+
861+func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T) {
862+ d := newTestDaemon(t, nil, stubClock{})
863+ client := newProxyTestTransport()
864+ ac := &attachedClient{}
865+ ac.replaceTransport(client)
866+ local := &session{sessionCore: sessionCore{client: ac}}
867+ ac.setSession(local)
868+
869+ cleanupAttempts := 0
870+ d.beforeClientGoneDetach = func() {
871+ cleanupAttempts++
872+ local.mu.Lock()
873+ local.client = &attachedClient{}
874+ local.mu.Unlock()
875+ }
876+ snapshots := 0
877+ d.afterConnectionSessionSnapshot = func(attachmentSession) {
878+ snapshots++
879+ if snapshots == 100 {
880+ ac.setSession(nil)
881+ }
882+ }
883+ client.recv <- proxyRecv{err: io.EOF}
884+
885+ d.runConnLoop(ac)
886+
887+ require.Equal(t, 1, cleanupAttempts)
888+ require.Equal(t, 1, snapshots)
889+}
890+
891+func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
892+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
893+ client := newProxyTestTransport()
894+ ac.replaceTransport(client)
895+ d.attachCoordinator(local, nil, ac, true)
896+
897+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
898+ remote := newProxyTestTransport()
899+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
900+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
901+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
902+ d.remoteTransportMode = ports.RemoteTransportUDP
903+ token := roleEffectForTest(t, local.attachmentToken(ac, client))
904+ snapshots := make(chan attachmentSession, 1)
905+ transitionErr := make(chan error, 1)
906+ d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
907+ d.afterConnectionSessionSnapshot = nil
908+ snapshots <- snapshot
909+ transitionErr <- d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
910+ }
911+
912+ loopDone := make(chan struct{})
913+ go func() {
914+ d.runConnLoop(ac)
915+ close(loopDone)
916+ }()
917+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
918+ require.Same(t, local, awaitTestValue(t, snapshots, "connection role was not snapshotted"))
919+ require.NoError(t, awaitTestValue(t, transitionErr, "handoff did not run during role resolution"))
920+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
921+ require.True(t, ok)
922+ t.Cleanup(func() { stopProxy(t, proxy) })
923+ _ = awaitFrame(t, client.sent, ports.MsgPong)
924+
925+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
926+ awaitTestCompletion(t, loopDone, "connection loop did not continue after retrying role resolution")
927+}
928+
929+func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) {
930+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
931+ client := newProxyTestTransport()
932+ ac.replaceTransport(client)
933+ d.attachCoordinator(local, nil, ac, true)
934+
935+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
936+ remote := newProxyTestTransport()
937+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
938+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
939+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
940+ d.remoteTransportMode = ports.RemoteTransportUDP
941+
942+ loopDone := make(chan struct{})
943+ go func() {
944+ d.runConnLoop(ac)
945+ close(loopDone)
946+ }()
947+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
948+
949+ token := roleEffectForTest(t, local.attachmentToken(ac, client))
950+ require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
951+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
952+ require.True(t, ok)
953+ t.Cleanup(func() { stopProxy(t, proxy) })
954+
955+ client.recv <- proxyRecv{err: io.EOF}
956+ awaitTestCompletion(t, loopDone, "connection loop did not return after receive failure")
957+ require.Nil(t, ac.currentAttachmentSession(), "receive failure cleaned the stale local role instead of the current proxy role")
958+}
959+
960+func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
961+ d, _, ac, _, handler := newProxyInputHarness(t)
962+ client, ok := ac.transport().(*proxyTestTransport)
963+ require.True(t, ok)
964+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
965+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
966+ publishTiledPaneOwners(local, tb)
967+ d.mu.Lock()
968+ require.True(t, d.registerSessionLocked(local))
969+ d.mu.Unlock()
970+
971+ loopDone := make(chan struct{})
972+ go func() {
973+ d.runConnLoop(ac)
974+ close(loopDone)
975+ }()
976+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
977+
978+ require.NoError(t, d.switchToTargetForRole(handler.roleToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
979+ require.Same(t, local, ac.currentSession())
980+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
981+ awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")
982+ require.Nil(t, ac.currentAttachmentSession())
983+}
984+
985+func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
986+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
987+ client := newProxyTestTransport()
988+ ac.replaceTransport(client)
989+ d.attachCoordinator(local, nil, ac, true)
990+
991+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
992+ remote := newProxyTestTransport()
993+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
994+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
995+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
996+ d.remoteTransportMode = ports.RemoteTransportUDP
997+
998+ loopDone := make(chan struct{})
999+ go func() {
1000+ d.runConnLoop(ac)
1001+ close(loopDone)
1002+ }()
1003+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
1004+
1005+ token := local.attachmentToken(ac, client)
1006+ effect, admitted := ac.beginRoleEffect(token)
1007+ require.True(t, admitted)
1008+ token.effect = effect
1009+ require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
1010+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
1011+ require.True(t, ok)
1012+ t.Cleanup(func() { stopProxy(t, proxy) })
1013+
1014+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
1015+ awaitTestCompletion(t, loopDone, "connection loop did not handle input after the proxy handoff")
1016+ require.Nil(t, ac.currentAttachmentSession(), "connection loop abandoned the client after the proxy handoff")
1017+}
M internal/usecase/daemon/remote_picker.go +1 -0
@@ -151,6 +151,7 @@ func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker
151151 view.Name += " [expired]"
152152 }
153153 view.TargetName = ""
154+ view.ExpectedCreatedAt = nil
154155 view.RemoteKey = &key
155156 view.RemoteAvailability = picker.RemoteFresh
156157 view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
@@ -151,6 +151,7 @@ func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker
151 view.Name += " [expired]"151 view.Name += " [expired]"
152 }152 }
153 view.TargetName = ""153 view.TargetName = ""
154+ view.ExpectedCreatedAt = nil
154 view.RemoteKey = &key155 view.RemoteKey = &key
155 view.RemoteAvailability = picker.RemoteFresh156 view.RemoteAvailability = picker.RemoteFresh
156 view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))157 view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
M internal/usecase/daemon/session_back.go +19 -3
@@ -1,6 +1,9 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/usecase/picker"
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
47
58 // backSession toggles this attachment between its current session and the
69 // immediately preceding successfully activated session.
@@ -13,7 +16,12 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
1316 token.ac.clearPreviousSessionIf(target)
1417 return nil
1518 }
16- return d.switchToTargetForRole(token, picker.Target{Session: target.core().id, TabIndex: -1}, sessionHandoffGuard{}, "back-session")
19+ pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}
20+ if proxy, ok := target.(*proxySession); ok {
21+ key := proxy.key
22+ pickerTarget.RemoteKey = &key
23+ }
24+ return d.switchToTargetForRole(token, pickerTarget, sessionHandoffGuard{}, "back-session")
1725 }
1826
1927 func (d *Daemon) backSession(current *session, ac *attachedClient) {
@@ -26,7 +34,15 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
2634 d.invalidateRender(current, ac, true, "session_back.go")
2735 return
2836 }
29- if err := d.switchToTarget(current, ac, picker.Target{Session: target.core().id, TabIndex: -1}); err != nil {
37+ token := attachmentToken(current, ac, ac.transport())
38+ effect, admitted := ac.beginRoleEffect(token)
39+ if !admitted {
40+ d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))
41+ return
42+ }
43+ token.effect = effect
44+ defer effect.End()
45+ if err := d.backSessionForRole(token); err != nil {
3046 d.reportError(current, err)
3147 }
3248 }
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/usecase/picker"3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
4 7
5 // backSession toggles this attachment between its current session and the8 // backSession toggles this attachment between its current session and the
6 // immediately preceding successfully activated session.9 // immediately preceding successfully activated session.
@@ -13,7 +16,12 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
13 token.ac.clearPreviousSessionIf(target)16 token.ac.clearPreviousSessionIf(target)
14 return nil17 return nil
15 }18 }
16- return d.switchToTargetForRole(token, picker.Target{Session: target.core().id, TabIndex: -1}, sessionHandoffGuard{}, "back-session")19+ pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}
20+ if proxy, ok := target.(*proxySession); ok {
21+ key := proxy.key
22+ pickerTarget.RemoteKey = &key
23+ }
24+ return d.switchToTargetForRole(token, pickerTarget, sessionHandoffGuard{}, "back-session")
17 }25 }
18 26
19 func (d *Daemon) backSession(current *session, ac *attachedClient) {27 func (d *Daemon) backSession(current *session, ac *attachedClient) {
@@ -26,7 +34,15 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
26 d.invalidateRender(current, ac, true, "session_back.go")34 d.invalidateRender(current, ac, true, "session_back.go")
27 return35 return
28 }36 }
29- if err := d.switchToTarget(current, ac, picker.Target{Session: target.core().id, TabIndex: -1}); err != nil {37+ token := attachmentToken(current, ac, ac.transport())
38+ effect, admitted := ac.beginRoleEffect(token)
39+ if !admitted {
40+ d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))
41+ return
42+ }
43+ token.effect = effect
44+ defer effect.End()
45+ if err := d.backSessionForRole(token); err != nil {
30 d.reportError(current, err)46 d.reportError(current, err)
31 }47 }
32 }48 }

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close