Merge pull request #212 from bnema/feat/parked-remote-session-switch
feat(remote): reuse parked UDP routes
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 | |||
| 64 | 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 | 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 | 68 | - Version negotiation requires strict equality. | |
| 69 | 69 | - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work. | |
| 70 | 70 | - 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 | |||
| 55 | 55 | cursor while holding raw input. The daemon either commits the fenced attachment | |
| 56 | 56 | transition and publishes the committed identity before the rebased full paint, | |
| 57 | 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, | |
| 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. | |
| 61 | 78 | ||
| 62 | 79 | ## Durable record compatibility | |
| 63 | 80 | ||
| @@ -90,6 +107,9 @@ picker selection changes. | |||
| 90 | 107 | - Each command request has a 10-second deadline for its result. A timeout | |
| 91 | 108 | abandons only that request; it does not complete a later request with a stale | |
| 92 | 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 | 113 | - Shared PTY content geometry follows the latest valid, non-superseded attach, | |
| 94 | 114 | resume, or resize claim. A later attachment can therefore replace the current | |
| 95 | 115 | 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 attachment | 55 | 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 direct | 59 | + | 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 compatibility | 79 | ## 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 timeout | 107 | - 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 stale | 108 | 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 current | 114 | resume, or resize claim. A later attachment can therefore replace the current | ||
| 95 | shared geometry; if the winning attachment detaches, the most recently | 115 | shared geometry; if the winning attachment detaches, the most recently | ||
M internal/ports/frame.go +57 -3
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | const HandshakeTimeout = 15 * time.Second | |
| 13 | 13 | ||
| 14 | 14 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 15 | - | const ProtocolVersion uint16 = 33 | |
| 15 | + | const ProtocolVersion uint16 = 34 | |
| 16 | 16 | ||
| 17 | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | |
| 18 | 18 | // and excluding the four-byte length prefix. | |
| @@ -22,8 +22,8 @@ const MaxFrameLen = 16 << 20 | |||
| 22 | 22 | type MsgType uint8 | |
| 23 | 23 | ||
| 24 | 24 | // 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 | |
| 27 | 27 | // future extensions. | |
| 28 | 28 | const ( | |
| 29 | 29 | MsgHello MsgType = 1 | |
| @@ -42,6 +42,7 @@ const ( | |||
| 42 | 42 | MsgRemotePreviewRequest MsgType = 15 | |
| 43 | 43 | MsgRouteAttentionSubscription MsgType = 32 | |
| 44 | 44 | MsgSamePeerSwitchRequest MsgType = 33 | |
| 45 | + | MsgParkedRouteRequest MsgType = 35 | |
| 45 | 46 | ||
| 46 | 47 | MsgWelcome MsgType = 16 | |
| 47 | 48 | MsgError MsgType = 17 | |
| @@ -61,6 +62,7 @@ const ( | |||
| 61 | 62 | MsgRouteNavigationFailure MsgType = 30 | |
| 62 | 63 | MsgRoutePosition MsgType = 31 | |
| 63 | 64 | MsgSamePeerSwitchFailure MsgType = 34 | |
| 65 | + | MsgParkedRouteResponse MsgType = 36 | |
| 64 | 66 | ) | |
| 65 | 67 | ||
| 66 | 68 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
| @@ -97,6 +99,58 @@ const ( | |||
| 97 | 99 | 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 | 154 | // RemotePreviewSchemaVersion is independent from the attachment IPC version. | |
| 101 | 155 | const RemotePreviewSchemaVersion uint16 = 1 | |
| 102 | 156 | ||
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | const HandshakeTimeout = 15 * time.Second | 12 | 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 = 33 | 15 | + | const ProtocolVersion uint16 = 34 |
| 16 | 16 | ||||
| 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | 17 | // 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 uint8 | 22 | type MsgType uint8 | ||
| 23 | 23 | ||||
| 24 | // Frame message types use non-contiguous allocations: the legacy client range | 24 | // 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 | 25 | + | // 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 for | 26 | + | // 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 = 1 | 29 | MsgHello MsgType = 1 | ||
| @@ -42,6 +42,7 @@ const ( | |||||
| 42 | MsgRemotePreviewRequest MsgType = 15 | 42 | MsgRemotePreviewRequest MsgType = 15 | ||
| 43 | MsgRouteAttentionSubscription MsgType = 32 | 43 | MsgRouteAttentionSubscription MsgType = 32 | ||
| 44 | MsgSamePeerSwitchRequest MsgType = 33 | 44 | MsgSamePeerSwitchRequest MsgType = 33 | ||
| 45 | + | MsgParkedRouteRequest MsgType = 35 | |||
| 45 | 46 | ||||
| 46 | MsgWelcome MsgType = 16 | 47 | MsgWelcome MsgType = 16 | ||
| 47 | MsgError MsgType = 17 | 48 | MsgError MsgType = 17 | ||
| @@ -61,6 +62,7 @@ const ( | |||||
| 61 | MsgRouteNavigationFailure MsgType = 30 | 62 | MsgRouteNavigationFailure MsgType = 30 | ||
| 62 | MsgRoutePosition MsgType = 31 | 63 | MsgRoutePosition MsgType = 31 | ||
| 63 | MsgSamePeerSwitchFailure MsgType = 34 | 64 | MsgSamePeerSwitchFailure MsgType = 34 | ||
| 65 | + | MsgParkedRouteResponse MsgType = 36 | |||
| 64 | ) | 66 | ) | ||
| 65 | 67 | ||||
| 66 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 68 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | ||
| @@ -97,6 +99,58 @@ const ( | |||||
| 97 | NavigationBack NavigationAction = 2 | 99 | 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 = 1 | 155 | const RemotePreviewSchemaVersion uint16 = 1 | ||
| 102 | 156 | ||||
M internal/ports/frame_test.go +5 -4
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | 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) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -32,14 +32,15 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 32 | 32 | {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList}, | |
| 33 | 33 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | |
| 34 | 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 | 37 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | |
| 37 | 38 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | |
| 38 | 39 | {"MsgCommandResult", MsgCommandResult}, | |
| 39 | 40 | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | |
| 40 | 41 | {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot}, | |
| 41 | 42 | {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure}, | |
| 42 | - | {"MsgRoutePosition", MsgRoutePosition}, | |
| 43 | + | {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse}, | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 45 | 46 | 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 ( | |||
| 8 | 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 | 13 | tests := []struct { | |
| 13 | - | name string | |
| 14 | - | action NavigationAction | |
| 15 | - | payload []byte | |
| 16 | - | valid bool | |
| 14 | + | name string | |
| 15 | + | directive NavigationDirective | |
| 16 | + | valid bool | |
| 17 | 17 | }{ | |
| 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}}, | |
| 22 | 24 | } | |
| 23 | 25 | ||
| 24 | 26 | for _, tt := range tests { | |
| 25 | 27 | t.Run(tt.name, func(t *testing.T) { | |
| 26 | - | got := MarshalNavigationAction(tt.action) | |
| 28 | + | got := MarshalNavigationDirective(tt.directive) | |
| 27 | 29 | if !tt.valid { | |
| 28 | 30 | require.Nil(t, got) | |
| 29 | - | _, err := UnmarshalNavigationAction(tt.payload) | |
| 30 | - | require.ErrorIs(t, err, ErrInvalidNavigation) | |
| 31 | 31 | return | |
| 32 | 32 | } | |
| 33 | - | require.Equal(t, tt.payload, got) | |
| 34 | - | decoded, err := UnmarshalNavigationAction(got) | |
| 33 | + | decoded, err := UnmarshalNavigationDirective(got) | |
| 35 | 34 | 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)) | |
| 39 | 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 | 134 | func TestHelloNavigationValidationTable(t *testing.T) { | |
| 45 | 135 | base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 46 | 136 | remoteTarget := &domain.RemoteSessionTarget{ | |
| @@ -62,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 62 | 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 | 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 | 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 | 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 | 157 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| 68 | 158 | {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 string | 14 | + | name string |
| 14 | - | action NavigationAction | 15 | + | directive NavigationDirective |
| 15 | - | payload []byte | 16 | + | 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 | return | 31 | 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) { | |||
| 2066 | 2066 | return p, nil | |
| 2067 | 2067 | } | |
| 2068 | 2068 | ||
| 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 { | |
| 2072 | 2072 | 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. | |
| 2078 | - | func UnmarshalNavigationAction(b []byte) (NavigationAction, error) { | |
| 2083 | + | // UnmarshalNavigationDirective decodes one strict navigation directive. | |
| 2084 | + | func UnmarshalNavigationDirective(b []byte) (NavigationDirective, error) { | |
| 2079 | 2085 | r := payloadReader{b: b} | |
| 2080 | 2086 | value, err := r.getUint8() | |
| 2081 | 2087 | 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 | |
| 2083 | 2093 | } | |
| 2084 | 2094 | 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 | |
| 2086 | 2187 | } | |
| 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 | |
| 2090 | 2191 | } | |
| 2091 | - | return action, nil | |
| 2192 | + | return response, nil | |
| 2092 | 2193 | } | |
| @@ -2066,27 +2066,128 @@ func UnmarshalRemotePreview(data []byte) (RemotePreview, error) { | |||||
| 2066 | return p, nil | 2066 | 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 nil | 2072 | 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, ErrInvalidNavigation | 2088 | + | 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, 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 | |||
| 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, ErrInvalidNavigation | 2190 | + | return ParkedRouteResponse{}, ErrInvalidNavigation |
| 2090 | } | 2191 | } | ||
| 2091 | - | return action, nil | 2192 | + | return response, nil |
| 2092 | } | 2193 | } | ||
M internal/ports/wire_final_test.go +3 -3
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | 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) | |
| 16 | 16 | } | |
| 17 | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 18 | 18 | if len(payload) < 2 { | |
| @@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 26 | 26 | ||
| 27 | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 28 | 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 | 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 | 31 | got := MarshalHello(msg) | |
| 32 | 32 | 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) { | |||
| 131 | 131 | } | |
| 132 | 132 | got := MarshalHello(msg) | |
| 133 | 133 | want := []byte{ | |
| 134 | - | 0x00, 0x21, 0x00, // version, intent, first client ID byte | |
| 134 | + | 0x00, 0x22, 0x00, // version, intent, first client ID byte | |
| 135 | 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 136 | 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 137 | 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 361 | 361 | } | |
| 362 | 362 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 363 | 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 | 367 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 522 | 522 | name: "minimal", | |
| 523 | 523 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 524 | 524 | want: []byte{ | |
| 525 | - | 0x00, 0x21, | |
| 525 | + | 0x00, 0x22, | |
| 526 | 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 527 | 527 | 0x00, // attached | |
| 528 | 528 | 0x00, // self | |
| @@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 545 | 545 | JSON: true, | |
| 546 | 546 | }, | |
| 547 | 547 | want: []byte{ | |
| 548 | - | 0x00, 0x21, | |
| 548 | + | 0x00, 0x22, | |
| 549 | 549 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 550 | 550 | 0x00, // attached | |
| 551 | 551 | 0x01, // self | |
| @@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 974 | 974 | name: "attached", | |
| 975 | 975 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 976 | 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 | 978 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 979 | 979 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 980 | 980 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 984 | 984 | name: "control", | |
| 985 | 985 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 986 | 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 | 988 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 989 | 989 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 990 | 990 | }, | |
| @@ -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 byte | 134 | + | 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 ID | 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 137 | 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 ID | 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 527 | 0x00, // attached | 527 | 0x00, // attached | ||
| 528 | 0x00, // self | 528 | 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 ID | 549 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 550 | 0x00, // attached | 550 | 0x00, // attached | ||
| 551 | 0x01, // self | 551 | 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) { | |||
| 32 | 32 | defer cancel() | |
| 33 | 33 | tr := &ackRecordingTransport{sent: make(chan ports.Frame, sendQueueDepth+1)} | |
| 34 | 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 | 37 | first := <-tr.sent | |
| 38 | 38 | require.Equal(t, ports.MsgAck, first.Type) | |
| @@ -54,7 +54,7 @@ func TestSamePeerSwitchControlBypassesHeldInput(t *testing.T) { | |||
| 54 | 54 | transport := &ackRecordingTransport{sent: make(chan ports.Frame, 2)} | |
| 55 | 55 | acks := newCumulativeAckQueue() | |
| 56 | 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 | 59 | normal <- ports.Frame{Type: ports.MsgInput, Payload: []byte("held")} | |
| 60 | 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 | 118 | func awaitSenderSignal(t *testing.T, signal <-chan struct{}) { | |
| 77 | 119 | t.Helper() | |
| 78 | 120 | 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.sent | 37 | 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) | |||
| 398 | 398 | rawEntered: &rawEntered, | |
| 399 | 399 | stage: reconnectStageOfflineRetrying, | |
| 400 | 400 | } | |
| 401 | + | transition := newTransitionUI(r.term, r.clock, &rawEntered) | |
| 402 | + | defer transition.stop() | |
| 401 | 403 | restoreReturnRoute := func(route attachRoute) { | |
| 402 | 404 | returnNavigationPending = true | |
| 403 | 405 | dialer = route.dialer | |
| @@ -444,12 +446,58 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 444 | 446 | 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 | 495 | for { | |
| 448 | 496 | if err := validateAttachRequest(attemptRequest); err != nil { | |
| 449 | 497 | return err | |
| 450 | 498 | } | |
| 451 | 499 | handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock) | |
| 452 | - | transport, err := boundedDial(handshakeCtx, dialer) | |
| 500 | + | transport, err := boundedDialWithTransition(handshakeCtx, dialer, transition) | |
| 453 | 501 | if err != nil { | |
| 454 | 502 | err = handshakeContextError(ctx, timedOut, err) | |
| 455 | 503 | finishHandshake() | |
| @@ -515,11 +563,13 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 515 | 563 | themeState: themeState, | |
| 516 | 564 | enterRaw: enterRaw, | |
| 517 | 565 | reconnect: reconnect, | |
| 566 | + | transition: transition, | |
| 518 | 567 | linkEvents: linkEvents, | |
| 519 | 568 | rememberRemoteHost: rememberRemoteHost, | |
| 520 | 569 | terminalInput: func() *terminalInputPump { | |
| 521 | 570 | return input | |
| 522 | 571 | }, | |
| 572 | + | openHomePicker: runTransientHomePicker, | |
| 523 | 573 | }).run(ctx) | |
| 524 | 574 | stopHandshakeTransport() | |
| 525 | 575 | finishHandshake() | |
| @@ -655,8 +705,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 655 | 705 | if r.ledger == nil { | |
| 656 | 706 | return errors.New("vev: route ledger unavailable") | |
| 657 | 707 | } | |
| 708 | + | baseRequest := attemptRequest | |
| 658 | 709 | 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 | 718 | } else { | |
| 661 | 719 | if r.attachHandoff == nil { | |
| 662 | 720 | return &AttachTargetError{Target: *result.target} | |
| @@ -910,9 +968,13 @@ type attachAttempt struct { | |||
| 910 | 968 | themeState *terminalThemeState | |
| 911 | 969 | enterRaw func() error | |
| 912 | 970 | reconnect *reconnectUI | |
| 971 | + | transition *transitionUI | |
| 913 | 972 | linkEvents <-chan ports.LinkEvent | |
| 914 | 973 | rememberRemoteHost func() | |
| 915 | 974 | terminalInput func() *terminalInputPump | |
| 975 | + | openHomePicker func(context.Context) attachResult | |
| 976 | + | transientPicker bool | |
| 977 | + | returnAttachTargets bool | |
| 916 | 978 | } | |
| 917 | 979 | ||
| 918 | 980 | type attachResult struct { | |
| @@ -924,6 +986,7 @@ type attachResult struct { | |||
| 924 | 986 | transportClosed bool | |
| 925 | 987 | target *ports.AttachTarget | |
| 926 | 988 | action ports.NavigationAction | |
| 989 | + | homePickerTarget bool | |
| 927 | 990 | routeAction *ports.RouteNavigationAction | |
| 928 | 991 | err error | |
| 929 | 992 | } | |
| @@ -979,6 +1042,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 979 | 1042 | themeState := a.themeState | |
| 980 | 1043 | enterRaw := a.enterRaw | |
| 981 | 1044 | reconnect := a.reconnect | |
| 1045 | + | transition := a.transition | |
| 982 | 1046 | linkEvents := a.linkEvents | |
| 983 | 1047 | remote := a.remote || a.request.Remote | |
| 984 | 1048 | clipboard := a.runner.clipboard | |
| @@ -1013,7 +1077,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1013 | 1077 | return attachResult{transportClosed: handshakeCtx.Err() != nil, err: fmt.Errorf("vev: %s: %w", stage, err)} | |
| 1014 | 1078 | } | |
| 1015 | 1079 | sendHandshake := func(operation func() error) error { | |
| 1016 | - | err := boundedHandshakeOperation(handshakeCtx, transport, operation) | |
| 1080 | + | err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, operation, transition) | |
| 1017 | 1081 | if err != nil { | |
| 1018 | 1082 | return handshakeContextError(ctx, handshakeTimedOut, err) | |
| 1019 | 1083 | } | |
| @@ -1067,11 +1131,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1067 | 1131 | ||
| 1068 | 1132 | // 2. Await Welcome or a typed rejection. | |
| 1069 | 1133 | var reply ports.Frame | |
| 1070 | - | if err := boundedHandshakeOperation(handshakeCtx, transport, func() error { | |
| 1134 | + | if err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, func() error { | |
| 1071 | 1135 | var recvErr error | |
| 1072 | 1136 | reply, recvErr = transport.Recv() | |
| 1073 | 1137 | return recvErr | |
| 1074 | - | }); err != nil { | |
| 1138 | + | }, transition); err != nil { | |
| 1075 | 1139 | return handshakeFailure("awaiting welcome", err) | |
| 1076 | 1140 | } | |
| 1077 | 1141 | var committedIdentity *ports.CommittedRouteIdentity | |
| @@ -1122,16 +1186,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1122 | 1186 | if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) { | |
| 1123 | 1187 | return welcomedResult(errRouteTargetChanged) | |
| 1124 | 1188 | } | |
| 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 | + | } | |
| 1135 | 1201 | } | |
| 1136 | 1202 | snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) | |
| 1137 | 1203 | if err != nil { | |
| @@ -1183,21 +1249,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1183 | 1249 | input.start() | |
| 1184 | 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 | 1252 | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 1200 | 1253 | controlCh := make(chan ports.Frame, 1) | |
| 1254 | + | barrierCh := make(chan chan struct{}) | |
| 1201 | 1255 | inputGate := newSamePeerInputGate() | |
| 1202 | 1256 | ackQueue := newCumulativeAckQueue() | |
| 1203 | 1257 | sendErrCh := make(chan error, 1) | |
| @@ -1309,8 +1363,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1309 | 1363 | awaitingReconnectReset := false | |
| 1310 | 1364 | outputState := outputApplyState{} | |
| 1311 | 1365 | outputResetRequested := false | |
| 1366 | + | transitionWaitingFull := false | |
| 1312 | 1367 | var samePeerSwitch *samePeerSwitchPending | |
| 1313 | 1368 | 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 | 1381 | if reconnect.showing { | |
| 1315 | 1382 | if reconnect.remote { | |
| 1316 | 1383 | // Before the asynchronous sender starts, preserve transport ordering by | |
| @@ -1339,27 +1406,195 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1339 | 1406 | // or unchanged screen may legitimately produce no initial Output frame. | |
| 1340 | 1407 | endHandshake() | |
| 1341 | 1408 | 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() | |
| 1359 | 1594 | ||
| 1360 | 1595 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| 1361 | 1596 | recvCh := make(chan recvResult, 1) | |
| 1362 | - | go runRecv(loopCtx, transport, recvCh, log) | |
| 1597 | + | go runRecv(loopCtx, transport, recvCh, transportFailed, log) | |
| 1363 | 1598 | ||
| 1364 | 1599 | requestReconnectReset := func() error { | |
| 1365 | 1600 | if awaitingReconnectReset { | |
| @@ -1447,6 +1682,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1447 | 1682 | select { | |
| 1448 | 1683 | case <-loopCtx.Done(): | |
| 1449 | 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 | 1695 | case ev, ok := <-linkEvents: | |
| 1451 | 1696 | if !ok { | |
| 1452 | 1697 | linkEvents = nil | |
| @@ -1537,6 +1782,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1537 | 1782 | } | |
| 1538 | 1783 | continue | |
| 1539 | 1784 | } | |
| 1785 | + | if parkedWaitingFull && !o.Full { | |
| 1786 | + | return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output")) | |
| 1787 | + | } | |
| 1540 | 1788 | if _, werr := term.Out().Write(o.Data); werr != nil { | |
| 1541 | 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 | 1803 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | |
| 1556 | 1804 | } | |
| 1557 | 1805 | 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 | 1814 | if o.New != 0 { | |
| 1559 | 1815 | ackQueue.offer(o.Epoch, o.New) | |
| 1560 | 1816 | outputResetRequested = false | |
| @@ -1573,6 +1829,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1573 | 1829 | if derr != nil { | |
| 1574 | 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 | 1841 | if target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil { | |
| 1577 | 1842 | if samePeerSwitch != nil { | |
| 1578 | 1843 | return welcomedResult(errors.New("vev: concurrent same-peer switch offer")) | |
| @@ -1599,13 +1864,71 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1599 | 1864 | handoff := welcomedResult(nil) | |
| 1600 | 1865 | handoff.target = &target | |
| 1601 | 1866 | 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 | 1914 | case ports.MsgNavigationAction: | |
| 1603 | - | action, derr := ports.UnmarshalNavigationAction(r.frame.Payload) | |
| 1915 | + | directive, derr := ports.UnmarshalNavigationDirective(r.frame.Payload) | |
| 1604 | 1916 | if derr != nil { | |
| 1605 | 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 | 1930 | navigation := welcomedResult(nil) | |
| 1608 | - | navigation.action = action | |
| 1931 | + | navigation.action = directive.Action | |
| 1609 | 1932 | return navigation | |
| 1610 | 1933 | case ports.MsgNavigateRecentRoute: | |
| 1611 | 1934 | action, derr := ports.UnmarshalRouteNavigationAction(r.frame.Payload) | |
| @@ -1662,6 +1985,19 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1662 | 1985 | if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID { | |
| 1663 | 1986 | samePeerSwitch = nil | |
| 1664 | 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 | 2002 | case ports.MsgRoutePosition: | |
| 1667 | 2003 | position, derr := ports.UnmarshalRoutePosition(r.frame.Payload) | |
| @@ -1712,10 +2048,13 @@ type recvResult struct { | |||
| 1712 | 2048 | ||
| 1713 | 2049 | // runRecv reads frames until an error, forwarding each to the main loop. | |
| 1714 | 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 | 2052 | defer log.Debug("receive pump exited") | |
| 1717 | 2053 | for { | |
| 1718 | 2054 | f, err := transport.Recv() | |
| 2055 | + | if err != nil && failed != nil { | |
| 2056 | + | close(failed) | |
| 2057 | + | } | |
| 1719 | 2058 | select { | |
| 1720 | 2059 | case out <- recvResult{frame: f, err: err}: | |
| 1721 | 2060 | case <-ctx.Done(): | |
| @@ -1766,7 +2105,7 @@ func (q *cumulativeAckQueue) take() (uint64, uint64) { | |||
| 1766 | 2105 | ||
| 1767 | 2106 | // runSender preserves normal-frame order while allowing switch control and | |
| 1768 | 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 | 2109 | defer log.Debug("sender pump exited") | |
| 1771 | 2110 | send := func(f ports.Frame) bool { | |
| 1772 | 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 | 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 | 2167 | var heldInput *ports.Frame | |
| 1799 | 2168 | for { | |
| 1800 | 2169 | if heldInput != nil { | |
| @@ -1815,6 +2184,10 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 1815 | 2184 | if !send(frame) { | |
| 1816 | 2185 | return | |
| 1817 | 2186 | } | |
| 2187 | + | case done := <-barriers: | |
| 2188 | + | if !completeBarrier(done) { | |
| 2189 | + | return | |
| 2190 | + | } | |
| 1818 | 2191 | case <-changed: | |
| 1819 | 2192 | case <-ctx.Done(): | |
| 1820 | 2193 | return | |
| @@ -1832,6 +2205,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 1832 | 2205 | return | |
| 1833 | 2206 | } | |
| 1834 | 2207 | continue | |
| 2208 | + | case done := <-barriers: | |
| 2209 | + | if !completeBarrier(done) { | |
| 2210 | + | return | |
| 2211 | + | } | |
| 2212 | + | continue | |
| 1835 | 2213 | default: | |
| 1836 | 2214 | } | |
| 1837 | 2215 | select { | |
| @@ -1843,6 +2221,10 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 1843 | 2221 | if !send(frame) { | |
| 1844 | 2222 | return | |
| 1845 | 2223 | } | |
| 2224 | + | case done := <-barriers: | |
| 2225 | + | if !completeBarrier(done) { | |
| 2226 | + | return | |
| 2227 | + | } | |
| 1846 | 2228 | case frame := <-in: | |
| 1847 | 2229 | select { | |
| 1848 | 2230 | case <-ctx.Done(): | |
| @@ -2185,6 +2567,36 @@ func (p *terminalInputPump) stop() { | |||
| 2185 | 2567 | // preserving all ordinary terminal input byte-for-byte. It never reads the | |
| 2186 | 2568 | // terminal directly, publishes a Theme, or writes terminal output; | |
| 2187 | 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 | 2600 | type stdinPump struct { | |
| 2189 | 2601 | ctx context.Context | |
| 2190 | 2602 | cancel context.CancelFunc | |
| @@ -2200,6 +2612,7 @@ type stdinPump struct { | |||
| 2200 | 2612 | afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook | |
| 2201 | 2613 | afterInputTake func() // test synchronization hook | |
| 2202 | 2614 | afterInputDelivered func() // test synchronization hook | |
| 2615 | + | sendLease *foregroundSendLease | |
| 2203 | 2616 | } | |
| 2204 | 2617 | ||
| 2205 | 2618 | func (p *stdinPump) run() { | |
| @@ -2215,31 +2628,35 @@ func (p *stdinPump) run() { | |||
| 2215 | 2628 | if !sendOK.Load() { | |
| 2216 | 2629 | return false | |
| 2217 | 2630 | } | |
| 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 | |
| 2222 | 2641 | } | |
| 2223 | - | return true | |
| 2224 | - | case <-p.ctx.Done(): | |
| 2225 | - | sendOK.Store(false) | |
| 2226 | - | return false | |
| 2227 | - | } | |
| 2642 | + | }) | |
| 2228 | 2643 | } | |
| 2229 | 2644 | sendEvent := func(event paletteGenerationEvent) bool { | |
| 2230 | 2645 | if !sendOK.Load() { | |
| 2231 | 2646 | return false | |
| 2232 | 2647 | } | |
| 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 | |
| 2237 | 2658 | } | |
| 2238 | - | return true | |
| 2239 | - | case <-p.ctx.Done(): | |
| 2240 | - | sendOK.Store(false) | |
| 2241 | - | return false | |
| 2242 | - | } | |
| 2659 | + | }) | |
| 2243 | 2660 | } | |
| 2244 | 2661 | coalescer := newPasteCoalescer(p.clock, func(data []byte) { | |
| 2245 | 2662 | if len(data) == 0 { | |
| @@ -2427,7 +2844,7 @@ func (p *stdinPump) run() { | |||
| 2427 | 2844 | // runResize forwards coalesced terminal resize events to the daemon. It | |
| 2428 | 2845 | // tolerates an already-closed resize channel (which the terminal adapter | |
| 2429 | 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 | 2848 | defer log.Debug("resize pump exited") | |
| 2432 | 2849 | for { | |
| 2433 | 2850 | select { | |
| @@ -2441,9 +2858,14 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports. | |||
| 2441 | 2858 | continue | |
| 2442 | 2859 | } | |
| 2443 | 2860 | 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 | + | }) { | |
| 2447 | 2869 | return | |
| 2448 | 2870 | } | |
| 2449 | 2871 | 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 = true | 404 | returnNavigationPending = true | ||
| 403 | dialer = route.dialer | 405 | dialer = route.dialer | ||
| @@ -444,12 +446,58 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 444 | return nil | 446 | 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 err | 497 | 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 input | 570 | 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 = dialer | 709 | 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 *terminalThemeState | 968 | themeState *terminalThemeState | ||
| 911 | enterRaw func() error | 969 | enterRaw func() error | ||
| 912 | reconnect *reconnectUI | 970 | reconnect *reconnectUI | ||
| 971 | + | transition *transitionUI | |||
| 913 | linkEvents <-chan ports.LinkEvent | 972 | linkEvents <-chan ports.LinkEvent | ||
| 914 | rememberRemoteHost func() | 973 | rememberRemoteHost func() | ||
| 915 | terminalInput func() *terminalInputPump | 974 | 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 bool | 986 | transportClosed bool | ||
| 925 | target *ports.AttachTarget | 987 | target *ports.AttachTarget | ||
| 926 | action ports.NavigationAction | 988 | action ports.NavigationAction | ||
| 989 | + | homePickerTarget bool | |||
| 927 | routeAction *ports.RouteNavigationAction | 990 | routeAction *ports.RouteNavigationAction | ||
| 928 | err error | 991 | err error | ||
| 929 | } | 992 | } | ||
| @@ -979,6 +1042,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 979 | themeState := a.themeState | 1042 | themeState := a.themeState | ||
| 980 | enterRaw := a.enterRaw | 1043 | enterRaw := a.enterRaw | ||
| 981 | reconnect := a.reconnect | 1044 | reconnect := a.reconnect | ||
| 1045 | + | transition := a.transition | |||
| 982 | linkEvents := a.linkEvents | 1046 | linkEvents := a.linkEvents | ||
| 983 | remote := a.remote || a.request.Remote | 1047 | remote := a.remote || a.request.Remote | ||
| 984 | clipboard := a.runner.clipboard | 1048 | 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.Frame | 1133 | var reply ports.Frame | ||
| 1070 | - | if err := boundedHandshakeOperation(handshakeCtx, transport, func() error { | 1134 | + | if err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, func() error { |
| 1071 | var recvErr error | 1135 | var recvErr error | ||
| 1072 | reply, recvErr = transport.Recv() | 1136 | reply, recvErr = transport.Recv() | ||
| 1073 | return recvErr | 1137 | 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.CommittedRouteIdentity | 1141 | 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 error | 1190 | + | candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken) |
| 1127 | - | if a.routeNavigationSelection != nil { | 1191 | + | var commitErr error |
| 1128 | - | selection := *a.routeNavigationSelection | 1192 | + | 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 := false | 1363 | awaitingReconnectReset := false | ||
| 1310 | outputState := outputApplyState{} | 1364 | outputState := outputApplyState{} | ||
| 1311 | outputResetRequested := false | 1365 | outputResetRequested := false | ||
| 1366 | + | transitionWaitingFull := false | |||
| 1312 | var samePeerSwitch *samePeerSwitchPending | 1367 | var samePeerSwitch *samePeerSwitchPending | ||
| 1313 | var nextSamePeerRequestID uint64 | 1368 | 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 by | 1383 | // 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 = true | 1408 | 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 a | 1415 | + | 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 replacement | 1417 | + | var foreground *foregroundRuntime |
| 1351 | - | // scanner could claim the reader between revocation and preservation and | 1418 | + | startForeground := func() { |
| 1352 | - | // lose the suffix. | 1419 | + | if foreground != nil { |
| 1353 | - | defer func() { | 1420 | + | return |
| 1354 | - | cancel() | 1421 | + | } |
| 1355 | - | <-stdinDone | 1422 | + | 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 = nil | 1697 | linkEvents = nil | ||
| @@ -1537,6 +1782,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1537 | } | 1782 | } | ||
| 1538 | continue | 1783 | 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 = nextState | 1805 | 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 = false | 1816 | 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 = &target | 1865 | handoff.target = &target | ||
| 1601 | return handoff | 1866 | 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 = action | 1931 | + | navigation.action = directive.Action |
| 1609 | return navigation | 1932 | 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 = nil | 1986 | 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 and | 2106 | // 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.Frame | 2167 | 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 | return | 2185 | 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 | return | 2193 | return | ||
| @@ -1832,6 +2205,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 1832 | return | 2205 | return | ||
| 1833 | } | 2206 | } | ||
| 1834 | continue | 2207 | 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 | return | 2222 | 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 the | 2567 | // 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.Context | 2601 | ctx context.Context | ||
| 2190 | cancel context.CancelFunc | 2602 | cancel context.CancelFunc | ||
| @@ -2200,6 +2612,7 @@ type stdinPump struct { | |||||
| 2200 | afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook | 2612 | afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook | ||
| 2201 | afterInputTake func() // test synchronization hook | 2613 | afterInputTake func() // test synchronization hook | ||
| 2202 | afterInputDelivered func() // test synchronization hook | 2614 | 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 false | 2629 | 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 true | 2642 | + | }) |
| 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 false | 2646 | 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 true | 2659 | + | }) |
| 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. It | 2844 | // runResize forwards coalesced terminal resize events to the daemon. It | ||
| 2428 | // tolerates an already-closed resize channel (which the terminal adapter | 2845 | // 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 | continue | 2858 | 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 | return | 2869 | 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 { | |||
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 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 | 64 | t.Helper() | |
| 60 | 65 | require.Eventually(t, func() bool { | |
| 61 | 66 | c.mu.Lock() | |
| 62 | 67 | defer c.mu.Unlock() | |
| 63 | 68 | for _, timer := range c.timers { | |
| 64 | 69 | timer.mu.Lock() | |
| 65 | - | if timer.duration != 100*time.Millisecond { | |
| 70 | + | if timer.duration != duration { | |
| 66 | 71 | timer.mu.Unlock() | |
| 67 | 72 | continue | |
| 68 | 73 | } | |
| @@ -111,8 +116,9 @@ func (t *reconnectTestTimer) Stop() bool { | |||
| 111 | 116 | ||
| 112 | 117 | // recvItem is one scripted Recv result. | |
| 113 | 118 | type recvItem struct { | |
| 114 | - | f ports.Frame | |
| 115 | - | err error | |
| 119 | + | f ports.Frame | |
| 120 | + | err error | |
| 121 | + | wait <-chan struct{} | |
| 116 | 122 | } | |
| 117 | 123 | ||
| 118 | 124 | type markedDatagramTransport struct{ ports.Transport } | |
| @@ -131,6 +137,13 @@ func scriptRecv(tr *portsmocks.MockTransport, items ...recvItem) func() { | |||
| 131 | 137 | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 132 | 138 | select { | |
| 133 | 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 | 147 | return it.f, it.err | |
| 135 | 148 | case <-done: | |
| 136 | 149 | return ports.Frame{}, io.EOF | |
| @@ -552,7 +565,7 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin | |||
| 552 | 565 | }} | |
| 553 | 566 | remote := &recordingTransport{recvs: []recvItem{ | |
| 554 | 567 | {f: welcome("work", remoteLifecycle)}, | |
| 555 | - | {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))}, | |
| 568 | + | {f: navigationDirectiveFrame(ports.NavigationOpenHomePicker)}, | |
| 556 | 569 | }} | |
| 557 | 570 | local2 := &recordingTransport{recvs: []recvItem{ | |
| 558 | 571 | {f: welcome("local", localLifecycle)}, | |
| @@ -578,6 +591,414 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin | |||
| 578 | 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 | 1002 | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | |
| 582 | 1003 | term := newRunTerminal() | |
| 583 | 1004 | defer term.in.unblock() | |
| @@ -622,7 +1043,7 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) | |||
| 622 | 1043 | }} | |
| 623 | 1044 | local3 := &recordingTransport{recvs: []recvItem{ | |
| 624 | 1045 | {f: welcome("test", localLifecycle, 11)}, | |
| 625 | - | {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationBack))}, | |
| 1046 | + | {f: navigationDirectiveFrame(ports.NavigationBack)}, | |
| 626 | 1047 | }} | |
| 627 | 1048 | remote1 := &recordingTransport{recvs: []recvItem{ | |
| 628 | 1049 | {f: welcome("remote-manual", remoteLifecycle, 22)}, | |
| @@ -636,7 +1057,7 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) | |||
| 636 | 1057 | {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{ | |
| 637 | 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 | 1062 | remote3 := &recordingTransport{recvs: []recvItem{ | |
| 642 | 1063 | {f: welcome("remote-new", remoteNewLifecycle, 22)}, | |
| @@ -667,6 +1088,14 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) | |||
| 667 | 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 | 1099 | func mustMarshalCommittedIdentity(identity ports.CommittedRouteIdentity) []byte { | |
| 671 | 1100 | payload, err := ports.MarshalCommittedRouteIdentity(identity) | |
| 672 | 1101 | if err != nil { | |
| @@ -1132,16 +1561,23 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 1132 | 1561 | } | |
| 1133 | 1562 | ||
| 1134 | 1563 | 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() | |
| 1139 | 1571 | } | |
| 1140 | 1572 | ||
| 1141 | 1573 | func (t *recordingTransport) Send(f ports.Frame) error { | |
| 1142 | 1574 | t.mu.Lock() | |
| 1143 | 1575 | t.sends = append(t.sends, f) | |
| 1576 | + | onSend := t.onSend | |
| 1144 | 1577 | t.mu.Unlock() | |
| 1578 | + | if onSend != nil { | |
| 1579 | + | onSend(f) | |
| 1580 | + | } | |
| 1145 | 1581 | return nil | |
| 1146 | 1582 | } | |
| 1147 | 1583 | ||
| @@ -1153,15 +1589,24 @@ func (t *recordingTransport) Sends() []ports.Frame { | |||
| 1153 | 1589 | ||
| 1154 | 1590 | func (t *recordingTransport) Recv() (ports.Frame, error) { | |
| 1155 | 1591 | if len(t.recvs) == 0 { | |
| 1592 | + | if t.stall != nil { | |
| 1593 | + | <-t.stall | |
| 1594 | + | } | |
| 1156 | 1595 | return ports.Frame{}, io.EOF | |
| 1157 | 1596 | } | |
| 1158 | 1597 | it := t.recvs[0] | |
| 1159 | 1598 | t.recvs = t.recvs[1:] | |
| 1599 | + | if it.wait != nil { | |
| 1600 | + | <-it.wait | |
| 1601 | + | } | |
| 1160 | 1602 | return it.f, it.err | |
| 1161 | 1603 | } | |
| 1162 | 1604 | ||
| 1163 | 1605 | func (t *recordingTransport) Close() error { | |
| 1164 | 1606 | t.closed.Add(1) | |
| 1607 | + | if t.onClose != nil { | |
| 1608 | + | t.onClose() | |
| 1609 | + | } | |
| 1165 | 1610 | return nil | |
| 1166 | 1611 | } | |
| 1167 | 1612 | ||
| @@ -1171,17 +1616,31 @@ type runTerminal struct { | |||
| 1171 | 1616 | rawCount atomic.Int32 | |
| 1172 | 1617 | restoreCount atomic.Int32 | |
| 1173 | 1618 | resizeCh chan domain.Size | |
| 1619 | + | sizeMu sync.Mutex | |
| 1620 | + | size domain.Size | |
| 1174 | 1621 | } | |
| 1175 | 1622 | ||
| 1176 | 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 | 1630 | func (t *runTerminal) EnterRaw() (func() error, error) { | |
| 1181 | 1631 | t.rawCount.Add(1) | |
| 1182 | 1632 | 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 | 1644 | func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh } | |
| 1186 | 1645 | func (t *runTerminal) In() io.Reader { return t.in } | |
| 1187 | 1646 | 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 | continue | 72 | 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.Frame | 119 | + | f ports.Frame |
| 115 | - | err error | 120 | + | 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.err | 147 | return it.f, it.err | ||
| 135 | case <-done: | 148 | case <-done: | ||
| 136 | return ports.Frame{}, io.EOF | 149 | 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.Mutex | 1564 | + | mu sync.Mutex |
| 1136 | - | recvs []recvItem | 1565 | + | recvs []recvItem |
| 1137 | - | sends []ports.Frame | 1566 | + | sends []ports.Frame |
| 1138 | - | closed atomic.Int32 | 1567 | + | 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 nil | 1581 | 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.EOF | 1595 | 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.err | 1602 | 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 nil | 1610 | return nil | ||
| 1166 | } | 1611 | } | ||
| 1167 | 1612 | ||||
| @@ -1171,17 +1616,31 @@ type runTerminal struct { | |||||
| 1171 | rawCount atomic.Int32 | 1616 | rawCount atomic.Int32 | ||
| 1172 | restoreCount atomic.Int32 | 1617 | restoreCount atomic.Int32 | ||
| 1173 | resizeCh chan domain.Size | 1618 | 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 }, nil | 1632 | 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 | |||
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | 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 | 90 | if err := ctx.Err(); err != nil { | |
| 87 | 91 | _ = transport.Close() | |
| 88 | 92 | return err | |
| 89 | 93 | } | |
| 90 | 94 | completed := make(chan error, 1) | |
| 91 | 95 | 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(): | |
| 95 | 110 | _ = 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() | |
| 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 | 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 | 123 | // The dial context only bounds startup. A successful carriage outlives the | |
| 109 | 124 | // handshake context; canceling that context after Welcome must not kill SSH. | |
| 110 | 125 | dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| 111 | - | result := make(chan struct { | |
| 126 | + | type dialResult struct { | |
| 112 | 127 | transport ports.Transport | |
| 113 | 128 | err error | |
| 114 | - | }, 1) | |
| 129 | + | } | |
| 130 | + | result := make(chan dialResult, 1) | |
| 115 | 131 | go func() { | |
| 116 | 132 | 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} | |
| 126 | 134 | }() | |
| 127 | - | select { | |
| 128 | - | case result := <-result: | |
| 129 | - | return result.transport, result.err | |
| 130 | - | case <-ctx.Done(): | |
| 135 | + | abandon := func() { | |
| 131 | 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 | ||
| @@ -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 err | 92 | 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 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() | |||
| 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 the | 123 | // 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.Transport | 127 | transport ports.Transport | ||
| 113 | err error | 128 | 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 { | |||
| 100 | 100 | pendingRouteIdentity bool | |
| 101 | 101 | samePeerOfferMu sync.Mutex | |
| 102 | 102 | samePeerOffer *ports.ExactSessionTarget | |
| 103 | + | parkedRouteMu sync.Mutex | |
| 104 | + | parkedRoute *parkedRouteLease | |
| 105 | + | parkedRouteOutput atomic.Bool | |
| 106 | + | parkedRouteFullPending atomic.Bool | |
| 103 | 107 | routeAttentionSubscription ports.RouteAttentionSubscription | |
| 104 | 108 | // routeCreatedSession marks a session created by this attachment's route. | |
| 105 | 109 | // 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 | 518 | if !ac.transportSnapshotCurrent(expected) { | |
| 515 | 519 | return expected.transport, errTransportReplaced | |
| 516 | 520 | } | |
| 521 | + | if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() { | |
| 522 | + | return expected.transport, nil | |
| 523 | + | } | |
| 517 | 524 | ac.output.lockView() | |
| 518 | 525 | defer ac.output.unlockView() | |
| 519 | 526 | frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load()) | |
| @@ -528,6 +535,9 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po | |||
| 528 | 535 | if !ac.transportSnapshotCurrent(expected) { | |
| 529 | 536 | return errTransportReplaced | |
| 530 | 537 | } | |
| 538 | + | if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() { | |
| 539 | + | return nil | |
| 540 | + | } | |
| 531 | 541 | ac.output.lockView() | |
| 532 | 542 | defer ac.output.unlockView() | |
| 533 | 543 | frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load()) | |
| @@ -784,7 +794,8 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *ren | |||
| 784 | 794 | // outputStateStream publishes capacity atomically. Do not take | |
| 785 | 795 | // attached.sendMu here: a slow transport may be holding it for an | |
| 786 | 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 | 801 | installAttachmentRenderCoordinator(entry, rc) | |
| @@ -100,6 +100,10 @@ type attachedClient struct { | |||||
| 100 | pendingRouteIdentity bool | 100 | pendingRouteIdentity bool | ||
| 101 | samePeerOfferMu sync.Mutex | 101 | samePeerOfferMu sync.Mutex | ||
| 102 | samePeerOffer *ports.ExactSessionTarget | 102 | samePeerOffer *ports.ExactSessionTarget | ||
| 103 | + | parkedRouteMu sync.Mutex | |||
| 104 | + | parkedRoute *parkedRouteLease | |||
| 105 | + | parkedRouteOutput atomic.Bool | |||
| 106 | + | parkedRouteFullPending atomic.Bool | |||
| 103 | routeAttentionSubscription ports.RouteAttentionSubscription | 107 | 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 empty | 109 | // 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, errTransportReplaced | 519 | 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 errTransportReplaced | 536 | 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 take | 794 | // outputStateStream publishes capacity atomically. Do not take | ||
| 785 | // attached.sendMu here: a slow transport may be holding it for an | 795 | // 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 | |||
| 133 | 133 | break | |
| 134 | 134 | } | |
| 135 | 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 | 143 | case ports.MsgRecentRouteSnapshot: | |
| 137 | 144 | snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload) | |
| 138 | 145 | if derr != nil { | |
| @@ -133,6 +133,13 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||||
| 133 | break | 133 | 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 | |||
| 66 | 66 | return | |
| 67 | 67 | } | |
| 68 | 68 | ac.clearSamePeerOffer() | |
| 69 | + | ac.clearParkedRoute() | |
| 69 | 70 | if d.afterClientGoneDetach != nil { | |
| 70 | 71 | d.afterClientGoneDetach() | |
| 71 | 72 | } | |
| @@ -172,6 +173,7 @@ func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentConnectionTok | |||
| 172 | 173 | ||
| 173 | 174 | func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) { | |
| 174 | 175 | ac.clearSamePeerOffer() | |
| 176 | + | ac.clearParkedRoute() | |
| 175 | 177 | name := sess.nameSnapshot() | |
| 176 | 178 | if rc := sess.renderCoordinator(); rc != nil { | |
| 177 | 179 | rc.noteDetach(ac) | |
| @@ -66,6 +66,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port | |||||
| 66 | return | 66 | 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 | |||
| 268 | 268 | if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) { | |
| 269 | 269 | return errAttachmentTransition | |
| 270 | 270 | } | |
| 271 | + | if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() { | |
| 272 | + | return nil | |
| 273 | + | } | |
| 271 | 274 | if !beginClipboardOwnerSend(item.owner, ticket, expected) { | |
| 272 | 275 | return errAttachmentTransition | |
| 273 | 276 | } | |
| @@ -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 errAttachmentTransition | 269 | 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 errAttachmentTransition | 275 | 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 | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 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 | 122 | if payload == nil { | |
| 123 | + | rollback() | |
| 108 | 124 | 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 | 133 | 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 errAttachmentTransition | 124 | 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) { | |||
| 855 | 855 | t.Run(tt.name, func(t *testing.T) { | |
| 856 | 856 | d := newRemotePickerDaemon(nil) | |
| 857 | 857 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 858 | + | if tt.action == ports.NavigationOpenHomePicker { | |
| 859 | + | ac.navigationCapabilities = ports.NavigationCapabilityHomePicker | |
| 860 | + | } | |
| 858 | 861 | token := sess.attachmentToken(ac, ac.transport()) | |
| 859 | 862 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 860 | 863 | require.True(t, admitted) | |
| @@ -862,9 +865,14 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) { | |||
| 862 | 865 | require.NoError(t, d.sendNavigationActionForAttachment(token, tt.action)) | |
| 863 | 866 | frame := receiveRemotePicker(t, sends, "navigation action") | |
| 864 | 867 | require.Equal(t, ports.MsgNavigationAction, frame.Type) | |
| 865 | - | got, err := ports.UnmarshalNavigationAction(frame.Payload) | |
| 868 | + | directive, err := ports.UnmarshalNavigationDirective(frame.Payload) | |
| 866 | 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 | } | |
| @@ -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 | |||
| 329 | 329 | entry.core().mu.Lock() | |
| 330 | 330 | _, owned := entry.core().attachments[ac] | |
| 331 | 331 | entry.core().mu.Unlock() | |
| 332 | - | if !owned || ac.currentAttachmentSession() != entry { | |
| 332 | + | if !owned || ac.currentAttachmentSession() != entry || ac.parkedRouteOutput.Load() { | |
| 333 | 333 | ac.sendMu.Unlock() | |
| 334 | 334 | return paintRejected | |
| 335 | 335 | } | |
| 336 | + | if ac.parkedRouteFullPending.Load() { | |
| 337 | + | reset = true | |
| 338 | + | } | |
| 336 | 339 | if lease != nil { | |
| 337 | 340 | rc := attachmentRenderCoordinator(entry) | |
| 338 | 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 | 458 | if ac.renderStages.compose != nil { | |
| 456 | 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 | 465 | return paintEmitted | |
| 460 | 466 | } | |
| 461 | 467 | ||
| @@ -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 paintRejected | 334 | 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 paintEmitted | 465 | return paintEmitted | ||
| 460 | } | 466 | } | ||
| 461 | 467 | ||||