detached 45e8d41

Merge pull request #212 from bnema/feat/parked-remote-session-switch

feat(remote): reuse parked UDP routes
commit
45e8d4195f5c67da3d901ada8132cd4688d21ffa
author
bnema · · 2026-08-26 03:35 UTC
committer
GitHub · · 2026-08-26 03:35 UTC
merge of
cd20560 + 46cc97f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
24 files · +2596 -169
M AGENTS.md +1 -1
@@ -64,7 +64,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
6464 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
6565
6666 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types occupy `1–13` (`MsgOutputResetRequest` is `13`) and `15` (`MsgRemotePreviewRequest`); server types occupy `16–23` (`MsgNavigationAction` is `23`) and `25–30` (`MsgAttachTarget`, `MsgRemotePreviewResponse`, `MsgCommittedRouteIdentity`, `MsgRecentRouteSnapshot`, `MsgNavigateRecentRoute`, and `MsgRouteNavigationFailure`). Types `14` and `24` remain reserved.
67+- Client message types occupy `1–13`, `15`, `32–33`, and `35` (`MsgParkedRouteRequest`); server types occupy `16–23`, `25–31`, `34`, and `36` (`MsgParkedRouteResponse`). Types `14` and `24` remain reserved.
6868 - Version negotiation requires strict equality.
6969 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
7070 - Bump `ProtocolVersion` for any message layout change.
@@ -64,7 +64,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
65 65
66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types occupy `1–13` (`MsgOutputResetRequest` is `13`) and `15` (`MsgRemotePreviewRequest`); server types occupy `16–23` (`MsgNavigationAction` is `23`) and `25–30` (`MsgAttachTarget`, `MsgRemotePreviewResponse`, `MsgCommittedRouteIdentity`, `MsgRecentRouteSnapshot`, `MsgNavigateRecentRoute`, and `MsgRouteNavigationFailure`). Types `14` and `24` remain reserved.67+- Client message types occupy `1–13`, `15`, `32–33`, and `35` (`MsgParkedRouteRequest`); server types occupy `16–23`, `25–31`, `34`, and `36` (`MsgParkedRouteResponse`). Types `14` and `24` remain reserved.
68 - Version negotiation requires strict equality.68 - Version negotiation requires strict equality.
69 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.69 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
70 - Bump `ProtocolVersion` for any message layout change.70 - Bump `ProtocolVersion` for any message layout change.
M docs/remote-resilience.md +23 -3
@@ -55,9 +55,26 @@ exact lifecycle target; the client confirms it with its remembered stable tab
5555 cursor while holding raw input. The daemon either commits the fenced attachment
5656 transition and publishes the committed identity before the rebased full paint,
5757 or sends a typed pre-commit rejection and leaves the source attachment usable.
58-No hostname, label, DNS result, or SSH alias authorizes reuse. Stopped,
59-explicit-tab, cross-origin, and remote-to-remote selections retain direct
60-close-and-dial handoff.
58+No hostname, label, DNS result, or SSH alias authorizes reuse. Stopped and
59+cross-origin targets outside the hybrid UDP flow retain direct close-and-dial
60+handoff.
61+
62+A UDP attachment opening the hybrid home picker keeps its authenticated remote
63+transport parked while a transient local connection renders the picker. The
64+remote daemon suspends that attachment's rendered output only after a
65+lease-bound prepare handshake, so the picker has sole terminal ownership and
66+the client does not acknowledge screen updates it did not display. Terminal-
67+external one-shot effects generated while parked, such as OSC 52 clipboard
68+writes, are intentionally suppressed and are not replayed after Back or a
69+switch; applying them later could affect a different terminal owner. Back
70+resumes the parked attachment's current VT state with a full paint. Selecting a
71+live or stopped session from the exact same configured endpoint sends the
72+structured lifecycle/tab target over the parked transport; the serving daemon
73+revalidates it and switches or restores with its persisted working directory
74+and daemon-owned environment. Different endpoints and SSH stdio routes close
75+and dial normally. While any switch takes longer than the short display
76+threshold, the client overlays an animated switching or starting toast until
77+the destination's authoritative output is flushed.
6178
6279 ## Durable record compatibility
6380
@@ -90,6 +107,9 @@ picker selection changes.
90107 - Each command request has a 10-second deadline for its result. A timeout
91108 abandons only that request; it does not complete a later request with a stale
92109 result.
110+- Each parked-route control response and its required full paint have separate
111+ 15-second client deadlines. A parked lease expires after 15 minutes and
112+ closes only its exact retained transport generation.
93113 - Shared PTY content geometry follows the latest valid, non-superseded attach,
94114 resume, or resize claim. A later attachment can therefore replace the current
95115 shared geometry; if the winning attachment detaches, the most recently
@@ -55,9 +55,26 @@ exact lifecycle target; the client confirms it with its remembered stable tab
55 cursor while holding raw input. The daemon either commits the fenced attachment55 cursor while holding raw input. The daemon either commits the fenced attachment
56 transition and publishes the committed identity before the rebased full paint,56 transition and publishes the committed identity before the rebased full paint,
57 or sends a typed pre-commit rejection and leaves the source attachment usable.57 or sends a typed pre-commit rejection and leaves the source attachment usable.
58-No hostname, label, DNS result, or SSH alias authorizes reuse. Stopped,58+No hostname, label, DNS result, or SSH alias authorizes reuse. Stopped and
59-explicit-tab, cross-origin, and remote-to-remote selections retain direct59+cross-origin targets outside the hybrid UDP flow retain direct close-and-dial
60-close-and-dial handoff.60+handoff.
61+
62+A UDP attachment opening the hybrid home picker keeps its authenticated remote
63+transport parked while a transient local connection renders the picker. The
64+remote daemon suspends that attachment's rendered output only after a
65+lease-bound prepare handshake, so the picker has sole terminal ownership and
66+the client does not acknowledge screen updates it did not display. Terminal-
67+external one-shot effects generated while parked, such as OSC 52 clipboard
68+writes, are intentionally suppressed and are not replayed after Back or a
69+switch; applying them later could affect a different terminal owner. Back
70+resumes the parked attachment's current VT state with a full paint. Selecting a
71+live or stopped session from the exact same configured endpoint sends the
72+structured lifecycle/tab target over the parked transport; the serving daemon
73+revalidates it and switches or restores with its persisted working directory
74+and daemon-owned environment. Different endpoints and SSH stdio routes close
75+and dial normally. While any switch takes longer than the short display
76+threshold, the client overlays an animated switching or starting toast until
77+the destination's authoritative output is flushed.
61 78
62 ## Durable record compatibility79 ## Durable record compatibility
63 80
@@ -90,6 +107,9 @@ picker selection changes.
90 - Each command request has a 10-second deadline for its result. A timeout107 - Each command request has a 10-second deadline for its result. A timeout
91 abandons only that request; it does not complete a later request with a stale108 abandons only that request; it does not complete a later request with a stale
92 result.109 result.
110+- Each parked-route control response and its required full paint have separate
111+ 15-second client deadlines. A parked lease expires after 15 minutes and
112+ closes only its exact retained transport generation.
93 - Shared PTY content geometry follows the latest valid, non-superseded attach,113 - Shared PTY content geometry follows the latest valid, non-superseded attach,
94 resume, or resize claim. A later attachment can therefore replace the current114 resume, or resize claim. A later attachment can therefore replace the current
95 shared geometry; if the winning attachment detaches, the most recently115 shared geometry; if the winning attachment detaches, the most recently
M internal/ports/frame.go +57 -3
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 33
15+const ProtocolVersion uint16 = 34
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -22,8 +22,8 @@ const MaxFrameLen = 16 << 20
2222 type MsgType uint8
2323
2424 // Frame message types use non-contiguous allocations: the legacy client range
25-// is 1–13 and 15, and newer client controls use 32–33. Server-originated
26-// messages occupy 16–23, 25–31, and 34. Values 14 and 24 remain reserved for
25+// is 1–13 and 15, and newer client controls use 32–33 and 35. Server-originated
26+// messages occupy 16–23, 25–31, 34, and 36. Values 14 and 24 remain reserved for
2727 // future extensions.
2828 const (
2929 MsgHello MsgType = 1
@@ -42,6 +42,7 @@ const (
4242 MsgRemotePreviewRequest MsgType = 15
4343 MsgRouteAttentionSubscription MsgType = 32
4444 MsgSamePeerSwitchRequest MsgType = 33
45+ MsgParkedRouteRequest MsgType = 35
4546
4647 MsgWelcome MsgType = 16
4748 MsgError MsgType = 17
@@ -61,6 +62,7 @@ const (
6162 MsgRouteNavigationFailure MsgType = 30
6263 MsgRoutePosition MsgType = 31
6364 MsgSamePeerSwitchFailure MsgType = 34
65+ MsgParkedRouteResponse MsgType = 36
6466 )
6567
6668 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -97,6 +99,58 @@ const (
9799 NavigationBack NavigationAction = 2
98100 )
99101
102+// ParkedRouteLeaseID is an opaque capability minted by the attached daemon for
103+// one home-picker excursion. It is bound server-side to the exact connection
104+// generation and transport that received the navigation directive.
105+type ParkedRouteLeaseID [16]byte
106+
107+func (id ParkedRouteLeaseID) IsZero() bool { return id == ParkedRouteLeaseID{} }
108+
109+// NavigationDirective requests one client-owned route transition. Opening the
110+// home picker always carries a fresh lease; Back is sent by the transient home
111+// route and therefore carries no lease.
112+type NavigationDirective struct {
113+ Action NavigationAction
114+ LeaseID ParkedRouteLeaseID
115+}
116+
117+// ParkedRouteAction advances one retained route through its one-shot lease.
118+type ParkedRouteAction uint8
119+
120+const (
121+ ParkedRoutePrepare ParkedRouteAction = iota + 1
122+ ParkedRouteResume
123+ ParkedRouteSwitch
124+)
125+
126+// ParkedRouteRequest prepares, resumes, or switches the retained authenticated
127+// route. Target is present only for Switch and retains exact lifecycle/tab
128+// authority from the home picker's remote catalogue row.
129+type ParkedRouteRequest struct {
130+ RequestID uint64
131+ LeaseID ParkedRouteLeaseID
132+ Action ParkedRouteAction
133+ Target *domain.RemoteSessionTarget
134+}
135+
136+// ParkedRouteStatus is the explicit outcome for a retained-route request.
137+type ParkedRouteStatus uint8
138+
139+const (
140+ ParkedRouteReady ParkedRouteStatus = iota + 1
141+ ParkedRouteResumed
142+ ParkedRouteSwitched
143+ ParkedRouteRejected
144+ ParkedRouteExpired
145+ ParkedRouteStaleTarget
146+)
147+
148+// ParkedRouteResponse correlates one retained-route request with its outcome.
149+type ParkedRouteResponse struct {
150+ RequestID uint64
151+ Status ParkedRouteStatus
152+}
153+
100154 // RemotePreviewSchemaVersion is independent from the attachment IPC version.
101155 const RemotePreviewSchemaVersion uint16 = 1
102156
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 3315+const ProtocolVersion uint16 = 34
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
@@ -22,8 +22,8 @@ const MaxFrameLen = 16 << 20
22 type MsgType uint822 type MsgType uint8
23 23
24 // Frame message types use non-contiguous allocations: the legacy client range24 // Frame message types use non-contiguous allocations: the legacy client range
25-// is 1–13 and 15, and newer client controls use 32–33. Server-originated25+// is 1–13 and 15, and newer client controls use 32–33 and 35. Server-originated
26-// messages occupy 16–23, 25–31, and 34. Values 14 and 24 remain reserved for26+// messages occupy 16–23, 25–31, 34, and 36. Values 14 and 24 remain reserved for
27 // future extensions.27 // future extensions.
28 const (28 const (
29 MsgHello MsgType = 129 MsgHello MsgType = 1
@@ -42,6 +42,7 @@ const (
42 MsgRemotePreviewRequest MsgType = 1542 MsgRemotePreviewRequest MsgType = 15
43 MsgRouteAttentionSubscription MsgType = 3243 MsgRouteAttentionSubscription MsgType = 32
44 MsgSamePeerSwitchRequest MsgType = 3344 MsgSamePeerSwitchRequest MsgType = 33
45+ MsgParkedRouteRequest MsgType = 35
45 46
46 MsgWelcome MsgType = 1647 MsgWelcome MsgType = 16
47 MsgError MsgType = 1748 MsgError MsgType = 17
@@ -61,6 +62,7 @@ const (
61 MsgRouteNavigationFailure MsgType = 3062 MsgRouteNavigationFailure MsgType = 30
62 MsgRoutePosition MsgType = 3163 MsgRoutePosition MsgType = 31
63 MsgSamePeerSwitchFailure MsgType = 3464 MsgSamePeerSwitchFailure MsgType = 34
65+ MsgParkedRouteResponse MsgType = 36
64 )66 )
65 67
66 // Frame is the unit of exchange over a Transport: a typed, length-delimited68 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -97,6 +99,58 @@ const (
97 NavigationBack NavigationAction = 299 NavigationBack NavigationAction = 2
98 )100 )
99 101
102+// ParkedRouteLeaseID is an opaque capability minted by the attached daemon for
103+// one home-picker excursion. It is bound server-side to the exact connection
104+// generation and transport that received the navigation directive.
105+type ParkedRouteLeaseID [16]byte
106+
107+func (id ParkedRouteLeaseID) IsZero() bool { return id == ParkedRouteLeaseID{} }
108+
109+// NavigationDirective requests one client-owned route transition. Opening the
110+// home picker always carries a fresh lease; Back is sent by the transient home
111+// route and therefore carries no lease.
112+type NavigationDirective struct {
113+ Action NavigationAction
114+ LeaseID ParkedRouteLeaseID
115+}
116+
117+// ParkedRouteAction advances one retained route through its one-shot lease.
118+type ParkedRouteAction uint8
119+
120+const (
121+ ParkedRoutePrepare ParkedRouteAction = iota + 1
122+ ParkedRouteResume
123+ ParkedRouteSwitch
124+)
125+
126+// ParkedRouteRequest prepares, resumes, or switches the retained authenticated
127+// route. Target is present only for Switch and retains exact lifecycle/tab
128+// authority from the home picker's remote catalogue row.
129+type ParkedRouteRequest struct {
130+ RequestID uint64
131+ LeaseID ParkedRouteLeaseID
132+ Action ParkedRouteAction
133+ Target *domain.RemoteSessionTarget
134+}
135+
136+// ParkedRouteStatus is the explicit outcome for a retained-route request.
137+type ParkedRouteStatus uint8
138+
139+const (
140+ ParkedRouteReady ParkedRouteStatus = iota + 1
141+ ParkedRouteResumed
142+ ParkedRouteSwitched
143+ ParkedRouteRejected
144+ ParkedRouteExpired
145+ ParkedRouteStaleTarget
146+)
147+
148+// ParkedRouteResponse correlates one retained-route request with its outcome.
149+type ParkedRouteResponse struct {
150+ RequestID uint64
151+ Status ParkedRouteStatus
152+}
153+
100 // RemotePreviewSchemaVersion is independent from the attachment IPC version.154 // RemotePreviewSchemaVersion is independent from the attachment IPC version.
101 const RemotePreviewSchemaVersion uint16 = 1155 const RemotePreviewSchemaVersion uint16 = 1
102 156
M internal/ports/frame_test.go +5 -4
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 33 {
7- t.Fatalf("ProtocolVersion = %d, want 33", ProtocolVersion)
6+ if ProtocolVersion != 34 {
7+ t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)
88 }
99 }
1010
@@ -32,14 +32,15 @@ func TestMsgTypeUnique(t *testing.T) {
3232 {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},
3333 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
3434 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
35- {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription},
35+ {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
36+ {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest},
3637 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
3738 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
3839 {"MsgCommandResult", MsgCommandResult},
3940 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
4041 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
4142 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
42- {"MsgRoutePosition", MsgRoutePosition},
43+ {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse},
4344 }
4445
4546 seen := make(map[MsgType]string, len(tests))
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 33 {6+ if ProtocolVersion != 34 {
7- t.Fatalf("ProtocolVersion = %d, want 33", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -32,14 +32,15 @@ func TestMsgTypeUnique(t *testing.T) {
32 {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},32 {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},
33 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},33 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
34 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},34 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
35- {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription},35+ {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
36+ {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest},
36 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},37 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
37 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},38 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
38 {"MsgCommandResult", MsgCommandResult},39 {"MsgCommandResult", MsgCommandResult},
39 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},40 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
40 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},41 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
41 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},42 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
42- {"MsgRoutePosition", MsgRoutePosition},43+ {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse},
43 }44 }
44 45
45 seen := make(map[MsgType]string, len(tests))46 seen := make(map[MsgType]string, len(tests))
M internal/ports/navigation_test.go +108 -18
@@ -8,39 +8,129 @@ import (
88 "github.com/stretchr/testify/require"
99 )
1010
11-func TestNavigationActionWireTable(t *testing.T) {
11+func TestNavigationDirectiveWireTable(t *testing.T) {
12+ lease := ParkedRouteLeaseID{1, 2, 3}
1213 tests := []struct {
13- name string
14- action NavigationAction
15- payload []byte
16- valid bool
14+ name string
15+ directive NavigationDirective
16+ valid bool
1717 }{
18- {name: "open home", action: NavigationOpenHomePicker, payload: []byte{1}, valid: true},
19- {name: "back", action: NavigationBack, payload: []byte{2}, valid: true},
20- {name: "zero", action: 0, payload: []byte{0}, valid: false},
21- {name: "unknown", action: 3, payload: []byte{3}, valid: false},
18+ {name: "open home", directive: NavigationDirective{Action: NavigationOpenHomePicker, LeaseID: lease}, valid: true},
19+ {name: "back", directive: NavigationDirective{Action: NavigationBack}, valid: true},
20+ {name: "open home without lease", directive: NavigationDirective{Action: NavigationOpenHomePicker}},
21+ {name: "back with lease", directive: NavigationDirective{Action: NavigationBack, LeaseID: lease}},
22+ {name: "zero", directive: NavigationDirective{}},
23+ {name: "unknown", directive: NavigationDirective{Action: 3}},
2224 }
2325
2426 for _, tt := range tests {
2527 t.Run(tt.name, func(t *testing.T) {
26- got := MarshalNavigationAction(tt.action)
28+ got := MarshalNavigationDirective(tt.directive)
2729 if !tt.valid {
2830 require.Nil(t, got)
29- _, err := UnmarshalNavigationAction(tt.payload)
30- require.ErrorIs(t, err, ErrInvalidNavigation)
3131 return
3232 }
33- require.Equal(t, tt.payload, got)
34- decoded, err := UnmarshalNavigationAction(got)
33+ decoded, err := UnmarshalNavigationDirective(got)
3534 require.NoError(t, err)
36- require.Equal(t, tt.action, decoded)
37- assertAllPrefixesFail(t, got, UnmarshalNavigationAction)
38- _, err = UnmarshalNavigationAction(append(append([]byte(nil), got...), 0))
35+ require.Equal(t, tt.directive, decoded)
36+ assertAllPrefixesFail(t, got, UnmarshalNavigationDirective)
37+ _, err = UnmarshalNavigationDirective(append(append([]byte(nil), got...), 0))
3938 require.ErrorIs(t, err, ErrInvalidNavigation)
4039 })
4140 }
4241 }
4342
43+func TestParkedRouteWireRoundTripsStrictly(t *testing.T) {
44+ lease := ParkedRouteLeaseID{1, 2, 3}
45+ target := &domain.RemoteSessionTarget{
46+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{4},
47+ SessionName: "work", LiveTabID: "tab-1",
48+ }
49+ requests := []struct {
50+ name string
51+ value ParkedRouteRequest
52+ golden string
53+ }{
54+ {name: "prepare", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRoutePrepare}, golden: "0000000000000001" + "01020300000000000000000000000000" + "010001"},
55+ {name: "resume", value: ParkedRouteRequest{RequestID: 2, LeaseID: lease, Action: ParkedRouteResume}},
56+ {name: "switch", value: ParkedRouteRequest{RequestID: 3, LeaseID: lease, Action: ParkedRouteSwitch, Target: target}},
57+ }
58+ for _, tt := range requests {
59+ t.Run("request "+tt.name, func(t *testing.T) {
60+ payload := MarshalParkedRouteRequest(tt.value)
61+ require.NotNil(t, payload)
62+ if tt.golden != "" {
63+ golden, err := hex.DecodeString(tt.golden)
64+ require.NoError(t, err)
65+ require.Equal(t, golden, payload)
66+ }
67+ decoded, err := UnmarshalParkedRouteRequest(payload)
68+ require.NoError(t, err)
69+ require.Equal(t, tt.value, decoded)
70+ assertAllPrefixesFail(t, payload, UnmarshalParkedRouteRequest)
71+ assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteRequest)
72+ })
73+ }
74+
75+ responses := []struct {
76+ name string
77+ value ParkedRouteResponse
78+ golden string
79+ }{
80+ {name: "ready", value: ParkedRouteResponse{RequestID: 1, Status: ParkedRouteReady}, golden: "000000000000000101"},
81+ {name: "resumed", value: ParkedRouteResponse{RequestID: 2, Status: ParkedRouteResumed}},
82+ {name: "switched", value: ParkedRouteResponse{RequestID: 3, Status: ParkedRouteSwitched}},
83+ {name: "rejected", value: ParkedRouteResponse{RequestID: 4, Status: ParkedRouteRejected}},
84+ {name: "expired", value: ParkedRouteResponse{RequestID: 5, Status: ParkedRouteExpired}},
85+ {name: "stale target", value: ParkedRouteResponse{RequestID: 6, Status: ParkedRouteStaleTarget}},
86+ }
87+ for _, tt := range responses {
88+ t.Run("response "+tt.name, func(t *testing.T) {
89+ payload := MarshalParkedRouteResponse(tt.value)
90+ if tt.golden != "" {
91+ golden, err := hex.DecodeString(tt.golden)
92+ require.NoError(t, err)
93+ require.Equal(t, golden, payload)
94+ }
95+ decoded, err := UnmarshalParkedRouteResponse(payload)
96+ require.NoError(t, err)
97+ require.Equal(t, tt.value, decoded)
98+ assertAllPrefixesFail(t, payload, UnmarshalParkedRouteResponse)
99+ assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteResponse)
100+ })
101+ }
102+
103+ invalidRequests := []struct {
104+ name string
105+ value ParkedRouteRequest
106+ }{
107+ {name: "zero request ID", value: ParkedRouteRequest{LeaseID: lease, Action: ParkedRoutePrepare}},
108+ {name: "zero lease", value: ParkedRouteRequest{RequestID: 1, Action: ParkedRoutePrepare}},
109+ {name: "unknown action", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: 99}},
110+ {name: "prepare target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRoutePrepare, Target: target}},
111+ {name: "resume target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteResume, Target: target}},
112+ {name: "missing switch target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteSwitch}},
113+ {name: "invalid switch target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}}},
114+ }
115+ for _, tt := range invalidRequests {
116+ t.Run("invalid request "+tt.name, func(t *testing.T) {
117+ require.Nil(t, MarshalParkedRouteRequest(tt.value))
118+ })
119+ }
120+ for _, tt := range []struct {
121+ name string
122+ value ParkedRouteResponse
123+ }{
124+ {name: "zero request ID", value: ParkedRouteResponse{Status: ParkedRouteReady}},
125+ {name: "zero status", value: ParkedRouteResponse{RequestID: 1}},
126+ {name: "unknown status", value: ParkedRouteResponse{RequestID: 1, Status: 99}},
127+ } {
128+ t.Run("invalid response "+tt.name, func(t *testing.T) {
129+ require.Nil(t, MarshalParkedRouteResponse(tt.value))
130+ })
131+ }
132+}
133+
44134 func TestHelloNavigationValidationTable(t *testing.T) {
45135 base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
46136 remoteTarget := &domain.RemoteSessionTarget{
@@ -62,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
62152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
63153 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
64154 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002102" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
155+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002202" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
66156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
67157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
68158 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
@@ -8,39 +8,129 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
11-func TestNavigationActionWireTable(t *testing.T) {11+func TestNavigationDirectiveWireTable(t *testing.T) {
12+ lease := ParkedRouteLeaseID{1, 2, 3}
12 tests := []struct {13 tests := []struct {
13- name string14+ name string
14- action NavigationAction15+ directive NavigationDirective
15- payload []byte16+ valid bool
16- valid bool
17 }{17 }{
18- {name: "open home", action: NavigationOpenHomePicker, payload: []byte{1}, valid: true},18+ {name: "open home", directive: NavigationDirective{Action: NavigationOpenHomePicker, LeaseID: lease}, valid: true},
19- {name: "back", action: NavigationBack, payload: []byte{2}, valid: true},19+ {name: "back", directive: NavigationDirective{Action: NavigationBack}, valid: true},
20- {name: "zero", action: 0, payload: []byte{0}, valid: false},20+ {name: "open home without lease", directive: NavigationDirective{Action: NavigationOpenHomePicker}},
21- {name: "unknown", action: 3, payload: []byte{3}, valid: false},21+ {name: "back with lease", directive: NavigationDirective{Action: NavigationBack, LeaseID: lease}},
22+ {name: "zero", directive: NavigationDirective{}},
23+ {name: "unknown", directive: NavigationDirective{Action: 3}},
22 }24 }
23 25
24 for _, tt := range tests {26 for _, tt := range tests {
25 t.Run(tt.name, func(t *testing.T) {27 t.Run(tt.name, func(t *testing.T) {
26- got := MarshalNavigationAction(tt.action)28+ got := MarshalNavigationDirective(tt.directive)
27 if !tt.valid {29 if !tt.valid {
28 require.Nil(t, got)30 require.Nil(t, got)
29- _, err := UnmarshalNavigationAction(tt.payload)
30- require.ErrorIs(t, err, ErrInvalidNavigation)
31 return31 return
32 }32 }
33- require.Equal(t, tt.payload, got)33+ decoded, err := UnmarshalNavigationDirective(got)
34- decoded, err := UnmarshalNavigationAction(got)
35 require.NoError(t, err)34 require.NoError(t, err)
36- require.Equal(t, tt.action, decoded)35+ require.Equal(t, tt.directive, decoded)
37- assertAllPrefixesFail(t, got, UnmarshalNavigationAction)36+ assertAllPrefixesFail(t, got, UnmarshalNavigationDirective)
38- _, err = UnmarshalNavigationAction(append(append([]byte(nil), got...), 0))37+ _, err = UnmarshalNavigationDirective(append(append([]byte(nil), got...), 0))
39 require.ErrorIs(t, err, ErrInvalidNavigation)38 require.ErrorIs(t, err, ErrInvalidNavigation)
40 })39 })
41 }40 }
42 }41 }
43 42
43+func TestParkedRouteWireRoundTripsStrictly(t *testing.T) {
44+ lease := ParkedRouteLeaseID{1, 2, 3}
45+ target := &domain.RemoteSessionTarget{
46+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{4},
47+ SessionName: "work", LiveTabID: "tab-1",
48+ }
49+ requests := []struct {
50+ name string
51+ value ParkedRouteRequest
52+ golden string
53+ }{
54+ {name: "prepare", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRoutePrepare}, golden: "0000000000000001" + "01020300000000000000000000000000" + "010001"},
55+ {name: "resume", value: ParkedRouteRequest{RequestID: 2, LeaseID: lease, Action: ParkedRouteResume}},
56+ {name: "switch", value: ParkedRouteRequest{RequestID: 3, LeaseID: lease, Action: ParkedRouteSwitch, Target: target}},
57+ }
58+ for _, tt := range requests {
59+ t.Run("request "+tt.name, func(t *testing.T) {
60+ payload := MarshalParkedRouteRequest(tt.value)
61+ require.NotNil(t, payload)
62+ if tt.golden != "" {
63+ golden, err := hex.DecodeString(tt.golden)
64+ require.NoError(t, err)
65+ require.Equal(t, golden, payload)
66+ }
67+ decoded, err := UnmarshalParkedRouteRequest(payload)
68+ require.NoError(t, err)
69+ require.Equal(t, tt.value, decoded)
70+ assertAllPrefixesFail(t, payload, UnmarshalParkedRouteRequest)
71+ assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteRequest)
72+ })
73+ }
74+
75+ responses := []struct {
76+ name string
77+ value ParkedRouteResponse
78+ golden string
79+ }{
80+ {name: "ready", value: ParkedRouteResponse{RequestID: 1, Status: ParkedRouteReady}, golden: "000000000000000101"},
81+ {name: "resumed", value: ParkedRouteResponse{RequestID: 2, Status: ParkedRouteResumed}},
82+ {name: "switched", value: ParkedRouteResponse{RequestID: 3, Status: ParkedRouteSwitched}},
83+ {name: "rejected", value: ParkedRouteResponse{RequestID: 4, Status: ParkedRouteRejected}},
84+ {name: "expired", value: ParkedRouteResponse{RequestID: 5, Status: ParkedRouteExpired}},
85+ {name: "stale target", value: ParkedRouteResponse{RequestID: 6, Status: ParkedRouteStaleTarget}},
86+ }
87+ for _, tt := range responses {
88+ t.Run("response "+tt.name, func(t *testing.T) {
89+ payload := MarshalParkedRouteResponse(tt.value)
90+ if tt.golden != "" {
91+ golden, err := hex.DecodeString(tt.golden)
92+ require.NoError(t, err)
93+ require.Equal(t, golden, payload)
94+ }
95+ decoded, err := UnmarshalParkedRouteResponse(payload)
96+ require.NoError(t, err)
97+ require.Equal(t, tt.value, decoded)
98+ assertAllPrefixesFail(t, payload, UnmarshalParkedRouteResponse)
99+ assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteResponse)
100+ })
101+ }
102+
103+ invalidRequests := []struct {
104+ name string
105+ value ParkedRouteRequest
106+ }{
107+ {name: "zero request ID", value: ParkedRouteRequest{LeaseID: lease, Action: ParkedRoutePrepare}},
108+ {name: "zero lease", value: ParkedRouteRequest{RequestID: 1, Action: ParkedRoutePrepare}},
109+ {name: "unknown action", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: 99}},
110+ {name: "prepare target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRoutePrepare, Target: target}},
111+ {name: "resume target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteResume, Target: target}},
112+ {name: "missing switch target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteSwitch}},
113+ {name: "invalid switch target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}}},
114+ }
115+ for _, tt := range invalidRequests {
116+ t.Run("invalid request "+tt.name, func(t *testing.T) {
117+ require.Nil(t, MarshalParkedRouteRequest(tt.value))
118+ })
119+ }
120+ for _, tt := range []struct {
121+ name string
122+ value ParkedRouteResponse
123+ }{
124+ {name: "zero request ID", value: ParkedRouteResponse{Status: ParkedRouteReady}},
125+ {name: "zero status", value: ParkedRouteResponse{RequestID: 1}},
126+ {name: "unknown status", value: ParkedRouteResponse{RequestID: 1, Status: 99}},
127+ } {
128+ t.Run("invalid response "+tt.name, func(t *testing.T) {
129+ require.Nil(t, MarshalParkedRouteResponse(tt.value))
130+ })
131+ }
132+}
133+
44 func TestHelloNavigationValidationTable(t *testing.T) {134 func TestHelloNavigationValidationTable(t *testing.T) {
45 base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}135 base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
46 remoteTarget := &domain.RemoteSessionTarget{136 remoteTarget := &domain.RemoteSessionTarget{
@@ -62,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},153 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},154 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002102" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},155+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002202" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},158 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
M internal/ports/wire.go +113 -12
@@ -2066,27 +2066,128 @@ func UnmarshalRemotePreview(data []byte) (RemotePreview, error) {
20662066 return p, nil
20672067 }
20682068
2069-// MarshalNavigationAction encodes one bounded navigation action.
2070-func MarshalNavigationAction(action NavigationAction) []byte {
2071- if action != NavigationOpenHomePicker && action != NavigationBack {
2069+// MarshalNavigationDirective encodes one bounded navigation directive.
2070+func MarshalNavigationDirective(directive NavigationDirective) []byte {
2071+ if directive.Action != NavigationOpenHomePicker && directive.Action != NavigationBack {
20722072 return nil
20732073 }
2074- return []byte{byte(action)}
2074+ if directive.Action == NavigationOpenHomePicker && directive.LeaseID.IsZero() || directive.Action == NavigationBack && !directive.LeaseID.IsZero() {
2075+ return nil
2076+ }
2077+ w := payloadWriter{}
2078+ w.putUint8(uint8(directive.Action))
2079+ w.putBytes(directive.LeaseID[:])
2080+ return w.b
20752081 }
20762082
2077-// UnmarshalNavigationAction decodes one strict navigation action payload.
2078-func UnmarshalNavigationAction(b []byte) (NavigationAction, error) {
2083+// UnmarshalNavigationDirective decodes one strict navigation directive.
2084+func UnmarshalNavigationDirective(b []byte) (NavigationDirective, error) {
20792085 r := payloadReader{b: b}
20802086 value, err := r.getUint8()
20812087 if err != nil {
2082- return 0, ErrInvalidNavigation
2088+ return NavigationDirective{}, ErrInvalidNavigation
2089+ }
2090+ lease, err := r.getBytes(len(ParkedRouteLeaseID{}))
2091+ if err != nil {
2092+ return NavigationDirective{}, ErrInvalidNavigation
20832093 }
20842094 if err := r.done(); err != nil {
2085- return 0, ErrInvalidNavigation
2095+ return NavigationDirective{}, ErrInvalidNavigation
2096+ }
2097+ directive := NavigationDirective{Action: NavigationAction(value)}
2098+ copy(directive.LeaseID[:], lease)
2099+ if MarshalNavigationDirective(directive) == nil {
2100+ return NavigationDirective{}, ErrInvalidNavigation
2101+ }
2102+ return directive, nil
2103+}
2104+
2105+// ValidateParkedRouteRequest enforces the closed action/target shape before a
2106+// request reaches either side's route state machine.
2107+func ValidateParkedRouteRequest(request ParkedRouteRequest) error {
2108+ if request.RequestID == 0 || request.LeaseID.IsZero() {
2109+ return ErrInvalidNavigation
2110+ }
2111+ switch request.Action {
2112+ case ParkedRoutePrepare, ParkedRouteResume:
2113+ if request.Target != nil {
2114+ return ErrInvalidNavigation
2115+ }
2116+ case ParkedRouteSwitch:
2117+ if request.Target == nil || validateWireRemoteTarget(*request.Target) != nil {
2118+ return ErrInvalidNavigation
2119+ }
2120+ default:
2121+ return ErrInvalidNavigation
2122+ }
2123+ return nil
2124+}
2125+
2126+// MarshalParkedRouteRequest encodes one retained-route operation.
2127+func MarshalParkedRouteRequest(request ParkedRouteRequest) []byte {
2128+ if ValidateParkedRouteRequest(request) != nil {
2129+ return nil
2130+ }
2131+ w := payloadWriter{}
2132+ w.putUint64(request.RequestID)
2133+ w.putBytes(request.LeaseID[:])
2134+ w.putUint8(uint8(request.Action))
2135+ marshalRemoteTargetSection(&w, request.Target, EnvironmentPolicyDaemonOwned)
2136+ return w.b
2137+}
2138+
2139+// UnmarshalParkedRouteRequest decodes one strict retained-route operation.
2140+func UnmarshalParkedRouteRequest(b []byte) (ParkedRouteRequest, error) {
2141+ r := payloadReader{b: b}
2142+ requestID, err := r.getUint64()
2143+ if err != nil {
2144+ return ParkedRouteRequest{}, ErrInvalidNavigation
2145+ }
2146+ lease, err := r.getBytes(len(ParkedRouteLeaseID{}))
2147+ if err != nil {
2148+ return ParkedRouteRequest{}, ErrInvalidNavigation
2149+ }
2150+ action, err := r.getUint8()
2151+ if err != nil {
2152+ return ParkedRouteRequest{}, ErrInvalidNavigation
2153+ }
2154+ target, policy, err := unmarshalRemoteTargetSection(&r)
2155+ if err != nil || policy != EnvironmentPolicyDaemonOwned || r.done() != nil {
2156+ return ParkedRouteRequest{}, ErrInvalidNavigation
2157+ }
2158+ request := ParkedRouteRequest{RequestID: requestID, Action: ParkedRouteAction(action), Target: target}
2159+ copy(request.LeaseID[:], lease)
2160+ if ValidateParkedRouteRequest(request) != nil {
2161+ return ParkedRouteRequest{}, ErrInvalidNavigation
2162+ }
2163+ return request, nil
2164+}
2165+
2166+// MarshalParkedRouteResponse encodes one correlated retained-route outcome.
2167+func MarshalParkedRouteResponse(response ParkedRouteResponse) []byte {
2168+ if response.RequestID == 0 || response.Status < ParkedRouteReady || response.Status > ParkedRouteStaleTarget {
2169+ return nil
2170+ }
2171+ w := payloadWriter{}
2172+ w.putUint64(response.RequestID)
2173+ w.putUint8(uint8(response.Status))
2174+ return w.b
2175+}
2176+
2177+// UnmarshalParkedRouteResponse decodes one strict retained-route outcome.
2178+func UnmarshalParkedRouteResponse(b []byte) (ParkedRouteResponse, error) {
2179+ r := payloadReader{b: b}
2180+ requestID, err := r.getUint64()
2181+ if err != nil {
2182+ return ParkedRouteResponse{}, ErrInvalidNavigation
2183+ }
2184+ status, err := r.getUint8()
2185+ if err != nil || r.done() != nil {
2186+ return ParkedRouteResponse{}, ErrInvalidNavigation
20862187 }
2087- action := NavigationAction(value)
2088- if action != NavigationOpenHomePicker && action != NavigationBack {
2089- return 0, ErrInvalidNavigation
2188+ response := ParkedRouteResponse{RequestID: requestID, Status: ParkedRouteStatus(status)}
2189+ if MarshalParkedRouteResponse(response) == nil {
2190+ return ParkedRouteResponse{}, ErrInvalidNavigation
20902191 }
2091- return action, nil
2192+ return response, nil
20922193 }
@@ -2066,27 +2066,128 @@ func UnmarshalRemotePreview(data []byte) (RemotePreview, error) {
2066 return p, nil2066 return p, nil
2067 }2067 }
2068 2068
2069-// MarshalNavigationAction encodes one bounded navigation action.2069+// MarshalNavigationDirective encodes one bounded navigation directive.
2070-func MarshalNavigationAction(action NavigationAction) []byte {2070+func MarshalNavigationDirective(directive NavigationDirective) []byte {
2071- if action != NavigationOpenHomePicker && action != NavigationBack {2071+ if directive.Action != NavigationOpenHomePicker && directive.Action != NavigationBack {
2072 return nil2072 return nil
2073 }2073 }
2074- return []byte{byte(action)}2074+ if directive.Action == NavigationOpenHomePicker && directive.LeaseID.IsZero() || directive.Action == NavigationBack && !directive.LeaseID.IsZero() {
2075+ return nil
2076+ }
2077+ w := payloadWriter{}
2078+ w.putUint8(uint8(directive.Action))
2079+ w.putBytes(directive.LeaseID[:])
2080+ return w.b
2075 }2081 }
2076 2082
2077-// UnmarshalNavigationAction decodes one strict navigation action payload.2083+// UnmarshalNavigationDirective decodes one strict navigation directive.
2078-func UnmarshalNavigationAction(b []byte) (NavigationAction, error) {2084+func UnmarshalNavigationDirective(b []byte) (NavigationDirective, error) {
2079 r := payloadReader{b: b}2085 r := payloadReader{b: b}
2080 value, err := r.getUint8()2086 value, err := r.getUint8()
2081 if err != nil {2087 if err != nil {
2082- return 0, ErrInvalidNavigation2088+ return NavigationDirective{}, ErrInvalidNavigation
2089+ }
2090+ lease, err := r.getBytes(len(ParkedRouteLeaseID{}))
2091+ if err != nil {
2092+ return NavigationDirective{}, ErrInvalidNavigation
2083 }2093 }
2084 if err := r.done(); err != nil {2094 if err := r.done(); err != nil {
2085- return 0, ErrInvalidNavigation2095+ return NavigationDirective{}, ErrInvalidNavigation
2096+ }
2097+ directive := NavigationDirective{Action: NavigationAction(value)}
2098+ copy(directive.LeaseID[:], lease)
2099+ if MarshalNavigationDirective(directive) == nil {
2100+ return NavigationDirective{}, ErrInvalidNavigation
2101+ }
2102+ return directive, nil
2103+}
2104+
2105+// ValidateParkedRouteRequest enforces the closed action/target shape before a
2106+// request reaches either side's route state machine.
2107+func ValidateParkedRouteRequest(request ParkedRouteRequest) error {
2108+ if request.RequestID == 0 || request.LeaseID.IsZero() {
2109+ return ErrInvalidNavigation
2110+ }
2111+ switch request.Action {
2112+ case ParkedRoutePrepare, ParkedRouteResume:
2113+ if request.Target != nil {
2114+ return ErrInvalidNavigation
2115+ }
2116+ case ParkedRouteSwitch:
2117+ if request.Target == nil || validateWireRemoteTarget(*request.Target) != nil {
2118+ return ErrInvalidNavigation
2119+ }
2120+ default:
2121+ return ErrInvalidNavigation
2122+ }
2123+ return nil
2124+}
2125+
2126+// MarshalParkedRouteRequest encodes one retained-route operation.
2127+func MarshalParkedRouteRequest(request ParkedRouteRequest) []byte {
2128+ if ValidateParkedRouteRequest(request) != nil {
2129+ return nil
2130+ }
2131+ w := payloadWriter{}
2132+ w.putUint64(request.RequestID)
2133+ w.putBytes(request.LeaseID[:])
2134+ w.putUint8(uint8(request.Action))
2135+ marshalRemoteTargetSection(&w, request.Target, EnvironmentPolicyDaemonOwned)
2136+ return w.b
2137+}
2138+
2139+// UnmarshalParkedRouteRequest decodes one strict retained-route operation.
2140+func UnmarshalParkedRouteRequest(b []byte) (ParkedRouteRequest, error) {
2141+ r := payloadReader{b: b}
2142+ requestID, err := r.getUint64()
2143+ if err != nil {
2144+ return ParkedRouteRequest{}, ErrInvalidNavigation
2145+ }
2146+ lease, err := r.getBytes(len(ParkedRouteLeaseID{}))
2147+ if err != nil {
2148+ return ParkedRouteRequest{}, ErrInvalidNavigation
2149+ }
2150+ action, err := r.getUint8()
2151+ if err != nil {
2152+ return ParkedRouteRequest{}, ErrInvalidNavigation
2153+ }
2154+ target, policy, err := unmarshalRemoteTargetSection(&r)
2155+ if err != nil || policy != EnvironmentPolicyDaemonOwned || r.done() != nil {
2156+ return ParkedRouteRequest{}, ErrInvalidNavigation
2157+ }
2158+ request := ParkedRouteRequest{RequestID: requestID, Action: ParkedRouteAction(action), Target: target}
2159+ copy(request.LeaseID[:], lease)
2160+ if ValidateParkedRouteRequest(request) != nil {
2161+ return ParkedRouteRequest{}, ErrInvalidNavigation
2162+ }
2163+ return request, nil
2164+}
2165+
2166+// MarshalParkedRouteResponse encodes one correlated retained-route outcome.
2167+func MarshalParkedRouteResponse(response ParkedRouteResponse) []byte {
2168+ if response.RequestID == 0 || response.Status < ParkedRouteReady || response.Status > ParkedRouteStaleTarget {
2169+ return nil
2170+ }
2171+ w := payloadWriter{}
2172+ w.putUint64(response.RequestID)
2173+ w.putUint8(uint8(response.Status))
2174+ return w.b
2175+}
2176+
2177+// UnmarshalParkedRouteResponse decodes one strict retained-route outcome.
2178+func UnmarshalParkedRouteResponse(b []byte) (ParkedRouteResponse, error) {
2179+ r := payloadReader{b: b}
2180+ requestID, err := r.getUint64()
2181+ if err != nil {
2182+ return ParkedRouteResponse{}, ErrInvalidNavigation
2183+ }
2184+ status, err := r.getUint8()
2185+ if err != nil || r.done() != nil {
2186+ return ParkedRouteResponse{}, ErrInvalidNavigation
2086 }2187 }
2087- action := NavigationAction(value)2188+ response := ParkedRouteResponse{RequestID: requestID, Status: ParkedRouteStatus(status)}
2088- if action != NavigationOpenHomePicker && action != NavigationBack {2189+ if MarshalParkedRouteResponse(response) == nil {
2089- return 0, ErrInvalidNavigation2190+ return ParkedRouteResponse{}, ErrInvalidNavigation
2090 }2191 }
2091- return action, nil2192+ return response, nil
2092 }2193 }
M internal/ports/wire_final_test.go +3 -3
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 33 {
15- t.Fatalf("ProtocolVersion = %d, want 33", ProtocolVersion)
14+ if ProtocolVersion != 34 {
15+ t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 33, 2}, make([]byte, 16+8)...)
29+ want := append([]byte{0, 34, 2}, make([]byte, 16+8)...)
3030 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 33 {14+ if ProtocolVersion != 34 {
15- t.Fatalf("ProtocolVersion = %d, want 33", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 33, 2}, make([]byte, 16+8)...)29+ want := append([]byte{0, 34, 2}, make([]byte, 16+8)...)
30 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)30 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
M internal/ports/wire_test.go +6 -6
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131131 }
132132 got := MarshalHello(msg)
133133 want := []byte{
134- 0x00, 0x21, 0x00, // version, intent, first client ID byte
134+ 0x00, 0x22, 0x00, // version, intent, first client ID byte
135135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361361 }
362362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363363 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(33), ProtocolVersion)
364+ require.Equal(t, uint16(34), ProtocolVersion)
365365 }
366366
367367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522522 name: "minimal",
523523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524524 want: []byte{
525- 0x00, 0x21,
525+ 0x00, 0x22,
526526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527527 0x00, // attached
528528 0x00, // self
@@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545545 JSON: true,
546546 },
547547 want: []byte{
548- 0x00, 0x21,
548+ 0x00, 0x22,
549549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550550 0x00, // attached
551551 0x01, // self
@@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974974 name: "attached",
975975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976976 want: []byte{
977- 0x00, 0x21, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
977+ 0x00, 0x22, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984984 name: "control",
985985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986986 want: []byte{
987- 0x00, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
987+ 0x00, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990990 },
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131 }131 }
132 got := MarshalHello(msg)132 got := MarshalHello(msg)
133 want := []byte{133 want := []byte{
134- 0x00, 0x21, 0x00, // version, intent, first client ID byte134+ 0x00, 0x22, 0x00, // version, intent, first client ID byte
135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361 }361 }
362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363 require.Equal(t, want, MarshalTheme(cleared))363 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(33), ProtocolVersion)364+ require.Equal(t, uint16(34), ProtocolVersion)
365 }365 }
366 366
367 func TestResizeGoldenAndRoundTrip(t *testing.T) {367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522 name: "minimal",522 name: "minimal",
523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524 want: []byte{524 want: []byte{
525- 0x00, 0x21,525+ 0x00, 0x22,
526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527 0x00, // attached527 0x00, // attached
528 0x00, // self528 0x00, // self
@@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545 JSON: true,545 JSON: true,
546 },546 },
547 want: []byte{547 want: []byte{
548- 0x00, 0x21,548+ 0x00, 0x22,
549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550 0x00, // attached550 0x00, // attached
551 0x01, // self551 0x01, // self
@@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974 name: "attached",974 name: "attached",
975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976 want: []byte{976 want: []byte{
977- 0x00, 0x21, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,977+ 0x00, 0x22, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984 name: "control",984 name: "control",
985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986 want: []byte{986 want: []byte{
987- 0x00, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,987+ 0x00, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988 0x00, 0x02, 'l', 's', 0x00, 0x00,988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990 },990 },
M internal/usecase/client/ack_queue_test.go +44 -2
@@ -32,7 +32,7 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) {
3232 defer cancel()
3333 tr := &ackRecordingTransport{sent: make(chan ports.Frame, sendQueueDepth+1)}
3434 errCh := make(chan error, 1)
35- go runSender(ctx, cancel, tr, nil, normal, nil, acks, errCh, slog.Default())
35+ go runSender(ctx, cancel, tr, nil, nil, normal, nil, acks, errCh, slog.Default())
3636
3737 first := <-tr.sent
3838 require.Equal(t, ports.MsgAck, first.Type)
@@ -54,7 +54,7 @@ func TestSamePeerSwitchControlBypassesHeldInput(t *testing.T) {
5454 transport := &ackRecordingTransport{sent: make(chan ports.Frame, 2)}
5555 acks := newCumulativeAckQueue()
5656 errs := make(chan error, 1)
57- go runSender(ctx, cancel, transport, control, normal, gate, acks, errs, slog.Default())
57+ go runSender(ctx, cancel, transport, control, nil, normal, gate, acks, errs, slog.Default())
5858
5959 normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")}
6060 awaitSenderSignal(t, held)
@@ -73,6 +73,48 @@ func TestSamePeerSwitchControlBypassesHeldInput(t *testing.T) {
7373 }
7474 }
7575
76+func TestSenderBarrierLeavesPausedInputQueued(t *testing.T) {
77+ ctx, cancel := context.WithCancel(context.Background())
78+ defer cancel()
79+ normal := make(chan ports.Frame, 1)
80+ barriers := make(chan chan struct{})
81+ gate := newSamePeerInputGate()
82+ gate.setPaused(true)
83+ transport := &ackRecordingTransport{sent: make(chan ports.Frame, 1)}
84+ go runSender(ctx, cancel, transport, nil, barriers, normal, gate, newCumulativeAckQueue(), make(chan error, 1), slog.Default())
85+
86+ normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")}
87+ barrierDone := make(chan struct{})
88+ barriers <- barrierDone
89+ awaitSenderSignal(t, barrierDone)
90+ select {
91+ case frame := <-transport.sent:
92+ t.Fatalf("barrier sent paused input frame %d", frame.Type)
93+ default:
94+ }
95+
96+ gate.setPaused(false)
97+ if got := awaitSenderFrame(t, transport.sent); got.Type != ports.MsgInput {
98+ t.Fatalf("released frame = %d, want input", got.Type)
99+ }
100+}
101+
102+func TestSenderBarrierFlushesPendingAck(t *testing.T) {
103+ ctx, cancel := context.WithCancel(context.Background())
104+ defer cancel()
105+ acks := newCumulativeAckQueue()
106+ acks.offer(3, 9)
107+ transport := &ackRecordingTransport{sent: make(chan ports.Frame, 1)}
108+ barriers := make(chan chan struct{})
109+ go runSender(ctx, cancel, transport, nil, barriers, make(chan ports.Frame, 1), newSamePeerInputGate(), acks, make(chan error, 1), slog.Default())
110+
111+ done := make(chan struct{})
112+ barriers <- done
113+ frame := awaitSenderFrame(t, transport.sent)
114+ require.Equal(t, ports.MsgAck, frame.Type)
115+ awaitSenderSignal(t, done)
116+}
117+
76118 func awaitSenderSignal(t *testing.T, signal <-chan struct{}) {
77119 t.Helper()
78120 select {
@@ -32,7 +32,7 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) {
32 defer cancel()32 defer cancel()
33 tr := &ackRecordingTransport{sent: make(chan ports.Frame, sendQueueDepth+1)}33 tr := &ackRecordingTransport{sent: make(chan ports.Frame, sendQueueDepth+1)}
34 errCh := make(chan error, 1)34 errCh := make(chan error, 1)
35- go runSender(ctx, cancel, tr, nil, normal, nil, acks, errCh, slog.Default())35+ go runSender(ctx, cancel, tr, nil, nil, normal, nil, acks, errCh, slog.Default())
36 36
37 first := <-tr.sent37 first := <-tr.sent
38 require.Equal(t, ports.MsgAck, first.Type)38 require.Equal(t, ports.MsgAck, first.Type)
@@ -54,7 +54,7 @@ func TestSamePeerSwitchControlBypassesHeldInput(t *testing.T) {
54 transport := &ackRecordingTransport{sent: make(chan ports.Frame, 2)}54 transport := &ackRecordingTransport{sent: make(chan ports.Frame, 2)}
55 acks := newCumulativeAckQueue()55 acks := newCumulativeAckQueue()
56 errs := make(chan error, 1)56 errs := make(chan error, 1)
57- go runSender(ctx, cancel, transport, control, normal, gate, acks, errs, slog.Default())57+ go runSender(ctx, cancel, transport, control, nil, normal, gate, acks, errs, slog.Default())
58 58
59 normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")}59 normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")}
60 awaitSenderSignal(t, held)60 awaitSenderSignal(t, held)
@@ -73,6 +73,48 @@ func TestSamePeerSwitchControlBypassesHeldInput(t *testing.T) {
73 }73 }
74 }74 }
75 75
76+func TestSenderBarrierLeavesPausedInputQueued(t *testing.T) {
77+ ctx, cancel := context.WithCancel(context.Background())
78+ defer cancel()
79+ normal := make(chan ports.Frame, 1)
80+ barriers := make(chan chan struct{})
81+ gate := newSamePeerInputGate()
82+ gate.setPaused(true)
83+ transport := &ackRecordingTransport{sent: make(chan ports.Frame, 1)}
84+ go runSender(ctx, cancel, transport, nil, barriers, normal, gate, newCumulativeAckQueue(), make(chan error, 1), slog.Default())
85+
86+ normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")}
87+ barrierDone := make(chan struct{})
88+ barriers <- barrierDone
89+ awaitSenderSignal(t, barrierDone)
90+ select {
91+ case frame := <-transport.sent:
92+ t.Fatalf("barrier sent paused input frame %d", frame.Type)
93+ default:
94+ }
95+
96+ gate.setPaused(false)
97+ if got := awaitSenderFrame(t, transport.sent); got.Type != ports.MsgInput {
98+ t.Fatalf("released frame = %d, want input", got.Type)
99+ }
100+}
101+
102+func TestSenderBarrierFlushesPendingAck(t *testing.T) {
103+ ctx, cancel := context.WithCancel(context.Background())
104+ defer cancel()
105+ acks := newCumulativeAckQueue()
106+ acks.offer(3, 9)
107+ transport := &ackRecordingTransport{sent: make(chan ports.Frame, 1)}
108+ barriers := make(chan chan struct{})
109+ go runSender(ctx, cancel, transport, nil, barriers, make(chan ports.Frame, 1), newSamePeerInputGate(), acks, make(chan error, 1), slog.Default())
110+
111+ done := make(chan struct{})
112+ barriers <- done
113+ frame := awaitSenderFrame(t, transport.sent)
114+ require.Equal(t, ports.MsgAck, frame.Type)
115+ awaitSenderSignal(t, done)
116+}
117+
76 func awaitSenderSignal(t *testing.T, signal <-chan struct{}) {118 func awaitSenderSignal(t *testing.T, signal <-chan struct{}) {
77 t.Helper()119 t.Helper()
78 select {120 select {
M internal/usecase/client/client.go +494 -72
@@ -398,6 +398,8 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
398398 rawEntered: &rawEntered,
399399 stage: reconnectStageOfflineRetrying,
400400 }
401+ transition := newTransitionUI(r.term, r.clock, &rawEntered)
402+ defer transition.stop()
401403 restoreReturnRoute := func(route attachRoute) {
402404 returnNavigationPending = true
403405 dialer = route.dialer
@@ -444,12 +446,58 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
444446 return nil
445447 }
446448
449+ runTransientHomePicker := func(attemptCtx context.Context) attachResult {
450+ if homeRoute == nil {
451+ return attachResult{err: errors.New("vev: home route unavailable")}
452+ }
453+ request := cloneAttachRequest(homeRoute.request)
454+ request.Intent = ports.IntentAttach
455+ request.NavigationCapabilities = ports.NavigationCapabilityBack
456+ request.StartupOverlay = ports.StartupOverlaySessionPicker
457+ request.RemoteTarget = nil
458+ request.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
459+ request.Remote = false
460+ if err := validateAttachRequest(request); err != nil {
461+ return attachResult{err: err}
462+ }
463+ handshakeCtx, timedOut, finishHandshake := newHandshakeContext(attemptCtx, r.clock)
464+ transport, err := boundedDial(handshakeCtx, homeRoute.dialer)
465+ if err != nil {
466+ err = handshakeContextError(attemptCtx, timedOut, err)
467+ finishHandshake()
468+ return attachResult{err: err}
469+ }
470+ connection, err := NewSessionConnection(transport, SessionTarget{Intent: request.Intent, SessionName: request.SessionName})
471+ if err != nil {
472+ finishHandshake()
473+ _ = transport.Close()
474+ return attachResult{err: err}
475+ }
476+ stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
477+ localReconnect := &reconnectUI{term: r.term, rawEntered: &rawEntered}
478+ result := (&attachAttempt{
479+ runner: r, dialer: homeRoute.dialer, connection: connection,
480+ handshakeCtx: handshakeCtx, handshakeTimedOut: timedOut,
481+ finishHandshake: finishHandshake, stopHandshakeTransport: stopHandshakeTransport,
482+ request: request, clientID: processClientID, milestones: &ms,
483+ themeState: themeState, enterRaw: enterRaw, reconnect: localReconnect,
484+ terminalInput: func() *terminalInputPump { return input },
485+ transientPicker: true, returnAttachTargets: true,
486+ }).run(attemptCtx)
487+ stopHandshakeTransport()
488+ finishHandshake()
489+ if !result.transportClosed {
490+ _ = connection.Close()
491+ }
492+ return result
493+ }
494+
447495 for {
448496 if err := validateAttachRequest(attemptRequest); err != nil {
449497 return err
450498 }
451499 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
452- transport, err := boundedDial(handshakeCtx, dialer)
500+ transport, err := boundedDialWithTransition(handshakeCtx, dialer, transition)
453501 if err != nil {
454502 err = handshakeContextError(ctx, timedOut, err)
455503 finishHandshake()
@@ -515,11 +563,13 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
515563 themeState: themeState,
516564 enterRaw: enterRaw,
517565 reconnect: reconnect,
566+ transition: transition,
518567 linkEvents: linkEvents,
519568 rememberRemoteHost: rememberRemoteHost,
520569 terminalInput: func() *terminalInputPump {
521570 return input
522571 },
572+ openHomePicker: runTransientHomePicker,
523573 }).run(ctx)
524574 stopHandshakeTransport()
525575 finishHandshake()
@@ -655,8 +705,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
655705 if r.ledger == nil {
656706 return errors.New("vev: route ledger unavailable")
657707 }
708+ baseRequest := attemptRequest
658709 nextDialer = dialer
659- nextRequest = r.ledger.samePeerHandoff(attemptRequest, *result.target)
710+ if result.homePickerTarget {
711+ if homeRoute == nil {
712+ return errors.New("vev: home route unavailable for picker target")
713+ }
714+ nextDialer = homeRoute.dialer
715+ baseRequest = homeRoute.request
716+ }
717+ nextRequest = r.ledger.samePeerHandoff(baseRequest, *result.target)
660718 } else {
661719 if r.attachHandoff == nil {
662720 return &AttachTargetError{Target: *result.target}
@@ -910,9 +968,13 @@ type attachAttempt struct {
910968 themeState *terminalThemeState
911969 enterRaw func() error
912970 reconnect *reconnectUI
971+ transition *transitionUI
913972 linkEvents <-chan ports.LinkEvent
914973 rememberRemoteHost func()
915974 terminalInput func() *terminalInputPump
975+ openHomePicker func(context.Context) attachResult
976+ transientPicker bool
977+ returnAttachTargets bool
916978 }
917979
918980 type attachResult struct {
@@ -924,6 +986,7 @@ type attachResult struct {
924986 transportClosed bool
925987 target *ports.AttachTarget
926988 action ports.NavigationAction
989+ homePickerTarget bool
927990 routeAction *ports.RouteNavigationAction
928991 err error
929992 }
@@ -979,6 +1042,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
9791042 themeState := a.themeState
9801043 enterRaw := a.enterRaw
9811044 reconnect := a.reconnect
1045+ transition := a.transition
9821046 linkEvents := a.linkEvents
9831047 remote := a.remote || a.request.Remote
9841048 clipboard := a.runner.clipboard
@@ -1013,7 +1077,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10131077 return attachResult{transportClosed: handshakeCtx.Err() != nil, err: fmt.Errorf("vev: %s: %w", stage, err)}
10141078 }
10151079 sendHandshake := func(operation func() error) error {
1016- err := boundedHandshakeOperation(handshakeCtx, transport, operation)
1080+ err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, operation, transition)
10171081 if err != nil {
10181082 return handshakeContextError(ctx, handshakeTimedOut, err)
10191083 }
@@ -1067,11 +1131,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10671131
10681132 // 2. Await Welcome or a typed rejection.
10691133 var reply ports.Frame
1070- if err := boundedHandshakeOperation(handshakeCtx, transport, func() error {
1134+ if err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, func() error {
10711135 var recvErr error
10721136 reply, recvErr = transport.Recv()
10731137 return recvErr
1074- }); err != nil {
1138+ }, transition); err != nil {
10751139 return handshakeFailure("awaiting welcome", err)
10761140 }
10771141 var committedIdentity *ports.CommittedRouteIdentity
@@ -1122,16 +1186,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
11221186 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
11231187 return welcomedResult(errRouteTargetChanged)
11241188 }
1125- candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1126- var commitErr error
1127- if a.routeNavigationSelection != nil {
1128- selection := *a.routeNavigationSelection
1129- commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)
1130- } else {
1131- _, commitErr = a.runner.ledger.commitAttach(candidate)
1132- }
1133- if commitErr != nil {
1134- return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
1189+ if !a.transientPicker {
1190+ candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1191+ var commitErr error
1192+ if a.routeNavigationSelection != nil {
1193+ selection := *a.routeNavigationSelection
1194+ commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)
1195+ } else {
1196+ _, commitErr = a.runner.ledger.commitAttach(candidate)
1197+ }
1198+ if commitErr != nil {
1199+ return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
1200+ }
11351201 }
11361202 snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
11371203 if err != nil {
@@ -1183,21 +1249,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
11831249 input.start()
11841250 defer input.stop()
11851251 }
1186- inputConsumer := input.claim()
1187- claimActive := true
1188- revokeInputClaim := func() {
1189- if claimActive {
1190- input.revoke(inputConsumer)
1191- claimActive = false
1192- }
1193- }
1194- // Register revocation before any palette publication or terminal I/O: both
1195- // can fail before the stdin scanner is started, and a reconnect must still
1196- // be able to claim this lifecycle-owned reader.
1197- defer revokeInputClaim()
1198-
11991252 sendCh := make(chan ports.Frame, sendQueueDepth)
12001253 controlCh := make(chan ports.Frame, 1)
1254+ barrierCh := make(chan chan struct{})
12011255 inputGate := newSamePeerInputGate()
12021256 ackQueue := newCumulativeAckQueue()
12031257 sendErrCh := make(chan error, 1)
@@ -1309,8 +1363,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
13091363 awaitingReconnectReset := false
13101364 outputState := outputApplyState{}
13111365 outputResetRequested := false
1366+ transitionWaitingFull := false
13121367 var samePeerSwitch *samePeerSwitchPending
13131368 var nextSamePeerRequestID uint64
1369+ type parkedRoutePending struct {
1370+ requestID uint64
1371+ action ports.ParkedRouteAction
1372+ leaseID ports.ParkedRouteLeaseID
1373+ fallbackTarget *ports.AttachTarget
1374+ timer ports.Timer
1375+ }
1376+ var parkedPending *parkedRoutePending
1377+ var nextParkedRequestID uint64
1378+ parkedWaitingFull := false
1379+ var parkedFullTimer ports.Timer
1380+ transportFailed := make(chan struct{})
13141381 if reconnect.showing {
13151382 if reconnect.remote {
13161383 // Before the asynchronous sender starts, preserve transport ordering by
@@ -1339,27 +1406,195 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
13391406 // or unchanged screen may legitimately produce no initial Output frame.
13401407 endHandshake()
13411408 senderStarted = true
1342- go runSender(loopCtx, cancel, transport, controlCh, sendCh, inputGate, ackQueue, sendErrCh, log)
1343- stdinDone := make(chan struct{})
1344- go func() {
1345- defer close(stdinDone)
1346- (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
1347- }()
1348- // Scanner cancellation can leave an undecided marker suffix (including a
1349- // standalone Escape) that it must hand back to the lifecycle-owned reader.
1350- // Wait for that handoff before revoking the claim: otherwise a replacement
1351- // scanner could claim the reader between revocation and preservation and
1352- // lose the suffix.
1353- defer func() {
1354- cancel()
1355- <-stdinDone
1356- revokeInputClaim()
1357- }()
1358- go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
1409+ go runSender(loopCtx, cancel, transport, controlCh, barrierCh, sendCh, inputGate, ackQueue, sendErrCh, log)
1410+
1411+ type foregroundRuntime struct {
1412+ cancel context.CancelFunc
1413+ consumer uint64
1414+ sendLease *foregroundSendLease
1415+ stdinDone, resizeDone chan struct{}
1416+ }
1417+ var foreground *foregroundRuntime
1418+ startForeground := func() {
1419+ if foreground != nil {
1420+ return
1421+ }
1422+ foregroundCtx, foregroundCancel := context.WithCancel(loopCtx)
1423+ consumer := input.claim()
1424+ stdinDone := make(chan struct{})
1425+ resizeDone := make(chan struct{})
1426+ sendLease := newForegroundSendLease()
1427+ foreground = &foregroundRuntime{cancel: foregroundCancel, consumer: consumer, sendLease: sendLease, stdinDone: stdinDone, resizeDone: resizeDone}
1428+ go func() {
1429+ defer close(stdinDone)
1430+ cancelAll := func() {
1431+ foregroundCancel()
1432+ cancel()
1433+ }
1434+ (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease}).run()
1435+ }()
1436+ go func() {
1437+ defer close(resizeDone)
1438+ runResize(foregroundCtx, term.ResizeEvents(), sendCh, sendLease, log)
1439+ }()
1440+ }
1441+ stopForeground := func() {
1442+ if foreground == nil {
1443+ return
1444+ }
1445+ current := foreground
1446+ foreground = nil
1447+ current.cancel()
1448+ current.sendLease.stop()
1449+ <-current.stdinDone
1450+ <-current.resizeDone
1451+ input.revoke(current.consumer)
1452+ for id := range drainTimers {
1453+ cancelTimer(drainTimers, id)
1454+ }
1455+ for id := range completionTimers {
1456+ cancelTimer(completionTimers, id)
1457+ }
1458+ }
1459+ flushSender := func() error {
1460+ done := make(chan struct{})
1461+ select {
1462+ case barrierCh <- done:
1463+ case <-loopCtx.Done():
1464+ return context.Canceled
1465+ }
1466+ select {
1467+ case <-done:
1468+ return nil
1469+ case <-loopCtx.Done():
1470+ return context.Canceled
1471+ }
1472+ }
1473+ clearParkedFull := func() {
1474+ parkedWaitingFull = false
1475+ if parkedFullTimer != nil {
1476+ parkedFullTimer.Stop()
1477+ parkedFullTimer = nil
1478+ }
1479+ }
1480+ awaitParkedFull := func() {
1481+ clearParkedFull()
1482+ parkedWaitingFull = true
1483+ parkedFullTimer = clk.NewTimer(ports.HandshakeTimeout)
1484+ }
1485+ parkedFullC := func() <-chan time.Time {
1486+ if parkedFullTimer == nil {
1487+ return nil
1488+ }
1489+ return parkedFullTimer.C()
1490+ }
1491+ defer clearParkedFull()
1492+ clearParkedPending := func() {
1493+ if parkedPending != nil && parkedPending.timer != nil {
1494+ parkedPending.timer.Stop()
1495+ }
1496+ parkedPending = nil
1497+ }
1498+ parkedResponseC := func() <-chan time.Time {
1499+ if parkedPending == nil || parkedPending.timer == nil {
1500+ return nil
1501+ }
1502+ return parkedPending.timer.C()
1503+ }
1504+ defer clearParkedPending()
1505+
1506+ sendParkedRouteSize := func() error {
1507+ current, err := term.Size()
1508+ if err != nil {
1509+ return fmt.Errorf("reading terminal size for parked route: %w", err)
1510+ }
1511+ payload, err := ports.MarshalResize(ports.Resize{Size: current})
1512+ if err != nil {
1513+ return fmt.Errorf("encoding terminal size for parked route: %w", err)
1514+ }
1515+ select {
1516+ case controlCh <- ports.Frame{Type: ports.MsgResize, Payload: payload}:
1517+ return nil
1518+ case <-loopCtx.Done():
1519+ return context.Canceled
1520+ }
1521+ }
1522+ sendParkedRouteRequest := func(action ports.ParkedRouteAction, leaseID ports.ParkedRouteLeaseID, target *domain.RemoteSessionTarget) error {
1523+ if parkedPending != nil {
1524+ return errors.New("vev: parked-route request already pending")
1525+ }
1526+ nextParkedRequestID++
1527+ request := ports.ParkedRouteRequest{RequestID: nextParkedRequestID, LeaseID: leaseID, Action: action, Target: target}
1528+ payload := ports.MarshalParkedRouteRequest(request)
1529+ if payload == nil {
1530+ return errors.New("vev: invalid parked-route request")
1531+ }
1532+ select {
1533+ case controlCh <- ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: payload}:
1534+ parkedPending = &parkedRoutePending{
1535+ requestID: request.RequestID, action: action, leaseID: leaseID,
1536+ timer: clk.NewTimer(ports.HandshakeTimeout),
1537+ }
1538+ return nil
1539+ case <-loopCtx.Done():
1540+ return context.Canceled
1541+ }
1542+ }
1543+ handleParkedPicker := func(leaseID ports.ParkedRouteLeaseID) (attachResult, bool) {
1544+ pickerCtx, cancelPicker := context.WithCancel(ctx)
1545+ pickerDone := make(chan struct{})
1546+ go func() {
1547+ select {
1548+ case <-transportFailed:
1549+ cancelPicker()
1550+ case <-pickerDone:
1551+ }
1552+ }()
1553+ selection := a.openHomePicker(pickerCtx)
1554+ close(pickerDone)
1555+ cancelPicker()
1556+ select {
1557+ case <-transportFailed:
1558+ return welcomedResult(errLinkOffline), true
1559+ default:
1560+ }
1561+ if selection.target != nil && transition != nil {
1562+ transitionWaitingFull = false
1563+ transition.start(*selection.target)
1564+ }
1565+ if selection.target != nil && request.Remote && request.OriginKey != "" && selection.target.Endpoint == request.OriginKey && selection.target.RemoteTarget != nil {
1566+ target := *selection.target.RemoteTarget
1567+ if err := sendParkedRouteSize(); err != nil {
1568+ return welcomedResult(err), true
1569+ }
1570+ if err := sendParkedRouteRequest(ports.ParkedRouteSwitch, leaseID, &target); err != nil {
1571+ return welcomedResult(fmt.Errorf("vev: switching parked route: %w", err)), true
1572+ }
1573+ fallback := *selection.target
1574+ parkedPending.fallbackTarget = &fallback
1575+ return attachResult{}, false
1576+ }
1577+ if selection.target != nil {
1578+ selection.homePickerTarget = true
1579+ return selection, true
1580+ }
1581+ if selection.err != nil {
1582+ log.Warn("transient home picker failed; resuming parked route", "err", selection.err)
1583+ }
1584+ if err := sendParkedRouteSize(); err != nil {
1585+ return welcomedResult(err), true
1586+ }
1587+ if err := sendParkedRouteRequest(ports.ParkedRouteResume, leaseID, nil); err != nil {
1588+ return welcomedResult(fmt.Errorf("vev: resuming parked route: %w", err)), true
1589+ }
1590+ return attachResult{}, false
1591+ }
1592+ startForeground()
1593+ defer stopForeground()
13591594
13601595 // 5. Output/main loop: the only goroutine that touches the terminal.
13611596 recvCh := make(chan recvResult, 1)
1362- go runRecv(loopCtx, transport, recvCh, log)
1597+ go runRecv(loopCtx, transport, recvCh, transportFailed, log)
13631598
13641599 requestReconnectReset := func() error {
13651600 if awaitingReconnectReset {
@@ -1447,6 +1682,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
14471682 select {
14481683 case <-loopCtx.Done():
14491684 return loopCanceledResult()
1685+ case <-transition.tickC():
1686+ if err := transition.advance(); err != nil {
1687+ return welcomedResult(err)
1688+ }
1689+ case <-parkedResponseC():
1690+ clearParkedPending()
1691+ return welcomedResult(errors.New("vev: timed out waiting for parked-route response"))
1692+ case <-parkedFullC():
1693+ clearParkedFull()
1694+ return welcomedResult(errors.New("vev: timed out waiting for parked-route full output"))
14501695 case ev, ok := <-linkEvents:
14511696 if !ok {
14521697 linkEvents = nil
@@ -1537,6 +1782,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15371782 }
15381783 continue
15391784 }
1785+ if parkedWaitingFull && !o.Full {
1786+ return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output"))
1787+ }
15401788 if _, werr := term.Out().Write(o.Data); werr != nil {
15411789 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
15421790 }
@@ -1555,6 +1803,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15551803 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
15561804 }
15571805 outputState = nextState
1806+ if transition != nil && transition.active && (!transitionWaitingFull || o.Full) {
1807+ transitionWaitingFull = false
1808+ transition.stop()
1809+ }
1810+ if parkedWaitingFull {
1811+ clearParkedFull()
1812+ startForeground()
1813+ }
15581814 if o.New != 0 {
15591815 ackQueue.offer(o.Epoch, o.New)
15601816 outputResetRequested = false
@@ -1573,6 +1829,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15731829 if derr != nil {
15741830 return welcomedResult(fmt.Errorf("vev: decoding attach target: %w", derr))
15751831 }
1832+ if a.returnAttachTargets {
1833+ handoff := welcomedResult(nil)
1834+ handoff.target = &target
1835+ return handoff
1836+ }
1837+ if transition != nil {
1838+ transitionWaitingFull = false
1839+ transition.start(target)
1840+ }
15761841 if target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil {
15771842 if samePeerSwitch != nil {
15781843 return welcomedResult(errors.New("vev: concurrent same-peer switch offer"))
@@ -1599,13 +1864,71 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15991864 handoff := welcomedResult(nil)
16001865 handoff.target = &target
16011866 return handoff
1867+ case ports.MsgParkedRouteResponse:
1868+ response, derr := ports.UnmarshalParkedRouteResponse(r.frame.Payload)
1869+ if derr != nil {
1870+ return welcomedResult(fmt.Errorf("vev: decoding parked-route response: %w", derr))
1871+ }
1872+ if parkedPending == nil || response.RequestID != parkedPending.requestID {
1873+ return welcomedResult(errors.New("vev: unexpected parked-route response"))
1874+ }
1875+ pending := *parkedPending
1876+ clearParkedPending()
1877+ switch pending.action {
1878+ case ports.ParkedRoutePrepare:
1879+ if response.Status != ports.ParkedRouteReady {
1880+ fallback := welcomedResult(nil)
1881+ fallback.action = ports.NavigationOpenHomePicker
1882+ return fallback
1883+ }
1884+ if selection, done := handleParkedPicker(pending.leaseID); done {
1885+ return selection
1886+ }
1887+ case ports.ParkedRouteResume:
1888+ if response.Status != ports.ParkedRouteResumed {
1889+ return welcomedResult(errors.New("vev: parked route could not resume"))
1890+ }
1891+ awaitParkedFull()
1892+ case ports.ParkedRouteSwitch:
1893+ switch response.Status {
1894+ case ports.ParkedRouteSwitched:
1895+ awaitParkedFull()
1896+ continue
1897+ case ports.ParkedRouteStaleTarget:
1898+ if transition != nil {
1899+ transition.stop()
1900+ }
1901+ if selection, done := handleParkedPicker(pending.leaseID); done {
1902+ return selection
1903+ }
1904+ default:
1905+ if pending.fallbackTarget == nil {
1906+ return welcomedResult(errors.New("vev: parked route switch failed"))
1907+ }
1908+ fallback := welcomedResult(nil)
1909+ fallback.target = pending.fallbackTarget
1910+ fallback.homePickerTarget = true
1911+ return fallback
1912+ }
1913+ }
16021914 case ports.MsgNavigationAction:
1603- action, derr := ports.UnmarshalNavigationAction(r.frame.Payload)
1915+ directive, derr := ports.UnmarshalNavigationDirective(r.frame.Payload)
16041916 if derr != nil {
16051917 return welcomedResult(fmt.Errorf("vev: decoding navigation action: %w", derr))
16061918 }
1919+ _, datagramRoute := transport.(ports.DatagramTransport)
1920+ if directive.Action == ports.NavigationOpenHomePicker && a.openHomePicker != nil && datagramRoute {
1921+ stopForeground()
1922+ if err := flushSender(); err != nil {
1923+ return welcomedResult(fmt.Errorf("vev: parking remote foreground: %w", err))
1924+ }
1925+ if err := sendParkedRouteRequest(ports.ParkedRoutePrepare, directive.LeaseID, nil); err != nil {
1926+ return welcomedResult(fmt.Errorf("vev: preparing parked route: %w", err))
1927+ }
1928+ continue
1929+ }
16071930 navigation := welcomedResult(nil)
1608- navigation.action = action
1931+ navigation.action = directive.Action
16091932 return navigation
16101933 case ports.MsgNavigateRecentRoute:
16111934 action, derr := ports.UnmarshalRouteNavigationAction(r.frame.Payload)
@@ -1662,6 +1985,19 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
16621985 if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID {
16631986 samePeerSwitch = nil
16641987 inputGate.setPaused(false)
1988+ if transition != nil && transition.showing {
1989+ transitionWaitingFull = true
1990+ if !outputResetRequested {
1991+ select {
1992+ case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}:
1993+ outputResetRequested = true
1994+ case <-loopCtx.Done():
1995+ return loopCanceledResult()
1996+ }
1997+ }
1998+ } else if transition != nil {
1999+ transition.stop()
2000+ }
16652001 }
16662002 case ports.MsgRoutePosition:
16672003 position, derr := ports.UnmarshalRoutePosition(r.frame.Payload)
@@ -1712,10 +2048,13 @@ type recvResult struct {
17122048
17132049 // runRecv reads frames until an error, forwarding each to the main loop.
17142050 // It exits on the first error or when the loop context is cancelled.
1715-func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, log *slog.Logger) {
2051+func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, failed chan<- struct{}, log *slog.Logger) {
17162052 defer log.Debug("receive pump exited")
17172053 for {
17182054 f, err := transport.Recv()
2055+ if err != nil && failed != nil {
2056+ close(failed)
2057+ }
17192058 select {
17202059 case out <- recvResult{frame: f, err: err}:
17212060 case <-ctx.Done():
@@ -1766,7 +2105,7 @@ func (q *cumulativeAckQueue) take() (uint64, uint64) {
17662105
17672106 // runSender preserves normal-frame order while allowing switch control and
17682107 // output ACKs to make progress when raw input is held for a same-peer switch.
1769-func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, control, in <-chan ports.Frame, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
2108+func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, control <-chan ports.Frame, barriers <-chan chan struct{}, in <-chan ports.Frame, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
17702109 defer log.Debug("sender pump exited")
17712110 send := func(f ports.Frame) bool {
17722111 if err := transport.Send(f); err != nil {
@@ -1795,6 +2134,36 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
17952134 }
17962135 return send(ports.Frame{Type: ports.MsgAck, Payload: payload})
17972136 }
2137+ flushPending := func() bool {
2138+ for {
2139+ select {
2140+ case <-acks.wake:
2141+ if !sendAck() {
2142+ return false
2143+ }
2144+ continue
2145+ default:
2146+ }
2147+ if paused, _ := inputGate.snapshot(); paused {
2148+ return sendAck()
2149+ }
2150+ select {
2151+ case frame := <-in:
2152+ if !send(frame) {
2153+ return false
2154+ }
2155+ default:
2156+ return sendAck()
2157+ }
2158+ }
2159+ }
2160+ completeBarrier := func(done chan struct{}) bool {
2161+ if !flushPending() {
2162+ return false
2163+ }
2164+ close(done)
2165+ return true
2166+ }
17982167 var heldInput *ports.Frame
17992168 for {
18002169 if heldInput != nil {
@@ -1815,6 +2184,10 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
18152184 if !send(frame) {
18162185 return
18172186 }
2187+ case done := <-barriers:
2188+ if !completeBarrier(done) {
2189+ return
2190+ }
18182191 case <-changed:
18192192 case <-ctx.Done():
18202193 return
@@ -1832,6 +2205,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
18322205 return
18332206 }
18342207 continue
2208+ case done := <-barriers:
2209+ if !completeBarrier(done) {
2210+ return
2211+ }
2212+ continue
18352213 default:
18362214 }
18372215 select {
@@ -1843,6 +2221,10 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
18432221 if !send(frame) {
18442222 return
18452223 }
2224+ case done := <-barriers:
2225+ if !completeBarrier(done) {
2226+ return
2227+ }
18462228 case frame := <-in:
18472229 select {
18482230 case <-ctx.Done():
@@ -2185,6 +2567,36 @@ func (p *terminalInputPump) stop() {
21852567 // preserving all ordinary terminal input byte-for-byte. It never reads the
21862568 // terminal directly, publishes a Theme, or writes terminal output;
21872569 // attachAttempt owns those operations.
2570+type foregroundSendLease struct {
2571+ mu sync.Mutex
2572+ active bool
2573+}
2574+
2575+func newForegroundSendLease() *foregroundSendLease {
2576+ return &foregroundSendLease{active: true}
2577+}
2578+
2579+func (l *foregroundSendLease) send(send func() bool) bool {
2580+ if l == nil {
2581+ return send()
2582+ }
2583+ l.mu.Lock()
2584+ defer l.mu.Unlock()
2585+ if !l.active {
2586+ return false
2587+ }
2588+ return send()
2589+}
2590+
2591+func (l *foregroundSendLease) stop() {
2592+ if l == nil {
2593+ return
2594+ }
2595+ l.mu.Lock()
2596+ l.active = false
2597+ l.mu.Unlock()
2598+}
2599+
21882600 type stdinPump struct {
21892601 ctx context.Context
21902602 cancel context.CancelFunc
@@ -2200,6 +2612,7 @@ type stdinPump struct {
22002612 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
22012613 afterInputTake func() // test synchronization hook
22022614 afterInputDelivered func() // test synchronization hook
2615+ sendLease *foregroundSendLease
22032616 }
22042617
22052618 func (p *stdinPump) run() {
@@ -2215,31 +2628,35 @@ func (p *stdinPump) run() {
22152628 if !sendOK.Load() {
22162629 return false
22172630 }
2218- select {
2219- case p.out <- frame:
2220- if p.afterInputDelivered != nil {
2221- p.afterInputDelivered()
2631+ return p.sendLease.send(func() bool {
2632+ select {
2633+ case p.out <- frame:
2634+ if p.afterInputDelivered != nil {
2635+ p.afterInputDelivered()
2636+ }
2637+ return true
2638+ case <-p.ctx.Done():
2639+ sendOK.Store(false)
2640+ return false
22222641 }
2223- return true
2224- case <-p.ctx.Done():
2225- sendOK.Store(false)
2226- return false
2227- }
2642+ })
22282643 }
22292644 sendEvent := func(event paletteGenerationEvent) bool {
22302645 if !sendOK.Load() {
22312646 return false
22322647 }
2233- select {
2234- case p.paletteEvents <- event:
2235- if p.afterPaletteEvent != nil {
2236- p.afterPaletteEvent(event)
2648+ return p.sendLease.send(func() bool {
2649+ select {
2650+ case p.paletteEvents <- event:
2651+ if p.afterPaletteEvent != nil {
2652+ p.afterPaletteEvent(event)
2653+ }
2654+ return true
2655+ case <-p.ctx.Done():
2656+ sendOK.Store(false)
2657+ return false
22372658 }
2238- return true
2239- case <-p.ctx.Done():
2240- sendOK.Store(false)
2241- return false
2242- }
2659+ })
22432660 }
22442661 coalescer := newPasteCoalescer(p.clock, func(data []byte) {
22452662 if len(data) == 0 {
@@ -2427,7 +2844,7 @@ func (p *stdinPump) run() {
24272844 // runResize forwards coalesced terminal resize events to the daemon. It
24282845 // tolerates an already-closed resize channel (which the terminal adapter
24292846 // hands back when restore ran before ResizeEvents was first called).
2430-func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, log *slog.Logger) {
2847+func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, sendLease *foregroundSendLease, log *slog.Logger) {
24312848 defer log.Debug("resize pump exited")
24322849 for {
24332850 select {
@@ -2441,9 +2858,14 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.
24412858 continue
24422859 }
24432860 frame := ports.Frame{Type: ports.MsgResize, Payload: payload}
2444- select {
2445- case out <- frame:
2446- case <-ctx.Done():
2861+ if !sendLease.send(func() bool {
2862+ select {
2863+ case out <- frame:
2864+ return true
2865+ case <-ctx.Done():
2866+ return false
2867+ }
2868+ }) {
24472869 return
24482870 }
24492871 case <-ctx.Done():
@@ -398,6 +398,8 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
398 rawEntered: &rawEntered,398 rawEntered: &rawEntered,
399 stage: reconnectStageOfflineRetrying,399 stage: reconnectStageOfflineRetrying,
400 }400 }
401+ transition := newTransitionUI(r.term, r.clock, &rawEntered)
402+ defer transition.stop()
401 restoreReturnRoute := func(route attachRoute) {403 restoreReturnRoute := func(route attachRoute) {
402 returnNavigationPending = true404 returnNavigationPending = true
403 dialer = route.dialer405 dialer = route.dialer
@@ -444,12 +446,58 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
444 return nil446 return nil
445 }447 }
446 448
449+ runTransientHomePicker := func(attemptCtx context.Context) attachResult {
450+ if homeRoute == nil {
451+ return attachResult{err: errors.New("vev: home route unavailable")}
452+ }
453+ request := cloneAttachRequest(homeRoute.request)
454+ request.Intent = ports.IntentAttach
455+ request.NavigationCapabilities = ports.NavigationCapabilityBack
456+ request.StartupOverlay = ports.StartupOverlaySessionPicker
457+ request.RemoteTarget = nil
458+ request.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
459+ request.Remote = false
460+ if err := validateAttachRequest(request); err != nil {
461+ return attachResult{err: err}
462+ }
463+ handshakeCtx, timedOut, finishHandshake := newHandshakeContext(attemptCtx, r.clock)
464+ transport, err := boundedDial(handshakeCtx, homeRoute.dialer)
465+ if err != nil {
466+ err = handshakeContextError(attemptCtx, timedOut, err)
467+ finishHandshake()
468+ return attachResult{err: err}
469+ }
470+ connection, err := NewSessionConnection(transport, SessionTarget{Intent: request.Intent, SessionName: request.SessionName})
471+ if err != nil {
472+ finishHandshake()
473+ _ = transport.Close()
474+ return attachResult{err: err}
475+ }
476+ stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
477+ localReconnect := &reconnectUI{term: r.term, rawEntered: &rawEntered}
478+ result := (&attachAttempt{
479+ runner: r, dialer: homeRoute.dialer, connection: connection,
480+ handshakeCtx: handshakeCtx, handshakeTimedOut: timedOut,
481+ finishHandshake: finishHandshake, stopHandshakeTransport: stopHandshakeTransport,
482+ request: request, clientID: processClientID, milestones: &ms,
483+ themeState: themeState, enterRaw: enterRaw, reconnect: localReconnect,
484+ terminalInput: func() *terminalInputPump { return input },
485+ transientPicker: true, returnAttachTargets: true,
486+ }).run(attemptCtx)
487+ stopHandshakeTransport()
488+ finishHandshake()
489+ if !result.transportClosed {
490+ _ = connection.Close()
491+ }
492+ return result
493+ }
494+
447 for {495 for {
448 if err := validateAttachRequest(attemptRequest); err != nil {496 if err := validateAttachRequest(attemptRequest); err != nil {
449 return err497 return err
450 }498 }
451 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)499 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
452- transport, err := boundedDial(handshakeCtx, dialer)500+ transport, err := boundedDialWithTransition(handshakeCtx, dialer, transition)
453 if err != nil {501 if err != nil {
454 err = handshakeContextError(ctx, timedOut, err)502 err = handshakeContextError(ctx, timedOut, err)
455 finishHandshake()503 finishHandshake()
@@ -515,11 +563,13 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
515 themeState: themeState,563 themeState: themeState,
516 enterRaw: enterRaw,564 enterRaw: enterRaw,
517 reconnect: reconnect,565 reconnect: reconnect,
566+ transition: transition,
518 linkEvents: linkEvents,567 linkEvents: linkEvents,
519 rememberRemoteHost: rememberRemoteHost,568 rememberRemoteHost: rememberRemoteHost,
520 terminalInput: func() *terminalInputPump {569 terminalInput: func() *terminalInputPump {
521 return input570 return input
522 },571 },
572+ openHomePicker: runTransientHomePicker,
523 }).run(ctx)573 }).run(ctx)
524 stopHandshakeTransport()574 stopHandshakeTransport()
525 finishHandshake()575 finishHandshake()
@@ -655,8 +705,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
655 if r.ledger == nil {705 if r.ledger == nil {
656 return errors.New("vev: route ledger unavailable")706 return errors.New("vev: route ledger unavailable")
657 }707 }
708+ baseRequest := attemptRequest
658 nextDialer = dialer709 nextDialer = dialer
659- nextRequest = r.ledger.samePeerHandoff(attemptRequest, *result.target)710+ if result.homePickerTarget {
711+ if homeRoute == nil {
712+ return errors.New("vev: home route unavailable for picker target")
713+ }
714+ nextDialer = homeRoute.dialer
715+ baseRequest = homeRoute.request
716+ }
717+ nextRequest = r.ledger.samePeerHandoff(baseRequest, *result.target)
660 } else {718 } else {
661 if r.attachHandoff == nil {719 if r.attachHandoff == nil {
662 return &AttachTargetError{Target: *result.target}720 return &AttachTargetError{Target: *result.target}
@@ -910,9 +968,13 @@ type attachAttempt struct {
910 themeState *terminalThemeState968 themeState *terminalThemeState
911 enterRaw func() error969 enterRaw func() error
912 reconnect *reconnectUI970 reconnect *reconnectUI
971+ transition *transitionUI
913 linkEvents <-chan ports.LinkEvent972 linkEvents <-chan ports.LinkEvent
914 rememberRemoteHost func()973 rememberRemoteHost func()
915 terminalInput func() *terminalInputPump974 terminalInput func() *terminalInputPump
975+ openHomePicker func(context.Context) attachResult
976+ transientPicker bool
977+ returnAttachTargets bool
916 }978 }
917 979
918 type attachResult struct {980 type attachResult struct {
@@ -924,6 +986,7 @@ type attachResult struct {
924 transportClosed bool986 transportClosed bool
925 target *ports.AttachTarget987 target *ports.AttachTarget
926 action ports.NavigationAction988 action ports.NavigationAction
989+ homePickerTarget bool
927 routeAction *ports.RouteNavigationAction990 routeAction *ports.RouteNavigationAction
928 err error991 err error
929 }992 }
@@ -979,6 +1042,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
979 themeState := a.themeState1042 themeState := a.themeState
980 enterRaw := a.enterRaw1043 enterRaw := a.enterRaw
981 reconnect := a.reconnect1044 reconnect := a.reconnect
1045+ transition := a.transition
982 linkEvents := a.linkEvents1046 linkEvents := a.linkEvents
983 remote := a.remote || a.request.Remote1047 remote := a.remote || a.request.Remote
984 clipboard := a.runner.clipboard1048 clipboard := a.runner.clipboard
@@ -1013,7 +1077,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1013 return attachResult{transportClosed: handshakeCtx.Err() != nil, err: fmt.Errorf("vev: %s: %w", stage, err)}1077 return attachResult{transportClosed: handshakeCtx.Err() != nil, err: fmt.Errorf("vev: %s: %w", stage, err)}
1014 }1078 }
1015 sendHandshake := func(operation func() error) error {1079 sendHandshake := func(operation func() error) error {
1016- err := boundedHandshakeOperation(handshakeCtx, transport, operation)1080+ err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, operation, transition)
1017 if err != nil {1081 if err != nil {
1018 return handshakeContextError(ctx, handshakeTimedOut, err)1082 return handshakeContextError(ctx, handshakeTimedOut, err)
1019 }1083 }
@@ -1067,11 +1131,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1067 1131
1068 // 2. Await Welcome or a typed rejection.1132 // 2. Await Welcome or a typed rejection.
1069 var reply ports.Frame1133 var reply ports.Frame
1070- if err := boundedHandshakeOperation(handshakeCtx, transport, func() error {1134+ if err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, func() error {
1071 var recvErr error1135 var recvErr error
1072 reply, recvErr = transport.Recv()1136 reply, recvErr = transport.Recv()
1073 return recvErr1137 return recvErr
1074- }); err != nil {1138+ }, transition); err != nil {
1075 return handshakeFailure("awaiting welcome", err)1139 return handshakeFailure("awaiting welcome", err)
1076 }1140 }
1077 var committedIdentity *ports.CommittedRouteIdentity1141 var committedIdentity *ports.CommittedRouteIdentity
@@ -1122,16 +1186,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1122 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {1186 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
1123 return welcomedResult(errRouteTargetChanged)1187 return welcomedResult(errRouteTargetChanged)
1124 }1188 }
1125- candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)1189+ if !a.transientPicker {
1126- var commitErr error1190+ candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1127- if a.routeNavigationSelection != nil {1191+ var commitErr error
1128- selection := *a.routeNavigationSelection1192+ if a.routeNavigationSelection != nil {
1129- commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)1193+ selection := *a.routeNavigationSelection
1130- } else {1194+ commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)
1131- _, commitErr = a.runner.ledger.commitAttach(candidate)1195+ } else {
1132- }1196+ _, commitErr = a.runner.ledger.commitAttach(candidate)
1133- if commitErr != nil {1197+ }
1134- return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))1198+ if commitErr != nil {
1199+ return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
1200+ }
1135 }1201 }
1136 snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())1202 snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1137 if err != nil {1203 if err != nil {
@@ -1183,21 +1249,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1183 input.start()1249 input.start()
1184 defer input.stop()1250 defer input.stop()
1185 }1251 }
1186- inputConsumer := input.claim()
1187- claimActive := true
1188- revokeInputClaim := func() {
1189- if claimActive {
1190- input.revoke(inputConsumer)
1191- claimActive = false
1192- }
1193- }
1194- // Register revocation before any palette publication or terminal I/O: both
1195- // can fail before the stdin scanner is started, and a reconnect must still
1196- // be able to claim this lifecycle-owned reader.
1197- defer revokeInputClaim()
1198-
1199 sendCh := make(chan ports.Frame, sendQueueDepth)1252 sendCh := make(chan ports.Frame, sendQueueDepth)
1200 controlCh := make(chan ports.Frame, 1)1253 controlCh := make(chan ports.Frame, 1)
1254+ barrierCh := make(chan chan struct{})
1201 inputGate := newSamePeerInputGate()1255 inputGate := newSamePeerInputGate()
1202 ackQueue := newCumulativeAckQueue()1256 ackQueue := newCumulativeAckQueue()
1203 sendErrCh := make(chan error, 1)1257 sendErrCh := make(chan error, 1)
@@ -1309,8 +1363,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1309 awaitingReconnectReset := false1363 awaitingReconnectReset := false
1310 outputState := outputApplyState{}1364 outputState := outputApplyState{}
1311 outputResetRequested := false1365 outputResetRequested := false
1366+ transitionWaitingFull := false
1312 var samePeerSwitch *samePeerSwitchPending1367 var samePeerSwitch *samePeerSwitchPending
1313 var nextSamePeerRequestID uint641368 var nextSamePeerRequestID uint64
1369+ type parkedRoutePending struct {
1370+ requestID uint64
1371+ action ports.ParkedRouteAction
1372+ leaseID ports.ParkedRouteLeaseID
1373+ fallbackTarget *ports.AttachTarget
1374+ timer ports.Timer
1375+ }
1376+ var parkedPending *parkedRoutePending
1377+ var nextParkedRequestID uint64
1378+ parkedWaitingFull := false
1379+ var parkedFullTimer ports.Timer
1380+ transportFailed := make(chan struct{})
1314 if reconnect.showing {1381 if reconnect.showing {
1315 if reconnect.remote {1382 if reconnect.remote {
1316 // Before the asynchronous sender starts, preserve transport ordering by1383 // Before the asynchronous sender starts, preserve transport ordering by
@@ -1339,27 +1406,195 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1339 // or unchanged screen may legitimately produce no initial Output frame.1406 // or unchanged screen may legitimately produce no initial Output frame.
1340 endHandshake()1407 endHandshake()
1341 senderStarted = true1408 senderStarted = true
1342- go runSender(loopCtx, cancel, transport, controlCh, sendCh, inputGate, ackQueue, sendErrCh, log)1409+ go runSender(loopCtx, cancel, transport, controlCh, barrierCh, sendCh, inputGate, ackQueue, sendErrCh, log)
1343- stdinDone := make(chan struct{})1410+
1344- go func() {1411+ type foregroundRuntime struct {
1345- defer close(stdinDone)1412+ cancel context.CancelFunc
1346- (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()1413+ consumer uint64
1347- }()1414+ sendLease *foregroundSendLease
1348- // Scanner cancellation can leave an undecided marker suffix (including a1415+ stdinDone, resizeDone chan struct{}
1349- // standalone Escape) that it must hand back to the lifecycle-owned reader.1416+ }
1350- // Wait for that handoff before revoking the claim: otherwise a replacement1417+ var foreground *foregroundRuntime
1351- // scanner could claim the reader between revocation and preservation and1418+ startForeground := func() {
1352- // lose the suffix.1419+ if foreground != nil {
1353- defer func() {1420+ return
1354- cancel()1421+ }
1355- <-stdinDone1422+ foregroundCtx, foregroundCancel := context.WithCancel(loopCtx)
1356- revokeInputClaim()1423+ consumer := input.claim()
1357- }()1424+ stdinDone := make(chan struct{})
1358- go runResize(loopCtx, term.ResizeEvents(), sendCh, log)1425+ resizeDone := make(chan struct{})
1426+ sendLease := newForegroundSendLease()
1427+ foreground = &foregroundRuntime{cancel: foregroundCancel, consumer: consumer, sendLease: sendLease, stdinDone: stdinDone, resizeDone: resizeDone}
1428+ go func() {
1429+ defer close(stdinDone)
1430+ cancelAll := func() {
1431+ foregroundCancel()
1432+ cancel()
1433+ }
1434+ (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease}).run()
1435+ }()
1436+ go func() {
1437+ defer close(resizeDone)
1438+ runResize(foregroundCtx, term.ResizeEvents(), sendCh, sendLease, log)
1439+ }()
1440+ }
1441+ stopForeground := func() {
1442+ if foreground == nil {
1443+ return
1444+ }
1445+ current := foreground
1446+ foreground = nil
1447+ current.cancel()
1448+ current.sendLease.stop()
1449+ <-current.stdinDone
1450+ <-current.resizeDone
1451+ input.revoke(current.consumer)
1452+ for id := range drainTimers {
1453+ cancelTimer(drainTimers, id)
1454+ }
1455+ for id := range completionTimers {
1456+ cancelTimer(completionTimers, id)
1457+ }
1458+ }
1459+ flushSender := func() error {
1460+ done := make(chan struct{})
1461+ select {
1462+ case barrierCh <- done:
1463+ case <-loopCtx.Done():
1464+ return context.Canceled
1465+ }
1466+ select {
1467+ case <-done:
1468+ return nil
1469+ case <-loopCtx.Done():
1470+ return context.Canceled
1471+ }
1472+ }
1473+ clearParkedFull := func() {
1474+ parkedWaitingFull = false
1475+ if parkedFullTimer != nil {
1476+ parkedFullTimer.Stop()
1477+ parkedFullTimer = nil
1478+ }
1479+ }
1480+ awaitParkedFull := func() {
1481+ clearParkedFull()
1482+ parkedWaitingFull = true
1483+ parkedFullTimer = clk.NewTimer(ports.HandshakeTimeout)
1484+ }
1485+ parkedFullC := func() <-chan time.Time {
1486+ if parkedFullTimer == nil {
1487+ return nil
1488+ }
1489+ return parkedFullTimer.C()
1490+ }
1491+ defer clearParkedFull()
1492+ clearParkedPending := func() {
1493+ if parkedPending != nil && parkedPending.timer != nil {
1494+ parkedPending.timer.Stop()
1495+ }
1496+ parkedPending = nil
1497+ }
1498+ parkedResponseC := func() <-chan time.Time {
1499+ if parkedPending == nil || parkedPending.timer == nil {
1500+ return nil
1501+ }
1502+ return parkedPending.timer.C()
1503+ }
1504+ defer clearParkedPending()
1505+
1506+ sendParkedRouteSize := func() error {
1507+ current, err := term.Size()
1508+ if err != nil {
1509+ return fmt.Errorf("reading terminal size for parked route: %w", err)
1510+ }
1511+ payload, err := ports.MarshalResize(ports.Resize{Size: current})
1512+ if err != nil {
1513+ return fmt.Errorf("encoding terminal size for parked route: %w", err)
1514+ }
1515+ select {
1516+ case controlCh <- ports.Frame{Type: ports.MsgResize, Payload: payload}:
1517+ return nil
1518+ case <-loopCtx.Done():
1519+ return context.Canceled
1520+ }
1521+ }
1522+ sendParkedRouteRequest := func(action ports.ParkedRouteAction, leaseID ports.ParkedRouteLeaseID, target *domain.RemoteSessionTarget) error {
1523+ if parkedPending != nil {
1524+ return errors.New("vev: parked-route request already pending")
1525+ }
1526+ nextParkedRequestID++
1527+ request := ports.ParkedRouteRequest{RequestID: nextParkedRequestID, LeaseID: leaseID, Action: action, Target: target}
1528+ payload := ports.MarshalParkedRouteRequest(request)
1529+ if payload == nil {
1530+ return errors.New("vev: invalid parked-route request")
1531+ }
1532+ select {
1533+ case controlCh <- ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: payload}:
1534+ parkedPending = &parkedRoutePending{
1535+ requestID: request.RequestID, action: action, leaseID: leaseID,
1536+ timer: clk.NewTimer(ports.HandshakeTimeout),
1537+ }
1538+ return nil
1539+ case <-loopCtx.Done():
1540+ return context.Canceled
1541+ }
1542+ }
1543+ handleParkedPicker := func(leaseID ports.ParkedRouteLeaseID) (attachResult, bool) {
1544+ pickerCtx, cancelPicker := context.WithCancel(ctx)
1545+ pickerDone := make(chan struct{})
1546+ go func() {
1547+ select {
1548+ case <-transportFailed:
1549+ cancelPicker()
1550+ case <-pickerDone:
1551+ }
1552+ }()
1553+ selection := a.openHomePicker(pickerCtx)
1554+ close(pickerDone)
1555+ cancelPicker()
1556+ select {
1557+ case <-transportFailed:
1558+ return welcomedResult(errLinkOffline), true
1559+ default:
1560+ }
1561+ if selection.target != nil && transition != nil {
1562+ transitionWaitingFull = false
1563+ transition.start(*selection.target)
1564+ }
1565+ if selection.target != nil && request.Remote && request.OriginKey != "" && selection.target.Endpoint == request.OriginKey && selection.target.RemoteTarget != nil {
1566+ target := *selection.target.RemoteTarget
1567+ if err := sendParkedRouteSize(); err != nil {
1568+ return welcomedResult(err), true
1569+ }
1570+ if err := sendParkedRouteRequest(ports.ParkedRouteSwitch, leaseID, &target); err != nil {
1571+ return welcomedResult(fmt.Errorf("vev: switching parked route: %w", err)), true
1572+ }
1573+ fallback := *selection.target
1574+ parkedPending.fallbackTarget = &fallback
1575+ return attachResult{}, false
1576+ }
1577+ if selection.target != nil {
1578+ selection.homePickerTarget = true
1579+ return selection, true
1580+ }
1581+ if selection.err != nil {
1582+ log.Warn("transient home picker failed; resuming parked route", "err", selection.err)
1583+ }
1584+ if err := sendParkedRouteSize(); err != nil {
1585+ return welcomedResult(err), true
1586+ }
1587+ if err := sendParkedRouteRequest(ports.ParkedRouteResume, leaseID, nil); err != nil {
1588+ return welcomedResult(fmt.Errorf("vev: resuming parked route: %w", err)), true
1589+ }
1590+ return attachResult{}, false
1591+ }
1592+ startForeground()
1593+ defer stopForeground()
1359 1594
1360 // 5. Output/main loop: the only goroutine that touches the terminal.1595 // 5. Output/main loop: the only goroutine that touches the terminal.
1361 recvCh := make(chan recvResult, 1)1596 recvCh := make(chan recvResult, 1)
1362- go runRecv(loopCtx, transport, recvCh, log)1597+ go runRecv(loopCtx, transport, recvCh, transportFailed, log)
1363 1598
1364 requestReconnectReset := func() error {1599 requestReconnectReset := func() error {
1365 if awaitingReconnectReset {1600 if awaitingReconnectReset {
@@ -1447,6 +1682,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1447 select {1682 select {
1448 case <-loopCtx.Done():1683 case <-loopCtx.Done():
1449 return loopCanceledResult()1684 return loopCanceledResult()
1685+ case <-transition.tickC():
1686+ if err := transition.advance(); err != nil {
1687+ return welcomedResult(err)
1688+ }
1689+ case <-parkedResponseC():
1690+ clearParkedPending()
1691+ return welcomedResult(errors.New("vev: timed out waiting for parked-route response"))
1692+ case <-parkedFullC():
1693+ clearParkedFull()
1694+ return welcomedResult(errors.New("vev: timed out waiting for parked-route full output"))
1450 case ev, ok := <-linkEvents:1695 case ev, ok := <-linkEvents:
1451 if !ok {1696 if !ok {
1452 linkEvents = nil1697 linkEvents = nil
@@ -1537,6 +1782,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1537 }1782 }
1538 continue1783 continue
1539 }1784 }
1785+ if parkedWaitingFull && !o.Full {
1786+ return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output"))
1787+ }
1540 if _, werr := term.Out().Write(o.Data); werr != nil {1788 if _, werr := term.Out().Write(o.Data); werr != nil {
1541 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))1789 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
1542 }1790 }
@@ -1555,6 +1803,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1555 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))1803 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
1556 }1804 }
1557 outputState = nextState1805 outputState = nextState
1806+ if transition != nil && transition.active && (!transitionWaitingFull || o.Full) {
1807+ transitionWaitingFull = false
1808+ transition.stop()
1809+ }
1810+ if parkedWaitingFull {
1811+ clearParkedFull()
1812+ startForeground()
1813+ }
1558 if o.New != 0 {1814 if o.New != 0 {
1559 ackQueue.offer(o.Epoch, o.New)1815 ackQueue.offer(o.Epoch, o.New)
1560 outputResetRequested = false1816 outputResetRequested = false
@@ -1573,6 +1829,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1573 if derr != nil {1829 if derr != nil {
1574 return welcomedResult(fmt.Errorf("vev: decoding attach target: %w", derr))1830 return welcomedResult(fmt.Errorf("vev: decoding attach target: %w", derr))
1575 }1831 }
1832+ if a.returnAttachTargets {
1833+ handoff := welcomedResult(nil)
1834+ handoff.target = &target
1835+ return handoff
1836+ }
1837+ if transition != nil {
1838+ transitionWaitingFull = false
1839+ transition.start(target)
1840+ }
1576 if target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil {1841 if target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil {
1577 if samePeerSwitch != nil {1842 if samePeerSwitch != nil {
1578 return welcomedResult(errors.New("vev: concurrent same-peer switch offer"))1843 return welcomedResult(errors.New("vev: concurrent same-peer switch offer"))
@@ -1599,13 +1864,71 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1599 handoff := welcomedResult(nil)1864 handoff := welcomedResult(nil)
1600 handoff.target = &target1865 handoff.target = &target
1601 return handoff1866 return handoff
1867+ case ports.MsgParkedRouteResponse:
1868+ response, derr := ports.UnmarshalParkedRouteResponse(r.frame.Payload)
1869+ if derr != nil {
1870+ return welcomedResult(fmt.Errorf("vev: decoding parked-route response: %w", derr))
1871+ }
1872+ if parkedPending == nil || response.RequestID != parkedPending.requestID {
1873+ return welcomedResult(errors.New("vev: unexpected parked-route response"))
1874+ }
1875+ pending := *parkedPending
1876+ clearParkedPending()
1877+ switch pending.action {
1878+ case ports.ParkedRoutePrepare:
1879+ if response.Status != ports.ParkedRouteReady {
1880+ fallback := welcomedResult(nil)
1881+ fallback.action = ports.NavigationOpenHomePicker
1882+ return fallback
1883+ }
1884+ if selection, done := handleParkedPicker(pending.leaseID); done {
1885+ return selection
1886+ }
1887+ case ports.ParkedRouteResume:
1888+ if response.Status != ports.ParkedRouteResumed {
1889+ return welcomedResult(errors.New("vev: parked route could not resume"))
1890+ }
1891+ awaitParkedFull()
1892+ case ports.ParkedRouteSwitch:
1893+ switch response.Status {
1894+ case ports.ParkedRouteSwitched:
1895+ awaitParkedFull()
1896+ continue
1897+ case ports.ParkedRouteStaleTarget:
1898+ if transition != nil {
1899+ transition.stop()
1900+ }
1901+ if selection, done := handleParkedPicker(pending.leaseID); done {
1902+ return selection
1903+ }
1904+ default:
1905+ if pending.fallbackTarget == nil {
1906+ return welcomedResult(errors.New("vev: parked route switch failed"))
1907+ }
1908+ fallback := welcomedResult(nil)
1909+ fallback.target = pending.fallbackTarget
1910+ fallback.homePickerTarget = true
1911+ return fallback
1912+ }
1913+ }
1602 case ports.MsgNavigationAction:1914 case ports.MsgNavigationAction:
1603- action, derr := ports.UnmarshalNavigationAction(r.frame.Payload)1915+ directive, derr := ports.UnmarshalNavigationDirective(r.frame.Payload)
1604 if derr != nil {1916 if derr != nil {
1605 return welcomedResult(fmt.Errorf("vev: decoding navigation action: %w", derr))1917 return welcomedResult(fmt.Errorf("vev: decoding navigation action: %w", derr))
1606 }1918 }
1919+ _, datagramRoute := transport.(ports.DatagramTransport)
1920+ if directive.Action == ports.NavigationOpenHomePicker && a.openHomePicker != nil && datagramRoute {
1921+ stopForeground()
1922+ if err := flushSender(); err != nil {
1923+ return welcomedResult(fmt.Errorf("vev: parking remote foreground: %w", err))
1924+ }
1925+ if err := sendParkedRouteRequest(ports.ParkedRoutePrepare, directive.LeaseID, nil); err != nil {
1926+ return welcomedResult(fmt.Errorf("vev: preparing parked route: %w", err))
1927+ }
1928+ continue
1929+ }
1607 navigation := welcomedResult(nil)1930 navigation := welcomedResult(nil)
1608- navigation.action = action1931+ navigation.action = directive.Action
1609 return navigation1932 return navigation
1610 case ports.MsgNavigateRecentRoute:1933 case ports.MsgNavigateRecentRoute:
1611 action, derr := ports.UnmarshalRouteNavigationAction(r.frame.Payload)1934 action, derr := ports.UnmarshalRouteNavigationAction(r.frame.Payload)
@@ -1662,6 +1985,19 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1662 if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID {1985 if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID {
1663 samePeerSwitch = nil1986 samePeerSwitch = nil
1664 inputGate.setPaused(false)1987 inputGate.setPaused(false)
1988+ if transition != nil && transition.showing {
1989+ transitionWaitingFull = true
1990+ if !outputResetRequested {
1991+ select {
1992+ case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}:
1993+ outputResetRequested = true
1994+ case <-loopCtx.Done():
1995+ return loopCanceledResult()
1996+ }
1997+ }
1998+ } else if transition != nil {
1999+ transition.stop()
2000+ }
1665 }2001 }
1666 case ports.MsgRoutePosition:2002 case ports.MsgRoutePosition:
1667 position, derr := ports.UnmarshalRoutePosition(r.frame.Payload)2003 position, derr := ports.UnmarshalRoutePosition(r.frame.Payload)
@@ -1712,10 +2048,13 @@ type recvResult struct {
1712 2048
1713 // runRecv reads frames until an error, forwarding each to the main loop.2049 // runRecv reads frames until an error, forwarding each to the main loop.
1714 // It exits on the first error or when the loop context is cancelled.2050 // It exits on the first error or when the loop context is cancelled.
1715-func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, log *slog.Logger) {2051+func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, failed chan<- struct{}, log *slog.Logger) {
1716 defer log.Debug("receive pump exited")2052 defer log.Debug("receive pump exited")
1717 for {2053 for {
1718 f, err := transport.Recv()2054 f, err := transport.Recv()
2055+ if err != nil && failed != nil {
2056+ close(failed)
2057+ }
1719 select {2058 select {
1720 case out <- recvResult{frame: f, err: err}:2059 case out <- recvResult{frame: f, err: err}:
1721 case <-ctx.Done():2060 case <-ctx.Done():
@@ -1766,7 +2105,7 @@ func (q *cumulativeAckQueue) take() (uint64, uint64) {
1766 2105
1767 // runSender preserves normal-frame order while allowing switch control and2106 // runSender preserves normal-frame order while allowing switch control and
1768 // output ACKs to make progress when raw input is held for a same-peer switch.2107 // output ACKs to make progress when raw input is held for a same-peer switch.
1769-func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, control, in <-chan ports.Frame, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {2108+func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, control <-chan ports.Frame, barriers <-chan chan struct{}, in <-chan ports.Frame, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
1770 defer log.Debug("sender pump exited")2109 defer log.Debug("sender pump exited")
1771 send := func(f ports.Frame) bool {2110 send := func(f ports.Frame) bool {
1772 if err := transport.Send(f); err != nil {2111 if err := transport.Send(f); err != nil {
@@ -1795,6 +2134,36 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1795 }2134 }
1796 return send(ports.Frame{Type: ports.MsgAck, Payload: payload})2135 return send(ports.Frame{Type: ports.MsgAck, Payload: payload})
1797 }2136 }
2137+ flushPending := func() bool {
2138+ for {
2139+ select {
2140+ case <-acks.wake:
2141+ if !sendAck() {
2142+ return false
2143+ }
2144+ continue
2145+ default:
2146+ }
2147+ if paused, _ := inputGate.snapshot(); paused {
2148+ return sendAck()
2149+ }
2150+ select {
2151+ case frame := <-in:
2152+ if !send(frame) {
2153+ return false
2154+ }
2155+ default:
2156+ return sendAck()
2157+ }
2158+ }
2159+ }
2160+ completeBarrier := func(done chan struct{}) bool {
2161+ if !flushPending() {
2162+ return false
2163+ }
2164+ close(done)
2165+ return true
2166+ }
1798 var heldInput *ports.Frame2167 var heldInput *ports.Frame
1799 for {2168 for {
1800 if heldInput != nil {2169 if heldInput != nil {
@@ -1815,6 +2184,10 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1815 if !send(frame) {2184 if !send(frame) {
1816 return2185 return
1817 }2186 }
2187+ case done := <-barriers:
2188+ if !completeBarrier(done) {
2189+ return
2190+ }
1818 case <-changed:2191 case <-changed:
1819 case <-ctx.Done():2192 case <-ctx.Done():
1820 return2193 return
@@ -1832,6 +2205,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1832 return2205 return
1833 }2206 }
1834 continue2207 continue
2208+ case done := <-barriers:
2209+ if !completeBarrier(done) {
2210+ return
2211+ }
2212+ continue
1835 default:2213 default:
1836 }2214 }
1837 select {2215 select {
@@ -1843,6 +2221,10 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1843 if !send(frame) {2221 if !send(frame) {
1844 return2222 return
1845 }2223 }
2224+ case done := <-barriers:
2225+ if !completeBarrier(done) {
2226+ return
2227+ }
1846 case frame := <-in:2228 case frame := <-in:
1847 select {2229 select {
1848 case <-ctx.Done():2230 case <-ctx.Done():
@@ -2185,6 +2567,36 @@ func (p *terminalInputPump) stop() {
2185 // preserving all ordinary terminal input byte-for-byte. It never reads the2567 // preserving all ordinary terminal input byte-for-byte. It never reads the
2186 // terminal directly, publishes a Theme, or writes terminal output;2568 // terminal directly, publishes a Theme, or writes terminal output;
2187 // attachAttempt owns those operations.2569 // attachAttempt owns those operations.
2570+type foregroundSendLease struct {
2571+ mu sync.Mutex
2572+ active bool
2573+}
2574+
2575+func newForegroundSendLease() *foregroundSendLease {
2576+ return &foregroundSendLease{active: true}
2577+}
2578+
2579+func (l *foregroundSendLease) send(send func() bool) bool {
2580+ if l == nil {
2581+ return send()
2582+ }
2583+ l.mu.Lock()
2584+ defer l.mu.Unlock()
2585+ if !l.active {
2586+ return false
2587+ }
2588+ return send()
2589+}
2590+
2591+func (l *foregroundSendLease) stop() {
2592+ if l == nil {
2593+ return
2594+ }
2595+ l.mu.Lock()
2596+ l.active = false
2597+ l.mu.Unlock()
2598+}
2599+
2188 type stdinPump struct {2600 type stdinPump struct {
2189 ctx context.Context2601 ctx context.Context
2190 cancel context.CancelFunc2602 cancel context.CancelFunc
@@ -2200,6 +2612,7 @@ type stdinPump struct {
2200 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook2612 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
2201 afterInputTake func() // test synchronization hook2613 afterInputTake func() // test synchronization hook
2202 afterInputDelivered func() // test synchronization hook2614 afterInputDelivered func() // test synchronization hook
2615+ sendLease *foregroundSendLease
2203 }2616 }
2204 2617
2205 func (p *stdinPump) run() {2618 func (p *stdinPump) run() {
@@ -2215,31 +2628,35 @@ func (p *stdinPump) run() {
2215 if !sendOK.Load() {2628 if !sendOK.Load() {
2216 return false2629 return false
2217 }2630 }
2218- select {2631+ return p.sendLease.send(func() bool {
2219- case p.out <- frame:2632+ select {
2220- if p.afterInputDelivered != nil {2633+ case p.out <- frame:
2221- p.afterInputDelivered()2634+ if p.afterInputDelivered != nil {
2635+ p.afterInputDelivered()
2636+ }
2637+ return true
2638+ case <-p.ctx.Done():
2639+ sendOK.Store(false)
2640+ return false
2222 }2641 }
2223- return true2642+ })
2224- case <-p.ctx.Done():
2225- sendOK.Store(false)
2226- return false
2227- }
2228 }2643 }
2229 sendEvent := func(event paletteGenerationEvent) bool {2644 sendEvent := func(event paletteGenerationEvent) bool {
2230 if !sendOK.Load() {2645 if !sendOK.Load() {
2231 return false2646 return false
2232 }2647 }
2233- select {2648+ return p.sendLease.send(func() bool {
2234- case p.paletteEvents <- event:2649+ select {
2235- if p.afterPaletteEvent != nil {2650+ case p.paletteEvents <- event:
2236- p.afterPaletteEvent(event)2651+ if p.afterPaletteEvent != nil {
2652+ p.afterPaletteEvent(event)
2653+ }
2654+ return true
2655+ case <-p.ctx.Done():
2656+ sendOK.Store(false)
2657+ return false
2237 }2658 }
2238- return true2659+ })
2239- case <-p.ctx.Done():
2240- sendOK.Store(false)
2241- return false
2242- }
2243 }2660 }
2244 coalescer := newPasteCoalescer(p.clock, func(data []byte) {2661 coalescer := newPasteCoalescer(p.clock, func(data []byte) {
2245 if len(data) == 0 {2662 if len(data) == 0 {
@@ -2427,7 +2844,7 @@ func (p *stdinPump) run() {
2427 // runResize forwards coalesced terminal resize events to the daemon. It2844 // runResize forwards coalesced terminal resize events to the daemon. It
2428 // tolerates an already-closed resize channel (which the terminal adapter2845 // tolerates an already-closed resize channel (which the terminal adapter
2429 // hands back when restore ran before ResizeEvents was first called).2846 // hands back when restore ran before ResizeEvents was first called).
2430-func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, log *slog.Logger) {2847+func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, sendLease *foregroundSendLease, log *slog.Logger) {
2431 defer log.Debug("resize pump exited")2848 defer log.Debug("resize pump exited")
2432 for {2849 for {
2433 select {2850 select {
@@ -2441,9 +2858,14 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.
2441 continue2858 continue
2442 }2859 }
2443 frame := ports.Frame{Type: ports.MsgResize, Payload: payload}2860 frame := ports.Frame{Type: ports.MsgResize, Payload: payload}
2444- select {2861+ if !sendLease.send(func() bool {
2445- case out <- frame:2862+ select {
2446- case <-ctx.Done():2863+ case out <- frame:
2864+ return true
2865+ case <-ctx.Done():
2866+ return false
2867+ }
2868+ }) {
2447 return2869 return
2448 }2870 }
2449 case <-ctx.Done():2871 case <-ctx.Done():
M internal/usecase/client/client_test.go +471 -12
@@ -56,13 +56,18 @@ func (c *reconnectTestClock) NewTimer(d time.Duration) ports.Timer {
5656 }
5757
5858 func (c *reconnectTestClock) fireReconnect(t *testing.T) {
59+ t.Helper()
60+ c.fireDuration(t, 100*time.Millisecond)
61+}
62+
63+func (c *reconnectTestClock) fireDuration(t *testing.T, duration time.Duration) {
5964 t.Helper()
6065 require.Eventually(t, func() bool {
6166 c.mu.Lock()
6267 defer c.mu.Unlock()
6368 for _, timer := range c.timers {
6469 timer.mu.Lock()
65- if timer.duration != 100*time.Millisecond {
70+ if timer.duration != duration {
6671 timer.mu.Unlock()
6772 continue
6873 }
@@ -111,8 +116,9 @@ func (t *reconnectTestTimer) Stop() bool {
111116
112117 // recvItem is one scripted Recv result.
113118 type recvItem struct {
114- f ports.Frame
115- err error
119+ f ports.Frame
120+ err error
121+ wait <-chan struct{}
116122 }
117123
118124 type markedDatagramTransport struct{ ports.Transport }
@@ -131,6 +137,13 @@ func scriptRecv(tr *portsmocks.MockTransport, items ...recvItem) func() {
131137 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
132138 select {
133139 case it := <-ch:
140+ if it.wait != nil {
141+ select {
142+ case <-it.wait:
143+ case <-done:
144+ return ports.Frame{}, io.EOF
145+ }
146+ }
134147 return it.f, it.err
135148 case <-done:
136149 return ports.Frame{}, io.EOF
@@ -552,7 +565,7 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin
552565 }}
553566 remote := &recordingTransport{recvs: []recvItem{
554567 {f: welcome("work", remoteLifecycle)},
555- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
568+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
556569 }}
557570 local2 := &recordingTransport{recvs: []recvItem{
558571 {f: welcome("local", localLifecycle)},
@@ -578,6 +591,414 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin
578591 require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay)
579592 }
580593
594+func hybridWelcomeFrame(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
595+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
596+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
597+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
598+ }))
599+}
600+
601+func hybridLocalBootstrap(lifecycle domain.SessionLifecycleID, target domain.RemoteSessionTarget) *recordingTransport {
602+ return &recordingTransport{recvs: []recvItem{
603+ {f: hybridWelcomeFrame("local", lifecycle)},
604+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
605+ Endpoint: target.Endpoint, Session: target.SessionName, Intent: ports.IntentAttach,
606+ RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
607+ }))},
608+ }}
609+}
610+
611+func hybridPickerDependencies(localDialer ports.Dialer, term ports.Terminal, clock ports.Clock, remoteByEndpoint map[string]ports.Dialer) client.Dependencies {
612+ deps := testDependencies(localDialer, term, clock, nil, nil)
613+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
614+ if target.RemoteTarget == nil {
615+ return nil, client.AttachRequest{}, errors.New("hybrid picker target is not remote")
616+ }
617+ dialer := remoteByEndpoint[target.Endpoint]
618+ if dialer == nil {
619+ return nil, client.AttachRequest{}, errors.New("hybrid picker endpoint has no dialer")
620+ }
621+ selection := *target.RemoteTarget
622+ return dialer, client.AttachRequest{
623+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
624+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
625+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
626+ }, nil
627+ }
628+ return deps
629+}
630+
631+func hybridParkedRequestHandler(requests chan<- ports.ParkedRouteRequest, requestErrors chan<- error, signals map[ports.ParkedRouteAction]chan struct{}) func(ports.Frame) {
632+ once := make(map[ports.ParkedRouteAction]*sync.Once, len(signals))
633+ for action := range signals {
634+ once[action] = &sync.Once{}
635+ }
636+ return func(frame ports.Frame) {
637+ if frame.Type != ports.MsgParkedRouteRequest {
638+ return
639+ }
640+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
641+ if err != nil {
642+ if requestErrors != nil {
643+ requestErrors <- err
644+ }
645+ return
646+ }
647+ if requests != nil {
648+ requests <- request
649+ }
650+ if signal := signals[request.Action]; signal != nil {
651+ once[request.Action].Do(func() { close(signal) })
652+ }
653+ }
654+}
655+
656+func TestHybridPickerSameHostSwitchReusesRemoteTransport(t *testing.T) {
657+ term := newRunTerminal()
658+ defer term.in.unblock()
659+
660+ localLifecycle := domain.SessionLifecycleID{1}
661+ remoteSourceLifecycle := domain.SessionLifecycleID{2}
662+ remoteTargetLifecycle := domain.SessionLifecycleID{3}
663+ sourceTarget := domain.RemoteSessionTarget{
664+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteSourceLifecycle,
665+ SessionName: "source", LiveTabID: "source-tab",
666+ }
667+ targetTarget := domain.RemoteSessionTarget{
668+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteTargetLifecycle,
669+ SessionName: "target", LiveTabID: "target-tab",
670+ }
671+ localInitial := hybridLocalBootstrap(localLifecycle, sourceTarget)
672+ parkSent := make(chan struct{})
673+ switchSent := make(chan struct{})
674+ parkedRequests := make(chan ports.ParkedRouteRequest, 2)
675+ parkedRequestErrors := make(chan error, 2)
676+ remote := &recordingTransport{recvs: []recvItem{
677+ {f: hybridWelcomeFrame("source", remoteSourceLifecycle)},
678+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
679+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
680+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: remoteTargetLifecycle, SessionName: "target"}})), wait: switchSent},
681+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteSwitched}))},
682+ {f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 2, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("target frame")}))},
683+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
684+ }}
685+ remote.onSend = hybridParkedRequestHandler(parkedRequests, parkedRequestErrors, map[ports.ParkedRouteAction]chan struct{}{
686+ ports.ParkedRoutePrepare: parkSent,
687+ ports.ParkedRouteSwitch: switchSent,
688+ })
689+ localPicker := &recordingTransport{recvs: []recvItem{
690+ {f: hybridWelcomeFrame("local", localLifecycle)},
691+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
692+ Endpoint: "remote", Session: "target", Intent: ports.IntentAttach,
693+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
694+ }))},
695+ }}
696+ var resizeOnce sync.Once
697+ localPicker.onSend = func(frame ports.Frame) {
698+ if frame.Type == ports.MsgHello {
699+ resizeOnce.Do(func() { term.setSize(domain.Size{Cols: 100, Rows: 40}) })
700+ }
701+ }
702+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
703+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
704+ deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.Dialer{"remote": remoteDialer})
705+
706+ err := runTestClient(context.Background(), deps, client.AttachRequest{
707+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
708+ })
709+
710+ require.NoError(t, err)
711+ select {
712+ case requestErr := <-parkedRequestErrors:
713+ require.NoError(t, requestErr)
714+ default:
715+ }
716+ prepareRequest := <-parkedRequests
717+ switchRequest := <-parkedRequests
718+ require.Equal(t, ports.ParkedRoutePrepare, prepareRequest.Action)
719+ require.Equal(t, ports.ParkedRouteSwitch, switchRequest.Action)
720+ require.NotNil(t, switchRequest.Target)
721+ require.Equal(t, targetTarget, *switchRequest.Target)
722+ frames := remote.Sends()
723+ switchIndex := -1
724+ for i, frame := range frames {
725+ if frame.Type != ports.MsgParkedRouteRequest {
726+ continue
727+ }
728+ request, requestErr := ports.UnmarshalParkedRouteRequest(frame.Payload)
729+ require.NoError(t, requestErr)
730+ if request.Action == ports.ParkedRouteSwitch {
731+ switchIndex = i
732+ break
733+ }
734+ }
735+ require.Positive(t, switchIndex)
736+ require.Equal(t, ports.MsgResize, frames[switchIndex-1].Type, "current size must precede the parked switch")
737+ resize, resizeErr := ports.UnmarshalResize(frames[switchIndex-1].Payload)
738+ require.NoError(t, resizeErr)
739+ require.Equal(t, domain.Size{Cols: 100, Rows: 40}, resize.Size)
740+ require.Equal(t, int32(1), remoteDialer.calls.Load(), "same-host picker switch must retain the authenticated remote transport")
741+}
742+
743+func TestHybridPickerExpiredSwitchFallsBackToNewDial(t *testing.T) {
744+ term := newRunTerminal()
745+ defer term.in.unblock()
746+
747+ localLifecycle := domain.SessionLifecycleID{1}
748+ sourceLifecycle := domain.SessionLifecycleID{2}
749+ targetLifecycle := domain.SessionLifecycleID{3}
750+ sourceTarget := domain.RemoteSessionTarget{
751+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: sourceLifecycle,
752+ SessionName: "source", LiveTabID: "source-tab",
753+ }
754+ targetTarget := domain.RemoteSessionTarget{
755+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle,
756+ SessionName: "target", LiveTabID: "target-tab",
757+ }
758+ localInitial := hybridLocalBootstrap(localLifecycle, sourceTarget)
759+ prepareSent := make(chan struct{})
760+ switchSent := make(chan struct{})
761+ parkedRequestErrors := make(chan error, 1)
762+ sourceRemote := &recordingTransport{recvs: []recvItem{
763+ {f: hybridWelcomeFrame("source", sourceLifecycle)},
764+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
765+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: prepareSent},
766+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteExpired})), wait: switchSent},
767+ }}
768+ sourceRemote.onSend = hybridParkedRequestHandler(nil, parkedRequestErrors, map[ports.ParkedRouteAction]chan struct{}{
769+ ports.ParkedRoutePrepare: prepareSent,
770+ ports.ParkedRouteSwitch: switchSent,
771+ })
772+ localPicker := &recordingTransport{recvs: []recvItem{
773+ {f: hybridWelcomeFrame("local", localLifecycle)},
774+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
775+ Endpoint: "remote", Session: "target", Intent: ports.IntentAttach,
776+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
777+ }))},
778+ }}
779+ targetRemote := &recordingTransport{recvs: []recvItem{
780+ {f: hybridWelcomeFrame("target", targetLifecycle)},
781+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
782+ }}
783+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
784+ remoteDialer := &sequenceDialer{trs: []ports.Transport{
785+ markedDatagramTransport{Transport: sourceRemote}, targetRemote,
786+ }}
787+ deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.Dialer{"remote": remoteDialer})
788+
789+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
790+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
791+ }))
792+ select {
793+ case requestErr := <-parkedRequestErrors:
794+ require.NoError(t, requestErr)
795+ default:
796+ }
797+ require.Equal(t, int32(2), remoteDialer.calls.Load(), "an expired parked lease must use the traditional dial path")
798+}
799+
800+func TestHybridPickerPrepareResponseTimeoutClosesRetainedTransport(t *testing.T) {
801+ term := newRunTerminal()
802+ defer term.in.unblock()
803+ prepareSent := make(chan struct{})
804+ type deadlineTimer struct {
805+ ch chan time.Time
806+ stopped atomic.Bool
807+ }
808+ var deadlineMu sync.Mutex
809+ var deadlineRecords []*deadlineTimer
810+ clock := portsmocks.NewMockClock(t)
811+ clock.EXPECT().Now().Return(time.Time{}).Maybe()
812+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(duration time.Duration) ports.Timer {
813+ timer := portsmocks.NewMockTimer(t)
814+ ch := make(chan time.Time, 1)
815+ timer.EXPECT().C().Return(ch).Maybe()
816+ timer.EXPECT().Reset(mock.Anything).Return(true).Maybe()
817+ if duration == ports.HandshakeTimeout {
818+ record := &deadlineTimer{ch: ch}
819+ timer.EXPECT().Stop().Run(func() { record.stopped.Store(true) }).Return(true).Maybe()
820+ deadlineMu.Lock()
821+ deadlineRecords = append(deadlineRecords, record)
822+ deadlineMu.Unlock()
823+ } else {
824+ timer.EXPECT().Stop().Return(true).Maybe()
825+ }
826+ return timer
827+ }).Maybe()
828+
829+ localLifecycle := domain.SessionLifecycleID{1}
830+ remoteLifecycle := domain.SessionLifecycleID{2}
831+ remoteTarget := domain.RemoteSessionTarget{
832+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
833+ SessionName: "work", LiveTabID: "tab-1",
834+ }
835+ localInitial := hybridLocalBootstrap(localLifecycle, remoteTarget)
836+ remoteClosed := make(chan struct{})
837+ var closeOnce sync.Once
838+ remote := &recordingTransport{
839+ recvs: []recvItem{
840+ {f: hybridWelcomeFrame("work", remoteLifecycle)},
841+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
842+ },
843+ stall: remoteClosed,
844+ }
845+ remote.onSend = hybridParkedRequestHandler(nil, nil, map[ports.ParkedRouteAction]chan struct{}{
846+ ports.ParkedRoutePrepare: prepareSent,
847+ })
848+
849+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial}}
850+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
851+ deps := hybridPickerDependencies(localDialer, term, clock, map[string]ports.Dialer{"remote": remoteDialer})
852+
853+ ctx, cancel := context.WithCancel(context.Background())
854+ defer cancel()
855+ remote.onClose = func() {
856+ closeOnce.Do(func() { close(remoteClosed) })
857+ cancel()
858+ }
859+ result := make(chan error, 1)
860+ go func() {
861+ result <- runTestClient(ctx, deps, client.AttachRequest{
862+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
863+ })
864+ }()
865+ select {
866+ case <-prepareSent:
867+ case <-time.After(time.Second):
868+ t.Fatal("timed out waiting for parked Prepare request")
869+ }
870+ var deadline chan time.Time
871+ require.Eventually(t, func() bool {
872+ deadlineMu.Lock()
873+ defer deadlineMu.Unlock()
874+ var active []*deadlineTimer
875+ for _, record := range deadlineRecords {
876+ if !record.stopped.Load() {
877+ active = append(active, record)
878+ }
879+ }
880+ if len(active) != 1 {
881+ return false
882+ }
883+ deadline = active[0].ch
884+ return true
885+ }, time.Second, time.Millisecond, "expected exactly one active parked-response deadline")
886+ deadline <- time.Time{}
887+ select {
888+ case err := <-result:
889+ require.ErrorContains(t, err, "timed out waiting for parked-route response")
890+ case <-time.After(time.Second):
891+ t.Fatal("client did not leave the parked request after its deadline")
892+ }
893+ require.Positive(t, remote.closed.Load())
894+}
895+
896+func TestHybridPickerBackResumesRetainedRemoteTransport(t *testing.T) {
897+ term := newRunTerminal()
898+ defer term.in.unblock()
899+
900+ localLifecycle := domain.SessionLifecycleID{1}
901+ remoteLifecycle := domain.SessionLifecycleID{2}
902+ remoteTarget := domain.RemoteSessionTarget{
903+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
904+ SessionName: "work", LiveTabID: "tab-1",
905+ }
906+ localInitial := hybridLocalBootstrap(localLifecycle, remoteTarget)
907+ parkSent := make(chan struct{})
908+ resumeSent := make(chan struct{})
909+ parkedRequestErrors := make(chan error, 2)
910+ remote := &recordingTransport{recvs: []recvItem{
911+ {f: hybridWelcomeFrame("work", remoteLifecycle)},
912+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
913+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
914+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteResumed})), wait: resumeSent},
915+ {f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("source frame")}))},
916+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
917+ }}
918+ remote.onSend = hybridParkedRequestHandler(nil, parkedRequestErrors, map[ports.ParkedRouteAction]chan struct{}{
919+ ports.ParkedRoutePrepare: parkSent,
920+ ports.ParkedRouteResume: resumeSent,
921+ })
922+ localPicker := &recordingTransport{recvs: []recvItem{
923+ {f: hybridWelcomeFrame("local", localLifecycle)},
924+ {f: navigationDirectiveFrame(ports.NavigationBack)},
925+ }}
926+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
927+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
928+ deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.Dialer{"remote": remoteDialer})
929+
930+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
931+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
932+ }))
933+ select {
934+ case requestErr := <-parkedRequestErrors:
935+ require.NoError(t, requestErr)
936+ default:
937+ }
938+ require.Equal(t, int32(1), remoteDialer.calls.Load())
939+}
940+
941+func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) {
942+ term := newRunTerminal()
943+ defer term.in.unblock()
944+
945+ localLifecycle := domain.SessionLifecycleID{1}
946+ sourceLifecycle := domain.SessionLifecycleID{2}
947+ targetLifecycle := domain.SessionLifecycleID{3}
948+ sourceTarget := domain.RemoteSessionTarget{
949+ Endpoint: "source-host", DisplayOrigin: "source-host", LifecycleID: sourceLifecycle,
950+ SessionName: "source", LiveTabID: "source-tab",
951+ }
952+ targetTarget := domain.RemoteSessionTarget{
953+ Endpoint: "target-host", DisplayOrigin: "target-host", LifecycleID: targetLifecycle,
954+ SessionName: "target", LiveTabID: "target-tab",
955+ }
956+ localInitial := hybridLocalBootstrap(localLifecycle, sourceTarget)
957+ parkSent := make(chan struct{})
958+ keepSourceOpen := make(chan struct{})
959+ parkedRequestErrors := make(chan error, 1)
960+ defer close(keepSourceOpen)
961+ sourceRemote := &recordingTransport{recvs: []recvItem{
962+ {f: hybridWelcomeFrame("source", sourceLifecycle)},
963+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
964+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
965+ {err: io.EOF, wait: keepSourceOpen},
966+ }}
967+ sourceRemote.onSend = hybridParkedRequestHandler(nil, parkedRequestErrors, map[ports.ParkedRouteAction]chan struct{}{
968+ ports.ParkedRoutePrepare: parkSent,
969+ })
970+ localPicker := &recordingTransport{recvs: []recvItem{
971+ {f: hybridWelcomeFrame("local", localLifecycle)},
972+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
973+ Endpoint: targetTarget.Endpoint, Session: targetTarget.SessionName, Intent: ports.IntentAttach,
974+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
975+ }))},
976+ }}
977+ targetRemote := &recordingTransport{recvs: []recvItem{
978+ {f: hybridWelcomeFrame("target", targetLifecycle)},
979+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
980+ }}
981+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
982+ sourceDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: sourceRemote}}}
983+ targetDialer := &sequenceDialer{trs: []ports.Transport{targetRemote}}
984+ deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.Dialer{
985+ sourceTarget.Endpoint: sourceDialer,
986+ targetTarget.Endpoint: targetDialer,
987+ })
988+
989+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
990+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
991+ }))
992+ select {
993+ case requestErr := <-parkedRequestErrors:
994+ require.NoError(t, requestErr)
995+ default:
996+ }
997+ require.Equal(t, int32(1), sourceDialer.calls.Load())
998+ require.Equal(t, int32(1), targetDialer.calls.Load())
999+ require.Positive(t, sourceRemote.closed.Load())
1000+}
1001+
5811002 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
5821003 term := newRunTerminal()
5831004 defer term.in.unblock()
@@ -622,7 +1043,7 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
6221043 }}
6231044 local3 := &recordingTransport{recvs: []recvItem{
6241045 {f: welcome("test", localLifecycle, 11)},
625- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationBack))},
1046+ {f: navigationDirectiveFrame(ports.NavigationBack)},
6261047 }}
6271048 remote1 := &recordingTransport{recvs: []recvItem{
6281049 {f: welcome("remote-manual", remoteLifecycle, 22)},
@@ -636,7 +1057,7 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
6361057 {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{
6371058 Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2",
6381059 }))},
639- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
1060+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
6401061 }}
6411062 remote3 := &recordingTransport{recvs: []recvItem{
6421063 {f: welcome("remote-new", remoteNewLifecycle, 22)},
@@ -667,6 +1088,14 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
6671088 require.Equal(t, &ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, remoteNewHello.ExactTarget)
6681089 }
6691090
1091+func navigationDirectiveFrame(action ports.NavigationAction) ports.Frame {
1092+ directive := ports.NavigationDirective{Action: action}
1093+ if action == ports.NavigationOpenHomePicker {
1094+ directive.LeaseID = ports.ParkedRouteLeaseID{1}
1095+ }
1096+ return frameOf(ports.MsgNavigationAction, ports.MarshalNavigationDirective(directive))
1097+}
1098+
6701099 func mustMarshalCommittedIdentity(identity ports.CommittedRouteIdentity) []byte {
6711100 payload, err := ports.MarshalCommittedRouteIdentity(identity)
6721101 if err != nil {
@@ -1132,16 +1561,23 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
11321561 }
11331562
11341563 type recordingTransport struct {
1135- mu sync.Mutex
1136- recvs []recvItem
1137- sends []ports.Frame
1138- closed atomic.Int32
1564+ mu sync.Mutex
1565+ recvs []recvItem
1566+ sends []ports.Frame
1567+ closed atomic.Int32
1568+ stall <-chan struct{}
1569+ onSend func(ports.Frame)
1570+ onClose func()
11391571 }
11401572
11411573 func (t *recordingTransport) Send(f ports.Frame) error {
11421574 t.mu.Lock()
11431575 t.sends = append(t.sends, f)
1576+ onSend := t.onSend
11441577 t.mu.Unlock()
1578+ if onSend != nil {
1579+ onSend(f)
1580+ }
11451581 return nil
11461582 }
11471583
@@ -1153,15 +1589,24 @@ func (t *recordingTransport) Sends() []ports.Frame {
11531589
11541590 func (t *recordingTransport) Recv() (ports.Frame, error) {
11551591 if len(t.recvs) == 0 {
1592+ if t.stall != nil {
1593+ <-t.stall
1594+ }
11561595 return ports.Frame{}, io.EOF
11571596 }
11581597 it := t.recvs[0]
11591598 t.recvs = t.recvs[1:]
1599+ if it.wait != nil {
1600+ <-it.wait
1601+ }
11601602 return it.f, it.err
11611603 }
11621604
11631605 func (t *recordingTransport) Close() error {
11641606 t.closed.Add(1)
1607+ if t.onClose != nil {
1608+ t.onClose()
1609+ }
11651610 return nil
11661611 }
11671612
@@ -1171,17 +1616,31 @@ type runTerminal struct {
11711616 rawCount atomic.Int32
11721617 restoreCount atomic.Int32
11731618 resizeCh chan domain.Size
1619+ sizeMu sync.Mutex
1620+ size domain.Size
11741621 }
11751622
11761623 func newRunTerminal() *runTerminal {
1177- return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)}
1624+ return &runTerminal{
1625+ in: newBlockingReader(), resizeCh: make(chan domain.Size),
1626+ size: domain.Size{Cols: 80, Rows: 24},
1627+ }
11781628 }
11791629
11801630 func (t *runTerminal) EnterRaw() (func() error, error) {
11811631 t.rawCount.Add(1)
11821632 return func() error { t.restoreCount.Add(1); return nil }, nil
11831633 }
1184-func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
1634+func (t *runTerminal) Size() (domain.Size, error) {
1635+ t.sizeMu.Lock()
1636+ defer t.sizeMu.Unlock()
1637+ return t.size, nil
1638+}
1639+func (t *runTerminal) setSize(size domain.Size) {
1640+ t.sizeMu.Lock()
1641+ t.size = size
1642+ t.sizeMu.Unlock()
1643+}
11851644 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
11861645 func (t *runTerminal) In() io.Reader { return t.in }
11871646 func (t *runTerminal) Out() io.Writer { return &t.out }
@@ -56,13 +56,18 @@ func (c *reconnectTestClock) NewTimer(d time.Duration) ports.Timer {
56 }56 }
57 57
58 func (c *reconnectTestClock) fireReconnect(t *testing.T) {58 func (c *reconnectTestClock) fireReconnect(t *testing.T) {
59+ t.Helper()
60+ c.fireDuration(t, 100*time.Millisecond)
61+}
62+
63+func (c *reconnectTestClock) fireDuration(t *testing.T, duration time.Duration) {
59 t.Helper()64 t.Helper()
60 require.Eventually(t, func() bool {65 require.Eventually(t, func() bool {
61 c.mu.Lock()66 c.mu.Lock()
62 defer c.mu.Unlock()67 defer c.mu.Unlock()
63 for _, timer := range c.timers {68 for _, timer := range c.timers {
64 timer.mu.Lock()69 timer.mu.Lock()
65- if timer.duration != 100*time.Millisecond {70+ if timer.duration != duration {
66 timer.mu.Unlock()71 timer.mu.Unlock()
67 continue72 continue
68 }73 }
@@ -111,8 +116,9 @@ func (t *reconnectTestTimer) Stop() bool {
111 116
112 // recvItem is one scripted Recv result.117 // recvItem is one scripted Recv result.
113 type recvItem struct {118 type recvItem struct {
114- f ports.Frame119+ f ports.Frame
115- err error120+ err error
121+ wait <-chan struct{}
116 }122 }
117 123
118 type markedDatagramTransport struct{ ports.Transport }124 type markedDatagramTransport struct{ ports.Transport }
@@ -131,6 +137,13 @@ func scriptRecv(tr *portsmocks.MockTransport, items ...recvItem) func() {
131 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {137 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
132 select {138 select {
133 case it := <-ch:139 case it := <-ch:
140+ if it.wait != nil {
141+ select {
142+ case <-it.wait:
143+ case <-done:
144+ return ports.Frame{}, io.EOF
145+ }
146+ }
134 return it.f, it.err147 return it.f, it.err
135 case <-done:148 case <-done:
136 return ports.Frame{}, io.EOF149 return ports.Frame{}, io.EOF
@@ -552,7 +565,7 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin
552 }}565 }}
553 remote := &recordingTransport{recvs: []recvItem{566 remote := &recordingTransport{recvs: []recvItem{
554 {f: welcome("work", remoteLifecycle)},567 {f: welcome("work", remoteLifecycle)},
555- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},568+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
556 }}569 }}
557 local2 := &recordingTransport{recvs: []recvItem{570 local2 := &recordingTransport{recvs: []recvItem{
558 {f: welcome("local", localLifecycle)},571 {f: welcome("local", localLifecycle)},
@@ -578,6 +591,414 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin
578 require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay)591 require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay)
579 }592 }
580 593
594+func hybridWelcomeFrame(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
595+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
596+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
597+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
598+ }))
599+}
600+
601+func hybridLocalBootstrap(lifecycle domain.SessionLifecycleID, target domain.RemoteSessionTarget) *recordingTransport {
602+ return &recordingTransport{recvs: []recvItem{
603+ {f: hybridWelcomeFrame("local", lifecycle)},
604+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
605+ Endpoint: target.Endpoint, Session: target.SessionName, Intent: ports.IntentAttach,
606+ RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
607+ }))},
608+ }}
609+}
610+
611+func hybridPickerDependencies(localDialer ports.Dialer, term ports.Terminal, clock ports.Clock, remoteByEndpoint map[string]ports.Dialer) client.Dependencies {
612+ deps := testDependencies(localDialer, term, clock, nil, nil)
613+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
614+ if target.RemoteTarget == nil {
615+ return nil, client.AttachRequest{}, errors.New("hybrid picker target is not remote")
616+ }
617+ dialer := remoteByEndpoint[target.Endpoint]
618+ if dialer == nil {
619+ return nil, client.AttachRequest{}, errors.New("hybrid picker endpoint has no dialer")
620+ }
621+ selection := *target.RemoteTarget
622+ return dialer, client.AttachRequest{
623+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
624+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
625+ RemoteTarget: &selection, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
626+ }, nil
627+ }
628+ return deps
629+}
630+
631+func hybridParkedRequestHandler(requests chan<- ports.ParkedRouteRequest, requestErrors chan<- error, signals map[ports.ParkedRouteAction]chan struct{}) func(ports.Frame) {
632+ once := make(map[ports.ParkedRouteAction]*sync.Once, len(signals))
633+ for action := range signals {
634+ once[action] = &sync.Once{}
635+ }
636+ return func(frame ports.Frame) {
637+ if frame.Type != ports.MsgParkedRouteRequest {
638+ return
639+ }
640+ request, err := ports.UnmarshalParkedRouteRequest(frame.Payload)
641+ if err != nil {
642+ if requestErrors != nil {
643+ requestErrors <- err
644+ }
645+ return
646+ }
647+ if requests != nil {
648+ requests <- request
649+ }
650+ if signal := signals[request.Action]; signal != nil {
651+ once[request.Action].Do(func() { close(signal) })
652+ }
653+ }
654+}
655+
656+func TestHybridPickerSameHostSwitchReusesRemoteTransport(t *testing.T) {
657+ term := newRunTerminal()
658+ defer term.in.unblock()
659+
660+ localLifecycle := domain.SessionLifecycleID{1}
661+ remoteSourceLifecycle := domain.SessionLifecycleID{2}
662+ remoteTargetLifecycle := domain.SessionLifecycleID{3}
663+ sourceTarget := domain.RemoteSessionTarget{
664+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteSourceLifecycle,
665+ SessionName: "source", LiveTabID: "source-tab",
666+ }
667+ targetTarget := domain.RemoteSessionTarget{
668+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteTargetLifecycle,
669+ SessionName: "target", LiveTabID: "target-tab",
670+ }
671+ localInitial := hybridLocalBootstrap(localLifecycle, sourceTarget)
672+ parkSent := make(chan struct{})
673+ switchSent := make(chan struct{})
674+ parkedRequests := make(chan ports.ParkedRouteRequest, 2)
675+ parkedRequestErrors := make(chan error, 2)
676+ remote := &recordingTransport{recvs: []recvItem{
677+ {f: hybridWelcomeFrame("source", remoteSourceLifecycle)},
678+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
679+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
680+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: remoteTargetLifecycle, SessionName: "target"}})), wait: switchSent},
681+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteSwitched}))},
682+ {f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 2, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("target frame")}))},
683+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
684+ }}
685+ remote.onSend = hybridParkedRequestHandler(parkedRequests, parkedRequestErrors, map[ports.ParkedRouteAction]chan struct{}{
686+ ports.ParkedRoutePrepare: parkSent,
687+ ports.ParkedRouteSwitch: switchSent,
688+ })
689+ localPicker := &recordingTransport{recvs: []recvItem{
690+ {f: hybridWelcomeFrame("local", localLifecycle)},
691+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
692+ Endpoint: "remote", Session: "target", Intent: ports.IntentAttach,
693+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
694+ }))},
695+ }}
696+ var resizeOnce sync.Once
697+ localPicker.onSend = func(frame ports.Frame) {
698+ if frame.Type == ports.MsgHello {
699+ resizeOnce.Do(func() { term.setSize(domain.Size{Cols: 100, Rows: 40}) })
700+ }
701+ }
702+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
703+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
704+ deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.Dialer{"remote": remoteDialer})
705+
706+ err := runTestClient(context.Background(), deps, client.AttachRequest{
707+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
708+ })
709+
710+ require.NoError(t, err)
711+ select {
712+ case requestErr := <-parkedRequestErrors:
713+ require.NoError(t, requestErr)
714+ default:
715+ }
716+ prepareRequest := <-parkedRequests
717+ switchRequest := <-parkedRequests
718+ require.Equal(t, ports.ParkedRoutePrepare, prepareRequest.Action)
719+ require.Equal(t, ports.ParkedRouteSwitch, switchRequest.Action)
720+ require.NotNil(t, switchRequest.Target)
721+ require.Equal(t, targetTarget, *switchRequest.Target)
722+ frames := remote.Sends()
723+ switchIndex := -1
724+ for i, frame := range frames {
725+ if frame.Type != ports.MsgParkedRouteRequest {
726+ continue
727+ }
728+ request, requestErr := ports.UnmarshalParkedRouteRequest(frame.Payload)
729+ require.NoError(t, requestErr)
730+ if request.Action == ports.ParkedRouteSwitch {
731+ switchIndex = i
732+ break
733+ }
734+ }
735+ require.Positive(t, switchIndex)
736+ require.Equal(t, ports.MsgResize, frames[switchIndex-1].Type, "current size must precede the parked switch")
737+ resize, resizeErr := ports.UnmarshalResize(frames[switchIndex-1].Payload)
738+ require.NoError(t, resizeErr)
739+ require.Equal(t, domain.Size{Cols: 100, Rows: 40}, resize.Size)
740+ require.Equal(t, int32(1), remoteDialer.calls.Load(), "same-host picker switch must retain the authenticated remote transport")
741+}
742+
743+func TestHybridPickerExpiredSwitchFallsBackToNewDial(t *testing.T) {
744+ term := newRunTerminal()
745+ defer term.in.unblock()
746+
747+ localLifecycle := domain.SessionLifecycleID{1}
748+ sourceLifecycle := domain.SessionLifecycleID{2}
749+ targetLifecycle := domain.SessionLifecycleID{3}
750+ sourceTarget := domain.RemoteSessionTarget{
751+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: sourceLifecycle,
752+ SessionName: "source", LiveTabID: "source-tab",
753+ }
754+ targetTarget := domain.RemoteSessionTarget{
755+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle,
756+ SessionName: "target", LiveTabID: "target-tab",
757+ }
758+ localInitial := hybridLocalBootstrap(localLifecycle, sourceTarget)
759+ prepareSent := make(chan struct{})
760+ switchSent := make(chan struct{})
761+ parkedRequestErrors := make(chan error, 1)
762+ sourceRemote := &recordingTransport{recvs: []recvItem{
763+ {f: hybridWelcomeFrame("source", sourceLifecycle)},
764+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
765+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: prepareSent},
766+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteExpired})), wait: switchSent},
767+ }}
768+ sourceRemote.onSend = hybridParkedRequestHandler(nil, parkedRequestErrors, map[ports.ParkedRouteAction]chan struct{}{
769+ ports.ParkedRoutePrepare: prepareSent,
770+ ports.ParkedRouteSwitch: switchSent,
771+ })
772+ localPicker := &recordingTransport{recvs: []recvItem{
773+ {f: hybridWelcomeFrame("local", localLifecycle)},
774+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
775+ Endpoint: "remote", Session: "target", Intent: ports.IntentAttach,
776+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
777+ }))},
778+ }}
779+ targetRemote := &recordingTransport{recvs: []recvItem{
780+ {f: hybridWelcomeFrame("target", targetLifecycle)},
781+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
782+ }}
783+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
784+ remoteDialer := &sequenceDialer{trs: []ports.Transport{
785+ markedDatagramTransport{Transport: sourceRemote}, targetRemote,
786+ }}
787+ deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.Dialer{"remote": remoteDialer})
788+
789+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
790+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
791+ }))
792+ select {
793+ case requestErr := <-parkedRequestErrors:
794+ require.NoError(t, requestErr)
795+ default:
796+ }
797+ require.Equal(t, int32(2), remoteDialer.calls.Load(), "an expired parked lease must use the traditional dial path")
798+}
799+
800+func TestHybridPickerPrepareResponseTimeoutClosesRetainedTransport(t *testing.T) {
801+ term := newRunTerminal()
802+ defer term.in.unblock()
803+ prepareSent := make(chan struct{})
804+ type deadlineTimer struct {
805+ ch chan time.Time
806+ stopped atomic.Bool
807+ }
808+ var deadlineMu sync.Mutex
809+ var deadlineRecords []*deadlineTimer
810+ clock := portsmocks.NewMockClock(t)
811+ clock.EXPECT().Now().Return(time.Time{}).Maybe()
812+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(duration time.Duration) ports.Timer {
813+ timer := portsmocks.NewMockTimer(t)
814+ ch := make(chan time.Time, 1)
815+ timer.EXPECT().C().Return(ch).Maybe()
816+ timer.EXPECT().Reset(mock.Anything).Return(true).Maybe()
817+ if duration == ports.HandshakeTimeout {
818+ record := &deadlineTimer{ch: ch}
819+ timer.EXPECT().Stop().Run(func() { record.stopped.Store(true) }).Return(true).Maybe()
820+ deadlineMu.Lock()
821+ deadlineRecords = append(deadlineRecords, record)
822+ deadlineMu.Unlock()
823+ } else {
824+ timer.EXPECT().Stop().Return(true).Maybe()
825+ }
826+ return timer
827+ }).Maybe()
828+
829+ localLifecycle := domain.SessionLifecycleID{1}
830+ remoteLifecycle := domain.SessionLifecycleID{2}
831+ remoteTarget := domain.RemoteSessionTarget{
832+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
833+ SessionName: "work", LiveTabID: "tab-1",
834+ }
835+ localInitial := hybridLocalBootstrap(localLifecycle, remoteTarget)
836+ remoteClosed := make(chan struct{})
837+ var closeOnce sync.Once
838+ remote := &recordingTransport{
839+ recvs: []recvItem{
840+ {f: hybridWelcomeFrame("work", remoteLifecycle)},
841+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
842+ },
843+ stall: remoteClosed,
844+ }
845+ remote.onSend = hybridParkedRequestHandler(nil, nil, map[ports.ParkedRouteAction]chan struct{}{
846+ ports.ParkedRoutePrepare: prepareSent,
847+ })
848+
849+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial}}
850+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
851+ deps := hybridPickerDependencies(localDialer, term, clock, map[string]ports.Dialer{"remote": remoteDialer})
852+
853+ ctx, cancel := context.WithCancel(context.Background())
854+ defer cancel()
855+ remote.onClose = func() {
856+ closeOnce.Do(func() { close(remoteClosed) })
857+ cancel()
858+ }
859+ result := make(chan error, 1)
860+ go func() {
861+ result <- runTestClient(ctx, deps, client.AttachRequest{
862+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
863+ })
864+ }()
865+ select {
866+ case <-prepareSent:
867+ case <-time.After(time.Second):
868+ t.Fatal("timed out waiting for parked Prepare request")
869+ }
870+ var deadline chan time.Time
871+ require.Eventually(t, func() bool {
872+ deadlineMu.Lock()
873+ defer deadlineMu.Unlock()
874+ var active []*deadlineTimer
875+ for _, record := range deadlineRecords {
876+ if !record.stopped.Load() {
877+ active = append(active, record)
878+ }
879+ }
880+ if len(active) != 1 {
881+ return false
882+ }
883+ deadline = active[0].ch
884+ return true
885+ }, time.Second, time.Millisecond, "expected exactly one active parked-response deadline")
886+ deadline <- time.Time{}
887+ select {
888+ case err := <-result:
889+ require.ErrorContains(t, err, "timed out waiting for parked-route response")
890+ case <-time.After(time.Second):
891+ t.Fatal("client did not leave the parked request after its deadline")
892+ }
893+ require.Positive(t, remote.closed.Load())
894+}
895+
896+func TestHybridPickerBackResumesRetainedRemoteTransport(t *testing.T) {
897+ term := newRunTerminal()
898+ defer term.in.unblock()
899+
900+ localLifecycle := domain.SessionLifecycleID{1}
901+ remoteLifecycle := domain.SessionLifecycleID{2}
902+ remoteTarget := domain.RemoteSessionTarget{
903+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
904+ SessionName: "work", LiveTabID: "tab-1",
905+ }
906+ localInitial := hybridLocalBootstrap(localLifecycle, remoteTarget)
907+ parkSent := make(chan struct{})
908+ resumeSent := make(chan struct{})
909+ parkedRequestErrors := make(chan error, 2)
910+ remote := &recordingTransport{recvs: []recvItem{
911+ {f: hybridWelcomeFrame("work", remoteLifecycle)},
912+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
913+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
914+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteResumed})), wait: resumeSent},
915+ {f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("source frame")}))},
916+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
917+ }}
918+ remote.onSend = hybridParkedRequestHandler(nil, parkedRequestErrors, map[ports.ParkedRouteAction]chan struct{}{
919+ ports.ParkedRoutePrepare: parkSent,
920+ ports.ParkedRouteResume: resumeSent,
921+ })
922+ localPicker := &recordingTransport{recvs: []recvItem{
923+ {f: hybridWelcomeFrame("local", localLifecycle)},
924+ {f: navigationDirectiveFrame(ports.NavigationBack)},
925+ }}
926+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
927+ remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
928+ deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.Dialer{"remote": remoteDialer})
929+
930+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
931+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
932+ }))
933+ select {
934+ case requestErr := <-parkedRequestErrors:
935+ require.NoError(t, requestErr)
936+ default:
937+ }
938+ require.Equal(t, int32(1), remoteDialer.calls.Load())
939+}
940+
941+func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) {
942+ term := newRunTerminal()
943+ defer term.in.unblock()
944+
945+ localLifecycle := domain.SessionLifecycleID{1}
946+ sourceLifecycle := domain.SessionLifecycleID{2}
947+ targetLifecycle := domain.SessionLifecycleID{3}
948+ sourceTarget := domain.RemoteSessionTarget{
949+ Endpoint: "source-host", DisplayOrigin: "source-host", LifecycleID: sourceLifecycle,
950+ SessionName: "source", LiveTabID: "source-tab",
951+ }
952+ targetTarget := domain.RemoteSessionTarget{
953+ Endpoint: "target-host", DisplayOrigin: "target-host", LifecycleID: targetLifecycle,
954+ SessionName: "target", LiveTabID: "target-tab",
955+ }
956+ localInitial := hybridLocalBootstrap(localLifecycle, sourceTarget)
957+ parkSent := make(chan struct{})
958+ keepSourceOpen := make(chan struct{})
959+ parkedRequestErrors := make(chan error, 1)
960+ defer close(keepSourceOpen)
961+ sourceRemote := &recordingTransport{recvs: []recvItem{
962+ {f: hybridWelcomeFrame("source", sourceLifecycle)},
963+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
964+ {f: frameOf(ports.MsgParkedRouteResponse, ports.MarshalParkedRouteResponse(ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})), wait: parkSent},
965+ {err: io.EOF, wait: keepSourceOpen},
966+ }}
967+ sourceRemote.onSend = hybridParkedRequestHandler(nil, parkedRequestErrors, map[ports.ParkedRouteAction]chan struct{}{
968+ ports.ParkedRoutePrepare: parkSent,
969+ })
970+ localPicker := &recordingTransport{recvs: []recvItem{
971+ {f: hybridWelcomeFrame("local", localLifecycle)},
972+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
973+ Endpoint: targetTarget.Endpoint, Session: targetTarget.SessionName, Intent: ports.IntentAttach,
974+ RemoteTarget: &targetTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
975+ }))},
976+ }}
977+ targetRemote := &recordingTransport{recvs: []recvItem{
978+ {f: hybridWelcomeFrame("target", targetLifecycle)},
979+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
980+ }}
981+ localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
982+ sourceDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: sourceRemote}}}
983+ targetDialer := &sequenceDialer{trs: []ports.Transport{targetRemote}}
984+ deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.Dialer{
985+ sourceTarget.Endpoint: sourceDialer,
986+ targetTarget.Endpoint: targetDialer,
987+ })
988+
989+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
990+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
991+ }))
992+ select {
993+ case requestErr := <-parkedRequestErrors:
994+ require.NoError(t, requestErr)
995+ default:
996+ }
997+ require.Equal(t, int32(1), sourceDialer.calls.Load())
998+ require.Equal(t, int32(1), targetDialer.calls.Load())
999+ require.Positive(t, sourceRemote.closed.Load())
1000+}
1001+
581 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {1002 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
582 term := newRunTerminal()1003 term := newRunTerminal()
583 defer term.in.unblock()1004 defer term.in.unblock()
@@ -622,7 +1043,7 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
622 }}1043 }}
623 local3 := &recordingTransport{recvs: []recvItem{1044 local3 := &recordingTransport{recvs: []recvItem{
624 {f: welcome("test", localLifecycle, 11)},1045 {f: welcome("test", localLifecycle, 11)},
625- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationBack))},1046+ {f: navigationDirectiveFrame(ports.NavigationBack)},
626 }}1047 }}
627 remote1 := &recordingTransport{recvs: []recvItem{1048 remote1 := &recordingTransport{recvs: []recvItem{
628 {f: welcome("remote-manual", remoteLifecycle, 22)},1049 {f: welcome("remote-manual", remoteLifecycle, 22)},
@@ -636,7 +1057,7 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
636 {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{1057 {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{
637 Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2",1058 Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2",
638 }))},1059 }))},
639- {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},1060+ {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)},
640 }}1061 }}
641 remote3 := &recordingTransport{recvs: []recvItem{1062 remote3 := &recordingTransport{recvs: []recvItem{
642 {f: welcome("remote-new", remoteNewLifecycle, 22)},1063 {f: welcome("remote-new", remoteNewLifecycle, 22)},
@@ -667,6 +1088,14 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
667 require.Equal(t, &ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, remoteNewHello.ExactTarget)1088 require.Equal(t, &ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, remoteNewHello.ExactTarget)
668 }1089 }
669 1090
1091+func navigationDirectiveFrame(action ports.NavigationAction) ports.Frame {
1092+ directive := ports.NavigationDirective{Action: action}
1093+ if action == ports.NavigationOpenHomePicker {
1094+ directive.LeaseID = ports.ParkedRouteLeaseID{1}
1095+ }
1096+ return frameOf(ports.MsgNavigationAction, ports.MarshalNavigationDirective(directive))
1097+}
1098+
670 func mustMarshalCommittedIdentity(identity ports.CommittedRouteIdentity) []byte {1099 func mustMarshalCommittedIdentity(identity ports.CommittedRouteIdentity) []byte {
671 payload, err := ports.MarshalCommittedRouteIdentity(identity)1100 payload, err := ports.MarshalCommittedRouteIdentity(identity)
672 if err != nil {1101 if err != nil {
@@ -1132,16 +1561,23 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
1132 }1561 }
1133 1562
1134 type recordingTransport struct {1563 type recordingTransport struct {
1135- mu sync.Mutex1564+ mu sync.Mutex
1136- recvs []recvItem1565+ recvs []recvItem
1137- sends []ports.Frame1566+ sends []ports.Frame
1138- closed atomic.Int321567+ closed atomic.Int32
1568+ stall <-chan struct{}
1569+ onSend func(ports.Frame)
1570+ onClose func()
1139 }1571 }
1140 1572
1141 func (t *recordingTransport) Send(f ports.Frame) error {1573 func (t *recordingTransport) Send(f ports.Frame) error {
1142 t.mu.Lock()1574 t.mu.Lock()
1143 t.sends = append(t.sends, f)1575 t.sends = append(t.sends, f)
1576+ onSend := t.onSend
1144 t.mu.Unlock()1577 t.mu.Unlock()
1578+ if onSend != nil {
1579+ onSend(f)
1580+ }
1145 return nil1581 return nil
1146 }1582 }
1147 1583
@@ -1153,15 +1589,24 @@ func (t *recordingTransport) Sends() []ports.Frame {
1153 1589
1154 func (t *recordingTransport) Recv() (ports.Frame, error) {1590 func (t *recordingTransport) Recv() (ports.Frame, error) {
1155 if len(t.recvs) == 0 {1591 if len(t.recvs) == 0 {
1592+ if t.stall != nil {
1593+ <-t.stall
1594+ }
1156 return ports.Frame{}, io.EOF1595 return ports.Frame{}, io.EOF
1157 }1596 }
1158 it := t.recvs[0]1597 it := t.recvs[0]
1159 t.recvs = t.recvs[1:]1598 t.recvs = t.recvs[1:]
1599+ if it.wait != nil {
1600+ <-it.wait
1601+ }
1160 return it.f, it.err1602 return it.f, it.err
1161 }1603 }
1162 1604
1163 func (t *recordingTransport) Close() error {1605 func (t *recordingTransport) Close() error {
1164 t.closed.Add(1)1606 t.closed.Add(1)
1607+ if t.onClose != nil {
1608+ t.onClose()
1609+ }
1165 return nil1610 return nil
1166 }1611 }
1167 1612
@@ -1171,17 +1616,31 @@ type runTerminal struct {
1171 rawCount atomic.Int321616 rawCount atomic.Int32
1172 restoreCount atomic.Int321617 restoreCount atomic.Int32
1173 resizeCh chan domain.Size1618 resizeCh chan domain.Size
1619+ sizeMu sync.Mutex
1620+ size domain.Size
1174 }1621 }
1175 1622
1176 func newRunTerminal() *runTerminal {1623 func newRunTerminal() *runTerminal {
1177- return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)}1624+ return &runTerminal{
1625+ in: newBlockingReader(), resizeCh: make(chan domain.Size),
1626+ size: domain.Size{Cols: 80, Rows: 24},
1627+ }
1178 }1628 }
1179 1629
1180 func (t *runTerminal) EnterRaw() (func() error, error) {1630 func (t *runTerminal) EnterRaw() (func() error, error) {
1181 t.rawCount.Add(1)1631 t.rawCount.Add(1)
1182 return func() error { t.restoreCount.Add(1); return nil }, nil1632 return func() error { t.restoreCount.Add(1); return nil }, nil
1183 }1633 }
1184-func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }1634+func (t *runTerminal) Size() (domain.Size, error) {
1635+ t.sizeMu.Lock()
1636+ defer t.sizeMu.Unlock()
1637+ return t.size, nil
1638+}
1639+func (t *runTerminal) setSize(size domain.Size) {
1640+ t.sizeMu.Lock()
1641+ t.size = size
1642+ t.sizeMu.Unlock()
1643+}
1185 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }1644 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
1186 func (t *runTerminal) In() io.Reader { return t.in }1645 func (t *runTerminal) In() io.Reader { return t.in }
1187 func (t *runTerminal) Out() io.Writer { return &t.out }1646 func (t *runTerminal) Out() io.Writer { return &t.out }
M internal/usecase/client/handshake.go +60 -26
@@ -83,53 +83,87 @@ func watchHandshakeTransport(ctx context.Context, transport ports.Transport) fun
8383 }
8484
8585 func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error {
86+ return boundedHandshakeOperationWithTransition(ctx, transport, operation, nil)
87+}
88+
89+func boundedHandshakeOperationWithTransition(ctx context.Context, transport ports.Transport, operation func() error, transition *transitionUI) error {
8690 if err := ctx.Err(); err != nil {
8791 _ = transport.Close()
8892 return err
8993 }
9094 completed := make(chan error, 1)
9195 go func() { completed <- operation() }()
92- select {
93- case err := <-completed:
94- if ctxErr := ctx.Err(); ctxErr != nil {
96+ for {
97+ select {
98+ case err := <-completed:
99+ if ctxErr := ctx.Err(); ctxErr != nil {
100+ _ = transport.Close()
101+ return ctxErr
102+ }
103+ return err
104+ case <-transition.tickC():
105+ if err := transition.advance(); err != nil {
106+ _ = transport.Close()
107+ return err
108+ }
109+ case <-ctx.Done():
95110 _ = transport.Close()
96- return ctxErr
111+ // The result channel is buffered, so the operation can publish its
112+ // completion after Close without keeping this cancellation path stuck.
113+ return ctx.Err()
97114 }
98- return err
99- case <-ctx.Done():
100- _ = transport.Close()
101- // The result channel is buffered, so the operation can publish its
102- // completion after Close without keeping this cancellation path stuck.
103- return ctx.Err()
104115 }
105116 }
106117
107118 func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, error) {
119+ return boundedDialWithTransition(ctx, dialer, nil)
120+}
121+
122+func boundedDialWithTransition(ctx context.Context, dialer ports.Dialer, transition *transitionUI) (ports.Transport, error) {
108123 // The dial context only bounds startup. A successful carriage outlives the
109124 // handshake context; canceling that context after Welcome must not kill SSH.
110125 dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
111- result := make(chan struct {
126+ type dialResult struct {
112127 transport ports.Transport
113128 err error
114- }, 1)
129+ }
130+ result := make(chan dialResult, 1)
115131 go func() {
116132 transport, err := dialer.Dial(dialCtx)
117- if err != nil {
118- cancel()
119- } else if ctx.Err() != nil && transport != nil {
120- _ = transport.Close()
121- }
122- result <- struct {
123- transport ports.Transport
124- err error
125- }{transport: transport, err: err}
133+ result <- dialResult{transport: transport, err: err}
126134 }()
127- select {
128- case result := <-result:
129- return result.transport, result.err
130- case <-ctx.Done():
135+ abandon := func() {
131136 cancel()
132- return nil, ctx.Err()
137+ go func() {
138+ late := <-result
139+ if late.transport != nil {
140+ _ = late.transport.Close()
141+ }
142+ }()
143+ }
144+ for {
145+ select {
146+ case result := <-result:
147+ if err := ctx.Err(); err != nil {
148+ cancel()
149+ if result.transport != nil {
150+ _ = result.transport.Close()
151+ }
152+ return nil, err
153+ }
154+ if result.err != nil {
155+ cancel()
156+ }
157+ return result.transport, result.err
158+ case <-transition.tickC():
159+ if err := transition.advance(); err != nil {
160+ abandon()
161+ return nil, err
162+ }
163+ case <-ctx.Done():
164+ abandon()
165+ return nil, ctx.Err()
166+ }
133167 }
134168 }
135169
@@ -83,53 +83,87 @@ func watchHandshakeTransport(ctx context.Context, transport ports.Transport) fun
83 }83 }
84 84
85 func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error {85 func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error {
86+ return boundedHandshakeOperationWithTransition(ctx, transport, operation, nil)
87+}
88+
89+func boundedHandshakeOperationWithTransition(ctx context.Context, transport ports.Transport, operation func() error, transition *transitionUI) error {
86 if err := ctx.Err(); err != nil {90 if err := ctx.Err(); err != nil {
87 _ = transport.Close()91 _ = transport.Close()
88 return err92 return err
89 }93 }
90 completed := make(chan error, 1)94 completed := make(chan error, 1)
91 go func() { completed <- operation() }()95 go func() { completed <- operation() }()
92- select {96+ for {
93- case err := <-completed:97+ select {
94- if ctxErr := ctx.Err(); ctxErr != nil {98+ case err := <-completed:
99+ if ctxErr := ctx.Err(); ctxErr != nil {
100+ _ = transport.Close()
101+ return ctxErr
102+ }
103+ return err
104+ case <-transition.tickC():
105+ if err := transition.advance(); err != nil {
106+ _ = transport.Close()
107+ return err
108+ }
109+ case <-ctx.Done():
95 _ = transport.Close()110 _ = transport.Close()
96- return ctxErr111+ // The result channel is buffered, so the operation can publish its
112+ // completion after Close without keeping this cancellation path stuck.
113+ return ctx.Err()
97 }114 }
98- return err
99- case <-ctx.Done():
100- _ = transport.Close()
101- // The result channel is buffered, so the operation can publish its
102- // completion after Close without keeping this cancellation path stuck.
103- return ctx.Err()
104 }115 }
105 }116 }
106 117
107 func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, error) {118 func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, error) {
119+ return boundedDialWithTransition(ctx, dialer, nil)
120+}
121+
122+func boundedDialWithTransition(ctx context.Context, dialer ports.Dialer, transition *transitionUI) (ports.Transport, error) {
108 // The dial context only bounds startup. A successful carriage outlives the123 // The dial context only bounds startup. A successful carriage outlives the
109 // handshake context; canceling that context after Welcome must not kill SSH.124 // handshake context; canceling that context after Welcome must not kill SSH.
110 dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))125 dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
111- result := make(chan struct {126+ type dialResult struct {
112 transport ports.Transport127 transport ports.Transport
113 err error128 err error
114- }, 1)129+ }
130+ result := make(chan dialResult, 1)
115 go func() {131 go func() {
116 transport, err := dialer.Dial(dialCtx)132 transport, err := dialer.Dial(dialCtx)
117- if err != nil {133+ result <- dialResult{transport: transport, err: err}
118- cancel()
119- } else if ctx.Err() != nil && transport != nil {
120- _ = transport.Close()
121- }
122- result <- struct {
123- transport ports.Transport
124- err error
125- }{transport: transport, err: err}
126 }()134 }()
127- select {135+ abandon := func() {
128- case result := <-result:
129- return result.transport, result.err
130- case <-ctx.Done():
131 cancel()136 cancel()
132- return nil, ctx.Err()137+ go func() {
138+ late := <-result
139+ if late.transport != nil {
140+ _ = late.transport.Close()
141+ }
142+ }()
143+ }
144+ for {
145+ select {
146+ case result := <-result:
147+ if err := ctx.Err(); err != nil {
148+ cancel()
149+ if result.transport != nil {
150+ _ = result.transport.Close()
151+ }
152+ return nil, err
153+ }
154+ if result.err != nil {
155+ cancel()
156+ }
157+ return result.transport, result.err
158+ case <-transition.tickC():
159+ if err := transition.advance(); err != nil {
160+ abandon()
161+ return nil, err
162+ }
163+ case <-ctx.Done():
164+ abandon()
165+ return nil, ctx.Err()
166+ }
133 }167 }
134 }168 }
135 169
A internal/usecase/client/handshake_test.go +28 -0
@@ -0,0 +1,28 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+type failedContextDialer struct {
14+ ctx context.Context
15+}
16+
17+func (d *failedContextDialer) Dial(ctx context.Context) (ports.Transport, error) {
18+ d.ctx = ctx
19+ return nil, errors.New("dial failed")
20+}
21+
22+func TestBoundedDialCancelsPrivateContextAfterFailure(t *testing.T) {
23+ dialer := &failedContextDialer{}
24+ _, err := boundedDial(context.Background(), dialer)
25+ require.ErrorContains(t, err, "dial failed")
26+ require.NotNil(t, dialer.ctx)
27+ require.ErrorIs(t, dialer.ctx.Err(), context.Canceled)
28+}
@@ -0,0 +1,28 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+type failedContextDialer struct {
14+ ctx context.Context
15+}
16+
17+func (d *failedContextDialer) Dial(ctx context.Context) (ports.Transport, error) {
18+ d.ctx = ctx
19+ return nil, errors.New("dial failed")
20+}
21+
22+func TestBoundedDialCancelsPrivateContextAfterFailure(t *testing.T) {
23+ dialer := &failedContextDialer{}
24+ _, err := boundedDial(context.Background(), dialer)
25+ require.ErrorContains(t, err, "dial failed")
26+ require.NotNil(t, dialer.ctx)
27+ require.ErrorIs(t, dialer.ctx.Err(), context.Canceled)
28+}
A internal/usecase/client/transition_ui.go +126 -0
@@ -0,0 +1,126 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "io"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+const (
13+ transitionSpinnerDelay = 120 * time.Millisecond
14+ transitionSpinnerInterval = 80 * time.Millisecond
15+)
16+
17+var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
18+
19+func transitionMessage(target ports.AttachTarget) string {
20+ origin := target.Endpoint
21+ if target.RemoteTarget != nil && target.RemoteTarget.DisplayOrigin != "" {
22+ origin = target.RemoteTarget.DisplayOrigin
23+ }
24+ label := target.Session
25+ if origin != "" {
26+ label = domain.RemoteSessionDisplay(target.Session, origin)
27+ }
28+ verb := "Switching to "
29+ if target.RemoteTarget != nil && target.RemoteTarget.Stopped {
30+ verb = "Starting "
31+ }
32+ return verb + label + "…"
33+}
34+
35+func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) {
36+ spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)]
37+ return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message))
38+}
39+
40+// transitionUI owns the client-local handoff presentation. Every method is
41+// called by the Runner or active attach loop, preserving the terminal's single
42+// writer rule while network operations expose timer/resize events.
43+type transitionUI struct {
44+ term ports.Terminal
45+ clock ports.Clock
46+ rawEntered *bool
47+ timer ports.Timer
48+ active bool
49+ showing bool
50+ frame int
51+ message string
52+}
53+
54+func newTransitionUI(term ports.Terminal, clock ports.Clock, rawEntered *bool) *transitionUI {
55+ return &transitionUI{term: term, clock: clock, rawEntered: rawEntered}
56+}
57+
58+func (u *transitionUI) start(target ports.AttachTarget) {
59+ if u == nil {
60+ return
61+ }
62+ u.stop()
63+ u.active = true
64+ u.frame = 0
65+ u.message = transitionMessage(target)
66+ if u.clock != nil {
67+ u.timer = u.clock.NewTimer(transitionSpinnerDelay)
68+ }
69+}
70+
71+func (u *transitionUI) tickC() <-chan time.Time {
72+ if u == nil || !u.active || u.timer == nil {
73+ return nil
74+ }
75+ return u.timer.C()
76+}
77+
78+func (u *transitionUI) advance() error {
79+ if u == nil || !u.active {
80+ return nil
81+ }
82+ size, err := u.term.Size()
83+ if err != nil {
84+ return fmt.Errorf("reading terminal size for transition toast: %w", err)
85+ }
86+ if err := u.redraw(size); err != nil {
87+ return err
88+ }
89+ u.frame = (u.frame + 1) % len(transitionSpinnerFrames)
90+ if u.timer != nil {
91+ u.timer.Reset(transitionSpinnerInterval)
92+ }
93+ return nil
94+}
95+
96+func (u *transitionUI) redraw(size domain.Size) error {
97+ if u == nil || !u.active || u.rawEntered == nil || !*u.rawEntered {
98+ return nil
99+ }
100+ bounds, err := drawTransitionToast(u.term.Out(), size, u.frame, u.message)
101+ if err != nil {
102+ return fmt.Errorf("drawing transition toast: %w", err)
103+ }
104+ if bounds.Width == 0 || bounds.Height == 0 {
105+ return nil
106+ }
107+ if err := u.term.Flush(); err != nil {
108+ return fmt.Errorf("flushing transition toast: %w", err)
109+ }
110+ u.showing = true
111+ return nil
112+}
113+
114+func (u *transitionUI) stop() {
115+ if u == nil {
116+ return
117+ }
118+ if u.timer != nil {
119+ u.timer.Stop()
120+ u.timer = nil
121+ }
122+ u.active = false
123+ u.showing = false
124+ u.frame = 0
125+ u.message = ""
126+}
@@ -0,0 +1,126 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "io"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+const (
13+ transitionSpinnerDelay = 120 * time.Millisecond
14+ transitionSpinnerInterval = 80 * time.Millisecond
15+)
16+
17+var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
18+
19+func transitionMessage(target ports.AttachTarget) string {
20+ origin := target.Endpoint
21+ if target.RemoteTarget != nil && target.RemoteTarget.DisplayOrigin != "" {
22+ origin = target.RemoteTarget.DisplayOrigin
23+ }
24+ label := target.Session
25+ if origin != "" {
26+ label = domain.RemoteSessionDisplay(target.Session, origin)
27+ }
28+ verb := "Switching to "
29+ if target.RemoteTarget != nil && target.RemoteTarget.Stopped {
30+ verb = "Starting "
31+ }
32+ return verb + label + "…"
33+}
34+
35+func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) {
36+ spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)]
37+ return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message))
38+}
39+
40+// transitionUI owns the client-local handoff presentation. Every method is
41+// called by the Runner or active attach loop, preserving the terminal's single
42+// writer rule while network operations expose timer/resize events.
43+type transitionUI struct {
44+ term ports.Terminal
45+ clock ports.Clock
46+ rawEntered *bool
47+ timer ports.Timer
48+ active bool
49+ showing bool
50+ frame int
51+ message string
52+}
53+
54+func newTransitionUI(term ports.Terminal, clock ports.Clock, rawEntered *bool) *transitionUI {
55+ return &transitionUI{term: term, clock: clock, rawEntered: rawEntered}
56+}
57+
58+func (u *transitionUI) start(target ports.AttachTarget) {
59+ if u == nil {
60+ return
61+ }
62+ u.stop()
63+ u.active = true
64+ u.frame = 0
65+ u.message = transitionMessage(target)
66+ if u.clock != nil {
67+ u.timer = u.clock.NewTimer(transitionSpinnerDelay)
68+ }
69+}
70+
71+func (u *transitionUI) tickC() <-chan time.Time {
72+ if u == nil || !u.active || u.timer == nil {
73+ return nil
74+ }
75+ return u.timer.C()
76+}
77+
78+func (u *transitionUI) advance() error {
79+ if u == nil || !u.active {
80+ return nil
81+ }
82+ size, err := u.term.Size()
83+ if err != nil {
84+ return fmt.Errorf("reading terminal size for transition toast: %w", err)
85+ }
86+ if err := u.redraw(size); err != nil {
87+ return err
88+ }
89+ u.frame = (u.frame + 1) % len(transitionSpinnerFrames)
90+ if u.timer != nil {
91+ u.timer.Reset(transitionSpinnerInterval)
92+ }
93+ return nil
94+}
95+
96+func (u *transitionUI) redraw(size domain.Size) error {
97+ if u == nil || !u.active || u.rawEntered == nil || !*u.rawEntered {
98+ return nil
99+ }
100+ bounds, err := drawTransitionToast(u.term.Out(), size, u.frame, u.message)
101+ if err != nil {
102+ return fmt.Errorf("drawing transition toast: %w", err)
103+ }
104+ if bounds.Width == 0 || bounds.Height == 0 {
105+ return nil
106+ }
107+ if err := u.term.Flush(); err != nil {
108+ return fmt.Errorf("flushing transition toast: %w", err)
109+ }
110+ u.showing = true
111+ return nil
112+}
113+
114+func (u *transitionUI) stop() {
115+ if u == nil {
116+ return
117+ }
118+ if u.timer != nil {
119+ u.timer.Stop()
120+ u.timer = nil
121+ }
122+ u.active = false
123+ u.showing = false
124+ u.frame = 0
125+ u.message = ""
126+}
A internal/usecase/client/transition_ui_test.go +125 -0
@@ -0,0 +1,125 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "io"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+func TestTransitionMessageDescribesSwitchAndStoppedRestore(t *testing.T) {
17+ live := ports.AttachTarget{Endpoint: "remote", Session: "work"}
18+ stoppedTarget := domain.RemoteSessionTarget{
19+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},
20+ SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"),
21+ }
22+ stopped := ports.AttachTarget{Endpoint: "remote", Session: "work", RemoteTarget: &stoppedTarget}
23+
24+ require.Equal(t, "Switching to work@remote…", transitionMessage(live))
25+ require.Equal(t, "Starting work@remote…", transitionMessage(stopped))
26+}
27+
28+func TestDrawTransitionToastUsesAnimatedFrames(t *testing.T) {
29+ var first, second bytes.Buffer
30+ _, err := drawTransitionToast(&first, domain.Size{Cols: 80, Rows: 24}, 0, "Switching to work@remote…")
31+ require.NoError(t, err)
32+ _, err = drawTransitionToast(&second, domain.Size{Cols: 80, Rows: 24}, 1, "Switching to work@remote…")
33+ require.NoError(t, err)
34+
35+ require.Contains(t, first.String(), string(transitionSpinnerFrames[0]))
36+ require.Contains(t, second.String(), string(transitionSpinnerFrames[1]))
37+ require.Contains(t, first.String(), "Switching to work@remote…")
38+ require.NotEqual(t, first.String(), second.String())
39+}
40+
41+type transitionTestTimer struct {
42+ ch chan time.Time
43+ resets []time.Duration
44+ stopped bool
45+}
46+
47+func (t *transitionTestTimer) C() <-chan time.Time { return t.ch }
48+func (t *transitionTestTimer) Reset(delay time.Duration) bool {
49+ t.resets = append(t.resets, delay)
50+ return true
51+}
52+func (t *transitionTestTimer) Stop() bool {
53+ t.stopped = true
54+ return true
55+}
56+
57+type transitionTestClock struct {
58+ timer *transitionTestTimer
59+ delays []time.Duration
60+}
61+
62+func (c *transitionTestClock) Now() time.Time { return time.Time{} }
63+func (c *transitionTestClock) NewTimer(delay time.Duration) ports.Timer {
64+ c.delays = append(c.delays, delay)
65+ return c.timer
66+}
67+
68+type transitionTestTerminal struct {
69+ out bytes.Buffer
70+}
71+
72+func (*transitionTestTerminal) EnterRaw() (func() error, error) {
73+ return func() error { return nil }, nil
74+}
75+func (*transitionTestTerminal) Size() (domain.Size, error) {
76+ return domain.Size{Cols: 80, Rows: 24}, nil
77+}
78+func (*transitionTestTerminal) ResizeEvents() <-chan domain.Size { return nil }
79+func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") }
80+func (t *transitionTestTerminal) Out() io.Writer { return &t.out }
81+func (*transitionTestTerminal) Flush() error { return nil }
82+
83+func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) {
84+ for _, tt := range []struct {
85+ name string
86+ raw bool
87+ }{
88+ {name: "raw", raw: true},
89+ {name: "not raw", raw: false},
90+ } {
91+ t.Run(tt.name, func(t *testing.T) {
92+ timer := &transitionTestTimer{ch: make(chan time.Time, 1)}
93+ clock := &transitionTestClock{timer: timer}
94+ term := &transitionTestTerminal{}
95+ raw := tt.raw
96+ ui := newTransitionUI(term, clock, &raw)
97+ ui.start(ports.AttachTarget{Endpoint: "remote", Session: "work"})
98+
99+ require.Equal(t, []time.Duration{transitionSpinnerDelay}, clock.delays)
100+ require.NotNil(t, ui.tickC())
101+ require.Empty(t, term.out.String(), "the delay prevents flashes on fast switches")
102+ require.NoError(t, ui.advance())
103+ require.Equal(t, []time.Duration{transitionSpinnerInterval}, timer.resets)
104+ if !tt.raw {
105+ require.Empty(t, term.out.String())
106+ require.False(t, ui.showing)
107+ ui.stop()
108+ require.True(t, timer.stopped)
109+ require.Nil(t, ui.tickC())
110+ return
111+ }
112+
113+ require.Contains(t, term.out.String(), "Switching to work@remote…")
114+ require.True(t, ui.showing)
115+ beforeResize := term.out.Len()
116+ require.NoError(t, ui.redraw(domain.Size{Cols: 100, Rows: 30}))
117+ require.Greater(t, term.out.Len(), beforeResize)
118+ require.True(t, strings.Contains(term.out.String(), "Switching to work@remote…"))
119+
120+ ui.stop()
121+ require.True(t, timer.stopped)
122+ require.Nil(t, ui.tickC())
123+ })
124+ }
125+}
@@ -0,0 +1,125 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "io"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+func TestTransitionMessageDescribesSwitchAndStoppedRestore(t *testing.T) {
17+ live := ports.AttachTarget{Endpoint: "remote", Session: "work"}
18+ stoppedTarget := domain.RemoteSessionTarget{
19+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},
20+ SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"),
21+ }
22+ stopped := ports.AttachTarget{Endpoint: "remote", Session: "work", RemoteTarget: &stoppedTarget}
23+
24+ require.Equal(t, "Switching to work@remote…", transitionMessage(live))
25+ require.Equal(t, "Starting work@remote…", transitionMessage(stopped))
26+}
27+
28+func TestDrawTransitionToastUsesAnimatedFrames(t *testing.T) {
29+ var first, second bytes.Buffer
30+ _, err := drawTransitionToast(&first, domain.Size{Cols: 80, Rows: 24}, 0, "Switching to work@remote…")
31+ require.NoError(t, err)
32+ _, err = drawTransitionToast(&second, domain.Size{Cols: 80, Rows: 24}, 1, "Switching to work@remote…")
33+ require.NoError(t, err)
34+
35+ require.Contains(t, first.String(), string(transitionSpinnerFrames[0]))
36+ require.Contains(t, second.String(), string(transitionSpinnerFrames[1]))
37+ require.Contains(t, first.String(), "Switching to work@remote…")
38+ require.NotEqual(t, first.String(), second.String())
39+}
40+
41+type transitionTestTimer struct {
42+ ch chan time.Time
43+ resets []time.Duration
44+ stopped bool
45+}
46+
47+func (t *transitionTestTimer) C() <-chan time.Time { return t.ch }
48+func (t *transitionTestTimer) Reset(delay time.Duration) bool {
49+ t.resets = append(t.resets, delay)
50+ return true
51+}
52+func (t *transitionTestTimer) Stop() bool {
53+ t.stopped = true
54+ return true
55+}
56+
57+type transitionTestClock struct {
58+ timer *transitionTestTimer
59+ delays []time.Duration
60+}
61+
62+func (c *transitionTestClock) Now() time.Time { return time.Time{} }
63+func (c *transitionTestClock) NewTimer(delay time.Duration) ports.Timer {
64+ c.delays = append(c.delays, delay)
65+ return c.timer
66+}
67+
68+type transitionTestTerminal struct {
69+ out bytes.Buffer
70+}
71+
72+func (*transitionTestTerminal) EnterRaw() (func() error, error) {
73+ return func() error { return nil }, nil
74+}
75+func (*transitionTestTerminal) Size() (domain.Size, error) {
76+ return domain.Size{Cols: 80, Rows: 24}, nil
77+}
78+func (*transitionTestTerminal) ResizeEvents() <-chan domain.Size { return nil }
79+func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") }
80+func (t *transitionTestTerminal) Out() io.Writer { return &t.out }
81+func (*transitionTestTerminal) Flush() error { return nil }
82+
83+func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) {
84+ for _, tt := range []struct {
85+ name string
86+ raw bool
87+ }{
88+ {name: "raw", raw: true},
89+ {name: "not raw", raw: false},
90+ } {
91+ t.Run(tt.name, func(t *testing.T) {
92+ timer := &transitionTestTimer{ch: make(chan time.Time, 1)}
93+ clock := &transitionTestClock{timer: timer}
94+ term := &transitionTestTerminal{}
95+ raw := tt.raw
96+ ui := newTransitionUI(term, clock, &raw)
97+ ui.start(ports.AttachTarget{Endpoint: "remote", Session: "work"})
98+
99+ require.Equal(t, []time.Duration{transitionSpinnerDelay}, clock.delays)
100+ require.NotNil(t, ui.tickC())
101+ require.Empty(t, term.out.String(), "the delay prevents flashes on fast switches")
102+ require.NoError(t, ui.advance())
103+ require.Equal(t, []time.Duration{transitionSpinnerInterval}, timer.resets)
104+ if !tt.raw {
105+ require.Empty(t, term.out.String())
106+ require.False(t, ui.showing)
107+ ui.stop()
108+ require.True(t, timer.stopped)
109+ require.Nil(t, ui.tickC())
110+ return
111+ }
112+
113+ require.Contains(t, term.out.String(), "Switching to work@remote…")
114+ require.True(t, ui.showing)
115+ beforeResize := term.out.Len()
116+ require.NoError(t, ui.redraw(domain.Size{Cols: 100, Rows: 30}))
117+ require.Greater(t, term.out.Len(), beforeResize)
118+ require.True(t, strings.Contains(term.out.String(), "Switching to work@remote…"))
119+
120+ ui.stop()
121+ require.True(t, timer.stopped)
122+ require.Nil(t, ui.tickC())
123+ })
124+ }
125+}
M internal/usecase/daemon/client.go +12 -1
@@ -100,6 +100,10 @@ type attachedClient struct {
100100 pendingRouteIdentity bool
101101 samePeerOfferMu sync.Mutex
102102 samePeerOffer *ports.ExactSessionTarget
103+ parkedRouteMu sync.Mutex
104+ parkedRoute *parkedRouteLease
105+ parkedRouteOutput atomic.Bool
106+ parkedRouteFullPending atomic.Bool
103107 routeAttentionSubscription ports.RouteAttentionSubscription
104108 // routeCreatedSession marks a session created by this attachment's route.
105109 // A handshake that never commits Welcome must tear down that exact empty
@@ -514,6 +518,9 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
514518 if !ac.transportSnapshotCurrent(expected) {
515519 return expected.transport, errTransportReplaced
516520 }
521+ if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
522+ return expected.transport, nil
523+ }
517524 ac.output.lockView()
518525 defer ac.output.unlockView()
519526 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
@@ -528,6 +535,9 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
528535 if !ac.transportSnapshotCurrent(expected) {
529536 return errTransportReplaced
530537 }
538+ if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
539+ return nil
540+ }
531541 ac.output.lockView()
532542 defer ac.output.unlockView()
533543 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
@@ -784,7 +794,8 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *ren
784794 // outputStateStream publishes capacity atomically. Do not take
785795 // attached.sendMu here: a slow transport may be holding it for an
786796 // in-flight Send, and that peer must not gate healthy attachments.
787- return attached == nil || attached.output == nil || !attached.output.atCapacity()
797+ return attached == nil ||
798+ (!attached.parkedRouteOutput.Load() && (attached.output == nil || !attached.output.atCapacity()))
788799 },
789800 })
790801 installAttachmentRenderCoordinator(entry, rc)
@@ -100,6 +100,10 @@ type attachedClient struct {
100 pendingRouteIdentity bool100 pendingRouteIdentity bool
101 samePeerOfferMu sync.Mutex101 samePeerOfferMu sync.Mutex
102 samePeerOffer *ports.ExactSessionTarget102 samePeerOffer *ports.ExactSessionTarget
103+ parkedRouteMu sync.Mutex
104+ parkedRoute *parkedRouteLease
105+ parkedRouteOutput atomic.Bool
106+ parkedRouteFullPending atomic.Bool
103 routeAttentionSubscription ports.RouteAttentionSubscription107 routeAttentionSubscription ports.RouteAttentionSubscription
104 // routeCreatedSession marks a session created by this attachment's route.108 // routeCreatedSession marks a session created by this attachment's route.
105 // A handshake that never commits Welcome must tear down that exact empty109 // A handshake that never commits Welcome must tear down that exact empty
@@ -514,6 +518,9 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
514 if !ac.transportSnapshotCurrent(expected) {518 if !ac.transportSnapshotCurrent(expected) {
515 return expected.transport, errTransportReplaced519 return expected.transport, errTransportReplaced
516 }520 }
521+ if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
522+ return expected.transport, nil
523+ }
517 ac.output.lockView()524 ac.output.lockView()
518 defer ac.output.unlockView()525 defer ac.output.unlockView()
519 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())526 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
@@ -528,6 +535,9 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
528 if !ac.transportSnapshotCurrent(expected) {535 if !ac.transportSnapshotCurrent(expected) {
529 return errTransportReplaced536 return errTransportReplaced
530 }537 }
538+ if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
539+ return nil
540+ }
531 ac.output.lockView()541 ac.output.lockView()
532 defer ac.output.unlockView()542 defer ac.output.unlockView()
533 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())543 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
@@ -784,7 +794,8 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *ren
784 // outputStateStream publishes capacity atomically. Do not take794 // outputStateStream publishes capacity atomically. Do not take
785 // attached.sendMu here: a slow transport may be holding it for an795 // attached.sendMu here: a slow transport may be holding it for an
786 // in-flight Send, and that peer must not gate healthy attachments.796 // in-flight Send, and that peer must not gate healthy attachments.
787- return attached == nil || attached.output == nil || !attached.output.atCapacity()797+ return attached == nil ||
798+ (!attached.parkedRouteOutput.Load() && (attached.output == nil || !attached.output.atCapacity()))
788 },799 },
789 })800 })
790 installAttachmentRenderCoordinator(entry, rc)801 installAttachmentRenderCoordinator(entry, rc)
M internal/usecase/daemon/client_frame_routing.go +7 -0
@@ -133,6 +133,13 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
133133 break
134134 }
135135 d.switchSamePeerForAttachment(token, request)
136+ case ports.MsgParkedRouteRequest:
137+ request, derr := ports.UnmarshalParkedRouteRequest(f.Payload)
138+ if derr != nil {
139+ d.log.Warn("malformed parked-route request", "err", derr)
140+ break
141+ }
142+ d.handleParkedRouteRequest(token, request)
136143 case ports.MsgRecentRouteSnapshot:
137144 snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload)
138145 if derr != nil {
@@ -133,6 +133,13 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
133 break133 break
134 }134 }
135 d.switchSamePeerForAttachment(token, request)135 d.switchSamePeerForAttachment(token, request)
136+ case ports.MsgParkedRouteRequest:
137+ request, derr := ports.UnmarshalParkedRouteRequest(f.Payload)
138+ if derr != nil {
139+ d.log.Warn("malformed parked-route request", "err", derr)
140+ break
141+ }
142+ d.handleParkedRouteRequest(token, request)
136 case ports.MsgRecentRouteSnapshot:143 case ports.MsgRecentRouteSnapshot:
137 snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload)144 snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload)
138 if derr != nil {145 if derr != nil {
M internal/usecase/daemon/client_transport_errors.go +2 -0
@@ -66,6 +66,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
6666 return
6767 }
6868 ac.clearSamePeerOffer()
69+ ac.clearParkedRoute()
6970 if d.afterClientGoneDetach != nil {
7071 d.afterClientGoneDetach()
7172 }
@@ -172,6 +173,7 @@ func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentConnectionTok
172173
173174 func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {
174175 ac.clearSamePeerOffer()
176+ ac.clearParkedRoute()
175177 name := sess.nameSnapshot()
176178 if rc := sess.renderCoordinator(); rc != nil {
177179 rc.noteDetach(ac)
@@ -66,6 +66,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
66 return66 return
67 }67 }
68 ac.clearSamePeerOffer()68 ac.clearSamePeerOffer()
69+ ac.clearParkedRoute()
69 if d.afterClientGoneDetach != nil {70 if d.afterClientGoneDetach != nil {
70 d.afterClientGoneDetach()71 d.afterClientGoneDetach()
71 }72 }
@@ -172,6 +173,7 @@ func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentConnectionTok
172 173
173 func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {174 func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {
174 ac.clearSamePeerOffer()175 ac.clearSamePeerOffer()
176+ ac.clearParkedRoute()
175 name := sess.nameSnapshot()177 name := sess.nameSnapshot()
176 if rc := sess.renderCoordinator(); rc != nil {178 if rc := sess.renderCoordinator(); rc != nil {
177 rc.noteDetach(ac)179 rc.noteDetach(ac)
M internal/usecase/daemon/clipboard.go +3 -0
@@ -268,6 +268,9 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
268268 if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) {
269269 return errAttachmentTransition
270270 }
271+ if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
272+ return nil
273+ }
271274 if !beginClipboardOwnerSend(item.owner, ticket, expected) {
272275 return errAttachmentTransition
273276 }
@@ -268,6 +268,9 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
268 if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) {268 if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) {
269 return errAttachmentTransition269 return errAttachmentTransition
270 }270 }
271+ if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
272+ return nil
273+ }
271 if !beginClipboardOwnerSend(item.owner, ticket, expected) {274 if !beginClipboardOwnerSend(item.owner, ticket, expected) {
272 return errAttachmentTransition275 return errAttachmentTransition
273 }276 }
A internal/usecase/daemon/parked_route.go +484 -0
@@ -0,0 +1,484 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "io"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+const parkedRouteLeaseTTL = 15 * time.Minute
14+
15+type parkedRouteLease struct {
16+ id ports.ParkedRouteLeaseID
17+ generation uint64
18+ transport transportSnapshot
19+ expiresAt time.Time
20+ timer ports.Timer
21+ stop chan struct{}
22+ suspended bool
23+ inFlight bool
24+ expired bool
25+}
26+
27+func (lease *parkedRouteLease) matches(id ports.ParkedRouteLeaseID, generation uint64, transport transportSnapshot) bool {
28+ return lease != nil && lease.id == id && lease.generation == generation && lease.transport == transport
29+}
30+
31+func newParkedRouteLeaseID() (ports.ParkedRouteLeaseID, error) {
32+ var id ports.ParkedRouteLeaseID
33+ if _, err := io.ReadFull(rand.Reader, id[:]); err != nil {
34+ return ports.ParkedRouteLeaseID{}, err
35+ }
36+ return id, nil
37+}
38+
39+func stopParkedRouteLeaseLocked(lease *parkedRouteLease) {
40+ if lease == nil {
41+ return
42+ }
43+ if lease.timer != nil {
44+ lease.timer.Stop()
45+ lease.timer = nil
46+ }
47+ if lease.stop != nil {
48+ close(lease.stop)
49+ lease.stop = nil
50+ }
51+}
52+
53+func (d *Daemon) armParkedRoute(token attachmentConnectionToken) (ports.ParkedRouteLeaseID, error) {
54+ ac := token.ac
55+ if ac == nil || !token.attachmentCurrent() || ac.navigationCapabilities&ports.NavigationCapabilityHomePicker == 0 {
56+ return ports.ParkedRouteLeaseID{}, errAttachmentTransition
57+ }
58+ id, err := newParkedRouteLeaseID()
59+ if err != nil {
60+ return ports.ParkedRouteLeaseID{}, err
61+ }
62+ lease := &parkedRouteLease{
63+ id: id, generation: token.generation, transport: token.transport,
64+ expiresAt: d.clock.Now().Add(parkedRouteLeaseTTL),
65+ timer: d.clock.NewTimer(parkedRouteLeaseTTL), stop: make(chan struct{}),
66+ }
67+ ac.parkedRouteMu.Lock()
68+ stopParkedRouteLeaseLocked(ac.parkedRoute)
69+ ac.parkedRoute = lease
70+ ac.parkedRouteOutput.Store(false)
71+ ac.parkedRouteFullPending.Store(false)
72+ timer, stop := lease.timer, lease.stop
73+ ac.parkedRouteMu.Unlock()
74+ go d.watchParkedRouteLease(ac, lease, timer, stop)
75+ return id, nil
76+}
77+
78+func (d *Daemon) watchParkedRouteLease(ac *attachedClient, lease *parkedRouteLease, timer ports.Timer, stop <-chan struct{}) {
79+ select {
80+ case <-timer.C():
81+ d.expireParkedRoute(ac, lease)
82+ case <-stop:
83+ }
84+}
85+
86+func (d *Daemon) expireParkedRoute(ac *attachedClient, lease *parkedRouteLease) {
87+ if ac == nil || lease == nil {
88+ return
89+ }
90+ ac.parkedRouteMu.Lock()
91+ if ac.parkedRoute != lease {
92+ ac.parkedRouteMu.Unlock()
93+ return
94+ }
95+ lease.timer = nil
96+ lease.stop = nil
97+ if lease.inFlight {
98+ lease.expired = true
99+ ac.parkedRouteMu.Unlock()
100+ return
101+ }
102+ ac.parkedRoute = nil
103+ ac.parkedRouteOutput.Store(false)
104+ ac.parkedRouteFullPending.Store(false)
105+ ac.parkedRouteMu.Unlock()
106+ if ac.connectionGeneration.Load() == lease.generation && ac.transportSnapshotCurrent(lease.transport) {
107+ _ = ac.closeCapturedTransport(lease.transport.transport)
108+ }
109+}
110+
111+func (ac *attachedClient) clearParkedRoute() {
112+ if ac == nil {
113+ return
114+ }
115+ ac.parkedRouteMu.Lock()
116+ stopParkedRouteLeaseLocked(ac.parkedRoute)
117+ ac.parkedRoute = nil
118+ ac.parkedRouteOutput.Store(false)
119+ ac.parkedRouteFullPending.Store(false)
120+ ac.parkedRouteMu.Unlock()
121+}
122+
123+func (ac *attachedClient) parkedRouteStatus(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time, suspended bool) ports.ParkedRouteStatus {
124+ if ac == nil || !token.attachmentCurrent() {
125+ return ports.ParkedRouteRejected
126+ }
127+ ac.parkedRouteMu.Lock()
128+ defer ac.parkedRouteMu.Unlock()
129+ lease := ac.parkedRoute
130+ if !lease.matches(id, token.generation, token.transport) {
131+ return ports.ParkedRouteRejected
132+ }
133+ if !now.Before(lease.expiresAt) {
134+ stopParkedRouteLeaseLocked(lease)
135+ ac.parkedRoute = nil
136+ ac.parkedRouteOutput.Store(false)
137+ ac.parkedRouteFullPending.Store(false)
138+ return ports.ParkedRouteExpired
139+ }
140+ if lease.suspended != suspended {
141+ return ports.ParkedRouteRejected
142+ }
143+ return 0
144+}
145+
146+func (ac *attachedClient) prepareParkedRoute(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
147+ if status := ac.parkedRouteStatus(token, id, now, false); status != 0 {
148+ return status
149+ }
150+ ac.parkedRouteMu.Lock()
151+ defer ac.parkedRouteMu.Unlock()
152+ lease := ac.parkedRoute
153+ if !lease.matches(id, token.generation, token.transport) || lease.suspended {
154+ return ports.ParkedRouteRejected
155+ }
156+ lease.suspended = true
157+ ac.parkedRouteOutput.Store(true)
158+ return 0
159+}
160+
161+func (ac *attachedClient) consumeParkedRoute(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
162+ if status := ac.parkedRouteStatus(token, id, now, true); status != 0 {
163+ return status
164+ }
165+ ac.parkedRouteMu.Lock()
166+ defer ac.parkedRouteMu.Unlock()
167+ lease := ac.parkedRoute
168+ if !lease.matches(id, token.generation, token.transport) || !lease.suspended || lease.inFlight {
169+ return ports.ParkedRouteRejected
170+ }
171+ stopParkedRouteLeaseLocked(lease)
172+ ac.parkedRoute = nil
173+ return 0
174+}
175+
176+func (ac *attachedClient) beginParkedRouteSwitch(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
177+ if ac == nil || !token.attachmentCurrent() {
178+ return ports.ParkedRouteRejected
179+ }
180+ ac.parkedRouteMu.Lock()
181+ defer ac.parkedRouteMu.Unlock()
182+ lease := ac.parkedRoute
183+ if !lease.matches(id, token.generation, token.transport) || !lease.suspended || lease.inFlight {
184+ return ports.ParkedRouteRejected
185+ }
186+ if lease.expired || !now.Before(lease.expiresAt) {
187+ stopParkedRouteLeaseLocked(lease)
188+ ac.parkedRoute = nil
189+ ac.parkedRouteOutput.Store(false)
190+ ac.parkedRouteFullPending.Store(false)
191+ return ports.ParkedRouteExpired
192+ }
193+ lease.inFlight = true
194+ return 0
195+}
196+
197+func (ac *attachedClient) rejectParkedRouteSwitch(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
198+ if ac == nil {
199+ return ports.ParkedRouteRejected
200+ }
201+ ac.parkedRouteMu.Lock()
202+ defer ac.parkedRouteMu.Unlock()
203+ lease := ac.parkedRoute
204+ if !lease.matches(id, token.generation, token.transport) || !lease.inFlight {
205+ return ports.ParkedRouteRejected
206+ }
207+ lease.inFlight = false
208+ if lease.expired || !now.Before(lease.expiresAt) {
209+ stopParkedRouteLeaseLocked(lease)
210+ ac.parkedRoute = nil
211+ ac.parkedRouteOutput.Store(false)
212+ ac.parkedRouteFullPending.Store(false)
213+ return ports.ParkedRouteExpired
214+ }
215+ return 0
216+}
217+
218+func (ac *attachedClient) consumePublishedParkedRoute(previous, published attachmentConnectionToken, id ports.ParkedRouteLeaseID) ports.ParkedRouteStatus {
219+ if ac == nil || published.ac != ac || !published.attachmentCurrent() {
220+ return ports.ParkedRouteRejected
221+ }
222+ ac.parkedRouteMu.Lock()
223+ defer ac.parkedRouteMu.Unlock()
224+ lease := ac.parkedRoute
225+ if !lease.matches(id, previous.generation, previous.transport) || !lease.suspended || !lease.inFlight {
226+ return ports.ParkedRouteRejected
227+ }
228+ // Expiry is checked when the request is admitted. Once publication starts,
229+ // the accepted request must commit atomically instead of turning a completed
230+ // identity publication into a late pre-commit rejection.
231+ stopParkedRouteLeaseLocked(lease)
232+ ac.parkedRoute = nil
233+ return 0
234+}
235+
236+func parkedRouteResponseFrame(response ports.ParkedRouteResponse) (ports.Frame, error) {
237+ payload := ports.MarshalParkedRouteResponse(response)
238+ if payload == nil {
239+ return ports.Frame{}, errAttachmentTransition
240+ }
241+ return ports.Frame{Type: ports.MsgParkedRouteResponse, Payload: payload}, nil
242+}
243+
244+func (d *Daemon) sendParkedRouteResponse(token attachmentConnectionToken, response ports.ParkedRouteResponse) error {
245+ frame, err := parkedRouteResponseFrame(response)
246+ if err != nil {
247+ return err
248+ }
249+ return token.sendControl(frame)
250+}
251+
252+// sendParkedRouteResponseLocked preserves response -> full-output ordering.
253+// Caller holds ac.sendMu and keeps parkedRouteOutput set through this send.
254+func (d *Daemon) sendParkedRouteResponseLocked(ac *attachedClient, expected transportSnapshot, response ports.ParkedRouteResponse) error {
255+ frame, err := parkedRouteResponseFrame(response)
256+ if err != nil {
257+ return err
258+ }
259+ if ac == nil || !ac.transportSnapshotCurrent(expected) || expected.transport == nil {
260+ return errAttachmentTransition
261+ }
262+ return expected.transport.Send(frame)
263+}
264+
265+func (ac *attachedClient) releaseParkedOutputLocked() {
266+ ac.rebaseOutput()
267+ ac.pipelineCache = composeCacheInput{}
268+ ac.pipelineScratch = composeCacheInput{}
269+ ac.parkedRouteFullPending.Store(true)
270+ ac.parkedRouteOutput.Store(false)
271+}
272+
273+func (d *Daemon) parkedRouteResponseFailed(token attachmentConnectionToken) {
274+ if token.effect == nil || token.effect.ended.Load() {
275+ return
276+ }
277+ launch := d.reserveAttachmentSendErrorCleanup(token, token.transport.transport)
278+ token.effect.End()
279+ launch()
280+}
281+
282+func (d *Daemon) respondParkedRoute(token attachmentConnectionToken, response ports.ParkedRouteResponse) bool {
283+ if err := d.sendParkedRouteResponse(token, response); err != nil {
284+ d.parkedRouteResponseFailed(token)
285+ return false
286+ }
287+ return true
288+}
289+
290+func (d *Daemon) handleParkedRouteRequest(token attachmentConnectionToken, request ports.ParkedRouteRequest) {
291+ if ports.ValidateParkedRouteRequest(request) != nil || token.ac == nil {
292+ return
293+ }
294+ switch request.Action {
295+ case ports.ParkedRoutePrepare:
296+ status := token.ac.prepareParkedRoute(token, request.LeaseID, d.clock.Now())
297+ if status == 0 {
298+ status = ports.ParkedRouteReady
299+ }
300+ d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
301+ case ports.ParkedRouteResume:
302+ d.resumeParkedRoute(token, request)
303+ case ports.ParkedRouteSwitch:
304+ d.switchParkedRoute(token, request)
305+ }
306+}
307+
308+func (d *Daemon) resumeParkedRoute(token attachmentConnectionToken, request ports.ParkedRouteRequest) {
309+ status := token.ac.consumeParkedRoute(token, request.LeaseID, d.clock.Now())
310+ if status != 0 {
311+ d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
312+ return
313+ }
314+ ac := token.ac
315+ ac.sendMu.Lock()
316+ err := d.sendParkedRouteResponseLocked(ac, token.transport, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: ports.ParkedRouteResumed})
317+ if err == nil {
318+ ac.releaseParkedOutputLocked()
319+ }
320+ ac.sendMu.Unlock()
321+ if err != nil {
322+ d.parkedRouteResponseFailed(token)
323+ return
324+ }
325+ if token.sess != nil {
326+ d.firstPaintWithLease(token.sess, ac, token.lease, true)
327+ }
328+}
329+
330+func (d *Daemon) rejectParkedRouteSwitch(token attachmentConnectionToken, request ports.ParkedRouteRequest, fallback ports.ParkedRouteStatus) {
331+ status := token.ac.rejectParkedRouteSwitch(token, request.LeaseID, d.clock.Now())
332+ if status == 0 {
333+ status = fallback
334+ }
335+ if d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {
336+ _ = token.ac.closeCapturedTransport(token.transport.transport)
337+ }
338+}
339+
340+func (d *Daemon) switchParkedRoute(token attachmentConnectionToken, request ports.ParkedRouteRequest) {
341+ if status := token.ac.beginParkedRouteSwitch(token, request.LeaseID, d.clock.Now()); status != 0 {
342+ if d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {
343+ _ = token.ac.closeCapturedTransport(token.transport.transport)
344+ }
345+ return
346+ }
347+ target := *request.Target
348+ ctx := d.serveCtx
349+ if ctx == nil {
350+ ctx = context.Background()
351+ }
352+ if err := d.waitForParkedTargetRestore(ctx, target.SessionName); err != nil {
353+ d.rejectParkedRouteSwitch(token, request, ports.ParkedRouteStaleTarget)
354+ return
355+ }
356+
357+ transition, targetSession, err := d.transitionParkedRouteTarget(token, target)
358+ if err != nil {
359+ d.rejectParkedRouteSwitch(token, request, ports.ParkedRouteStaleTarget)
360+ return
361+ }
362+ if status := token.ac.consumePublishedParkedRoute(token, transition.published, request.LeaseID); status != 0 {
363+ d.abortPublishedAttachmentTransition(transition)
364+ return
365+ }
366+ fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
367+ if !admitted {
368+ d.abortPublishedAttachmentTransition(transition)
369+ return
370+ }
371+ freshToken := fresh.connectionToken()
372+ ac := token.ac
373+ ac.sendMu.Lock()
374+ responseErr := d.sendParkedRouteResponseLocked(ac, freshToken.transport, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: ports.ParkedRouteSwitched})
375+ if responseErr == nil {
376+ ac.releaseParkedOutputLocked()
377+ }
378+ ac.sendMu.Unlock()
379+ if responseErr != nil {
380+ launch := d.reserveAttachmentSendErrorCleanup(freshToken, freshToken.transport.transport)
381+ fresh.End()
382+ launch()
383+ d.deferAttachmentTransitionCleanups(transition)
384+ return
385+ }
386+ fresh.End()
387+ d.touchMRU(targetSession)
388+ d.deferAttachmentTransitionCleanups(transition)
389+ transition.published.rebase = false
390+ d.firstPaintForTransition(transition.published)
391+}
392+
393+func (d *Daemon) waitForParkedTargetRestore(parent context.Context, name string) error {
394+ ctx, cancel := context.WithCancel(parent)
395+ timer := d.clock.NewTimer(ports.HandshakeTimeout)
396+ stop := make(chan struct{})
397+ go func() {
398+ select {
399+ case <-timer.C():
400+ cancel()
401+ case <-ctx.Done():
402+ case <-stop:
403+ }
404+ }()
405+ err := d.waitForTargetRestore(ctx, name)
406+ close(stop)
407+ timer.Stop()
408+ cancel()
409+ return err
410+}
411+
412+func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, target domain.RemoteSessionTarget) (attachmentTransitionResult, *session, error) {
413+ d.mu.Lock()
414+ if d.closing {
415+ d.mu.Unlock()
416+ return attachmentTransitionResult{}, nil, errAttachmentTransition
417+ }
418+ if live := d.findByNameLocked(target.SessionName); live != nil {
419+ if live.incarnation != target.LifecycleID {
420+ d.mu.Unlock()
421+ return attachmentTransitionResult{}, nil, errAttachmentTransition
422+ }
423+ tabIndex, ok := remoteTargetTabIndexLocked(live, target)
424+ if !ok {
425+ d.mu.Unlock()
426+ return attachmentTransitionResult{}, nil, errAttachmentTransition
427+ }
428+ live.mu.Lock()
429+ tabID := domain.TabStableID(live.tabs[tabIndex].stableID)
430+ live.mu.Unlock()
431+ d.mu.Unlock()
432+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
433+ source: token.sess, target: live, next: token.ac,
434+ expectedTransport: token.transport, sourceToken: &token, action: "parked-route-switch",
435+ expectedTargetLifecycle: &attachmentLifecycleFence{
436+ name: target.SessionName, incarnation: target.LifecycleID, checkIncarnation: true,
437+ tabID: tabID, tabIndex: tabIndex, checkTab: true,
438+ },
439+ activateTargetTab: true, targetTabIndex: tabIndex, ready: true,
440+ })
441+ return transition, live, err
442+ }
443+
444+ inactive, ok := d.inactive[target.SessionName]
445+ if !ok || !target.Stopped || inactive.incarnation != target.LifecycleID || !inactive.canResume() {
446+ d.mu.Unlock()
447+ return attachmentTransitionResult{}, nil, errAttachmentTransition
448+ }
449+ tabIndex, ok := remoteTargetTabIndexInactive(inactive, target)
450+ if !ok {
451+ d.mu.Unlock()
452+ return attachmentTransitionResult{}, nil, errAttachmentTransition
453+ }
454+ d.mu.Unlock()
455+
456+ var created *session
457+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
458+ source: token.sess, next: token.ac,
459+ expectedTransport: token.transport, sourceToken: &token, action: "parked-route-restore",
460+ activateTargetTab: true, targetTabIndex: tabIndex, ready: true,
461+ createTargetLocked: func() (*session, error) {
462+ current, ok := d.inactive[target.SessionName]
463+ if !ok || current.incarnation != target.LifecycleID || !current.canResume() {
464+ return nil, errAttachmentTransition
465+ }
466+ resolved, ok := remoteTargetTabIndexInactive(current, target)
467+ if !ok || resolved != tabIndex {
468+ return nil, errAttachmentTransition
469+ }
470+ env := copyEnvironment(d.baseEnv)
471+ cwd := d.dirOrHome(current.cwd)
472+ var createErr error
473+ created, createErr = d.resumeRemoteInactiveSessionLocked(target, cwd, token.ac.sizeSnapshot(), env, current)
474+ return created, createErr
475+ },
476+ })
477+ if err != nil && created != nil {
478+ _ = d.killSession(created, ports.ReasonSessionKilled, true)
479+ }
480+ if err != nil {
481+ return attachmentTransitionResult{}, created, err
482+ }
483+ return transition, created, nil
484+}
@@ -0,0 +1,484 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "io"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+const parkedRouteLeaseTTL = 15 * time.Minute
14+
15+type parkedRouteLease struct {
16+ id ports.ParkedRouteLeaseID
17+ generation uint64
18+ transport transportSnapshot
19+ expiresAt time.Time
20+ timer ports.Timer
21+ stop chan struct{}
22+ suspended bool
23+ inFlight bool
24+ expired bool
25+}
26+
27+func (lease *parkedRouteLease) matches(id ports.ParkedRouteLeaseID, generation uint64, transport transportSnapshot) bool {
28+ return lease != nil && lease.id == id && lease.generation == generation && lease.transport == transport
29+}
30+
31+func newParkedRouteLeaseID() (ports.ParkedRouteLeaseID, error) {
32+ var id ports.ParkedRouteLeaseID
33+ if _, err := io.ReadFull(rand.Reader, id[:]); err != nil {
34+ return ports.ParkedRouteLeaseID{}, err
35+ }
36+ return id, nil
37+}
38+
39+func stopParkedRouteLeaseLocked(lease *parkedRouteLease) {
40+ if lease == nil {
41+ return
42+ }
43+ if lease.timer != nil {
44+ lease.timer.Stop()
45+ lease.timer = nil
46+ }
47+ if lease.stop != nil {
48+ close(lease.stop)
49+ lease.stop = nil
50+ }
51+}
52+
53+func (d *Daemon) armParkedRoute(token attachmentConnectionToken) (ports.ParkedRouteLeaseID, error) {
54+ ac := token.ac
55+ if ac == nil || !token.attachmentCurrent() || ac.navigationCapabilities&ports.NavigationCapabilityHomePicker == 0 {
56+ return ports.ParkedRouteLeaseID{}, errAttachmentTransition
57+ }
58+ id, err := newParkedRouteLeaseID()
59+ if err != nil {
60+ return ports.ParkedRouteLeaseID{}, err
61+ }
62+ lease := &parkedRouteLease{
63+ id: id, generation: token.generation, transport: token.transport,
64+ expiresAt: d.clock.Now().Add(parkedRouteLeaseTTL),
65+ timer: d.clock.NewTimer(parkedRouteLeaseTTL), stop: make(chan struct{}),
66+ }
67+ ac.parkedRouteMu.Lock()
68+ stopParkedRouteLeaseLocked(ac.parkedRoute)
69+ ac.parkedRoute = lease
70+ ac.parkedRouteOutput.Store(false)
71+ ac.parkedRouteFullPending.Store(false)
72+ timer, stop := lease.timer, lease.stop
73+ ac.parkedRouteMu.Unlock()
74+ go d.watchParkedRouteLease(ac, lease, timer, stop)
75+ return id, nil
76+}
77+
78+func (d *Daemon) watchParkedRouteLease(ac *attachedClient, lease *parkedRouteLease, timer ports.Timer, stop <-chan struct{}) {
79+ select {
80+ case <-timer.C():
81+ d.expireParkedRoute(ac, lease)
82+ case <-stop:
83+ }
84+}
85+
86+func (d *Daemon) expireParkedRoute(ac *attachedClient, lease *parkedRouteLease) {
87+ if ac == nil || lease == nil {
88+ return
89+ }
90+ ac.parkedRouteMu.Lock()
91+ if ac.parkedRoute != lease {
92+ ac.parkedRouteMu.Unlock()
93+ return
94+ }
95+ lease.timer = nil
96+ lease.stop = nil
97+ if lease.inFlight {
98+ lease.expired = true
99+ ac.parkedRouteMu.Unlock()
100+ return
101+ }
102+ ac.parkedRoute = nil
103+ ac.parkedRouteOutput.Store(false)
104+ ac.parkedRouteFullPending.Store(false)
105+ ac.parkedRouteMu.Unlock()
106+ if ac.connectionGeneration.Load() == lease.generation && ac.transportSnapshotCurrent(lease.transport) {
107+ _ = ac.closeCapturedTransport(lease.transport.transport)
108+ }
109+}
110+
111+func (ac *attachedClient) clearParkedRoute() {
112+ if ac == nil {
113+ return
114+ }
115+ ac.parkedRouteMu.Lock()
116+ stopParkedRouteLeaseLocked(ac.parkedRoute)
117+ ac.parkedRoute = nil
118+ ac.parkedRouteOutput.Store(false)
119+ ac.parkedRouteFullPending.Store(false)
120+ ac.parkedRouteMu.Unlock()
121+}
122+
123+func (ac *attachedClient) parkedRouteStatus(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time, suspended bool) ports.ParkedRouteStatus {
124+ if ac == nil || !token.attachmentCurrent() {
125+ return ports.ParkedRouteRejected
126+ }
127+ ac.parkedRouteMu.Lock()
128+ defer ac.parkedRouteMu.Unlock()
129+ lease := ac.parkedRoute
130+ if !lease.matches(id, token.generation, token.transport) {
131+ return ports.ParkedRouteRejected
132+ }
133+ if !now.Before(lease.expiresAt) {
134+ stopParkedRouteLeaseLocked(lease)
135+ ac.parkedRoute = nil
136+ ac.parkedRouteOutput.Store(false)
137+ ac.parkedRouteFullPending.Store(false)
138+ return ports.ParkedRouteExpired
139+ }
140+ if lease.suspended != suspended {
141+ return ports.ParkedRouteRejected
142+ }
143+ return 0
144+}
145+
146+func (ac *attachedClient) prepareParkedRoute(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
147+ if status := ac.parkedRouteStatus(token, id, now, false); status != 0 {
148+ return status
149+ }
150+ ac.parkedRouteMu.Lock()
151+ defer ac.parkedRouteMu.Unlock()
152+ lease := ac.parkedRoute
153+ if !lease.matches(id, token.generation, token.transport) || lease.suspended {
154+ return ports.ParkedRouteRejected
155+ }
156+ lease.suspended = true
157+ ac.parkedRouteOutput.Store(true)
158+ return 0
159+}
160+
161+func (ac *attachedClient) consumeParkedRoute(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
162+ if status := ac.parkedRouteStatus(token, id, now, true); status != 0 {
163+ return status
164+ }
165+ ac.parkedRouteMu.Lock()
166+ defer ac.parkedRouteMu.Unlock()
167+ lease := ac.parkedRoute
168+ if !lease.matches(id, token.generation, token.transport) || !lease.suspended || lease.inFlight {
169+ return ports.ParkedRouteRejected
170+ }
171+ stopParkedRouteLeaseLocked(lease)
172+ ac.parkedRoute = nil
173+ return 0
174+}
175+
176+func (ac *attachedClient) beginParkedRouteSwitch(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
177+ if ac == nil || !token.attachmentCurrent() {
178+ return ports.ParkedRouteRejected
179+ }
180+ ac.parkedRouteMu.Lock()
181+ defer ac.parkedRouteMu.Unlock()
182+ lease := ac.parkedRoute
183+ if !lease.matches(id, token.generation, token.transport) || !lease.suspended || lease.inFlight {
184+ return ports.ParkedRouteRejected
185+ }
186+ if lease.expired || !now.Before(lease.expiresAt) {
187+ stopParkedRouteLeaseLocked(lease)
188+ ac.parkedRoute = nil
189+ ac.parkedRouteOutput.Store(false)
190+ ac.parkedRouteFullPending.Store(false)
191+ return ports.ParkedRouteExpired
192+ }
193+ lease.inFlight = true
194+ return 0
195+}
196+
197+func (ac *attachedClient) rejectParkedRouteSwitch(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
198+ if ac == nil {
199+ return ports.ParkedRouteRejected
200+ }
201+ ac.parkedRouteMu.Lock()
202+ defer ac.parkedRouteMu.Unlock()
203+ lease := ac.parkedRoute
204+ if !lease.matches(id, token.generation, token.transport) || !lease.inFlight {
205+ return ports.ParkedRouteRejected
206+ }
207+ lease.inFlight = false
208+ if lease.expired || !now.Before(lease.expiresAt) {
209+ stopParkedRouteLeaseLocked(lease)
210+ ac.parkedRoute = nil
211+ ac.parkedRouteOutput.Store(false)
212+ ac.parkedRouteFullPending.Store(false)
213+ return ports.ParkedRouteExpired
214+ }
215+ return 0
216+}
217+
218+func (ac *attachedClient) consumePublishedParkedRoute(previous, published attachmentConnectionToken, id ports.ParkedRouteLeaseID) ports.ParkedRouteStatus {
219+ if ac == nil || published.ac != ac || !published.attachmentCurrent() {
220+ return ports.ParkedRouteRejected
221+ }
222+ ac.parkedRouteMu.Lock()
223+ defer ac.parkedRouteMu.Unlock()
224+ lease := ac.parkedRoute
225+ if !lease.matches(id, previous.generation, previous.transport) || !lease.suspended || !lease.inFlight {
226+ return ports.ParkedRouteRejected
227+ }
228+ // Expiry is checked when the request is admitted. Once publication starts,
229+ // the accepted request must commit atomically instead of turning a completed
230+ // identity publication into a late pre-commit rejection.
231+ stopParkedRouteLeaseLocked(lease)
232+ ac.parkedRoute = nil
233+ return 0
234+}
235+
236+func parkedRouteResponseFrame(response ports.ParkedRouteResponse) (ports.Frame, error) {
237+ payload := ports.MarshalParkedRouteResponse(response)
238+ if payload == nil {
239+ return ports.Frame{}, errAttachmentTransition
240+ }
241+ return ports.Frame{Type: ports.MsgParkedRouteResponse, Payload: payload}, nil
242+}
243+
244+func (d *Daemon) sendParkedRouteResponse(token attachmentConnectionToken, response ports.ParkedRouteResponse) error {
245+ frame, err := parkedRouteResponseFrame(response)
246+ if err != nil {
247+ return err
248+ }
249+ return token.sendControl(frame)
250+}
251+
252+// sendParkedRouteResponseLocked preserves response -> full-output ordering.
253+// Caller holds ac.sendMu and keeps parkedRouteOutput set through this send.
254+func (d *Daemon) sendParkedRouteResponseLocked(ac *attachedClient, expected transportSnapshot, response ports.ParkedRouteResponse) error {
255+ frame, err := parkedRouteResponseFrame(response)
256+ if err != nil {
257+ return err
258+ }
259+ if ac == nil || !ac.transportSnapshotCurrent(expected) || expected.transport == nil {
260+ return errAttachmentTransition
261+ }
262+ return expected.transport.Send(frame)
263+}
264+
265+func (ac *attachedClient) releaseParkedOutputLocked() {
266+ ac.rebaseOutput()
267+ ac.pipelineCache = composeCacheInput{}
268+ ac.pipelineScratch = composeCacheInput{}
269+ ac.parkedRouteFullPending.Store(true)
270+ ac.parkedRouteOutput.Store(false)
271+}
272+
273+func (d *Daemon) parkedRouteResponseFailed(token attachmentConnectionToken) {
274+ if token.effect == nil || token.effect.ended.Load() {
275+ return
276+ }
277+ launch := d.reserveAttachmentSendErrorCleanup(token, token.transport.transport)
278+ token.effect.End()
279+ launch()
280+}
281+
282+func (d *Daemon) respondParkedRoute(token attachmentConnectionToken, response ports.ParkedRouteResponse) bool {
283+ if err := d.sendParkedRouteResponse(token, response); err != nil {
284+ d.parkedRouteResponseFailed(token)
285+ return false
286+ }
287+ return true
288+}
289+
290+func (d *Daemon) handleParkedRouteRequest(token attachmentConnectionToken, request ports.ParkedRouteRequest) {
291+ if ports.ValidateParkedRouteRequest(request) != nil || token.ac == nil {
292+ return
293+ }
294+ switch request.Action {
295+ case ports.ParkedRoutePrepare:
296+ status := token.ac.prepareParkedRoute(token, request.LeaseID, d.clock.Now())
297+ if status == 0 {
298+ status = ports.ParkedRouteReady
299+ }
300+ d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
301+ case ports.ParkedRouteResume:
302+ d.resumeParkedRoute(token, request)
303+ case ports.ParkedRouteSwitch:
304+ d.switchParkedRoute(token, request)
305+ }
306+}
307+
308+func (d *Daemon) resumeParkedRoute(token attachmentConnectionToken, request ports.ParkedRouteRequest) {
309+ status := token.ac.consumeParkedRoute(token, request.LeaseID, d.clock.Now())
310+ if status != 0 {
311+ d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
312+ return
313+ }
314+ ac := token.ac
315+ ac.sendMu.Lock()
316+ err := d.sendParkedRouteResponseLocked(ac, token.transport, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: ports.ParkedRouteResumed})
317+ if err == nil {
318+ ac.releaseParkedOutputLocked()
319+ }
320+ ac.sendMu.Unlock()
321+ if err != nil {
322+ d.parkedRouteResponseFailed(token)
323+ return
324+ }
325+ if token.sess != nil {
326+ d.firstPaintWithLease(token.sess, ac, token.lease, true)
327+ }
328+}
329+
330+func (d *Daemon) rejectParkedRouteSwitch(token attachmentConnectionToken, request ports.ParkedRouteRequest, fallback ports.ParkedRouteStatus) {
331+ status := token.ac.rejectParkedRouteSwitch(token, request.LeaseID, d.clock.Now())
332+ if status == 0 {
333+ status = fallback
334+ }
335+ if d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {
336+ _ = token.ac.closeCapturedTransport(token.transport.transport)
337+ }
338+}
339+
340+func (d *Daemon) switchParkedRoute(token attachmentConnectionToken, request ports.ParkedRouteRequest) {
341+ if status := token.ac.beginParkedRouteSwitch(token, request.LeaseID, d.clock.Now()); status != 0 {
342+ if d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {
343+ _ = token.ac.closeCapturedTransport(token.transport.transport)
344+ }
345+ return
346+ }
347+ target := *request.Target
348+ ctx := d.serveCtx
349+ if ctx == nil {
350+ ctx = context.Background()
351+ }
352+ if err := d.waitForParkedTargetRestore(ctx, target.SessionName); err != nil {
353+ d.rejectParkedRouteSwitch(token, request, ports.ParkedRouteStaleTarget)
354+ return
355+ }
356+
357+ transition, targetSession, err := d.transitionParkedRouteTarget(token, target)
358+ if err != nil {
359+ d.rejectParkedRouteSwitch(token, request, ports.ParkedRouteStaleTarget)
360+ return
361+ }
362+ if status := token.ac.consumePublishedParkedRoute(token, transition.published, request.LeaseID); status != 0 {
363+ d.abortPublishedAttachmentTransition(transition)
364+ return
365+ }
366+ fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
367+ if !admitted {
368+ d.abortPublishedAttachmentTransition(transition)
369+ return
370+ }
371+ freshToken := fresh.connectionToken()
372+ ac := token.ac
373+ ac.sendMu.Lock()
374+ responseErr := d.sendParkedRouteResponseLocked(ac, freshToken.transport, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: ports.ParkedRouteSwitched})
375+ if responseErr == nil {
376+ ac.releaseParkedOutputLocked()
377+ }
378+ ac.sendMu.Unlock()
379+ if responseErr != nil {
380+ launch := d.reserveAttachmentSendErrorCleanup(freshToken, freshToken.transport.transport)
381+ fresh.End()
382+ launch()
383+ d.deferAttachmentTransitionCleanups(transition)
384+ return
385+ }
386+ fresh.End()
387+ d.touchMRU(targetSession)
388+ d.deferAttachmentTransitionCleanups(transition)
389+ transition.published.rebase = false
390+ d.firstPaintForTransition(transition.published)
391+}
392+
393+func (d *Daemon) waitForParkedTargetRestore(parent context.Context, name string) error {
394+ ctx, cancel := context.WithCancel(parent)
395+ timer := d.clock.NewTimer(ports.HandshakeTimeout)
396+ stop := make(chan struct{})
397+ go func() {
398+ select {
399+ case <-timer.C():
400+ cancel()
401+ case <-ctx.Done():
402+ case <-stop:
403+ }
404+ }()
405+ err := d.waitForTargetRestore(ctx, name)
406+ close(stop)
407+ timer.Stop()
408+ cancel()
409+ return err
410+}
411+
412+func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, target domain.RemoteSessionTarget) (attachmentTransitionResult, *session, error) {
413+ d.mu.Lock()
414+ if d.closing {
415+ d.mu.Unlock()
416+ return attachmentTransitionResult{}, nil, errAttachmentTransition
417+ }
418+ if live := d.findByNameLocked(target.SessionName); live != nil {
419+ if live.incarnation != target.LifecycleID {
420+ d.mu.Unlock()
421+ return attachmentTransitionResult{}, nil, errAttachmentTransition
422+ }
423+ tabIndex, ok := remoteTargetTabIndexLocked(live, target)
424+ if !ok {
425+ d.mu.Unlock()
426+ return attachmentTransitionResult{}, nil, errAttachmentTransition
427+ }
428+ live.mu.Lock()
429+ tabID := domain.TabStableID(live.tabs[tabIndex].stableID)
430+ live.mu.Unlock()
431+ d.mu.Unlock()
432+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
433+ source: token.sess, target: live, next: token.ac,
434+ expectedTransport: token.transport, sourceToken: &token, action: "parked-route-switch",
435+ expectedTargetLifecycle: &attachmentLifecycleFence{
436+ name: target.SessionName, incarnation: target.LifecycleID, checkIncarnation: true,
437+ tabID: tabID, tabIndex: tabIndex, checkTab: true,
438+ },
439+ activateTargetTab: true, targetTabIndex: tabIndex, ready: true,
440+ })
441+ return transition, live, err
442+ }
443+
444+ inactive, ok := d.inactive[target.SessionName]
445+ if !ok || !target.Stopped || inactive.incarnation != target.LifecycleID || !inactive.canResume() {
446+ d.mu.Unlock()
447+ return attachmentTransitionResult{}, nil, errAttachmentTransition
448+ }
449+ tabIndex, ok := remoteTargetTabIndexInactive(inactive, target)
450+ if !ok {
451+ d.mu.Unlock()
452+ return attachmentTransitionResult{}, nil, errAttachmentTransition
453+ }
454+ d.mu.Unlock()
455+
456+ var created *session
457+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
458+ source: token.sess, next: token.ac,
459+ expectedTransport: token.transport, sourceToken: &token, action: "parked-route-restore",
460+ activateTargetTab: true, targetTabIndex: tabIndex, ready: true,
461+ createTargetLocked: func() (*session, error) {
462+ current, ok := d.inactive[target.SessionName]
463+ if !ok || current.incarnation != target.LifecycleID || !current.canResume() {
464+ return nil, errAttachmentTransition
465+ }
466+ resolved, ok := remoteTargetTabIndexInactive(current, target)
467+ if !ok || resolved != tabIndex {
468+ return nil, errAttachmentTransition
469+ }
470+ env := copyEnvironment(d.baseEnv)
471+ cwd := d.dirOrHome(current.cwd)
472+ var createErr error
473+ created, createErr = d.resumeRemoteInactiveSessionLocked(target, cwd, token.ac.sizeSnapshot(), env, current)
474+ return created, createErr
475+ },
476+ })
477+ if err != nil && created != nil {
478+ _ = d.killSession(created, ports.ReasonSessionKilled, true)
479+ }
480+ if err != nil {
481+ return attachmentTransitionResult{}, created, err
482+ }
483+ return transition, created, nil
484+}
A internal/usecase/daemon/parked_route_test.go +384 -0
@@ -0,0 +1,384 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+)
17+
18+type parkedRouteFailTransport struct{}
19+
20+func (parkedRouteFailTransport) Send(ports.Frame) error { return errors.New("send failed") }
21+func (parkedRouteFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
22+func (parkedRouteFailTransport) Close() error { return nil }
23+
24+type parkedRouteExpiryTransport struct {
25+ sends chan ports.Frame
26+ closed chan struct{}
27+ once sync.Once
28+}
29+
30+func (t *parkedRouteExpiryTransport) Send(frame ports.Frame) error {
31+ t.sends <- frame
32+ return nil
33+}
34+
35+func (t *parkedRouteExpiryTransport) Recv() (ports.Frame, error) {
36+ <-t.closed
37+ return ports.Frame{}, io.EOF
38+}
39+
40+func (t *parkedRouteExpiryTransport) Close() error {
41+ t.once.Do(func() { close(t.closed) })
42+ return nil
43+}
44+
45+type parkedRouteClock struct {
46+ mu sync.Mutex
47+ now time.Time
48+}
49+
50+func (c *parkedRouteClock) Now() time.Time {
51+ c.mu.Lock()
52+ defer c.mu.Unlock()
53+ return c.now
54+}
55+
56+func (c *parkedRouteClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
57+
58+func (c *parkedRouteClock) setNow(now time.Time) {
59+ c.mu.Lock()
60+ c.now = now
61+ c.mu.Unlock()
62+}
63+
64+func armAndPrepareParkedRoute(t *testing.T, d *Daemon, source *session, ac *attachedClient, sends chan ports.Frame) ports.ParkedRouteLeaseID {
65+ t.Helper()
66+ ac.navigationCapabilities |= ports.NavigationCapabilityHomePicker
67+ token := source.attachmentToken(ac, ac.transport())
68+ effect, admitted := ac.beginAttachmentEffect(token)
69+ require.True(t, admitted)
70+ token.effect = effect
71+ require.NoError(t, d.sendNavigationActionForAttachment(token, ports.NavigationOpenHomePicker))
72+ effect.End()
73+ directiveFrame := awaitFrame(t, sends, ports.MsgNavigationAction)
74+ directive, err := ports.UnmarshalNavigationDirective(directiveFrame.Payload)
75+ require.NoError(t, err)
76+
77+ prepare := ports.ParkedRouteRequest{RequestID: 1, LeaseID: directive.LeaseID, Action: ports.ParkedRoutePrepare}
78+ require.False(t, d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(prepare)}))
79+ readyFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
80+ ready, err := ports.UnmarshalParkedRouteResponse(readyFrame.Payload)
81+ require.NoError(t, err)
82+ require.Equal(t, ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady}, ready)
83+ return directive.LeaseID
84+}
85+
86+func TestParkedRouteSwitchesExactLiveTargetOnRetainedTransport(t *testing.T) {
87+ d, source, ac, sends, releases := newManualTabSession(t, 1)
88+ defer releaseAll(releases)
89+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
90+
91+ targetLifecycle := domain.SessionLifecycleID{7}
92+ target := &session{
93+ sessionCore: sessionCore{id: "target", name: "target", incarnation: targetLifecycle, attachments: make(map[*attachedClient]struct{})},
94+ ctx: source.ctx, cancel: func() {},
95+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
96+ }
97+ publishTiledPaneOwners(target, target.tabs[0])
98+ d.mu.Lock()
99+ d.sessions[target.id] = target
100+ d.mu.Unlock()
101+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
102+
103+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
104+ require.True(t, ac.parkedRouteOutput.Load())
105+
106+ targetRequest := domain.RemoteSessionTarget{
107+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle,
108+ SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),
109+ }
110+ switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &targetRequest}
111+ require.False(t, d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)}))
112+
113+ require.Same(t, target, ac.currentAttachmentSession())
114+ require.False(t, ac.parkedRouteOutput.Load())
115+ responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
116+ response, err := ports.UnmarshalParkedRouteResponse(responseFrame.Payload)
117+ require.NoError(t, err)
118+ require.Equal(t, ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteSwitched}, response)
119+}
120+
121+func TestParkedRouteSwitchAcceptedBeforeLeaseExpiryCommitsAtomically(t *testing.T) {
122+ d, source, ac, sends, releases := newManualTabSession(t, 1)
123+ defer releaseAll(releases)
124+ clock := &parkedRouteClock{}
125+ d.clock = clock
126+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
127+
128+ targetLifecycle := domain.SessionLifecycleID{7}
129+ target := &session{
130+ sessionCore: sessionCore{id: "target", name: "target", incarnation: targetLifecycle, attachments: make(map[*attachedClient]struct{})},
131+ ctx: source.ctx, cancel: func() {},
132+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
133+ }
134+ publishTiledPaneOwners(target, target.tabs[0])
135+ d.mu.Lock()
136+ d.sessions[target.id] = target
137+ d.mu.Unlock()
138+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
139+
140+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
141+ d.afterAttachmentEffectsFrozen = func() {
142+ clock.setNow(time.Time{}.Add(parkedRouteLeaseTTL))
143+ }
144+
145+ targetRequest := domain.RemoteSessionTarget{
146+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle,
147+ SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),
148+ }
149+ switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &targetRequest}
150+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
151+
152+ require.Same(t, target, ac.currentAttachmentSession())
153+ responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
154+ response, err := ports.UnmarshalParkedRouteResponse(responseFrame.Payload)
155+ require.NoError(t, err)
156+ require.Equal(t, ports.ParkedRouteSwitched, response.Status)
157+}
158+
159+func TestParkedRouteRestoresExactStoppedTargetWithDaemonEnvironment(t *testing.T) {
160+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
161+ d.baseEnv = []string{"OWNER=daemon"}
162+ source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source")
163+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
164+ source.mu.Lock()
165+ source.env = []string{"OWNER=source"}
166+ source.mu.Unlock()
167+
168+ lifecycle := domain.SessionLifecycleID{8}
169+ d.mu.Lock()
170+ d.inactive["stopped"] = inactiveSession{
171+ name: "stopped", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown,
172+ tabNames: []string{"alpha", "beta"},
173+ tabRecords: []domain.CatalogueTabRecord{
174+ {StableID: "tab-a", Name: "alpha"},
175+ {StableID: "tab-b", Name: "beta"},
176+ },
177+ }
178+ d.mu.Unlock()
179+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
180+
181+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
182+
183+ target := domain.RemoteSessionTarget{
184+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: lifecycle,
185+ SessionName: "stopped", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-b"),
186+ }
187+ switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &target}
188+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
189+
190+ restored := ac.currentAttachmentSession()
191+ require.NotNil(t, restored)
192+ require.Equal(t, "stopped", restored.name)
193+ require.Equal(t, lifecycle, restored.incarnation)
194+ require.Equal(t, domain.TabStableID("tab-b"), ac.viewSnapshot().tabID)
195+ restored.mu.Lock()
196+ require.Equal(t, []string{"OWNER=daemon"}, restored.env)
197+ restored.mu.Unlock()
198+}
199+
200+func TestParkedRouteStaleTargetLeavesSourceResumable(t *testing.T) {
201+ d, source, ac, sends, releases := newManualTabSession(t, 1)
202+ defer releaseAll(releases)
203+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
204+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
205+
206+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
207+
208+ missing := domain.RemoteSessionTarget{
209+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{9},
210+ SessionName: "missing", LiveTabID: "tab-1",
211+ }
212+ switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &missing}
213+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
214+ staleFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
215+ stale, err := ports.UnmarshalParkedRouteResponse(staleFrame.Payload)
216+ require.NoError(t, err)
217+ require.Equal(t, ports.ParkedRouteStaleTarget, stale.Status)
218+ require.Same(t, source, ac.currentAttachmentSession())
219+ require.True(t, ac.parkedRouteOutput.Load())
220+
221+ resume := ports.ParkedRouteRequest{RequestID: 3, LeaseID: leaseID, Action: ports.ParkedRouteResume}
222+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
223+ resumedFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
224+ resumed, err := ports.UnmarshalParkedRouteResponse(resumedFrame.Payload)
225+ require.NoError(t, err)
226+ require.Equal(t, ports.ParkedRouteResumed, resumed.Status)
227+ require.False(t, ac.parkedRouteOutput.Load())
228+ outputFrame := awaitFrame(t, sends, ports.MsgOutput)
229+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
230+ require.NoError(t, err)
231+ require.True(t, output.Full)
232+}
233+
234+func TestParkedRouteDoesNotReplayOneShotEffectsAfterResume(t *testing.T) {
235+ d, source, ac, sends, releases := newManualTabSession(t, 1)
236+ defer releaseAll(releases)
237+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
238+
239+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
240+ require.NoError(t, d.boundedSendOutputErr(ac, []byte("one-shot effect")))
241+ select {
242+ case frame := <-sends:
243+ t.Fatalf("parked one-shot effect emitted frame type %d", frame.Type)
244+ default:
245+ }
246+
247+ resume := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteResume}
248+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
249+ _ = awaitFrame(t, sends, ports.MsgParkedRouteResponse)
250+ outputFrame := awaitFrame(t, sends, ports.MsgOutput)
251+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
252+ require.NoError(t, err)
253+ require.True(t, output.Full)
254+ require.NotContains(t, string(output.Data), "one-shot effect")
255+ select {
256+ case frame := <-sends:
257+ t.Fatalf("parked one-shot effect replayed as frame type %d", frame.Type)
258+ default:
259+ }
260+}
261+
262+func TestParkedRouteExpiredLeaseFailsClosed(t *testing.T) {
263+ d, source, ac, sends, releases := newManualTabSession(t, 1)
264+ defer releaseAll(releases)
265+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
266+
267+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
268+
269+ ac.parkedRouteMu.Lock()
270+ ac.parkedRoute.expiresAt = d.clock.Now()
271+ ac.parkedRouteMu.Unlock()
272+ resume := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteResume}
273+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
274+ responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
275+ response, err := ports.UnmarshalParkedRouteResponse(responseFrame.Payload)
276+ require.NoError(t, err)
277+ require.Equal(t, ports.ParkedRouteExpired, response.Status)
278+ require.Same(t, source, ac.currentAttachmentSession())
279+ require.False(t, ac.parkedRouteOutput.Load())
280+}
281+
282+func TestParkedRouteResumeRejectsSwitchInFlight(t *testing.T) {
283+ d, source, ac, sends, releases := newManualTabSession(t, 1)
284+ defer releaseAll(releases)
285+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
286+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
287+
288+ ac.parkedRouteMu.Lock()
289+ ac.parkedRoute.inFlight = true
290+ ac.parkedRouteMu.Unlock()
291+ status := ac.consumeParkedRoute(source.attachmentToken(ac, ac.transport()), leaseID, d.clock.Now())
292+
293+ require.Equal(t, ports.ParkedRouteRejected, status)
294+ ac.parkedRouteMu.Lock()
295+ require.NotNil(t, ac.parkedRoute)
296+ ac.parkedRouteMu.Unlock()
297+ require.True(t, ac.parkedRouteOutput.Load())
298+}
299+
300+func TestParkedRouteLeaseExpiryClosesExactRetainedTransport(t *testing.T) {
301+ d, source, ac, _, releases := newManualTabSession(t, 1)
302+ defer releaseAll(releases)
303+ expiry := make(chan time.Time, 1)
304+ timer := portsmocks.NewMockTimer(t)
305+ timer.EXPECT().C().Return(expiry).Maybe()
306+ timer.EXPECT().Stop().Return(true).Maybe()
307+ clock := portsmocks.NewMockClock(t)
308+ clock.EXPECT().Now().Return(time.Time{}).Maybe()
309+ clock.EXPECT().NewTimer(mock.Anything).Return(timer).Once()
310+ d.clock = clock
311+ transport := &parkedRouteExpiryTransport{sends: make(chan ports.Frame, 8), closed: make(chan struct{})}
312+ ac.replaceTransport(transport)
313+
314+ _ = armAndPrepareParkedRoute(t, d, source, ac, transport.sends)
315+ expiry <- time.Time{}.Add(parkedRouteLeaseTTL)
316+
317+ select {
318+ case <-transport.closed:
319+ case <-time.After(time.Second):
320+ t.Fatal("parked transport remained open after its lease expired")
321+ }
322+ ac.parkedRouteMu.Lock()
323+ require.Nil(t, ac.parkedRoute)
324+ ac.parkedRouteMu.Unlock()
325+ require.False(t, ac.parkedRouteOutput.Load())
326+ require.False(t, ac.parkedRouteFullPending.Load())
327+}
328+
329+func TestParkedRouteMalformedSwitchRequestsFailClosed(t *testing.T) {
330+ d, source, ac, sends, releases := newManualTabSession(t, 1)
331+ defer releaseAll(releases)
332+ leaseID := ports.ParkedRouteLeaseID{1}
333+ for _, request := range []ports.ParkedRouteRequest{
334+ {RequestID: 1, LeaseID: leaseID, Action: ports.ParkedRouteSwitch},
335+ {RequestID: 2, Action: ports.ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}},
336+ {RequestID: 3, LeaseID: leaseID, Action: 99},
337+ } {
338+ d.handleParkedRouteRequest(source.attachmentToken(ac, ac.transport()), request)
339+ }
340+ require.Same(t, source, ac.currentAttachmentSession())
341+ select {
342+ case frame := <-sends:
343+ t.Fatalf("malformed parked route emitted frame type %d", frame.Type)
344+ default:
345+ }
346+}
347+
348+func TestParkedRouteResponseFailureDoesNotDrainItsOwnFrameEffect(t *testing.T) {
349+ d, source, ac, _, releases := newManualTabSession(t, 1)
350+ defer releaseAll(releases)
351+ ac.replaceTransport(parkedRouteFailTransport{})
352+ token := source.attachmentToken(ac, ac.transport())
353+ effect, admitted := ac.beginAttachmentEffect(token)
354+ require.True(t, admitted)
355+ token.effect = effect
356+
357+ result := make(chan bool, 1)
358+ go func() {
359+ result <- d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})
360+ }()
361+ require.False(t, awaitTestValue(t, result, "parked-route send failure cleanup"))
362+ d.attachmentCleanupWg.Wait()
363+}
364+
365+func TestParkedRouteDetachClearsLeaseAndSuspension(t *testing.T) {
366+ d, source, ac, sends, releases := newManualTabSession(t, 1)
367+ defer releaseAll(releases)
368+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
369+
370+ _ = armAndPrepareParkedRoute(t, d, source, ac, sends)
371+ require.True(t, ac.parkedRouteOutput.Load())
372+ require.NoError(t, d.boundedSendOutputErr(ac, []byte("suppressed side effect")))
373+ select {
374+ case frame := <-sends:
375+ t.Fatalf("parked side effect emitted frame type %d", frame.Type)
376+ default:
377+ }
378+
379+ d.clientGone(source, ac, ac.transport(), true)
380+ ac.parkedRouteMu.Lock()
381+ require.Nil(t, ac.parkedRoute)
382+ ac.parkedRouteMu.Unlock()
383+ require.False(t, ac.parkedRouteOutput.Load())
384+}
@@ -0,0 +1,384 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+)
17+
18+type parkedRouteFailTransport struct{}
19+
20+func (parkedRouteFailTransport) Send(ports.Frame) error { return errors.New("send failed") }
21+func (parkedRouteFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
22+func (parkedRouteFailTransport) Close() error { return nil }
23+
24+type parkedRouteExpiryTransport struct {
25+ sends chan ports.Frame
26+ closed chan struct{}
27+ once sync.Once
28+}
29+
30+func (t *parkedRouteExpiryTransport) Send(frame ports.Frame) error {
31+ t.sends <- frame
32+ return nil
33+}
34+
35+func (t *parkedRouteExpiryTransport) Recv() (ports.Frame, error) {
36+ <-t.closed
37+ return ports.Frame{}, io.EOF
38+}
39+
40+func (t *parkedRouteExpiryTransport) Close() error {
41+ t.once.Do(func() { close(t.closed) })
42+ return nil
43+}
44+
45+type parkedRouteClock struct {
46+ mu sync.Mutex
47+ now time.Time
48+}
49+
50+func (c *parkedRouteClock) Now() time.Time {
51+ c.mu.Lock()
52+ defer c.mu.Unlock()
53+ return c.now
54+}
55+
56+func (c *parkedRouteClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
57+
58+func (c *parkedRouteClock) setNow(now time.Time) {
59+ c.mu.Lock()
60+ c.now = now
61+ c.mu.Unlock()
62+}
63+
64+func armAndPrepareParkedRoute(t *testing.T, d *Daemon, source *session, ac *attachedClient, sends chan ports.Frame) ports.ParkedRouteLeaseID {
65+ t.Helper()
66+ ac.navigationCapabilities |= ports.NavigationCapabilityHomePicker
67+ token := source.attachmentToken(ac, ac.transport())
68+ effect, admitted := ac.beginAttachmentEffect(token)
69+ require.True(t, admitted)
70+ token.effect = effect
71+ require.NoError(t, d.sendNavigationActionForAttachment(token, ports.NavigationOpenHomePicker))
72+ effect.End()
73+ directiveFrame := awaitFrame(t, sends, ports.MsgNavigationAction)
74+ directive, err := ports.UnmarshalNavigationDirective(directiveFrame.Payload)
75+ require.NoError(t, err)
76+
77+ prepare := ports.ParkedRouteRequest{RequestID: 1, LeaseID: directive.LeaseID, Action: ports.ParkedRoutePrepare}
78+ require.False(t, d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(prepare)}))
79+ readyFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
80+ ready, err := ports.UnmarshalParkedRouteResponse(readyFrame.Payload)
81+ require.NoError(t, err)
82+ require.Equal(t, ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady}, ready)
83+ return directive.LeaseID
84+}
85+
86+func TestParkedRouteSwitchesExactLiveTargetOnRetainedTransport(t *testing.T) {
87+ d, source, ac, sends, releases := newManualTabSession(t, 1)
88+ defer releaseAll(releases)
89+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
90+
91+ targetLifecycle := domain.SessionLifecycleID{7}
92+ target := &session{
93+ sessionCore: sessionCore{id: "target", name: "target", incarnation: targetLifecycle, attachments: make(map[*attachedClient]struct{})},
94+ ctx: source.ctx, cancel: func() {},
95+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
96+ }
97+ publishTiledPaneOwners(target, target.tabs[0])
98+ d.mu.Lock()
99+ d.sessions[target.id] = target
100+ d.mu.Unlock()
101+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
102+
103+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
104+ require.True(t, ac.parkedRouteOutput.Load())
105+
106+ targetRequest := domain.RemoteSessionTarget{
107+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle,
108+ SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),
109+ }
110+ switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &targetRequest}
111+ require.False(t, d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)}))
112+
113+ require.Same(t, target, ac.currentAttachmentSession())
114+ require.False(t, ac.parkedRouteOutput.Load())
115+ responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
116+ response, err := ports.UnmarshalParkedRouteResponse(responseFrame.Payload)
117+ require.NoError(t, err)
118+ require.Equal(t, ports.ParkedRouteResponse{RequestID: 2, Status: ports.ParkedRouteSwitched}, response)
119+}
120+
121+func TestParkedRouteSwitchAcceptedBeforeLeaseExpiryCommitsAtomically(t *testing.T) {
122+ d, source, ac, sends, releases := newManualTabSession(t, 1)
123+ defer releaseAll(releases)
124+ clock := &parkedRouteClock{}
125+ d.clock = clock
126+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
127+
128+ targetLifecycle := domain.SessionLifecycleID{7}
129+ target := &session{
130+ sessionCore: sessionCore{id: "target", name: "target", incarnation: targetLifecycle, attachments: make(map[*attachedClient]struct{})},
131+ ctx: source.ctx, cancel: func() {},
132+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
133+ }
134+ publishTiledPaneOwners(target, target.tabs[0])
135+ d.mu.Lock()
136+ d.sessions[target.id] = target
137+ d.mu.Unlock()
138+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
139+
140+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
141+ d.afterAttachmentEffectsFrozen = func() {
142+ clock.setNow(time.Time{}.Add(parkedRouteLeaseTTL))
143+ }
144+
145+ targetRequest := domain.RemoteSessionTarget{
146+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle,
147+ SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),
148+ }
149+ switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &targetRequest}
150+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
151+
152+ require.Same(t, target, ac.currentAttachmentSession())
153+ responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
154+ response, err := ports.UnmarshalParkedRouteResponse(responseFrame.Payload)
155+ require.NoError(t, err)
156+ require.Equal(t, ports.ParkedRouteSwitched, response.Status)
157+}
158+
159+func TestParkedRouteRestoresExactStoppedTargetWithDaemonEnvironment(t *testing.T) {
160+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
161+ d.baseEnv = []string{"OWNER=daemon"}
162+ source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source")
163+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
164+ source.mu.Lock()
165+ source.env = []string{"OWNER=source"}
166+ source.mu.Unlock()
167+
168+ lifecycle := domain.SessionLifecycleID{8}
169+ d.mu.Lock()
170+ d.inactive["stopped"] = inactiveSession{
171+ name: "stopped", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown,
172+ tabNames: []string{"alpha", "beta"},
173+ tabRecords: []domain.CatalogueTabRecord{
174+ {StableID: "tab-a", Name: "alpha"},
175+ {StableID: "tab-b", Name: "beta"},
176+ },
177+ }
178+ d.mu.Unlock()
179+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
180+
181+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
182+
183+ target := domain.RemoteSessionTarget{
184+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: lifecycle,
185+ SessionName: "stopped", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-b"),
186+ }
187+ switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &target}
188+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
189+
190+ restored := ac.currentAttachmentSession()
191+ require.NotNil(t, restored)
192+ require.Equal(t, "stopped", restored.name)
193+ require.Equal(t, lifecycle, restored.incarnation)
194+ require.Equal(t, domain.TabStableID("tab-b"), ac.viewSnapshot().tabID)
195+ restored.mu.Lock()
196+ require.Equal(t, []string{"OWNER=daemon"}, restored.env)
197+ restored.mu.Unlock()
198+}
199+
200+func TestParkedRouteStaleTargetLeavesSourceResumable(t *testing.T) {
201+ d, source, ac, sends, releases := newManualTabSession(t, 1)
202+ defer releaseAll(releases)
203+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
204+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
205+
206+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
207+
208+ missing := domain.RemoteSessionTarget{
209+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{9},
210+ SessionName: "missing", LiveTabID: "tab-1",
211+ }
212+ switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &missing}
213+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
214+ staleFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
215+ stale, err := ports.UnmarshalParkedRouteResponse(staleFrame.Payload)
216+ require.NoError(t, err)
217+ require.Equal(t, ports.ParkedRouteStaleTarget, stale.Status)
218+ require.Same(t, source, ac.currentAttachmentSession())
219+ require.True(t, ac.parkedRouteOutput.Load())
220+
221+ resume := ports.ParkedRouteRequest{RequestID: 3, LeaseID: leaseID, Action: ports.ParkedRouteResume}
222+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
223+ resumedFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
224+ resumed, err := ports.UnmarshalParkedRouteResponse(resumedFrame.Payload)
225+ require.NoError(t, err)
226+ require.Equal(t, ports.ParkedRouteResumed, resumed.Status)
227+ require.False(t, ac.parkedRouteOutput.Load())
228+ outputFrame := awaitFrame(t, sends, ports.MsgOutput)
229+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
230+ require.NoError(t, err)
231+ require.True(t, output.Full)
232+}
233+
234+func TestParkedRouteDoesNotReplayOneShotEffectsAfterResume(t *testing.T) {
235+ d, source, ac, sends, releases := newManualTabSession(t, 1)
236+ defer releaseAll(releases)
237+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
238+
239+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
240+ require.NoError(t, d.boundedSendOutputErr(ac, []byte("one-shot effect")))
241+ select {
242+ case frame := <-sends:
243+ t.Fatalf("parked one-shot effect emitted frame type %d", frame.Type)
244+ default:
245+ }
246+
247+ resume := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteResume}
248+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
249+ _ = awaitFrame(t, sends, ports.MsgParkedRouteResponse)
250+ outputFrame := awaitFrame(t, sends, ports.MsgOutput)
251+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
252+ require.NoError(t, err)
253+ require.True(t, output.Full)
254+ require.NotContains(t, string(output.Data), "one-shot effect")
255+ select {
256+ case frame := <-sends:
257+ t.Fatalf("parked one-shot effect replayed as frame type %d", frame.Type)
258+ default:
259+ }
260+}
261+
262+func TestParkedRouteExpiredLeaseFailsClosed(t *testing.T) {
263+ d, source, ac, sends, releases := newManualTabSession(t, 1)
264+ defer releaseAll(releases)
265+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
266+
267+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
268+
269+ ac.parkedRouteMu.Lock()
270+ ac.parkedRoute.expiresAt = d.clock.Now()
271+ ac.parkedRouteMu.Unlock()
272+ resume := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteResume}
273+ d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
274+ responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
275+ response, err := ports.UnmarshalParkedRouteResponse(responseFrame.Payload)
276+ require.NoError(t, err)
277+ require.Equal(t, ports.ParkedRouteExpired, response.Status)
278+ require.Same(t, source, ac.currentAttachmentSession())
279+ require.False(t, ac.parkedRouteOutput.Load())
280+}
281+
282+func TestParkedRouteResumeRejectsSwitchInFlight(t *testing.T) {
283+ d, source, ac, sends, releases := newManualTabSession(t, 1)
284+ defer releaseAll(releases)
285+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
286+ leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
287+
288+ ac.parkedRouteMu.Lock()
289+ ac.parkedRoute.inFlight = true
290+ ac.parkedRouteMu.Unlock()
291+ status := ac.consumeParkedRoute(source.attachmentToken(ac, ac.transport()), leaseID, d.clock.Now())
292+
293+ require.Equal(t, ports.ParkedRouteRejected, status)
294+ ac.parkedRouteMu.Lock()
295+ require.NotNil(t, ac.parkedRoute)
296+ ac.parkedRouteMu.Unlock()
297+ require.True(t, ac.parkedRouteOutput.Load())
298+}
299+
300+func TestParkedRouteLeaseExpiryClosesExactRetainedTransport(t *testing.T) {
301+ d, source, ac, _, releases := newManualTabSession(t, 1)
302+ defer releaseAll(releases)
303+ expiry := make(chan time.Time, 1)
304+ timer := portsmocks.NewMockTimer(t)
305+ timer.EXPECT().C().Return(expiry).Maybe()
306+ timer.EXPECT().Stop().Return(true).Maybe()
307+ clock := portsmocks.NewMockClock(t)
308+ clock.EXPECT().Now().Return(time.Time{}).Maybe()
309+ clock.EXPECT().NewTimer(mock.Anything).Return(timer).Once()
310+ d.clock = clock
311+ transport := &parkedRouteExpiryTransport{sends: make(chan ports.Frame, 8), closed: make(chan struct{})}
312+ ac.replaceTransport(transport)
313+
314+ _ = armAndPrepareParkedRoute(t, d, source, ac, transport.sends)
315+ expiry <- time.Time{}.Add(parkedRouteLeaseTTL)
316+
317+ select {
318+ case <-transport.closed:
319+ case <-time.After(time.Second):
320+ t.Fatal("parked transport remained open after its lease expired")
321+ }
322+ ac.parkedRouteMu.Lock()
323+ require.Nil(t, ac.parkedRoute)
324+ ac.parkedRouteMu.Unlock()
325+ require.False(t, ac.parkedRouteOutput.Load())
326+ require.False(t, ac.parkedRouteFullPending.Load())
327+}
328+
329+func TestParkedRouteMalformedSwitchRequestsFailClosed(t *testing.T) {
330+ d, source, ac, sends, releases := newManualTabSession(t, 1)
331+ defer releaseAll(releases)
332+ leaseID := ports.ParkedRouteLeaseID{1}
333+ for _, request := range []ports.ParkedRouteRequest{
334+ {RequestID: 1, LeaseID: leaseID, Action: ports.ParkedRouteSwitch},
335+ {RequestID: 2, Action: ports.ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}},
336+ {RequestID: 3, LeaseID: leaseID, Action: 99},
337+ } {
338+ d.handleParkedRouteRequest(source.attachmentToken(ac, ac.transport()), request)
339+ }
340+ require.Same(t, source, ac.currentAttachmentSession())
341+ select {
342+ case frame := <-sends:
343+ t.Fatalf("malformed parked route emitted frame type %d", frame.Type)
344+ default:
345+ }
346+}
347+
348+func TestParkedRouteResponseFailureDoesNotDrainItsOwnFrameEffect(t *testing.T) {
349+ d, source, ac, _, releases := newManualTabSession(t, 1)
350+ defer releaseAll(releases)
351+ ac.replaceTransport(parkedRouteFailTransport{})
352+ token := source.attachmentToken(ac, ac.transport())
353+ effect, admitted := ac.beginAttachmentEffect(token)
354+ require.True(t, admitted)
355+ token.effect = effect
356+
357+ result := make(chan bool, 1)
358+ go func() {
359+ result <- d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})
360+ }()
361+ require.False(t, awaitTestValue(t, result, "parked-route send failure cleanup"))
362+ d.attachmentCleanupWg.Wait()
363+}
364+
365+func TestParkedRouteDetachClearsLeaseAndSuspension(t *testing.T) {
366+ d, source, ac, sends, releases := newManualTabSession(t, 1)
367+ defer releaseAll(releases)
368+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
369+
370+ _ = armAndPrepareParkedRoute(t, d, source, ac, sends)
371+ require.True(t, ac.parkedRouteOutput.Load())
372+ require.NoError(t, d.boundedSendOutputErr(ac, []byte("suppressed side effect")))
373+ select {
374+ case frame := <-sends:
375+ t.Fatalf("parked side effect emitted frame type %d", frame.Type)
376+ default:
377+ }
378+
379+ d.clientGone(source, ac, ac.transport(), true)
380+ ac.parkedRouteMu.Lock()
381+ require.Nil(t, ac.parkedRoute)
382+ ac.parkedRouteMu.Unlock()
383+ require.False(t, ac.parkedRouteOutput.Load())
384+}
M internal/usecase/daemon/remote_attach.go +22 -2
@@ -103,11 +103,31 @@ func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTa
103103 }
104104
105105 func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
106- payload := ports.MarshalNavigationAction(action)
106+ directive := ports.NavigationDirective{Action: action}
107+ armed := false
108+ if action == ports.NavigationOpenHomePicker {
109+ leaseID, err := d.armParkedRoute(token)
110+ if err != nil {
111+ return err
112+ }
113+ directive.LeaseID = leaseID
114+ armed = true
115+ }
116+ rollback := func() {
117+ if armed {
118+ token.ac.clearParkedRoute()
119+ }
120+ }
121+ payload := ports.MarshalNavigationDirective(directive)
107122 if payload == nil {
123+ rollback()
108124 return errAttachmentTransition
109125 }
110- return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload})
126+ if err := token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload}); err != nil {
127+ rollback()
128+ return err
129+ }
130+ return nil
111131 }
112132
113133 func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error {
@@ -103,11 +103,31 @@ func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTa
103 }103 }
104 104
105 func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {105 func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
106- payload := ports.MarshalNavigationAction(action)106+ directive := ports.NavigationDirective{Action: action}
107+ armed := false
108+ if action == ports.NavigationOpenHomePicker {
109+ leaseID, err := d.armParkedRoute(token)
110+ if err != nil {
111+ return err
112+ }
113+ directive.LeaseID = leaseID
114+ armed = true
115+ }
116+ rollback := func() {
117+ if armed {
118+ token.ac.clearParkedRoute()
119+ }
120+ }
121+ payload := ports.MarshalNavigationDirective(directive)
107 if payload == nil {122 if payload == nil {
123+ rollback()
108 return errAttachmentTransition124 return errAttachmentTransition
109 }125 }
110- return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload})126+ if err := token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload}); err != nil {
127+ rollback()
128+ return err
129+ }
130+ return nil
111 }131 }
112 132
113 func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error {133 func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error {
M internal/usecase/daemon/remote_picker_test.go +10 -2
@@ -855,6 +855,9 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
855855 t.Run(tt.name, func(t *testing.T) {
856856 d := newRemotePickerDaemon(nil)
857857 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
858+ if tt.action == ports.NavigationOpenHomePicker {
859+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
860+ }
858861 token := sess.attachmentToken(ac, ac.transport())
859862 effect, admitted := ac.beginAttachmentEffect(token)
860863 require.True(t, admitted)
@@ -862,9 +865,14 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
862865 require.NoError(t, d.sendNavigationActionForAttachment(token, tt.action))
863866 frame := receiveRemotePicker(t, sends, "navigation action")
864867 require.Equal(t, ports.MsgNavigationAction, frame.Type)
865- got, err := ports.UnmarshalNavigationAction(frame.Payload)
868+ directive, err := ports.UnmarshalNavigationDirective(frame.Payload)
866869 require.NoError(t, err)
867- require.Equal(t, tt.action, got)
870+ require.Equal(t, tt.action, directive.Action)
871+ if tt.action == ports.NavigationOpenHomePicker {
872+ require.False(t, directive.LeaseID.IsZero())
873+ } else {
874+ require.True(t, directive.LeaseID.IsZero())
875+ }
868876 })
869877 }
870878 }
@@ -855,6 +855,9 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
855 t.Run(tt.name, func(t *testing.T) {855 t.Run(tt.name, func(t *testing.T) {
856 d := newRemotePickerDaemon(nil)856 d := newRemotePickerDaemon(nil)
857 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")857 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
858+ if tt.action == ports.NavigationOpenHomePicker {
859+ ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
860+ }
858 token := sess.attachmentToken(ac, ac.transport())861 token := sess.attachmentToken(ac, ac.transport())
859 effect, admitted := ac.beginAttachmentEffect(token)862 effect, admitted := ac.beginAttachmentEffect(token)
860 require.True(t, admitted)863 require.True(t, admitted)
@@ -862,9 +865,14 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
862 require.NoError(t, d.sendNavigationActionForAttachment(token, tt.action))865 require.NoError(t, d.sendNavigationActionForAttachment(token, tt.action))
863 frame := receiveRemotePicker(t, sends, "navigation action")866 frame := receiveRemotePicker(t, sends, "navigation action")
864 require.Equal(t, ports.MsgNavigationAction, frame.Type)867 require.Equal(t, ports.MsgNavigationAction, frame.Type)
865- got, err := ports.UnmarshalNavigationAction(frame.Payload)868+ directive, err := ports.UnmarshalNavigationDirective(frame.Payload)
866 require.NoError(t, err)869 require.NoError(t, err)
867- require.Equal(t, tt.action, got)870+ require.Equal(t, tt.action, directive.Action)
871+ if tt.action == ports.NavigationOpenHomePicker {
872+ require.False(t, directive.LeaseID.IsZero())
873+ } else {
874+ require.True(t, directive.LeaseID.IsZero())
875+ }
868 })876 })
869 }877 }
870 }878 }
M internal/usecase/daemon/render.go +8 -2
@@ -329,10 +329,13 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
329329 entry.core().mu.Lock()
330330 _, owned := entry.core().attachments[ac]
331331 entry.core().mu.Unlock()
332- if !owned || ac.currentAttachmentSession() != entry {
332+ if !owned || ac.currentAttachmentSession() != entry || ac.parkedRouteOutput.Load() {
333333 ac.sendMu.Unlock()
334334 return paintRejected
335335 }
336+ if ac.parkedRouteFullPending.Load() {
337+ reset = true
338+ }
336339 if lease != nil {
337340 rc := attachmentRenderCoordinator(entry)
338341 if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
@@ -455,7 +458,10 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
455458 if ac.renderStages.compose != nil {
456459 ac.renderStages.compose()
457460 }
458- d.emitFrame(entry, ac, state, composed, &marks)
461+ full := state.reset
462+ if d.emitFrame(entry, ac, state, composed, &marks) && full {
463+ ac.parkedRouteFullPending.Store(false)
464+ }
459465 return paintEmitted
460466 }
461467
@@ -329,10 +329,13 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
329 entry.core().mu.Lock()329 entry.core().mu.Lock()
330 _, owned := entry.core().attachments[ac]330 _, owned := entry.core().attachments[ac]
331 entry.core().mu.Unlock()331 entry.core().mu.Unlock()
332- if !owned || ac.currentAttachmentSession() != entry {332+ if !owned || ac.currentAttachmentSession() != entry || ac.parkedRouteOutput.Load() {
333 ac.sendMu.Unlock()333 ac.sendMu.Unlock()
334 return paintRejected334 return paintRejected
335 }335 }
336+ if ac.parkedRouteFullPending.Load() {
337+ reset = true
338+ }
336 if lease != nil {339 if lease != nil {
337 rc := attachmentRenderCoordinator(entry)340 rc := attachmentRenderCoordinator(entry)
338 if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {341 if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
@@ -455,7 +458,10 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
455 if ac.renderStages.compose != nil {458 if ac.renderStages.compose != nil {
456 ac.renderStages.compose()459 ac.renderStages.compose()
457 }460 }
458- d.emitFrame(entry, ac, state, composed, &marks)461+ full := state.reset
462+ if d.emitFrame(entry, ac, state, composed, &marks) && full {
463+ ac.parkedRouteFullPending.Store(false)
464+ }
459 return paintEmitted465 return paintEmitted
460 }466 }
461 467

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