Coherent session navigation (protocol v42 + inventory relay) (#263)
* refactor(daemon): share session inventory capture across palette picker catalog * feat(protocol): add navigation inventory semantic types and capability * feat(protocol): wire inventory 42-47, version 42, daemon control source * chore(navigation): version 42 references and unknown-capability fixtures * feat(client): shared navigation transition with distinct authority and return route * test(harness): migrate acceptance to Arch image with local and two remote roles * test(navigation): characterize fallback capture, known-red relay gap, D5 table * refactor(client): route creation and recent fallbacks through navigationTransition * feat(client): inventory relay core with dial-only local control source * feat(daemon): palette inventory demand, publication admission, imported selection * feat(client): inventory relay attempt wiring with poll, resolve, and handoff * feat(nav): read-only remote vision tags with attach guard plus compat matrix * docs(nav): session navigation with inventory relay and compat * fix(nav): senior review blockers and correctness findings * fix(nav): copilot review findings on relay prod path and failure durability * fix(navigation): qualify palette origins and preserve exact session returns Retire resume credentials from previous routes when an attachment moves between sessions. Deduplicate imported local destinations against exact history identities and correlate inventory handoffs with UI actions. * fix(palette): qualify hybrid sessions with at-sign origins * refactor(navigation): simplify recovery state and replace obsolete acceptance harness Capture inventory recovery after serving metadata rebasing, cancel closed snapshot queries without releasing their physical slot, and emit a single palette close. Replace the unused controller and pending matrix with executable UI-driver navigation acceptance. * fix(client): correlate inventory handoff failures and settle at commit Settle the inventory transition when the destination's initial publications commit instead of when the long-lived attachment exits, so a later transport failure reconnects the active destination. Emit correlated stale-identity failures for selections retired before arrival and for resolves retired in flight. Retain the palette selection in the transition and publish NavigationInventoryNavigationFailed to the restored serving daemon when the destination dial fails.
- commit
- 680dbb128af4796c94927906b6a0b54bd462a1e2
- author
- bnema · · 2026-09-08 10:50 UTC
- committer
- GitHub · · 2026-09-08 10:50 UTC
- parent
- 1de3199
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
67 files · +5525 -2478
M docs/architecture.md +1 -1
| @@ -56,4 +56,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||
| 56 | 56 | - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`. | |
| 57 | 57 | - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`. | |
| 58 | 58 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | |
| 59 | - | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `41`, including UI view context and processing-fence messages). | |
| 59 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `42`, including navigation inventory, UI view context and processing-fence messages). | |
A docs/session-navigation.md +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | # Session navigation | |
| 2 | + | ||
| 3 | + | Palette destinations use bare session names in local-only mode. With remote | |
| 4 | + | hosts configured, local sessions use `session@local` and remote sessions use | |
| 5 | + | `session@host`, relative to the attaching client regardless of the serving | |
| 6 | + | daemon. Offline hosts still enable qualified local labels. Local inventory | |
| 7 | + | rows replace history rows for the same exact session; | |
| 8 | + | homonyms with different origins or lifecycles remain distinct. A resume | |
| 9 | + | credential belongs only to the route currently holding its attachment, so | |
| 10 | + | older sessions in history use exact attachment after a same-daemon switch. | |
| 11 | + | ||
| 12 | + | The command palette lists sessions from two origins: native results owned by | |
| 13 | + | the serving daemon (active, stopped, remote, recent routes) and imported rows | |
| 14 | + | relayed from the attaching client's own local daemon. Imported rows carry | |
| 15 | + | only opaque keys plus display metadata; resolution happens through a | |
| 16 | + | dial-only local control connection in the same interaction namespace. | |
| 17 | + | ||
| 18 | + | ## Inventory relay | |
| 19 | + | ||
| 20 | + | While attached to a remote daemon, the client runs one relay per attachment | |
| 21 | + | when it holds a committed local route. The relay dials only the local | |
| 22 | + | control socket: no Hello, no session creation, no attachment, no daemon | |
| 23 | + | startup. | |
| 24 | + | ||
| 25 | + | - The serving palette sends an open demand with an interaction generation | |
| 26 | + | when it opens, and a close demand on every successful close (execute, | |
| 27 | + | select, or cancel). Demands are capability-gated on both sides: the | |
| 28 | + | client advertises inventory in Hello only while a committed local route | |
| 29 | + | and the dial-only control source are available. | |
| 30 | + | - While open, the relay polls the local source once per second with at most | |
| 31 | + | one query in flight plus one coalesced latest update. Only changed | |
| 32 | + | snapshots publish, with a monotonic publication generation per | |
| 33 | + | interaction. Admitted keys and retirements never cross interactions. | |
| 34 | + | - Enter on an imported row sends a selection with the opaque keys and the | |
| 35 | + | admitted input cause, which stays zero for ordinary keyboard input. | |
| 36 | + | Selection crosses before the close demand on the guarded serving | |
| 37 | + | connection. | |
| 38 | + | - Resolve revalidates the key against a fresh capture and returns a | |
| 39 | + | non-mutating attach target. Endpoint-empty targets resolve against the | |
| 40 | + | committed local route as authority; the serving route stays the recovery | |
| 41 | + | destination through the inventory transition (settle once, restore on | |
| 42 | + | every destination failure). | |
| 43 | + | - Failures (stale identity, gone source, incompatible, invalid target, | |
| 44 | + | navigation or restore failure) surface as palette feedback with the exact | |
| 45 | + | cause while the originating interaction is still open; stale failures | |
| 46 | + | from closed interactions drop. Native commands stay usable throughout. | |
| 47 | + | ||
| 48 | + | ## Remote vision | |
| 49 | + | ||
| 50 | + | Remote-origin rows display availability tags (`down`, `broken`, stopped) | |
| 51 | + | inline so they never look attachable. Remote vision is read-only: Enter on | |
| 52 | + | a remote row reports feedback and emits no selection, and selections for | |
| 53 | + | non-local sources reject before resolve. Non-OK groups project no rows. | |
| 54 | + | ||
| 55 | + | ## Compatibility | |
| 56 | + | ||
| 57 | + | Demands gate both sides on the negotiated inventory capability: a serving | |
| 58 | + | palette that never receives the bit never demands, and a client without a | |
| 59 | + | committed local route or control dialer ignores demands that decode. With | |
| 60 | + | no demands the relay stays idle with zero control dials, and local | |
| 61 | + | attachments ignore demands even when they decode. Unknown frame types | |
| 62 | + | stay ignored by the receive pump. Cross-version attachment still fails at | |
| 63 | + | the strict-equality handshake; graceful degradation applies only within | |
| 64 | + | one negotiated version. | |
M internal/adapters/sessionwire/client.go +38 -1
| @@ -111,6 +111,12 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 111 | 111 | return wire.UnmarshalSamePeerSwitchFailure(frame.Payload) | |
| 112 | 112 | case wire.MsgParkedRouteResponse: | |
| 113 | 113 | return wire.UnmarshalParkedRouteResponse(frame.Payload) | |
| 114 | + | case wire.MsgNavigationInventoryResponse: | |
| 115 | + | return wire.UnmarshalNavigationInventoryResponse(frame.Payload) | |
| 116 | + | case wire.MsgNavigationInventoryDemand: | |
| 117 | + | return wire.UnmarshalNavigationInventoryDemand(frame.Payload) | |
| 118 | + | case wire.MsgNavigationInventorySelection: | |
| 119 | + | return wire.UnmarshalNavigationInventorySelection(frame.Payload) | |
| 114 | 120 | case wire.MsgUIReceipt: | |
| 115 | 121 | return wire.UnmarshalUIReceipt(frame.Payload) | |
| 116 | 122 | case wire.MsgUIViewUpdate: | |
| @@ -120,7 +126,8 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 120 | 126 | wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest, | |
| 121 | 127 | wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription, | |
| 122 | 128 | wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest, | |
| 123 | - | wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence: | |
| 129 | + | wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence, | |
| 130 | + | wire.MsgNavigationInventoryRequest, wire.MsgNavigationInventoryPublication, wire.MsgNavigationInventoryFailure: | |
| 124 | 131 | return nil, ErrWrongDirection | |
| 125 | 132 | default: | |
| 126 | 133 | return nil, ErrUnknownMessageType | |
| @@ -186,6 +193,24 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 186 | 193 | return wire.Frame{}, ErrInvalidMessage | |
| 187 | 194 | } | |
| 188 | 195 | return wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: payload}, nil | |
| 196 | + | case protocol.NavigationInventoryRequest: | |
| 197 | + | payload := wire.MarshalNavigationInventoryRequest(m) | |
| 198 | + | if payload == nil { | |
| 199 | + | return wire.Frame{}, ErrInvalidMessage | |
| 200 | + | } | |
| 201 | + | return wire.Frame{Type: wire.MsgNavigationInventoryRequest, Payload: payload}, nil | |
| 202 | + | case protocol.NavigationInventoryPublication: | |
| 203 | + | payload := wire.MarshalNavigationInventoryPublication(m) | |
| 204 | + | if payload == nil { | |
| 205 | + | return wire.Frame{}, ErrInvalidMessage | |
| 206 | + | } | |
| 207 | + | return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: payload}, nil | |
| 208 | + | case protocol.NavigationInventoryFailure: | |
| 209 | + | payload := wire.MarshalNavigationInventoryFailure(m) | |
| 210 | + | if payload == nil { | |
| 211 | + | return wire.Frame{}, ErrInvalidMessage | |
| 212 | + | } | |
| 213 | + | return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: payload}, nil | |
| 189 | 214 | case protocol.RecentRouteSnapshot: | |
| 190 | 215 | payload, err := wire.MarshalRecentRouteSnapshot(m) | |
| 191 | 216 | return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: payload}, err | |
| @@ -267,6 +292,18 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 267 | 292 | if m != nil { | |
| 268 | 293 | return encodeClient(*m) | |
| 269 | 294 | } | |
| 295 | + | case *protocol.NavigationInventoryRequest: | |
| 296 | + | if m != nil { | |
| 297 | + | return encodeClient(*m) | |
| 298 | + | } | |
| 299 | + | case *protocol.NavigationInventoryPublication: | |
| 300 | + | if m != nil { | |
| 301 | + | return encodeClient(*m) | |
| 302 | + | } | |
| 303 | + | case *protocol.NavigationInventoryFailure: | |
| 304 | + | if m != nil { | |
| 305 | + | return encodeClient(*m) | |
| 306 | + | } | |
| 270 | 307 | case *protocol.RecentRouteSnapshot: | |
| 271 | 308 | if m != nil { | |
| 272 | 309 | return encodeClient(*m) | |
M internal/adapters/sessionwire/server.go +42 -1
| @@ -55,6 +55,10 @@ func (c *serverConnection) ReceiveClient() (protocol.ClientMessage, error) { | |||
| 55 | 55 | failure.Kind = protocol.DecodeMessageKill | |
| 56 | 56 | case wire.MsgRemotePreviewRequest: | |
| 57 | 57 | failure.Kind = protocol.DecodeMessageRemotePreview | |
| 58 | + | case wire.MsgNavigationInventoryRequest: | |
| 59 | + | failure.Kind = protocol.DecodeMessageNavigationInventory | |
| 60 | + | failure.Version, _ = wire.PeekNavigationInventoryVersion(frame.Payload) | |
| 61 | + | failure.RequestID, failure.HasRequestID = wire.PeekNavigationInventoryRequestID(frame.Payload) | |
| 58 | 62 | } | |
| 59 | 63 | return nil, failure | |
| 60 | 64 | } | |
| @@ -228,6 +232,12 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) { | |||
| 228 | 232 | return wire.UnmarshalSamePeerSwitchRequest(frame.Payload) | |
| 229 | 233 | case wire.MsgParkedRouteRequest: | |
| 230 | 234 | return wire.UnmarshalParkedRouteRequest(frame.Payload) | |
| 235 | + | case wire.MsgNavigationInventoryRequest: | |
| 236 | + | return wire.UnmarshalNavigationInventoryRequest(frame.Payload) | |
| 237 | + | case wire.MsgNavigationInventoryPublication: | |
| 238 | + | return wire.UnmarshalNavigationInventoryPublication(frame.Payload) | |
| 239 | + | case wire.MsgNavigationInventoryFailure: | |
| 240 | + | return wire.UnmarshalNavigationInventoryFailure(frame.Payload) | |
| 231 | 241 | case wire.MsgRecentRouteSnapshot: | |
| 232 | 242 | return wire.UnmarshalRecentRouteSnapshot(frame.Payload) | |
| 233 | 243 | case wire.MsgRouteNavigationFailure: | |
| @@ -241,7 +251,8 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) { | |||
| 241 | 251 | wire.MsgAttachTarget, wire.MsgRemotePreviewResponse, | |
| 242 | 252 | wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession, | |
| 243 | 253 | wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse, | |
| 244 | - | wire.MsgUIReceipt, wire.MsgUIViewUpdate: | |
| 254 | + | wire.MsgUIReceipt, wire.MsgUIViewUpdate, wire.MsgNavigationInventoryResponse, | |
| 255 | + | wire.MsgNavigationInventoryDemand, wire.MsgNavigationInventorySelection: | |
| 245 | 256 | return nil, ErrWrongDirection | |
| 246 | 257 | default: | |
| 247 | 258 | return nil, ErrUnknownMessageType | |
| @@ -320,6 +331,24 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 320 | 331 | return wire.Frame{}, ErrInvalidMessage | |
| 321 | 332 | } | |
| 322 | 333 | return wire.Frame{Type: wire.MsgParkedRouteResponse, Payload: payload}, nil | |
| 334 | + | case protocol.NavigationInventoryResponse: | |
| 335 | + | payload := wire.MarshalNavigationInventoryResponse(m) | |
| 336 | + | if payload == nil { | |
| 337 | + | return wire.Frame{}, ErrInvalidMessage | |
| 338 | + | } | |
| 339 | + | return wire.Frame{Type: wire.MsgNavigationInventoryResponse, Payload: payload}, nil | |
| 340 | + | case protocol.NavigationInventoryDemand: | |
| 341 | + | payload := wire.MarshalNavigationInventoryDemand(m) | |
| 342 | + | if payload == nil { | |
| 343 | + | return wire.Frame{}, ErrInvalidMessage | |
| 344 | + | } | |
| 345 | + | return wire.Frame{Type: wire.MsgNavigationInventoryDemand, Payload: payload}, nil | |
| 346 | + | case protocol.NavigationInventorySelection: | |
| 347 | + | payload := wire.MarshalNavigationInventorySelection(m) | |
| 348 | + | if payload == nil { | |
| 349 | + | return wire.Frame{}, ErrInvalidMessage | |
| 350 | + | } | |
| 351 | + | return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: payload}, nil | |
| 323 | 352 | case *protocol.Welcome: | |
| 324 | 353 | if m != nil { | |
| 325 | 354 | return encodeServer(*m) | |
| @@ -396,6 +425,18 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 396 | 425 | if m != nil { | |
| 397 | 426 | return encodeServer(*m) | |
| 398 | 427 | } | |
| 428 | + | case *protocol.NavigationInventoryResponse: | |
| 429 | + | if m != nil { | |
| 430 | + | return encodeServer(*m) | |
| 431 | + | } | |
| 432 | + | case *protocol.NavigationInventoryDemand: | |
| 433 | + | if m != nil { | |
| 434 | + | return encodeServer(*m) | |
| 435 | + | } | |
| 436 | + | case *protocol.NavigationInventorySelection: | |
| 437 | + | if m != nil { | |
| 438 | + | return encodeServer(*m) | |
| 439 | + | } | |
| 399 | 440 | default: | |
| 400 | 441 | return wire.Frame{}, ErrWrongDirection | |
| 401 | 442 | } | |
A internal/app/dial_only_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // TestDialOnlyLocalDialerNeverStartsDaemon pins the D2 source contract: the | |
| 10 | + | // inventory control dialer performs a bare socket dial with no ensure, spawn, | |
| 11 | + | // or attachment path. Against an isolated directory it must fail fast with a | |
| 12 | + | // dial error instead of implicitly starting a daemon. | |
| 13 | + | func TestDialOnlyLocalDialerNeverStartsDaemon(t *testing.T) { | |
| 14 | + | dir := t.TempDir() | |
| 15 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 16 | + | defer cancel() | |
| 17 | + | start := time.Now() | |
| 18 | + | dialer := dialOnlyLocalDialer{dir: dir} | |
| 19 | + | _, err := dialer.Dial(ctx) | |
| 20 | + | if err == nil { | |
| 21 | + | t.Fatal("dial-only control dial against an empty dir must fail") | |
| 22 | + | } | |
| 23 | + | if elapsed := time.Since(start); elapsed > 10*time.Second { | |
| 24 | + | t.Fatalf("dial-only control dial took %v; must not retry or spawn", elapsed) | |
| 25 | + | } | |
| 26 | + | plain := dialOnlyLocalDialer{dir: dir, observer: nil} | |
| 27 | + | if _, err := plain.Dial(context.Background()); err == nil { | |
| 28 | + | t.Fatal("dial-only control dial without observer must also fail fast") | |
| 29 | + | } | |
| 30 | + | } | |
M internal/app/run.go +19 -0
| @@ -1046,6 +1046,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1046 | 1046 | } | |
| 1047 | 1047 | err = runClient(ctx, client.Dependencies{ | |
| 1048 | 1048 | Dialer: sessionwire.NewClientDialer(dialer), | |
| 1049 | + | LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}), | |
| 1049 | 1050 | Terminal: clientTerminal(deps), | |
| 1050 | 1051 | Clock: clientClock(deps), | |
| 1051 | 1052 | DisableCapabilityProbe: deps.disableCapabilityProbe, | |
| @@ -1075,6 +1076,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1075 | 1076 | } | |
| 1076 | 1077 | err = runClient(ctx, client.Dependencies{ | |
| 1077 | 1078 | Dialer: sessionwire.NewClientDialer(localDialer()), | |
| 1079 | + | LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}), | |
| 1078 | 1080 | Terminal: clientTerminal(deps), | |
| 1079 | 1081 | Clock: clientClock(deps), | |
| 1080 | 1082 | DisableCapabilityProbe: deps.disableCapabilityProbe, | |
| @@ -1136,6 +1138,23 @@ type localDaemonDialer struct { | |||
| 1136 | 1138 | environment []string | |
| 1137 | 1139 | } | |
| 1138 | 1140 | ||
| 1141 | + | // dialOnlyLocalDialer is the inventory control source: a typed dial-only | |
| 1142 | + | // connection to the local daemon socket. Unlike localDaemonDialer it never | |
| 1143 | + | // ensures, spawns, or starts the daemon and never creates an attachment: a | |
| 1144 | + | // missing socket reports local control unavailability instead of starting | |
| 1145 | + | // a daemon implicitly. | |
| 1146 | + | type dialOnlyLocalDialer struct { | |
| 1147 | + | dir string | |
| 1148 | + | observer ports.SerializedRuntimeObserver | |
| 1149 | + | } | |
| 1150 | + | ||
| 1151 | + | func (d dialOnlyLocalDialer) Dial(ctx context.Context) (wire.Transport, error) { | |
| 1152 | + | if d.observer != nil { | |
| 1153 | + | return ipc.DialContext(ctx, d.dir, ipc.WithRuntimeObserver(d.observer)) | |
| 1154 | + | } | |
| 1155 | + | return ipc.DialContext(ctx, d.dir) | |
| 1156 | + | } | |
| 1157 | + | ||
| 1139 | 1158 | func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) { | |
| 1140 | 1159 | dial := realDial | |
| 1141 | 1160 | if d.observer != nil { | |
M internal/domain/notice.go +23 -21
| @@ -42,6 +42,7 @@ const ( | |||
| 42 | 42 | NoticeAutoResume | |
| 43 | 43 | NoticeConnection | |
| 44 | 44 | NoticeUser | |
| 45 | + | NoticeNavigationInventory | |
| 45 | 46 | ||
| 46 | 47 | // noticeCodeLimit is an append-only declaration sentinel, not a persisted | |
| 47 | 48 | // valid notice code. Keep it last when adding a NoticeCode. | |
| @@ -49,27 +50,28 @@ const ( | |||
| 49 | 50 | ) | |
| 50 | 51 | ||
| 51 | 52 | var noticeSlugs = map[NoticeCode]string{ | |
| 52 | - | NoticeInternal: "internal", | |
| 53 | - | NoticePaneSpawn: "pane-spawn", | |
| 54 | - | NoticeTabSpawn: "tab-spawn", | |
| 55 | - | NoticeFloatingSpawn: "floating-spawn", | |
| 56 | - | NoticeSessionSpawn: "session-spawn", | |
| 57 | - | NoticeLayoutTooSmall: "layout-too-small", | |
| 58 | - | NoticePaneNotFound: "pane-not-found", | |
| 59 | - | NoticeSessionUnavailable: "session-unavailable", | |
| 60 | - | NoticePersistDisabled: "persist-disabled", | |
| 61 | - | NoticeSnapshotWrite: "snapshot-write", | |
| 62 | - | NoticeSnapshotRestore: "snapshot-restore", | |
| 63 | - | NoticeSnapshotSaturated: "snapshot-saturated", | |
| 64 | - | NoticePersistDelete: "persist-delete", | |
| 65 | - | NoticeConfigReload: "config-reload", | |
| 66 | - | NoticeInputDropped: "input-dropped", | |
| 67 | - | NoticeResizeFailed: "resize-failed", | |
| 68 | - | NoticeClipboard: "clipboard", | |
| 69 | - | NoticeClipboardTooLarge: "clipboard-too-large", | |
| 70 | - | NoticeAutoResume: "auto-resume", | |
| 71 | - | NoticeConnection: "connection", | |
| 72 | - | NoticeUser: "user", | |
| 53 | + | NoticeInternal: "internal", | |
| 54 | + | NoticePaneSpawn: "pane-spawn", | |
| 55 | + | NoticeTabSpawn: "tab-spawn", | |
| 56 | + | NoticeFloatingSpawn: "floating-spawn", | |
| 57 | + | NoticeSessionSpawn: "session-spawn", | |
| 58 | + | NoticeLayoutTooSmall: "layout-too-small", | |
| 59 | + | NoticePaneNotFound: "pane-not-found", | |
| 60 | + | NoticeSessionUnavailable: "session-unavailable", | |
| 61 | + | NoticePersistDisabled: "persist-disabled", | |
| 62 | + | NoticeSnapshotWrite: "snapshot-write", | |
| 63 | + | NoticeSnapshotRestore: "snapshot-restore", | |
| 64 | + | NoticeSnapshotSaturated: "snapshot-saturated", | |
| 65 | + | NoticePersistDelete: "persist-delete", | |
| 66 | + | NoticeConfigReload: "config-reload", | |
| 67 | + | NoticeInputDropped: "input-dropped", | |
| 68 | + | NoticeResizeFailed: "resize-failed", | |
| 69 | + | NoticeClipboard: "clipboard", | |
| 70 | + | NoticeClipboardTooLarge: "clipboard-too-large", | |
| 71 | + | NoticeAutoResume: "auto-resume", | |
| 72 | + | NoticeConnection: "connection", | |
| 73 | + | NoticeUser: "user", | |
| 74 | + | NoticeNavigationInventory: "navigation-inventory", | |
| 73 | 75 | } | |
| 74 | 76 | ||
| 75 | 77 | func (c NoticeCode) String() string { | |
M internal/protocol/decode.go +1 -0
| @@ -21,6 +21,7 @@ const ( | |||
| 21 | 21 | DecodeMessageCommand | |
| 22 | 22 | DecodeMessageKill | |
| 23 | 23 | DecodeMessageRemotePreview | |
| 24 | + | DecodeMessageNavigationInventory | |
| 24 | 25 | ) | |
| 25 | 26 | ||
| 26 | 27 | // DecodeFailure describes rejected encoded input without retaining payload bytes. | |
M internal/protocol/messages.go +46 -40
| @@ -6,44 +6,50 @@ type ClientMessage interface{ clientMessage() } | |||
| 6 | 6 | // ServerMessage is a closed set of messages emitted by a session daemon. | |
| 7 | 7 | type ServerMessage interface{ serverMessage() } | |
| 8 | 8 | ||
| 9 | - | func (Hello) clientMessage() {} | |
| 10 | - | func (Input) clientMessage() {} | |
| 11 | - | func (Resize) clientMessage() {} | |
| 12 | - | func (Detach) clientMessage() {} | |
| 13 | - | func (Ping) clientMessage() {} | |
| 14 | - | func (List) clientMessage() {} | |
| 15 | - | func (Kill) clientMessage() {} | |
| 16 | - | func (Theme) clientMessage() {} | |
| 17 | - | func (Ack) clientMessage() {} | |
| 18 | - | func (ImagePush) clientMessage() {} | |
| 19 | - | func (ClientNotice) clientMessage() {} | |
| 20 | - | func (CommandRequest) clientMessage() {} | |
| 21 | - | func (OutputResetRequest) clientMessage() {} | |
| 22 | - | func (RemotePreviewRequest) clientMessage() {} | |
| 23 | - | func (RouteAttentionSubscription) clientMessage() {} | |
| 24 | - | func (SamePeerSwitchRequest) clientMessage() {} | |
| 25 | - | func (ParkedRouteRequest) clientMessage() {} | |
| 26 | - | func (RecentRouteSnapshot) clientMessage() {} | |
| 27 | - | func (RouteNavigationFailure) clientMessage() {} | |
| 28 | - | func (SessionCreationFailure) clientMessage() {} | |
| 29 | - | func (UIFence) clientMessage() {} | |
| 9 | + | func (Hello) clientMessage() {} | |
| 10 | + | func (Input) clientMessage() {} | |
| 11 | + | func (Resize) clientMessage() {} | |
| 12 | + | func (Detach) clientMessage() {} | |
| 13 | + | func (Ping) clientMessage() {} | |
| 14 | + | func (List) clientMessage() {} | |
| 15 | + | func (Kill) clientMessage() {} | |
| 16 | + | func (Theme) clientMessage() {} | |
| 17 | + | func (Ack) clientMessage() {} | |
| 18 | + | func (ImagePush) clientMessage() {} | |
| 19 | + | func (ClientNotice) clientMessage() {} | |
| 20 | + | func (CommandRequest) clientMessage() {} | |
| 21 | + | func (OutputResetRequest) clientMessage() {} | |
| 22 | + | func (RemotePreviewRequest) clientMessage() {} | |
| 23 | + | func (RouteAttentionSubscription) clientMessage() {} | |
| 24 | + | func (SamePeerSwitchRequest) clientMessage() {} | |
| 25 | + | func (ParkedRouteRequest) clientMessage() {} | |
| 26 | + | func (RecentRouteSnapshot) clientMessage() {} | |
| 27 | + | func (RouteNavigationFailure) clientMessage() {} | |
| 28 | + | func (SessionCreationFailure) clientMessage() {} | |
| 29 | + | func (UIFence) clientMessage() {} | |
| 30 | + | func (NavigationInventoryRequest) clientMessage() {} | |
| 31 | + | func (NavigationInventoryPublication) clientMessage() {} | |
| 32 | + | func (NavigationInventoryFailure) clientMessage() {} | |
| 30 | 33 | ||
| 31 | - | func (Welcome) serverMessage() {} | |
| 32 | - | func (ErrorMsg) serverMessage() {} | |
| 33 | - | func (Output) serverMessage() {} | |
| 34 | - | func (Detached) serverMessage() {} | |
| 35 | - | func (Pong) serverMessage() {} | |
| 36 | - | func (Sessions) serverMessage() {} | |
| 37 | - | func (CommandResult) serverMessage() {} | |
| 38 | - | func (NavigationDirective) serverMessage() {} | |
| 39 | - | func (AttachTarget) serverMessage() {} | |
| 40 | - | func (RemotePreview) serverMessage() {} | |
| 41 | - | func (CommittedRouteIdentity) serverMessage() {} | |
| 42 | - | func (RouteNavigationAction) serverMessage() {} | |
| 43 | - | func (RouteCreateSessionAction) serverMessage() {} | |
| 44 | - | func (RouteNavigationFailure) serverMessage() {} | |
| 45 | - | func (RoutePosition) serverMessage() {} | |
| 46 | - | func (SamePeerSwitchFailure) serverMessage() {} | |
| 47 | - | func (ParkedRouteResponse) serverMessage() {} | |
| 48 | - | func (UIReceipt) serverMessage() {} | |
| 49 | - | func (UIViewUpdate) serverMessage() {} | |
| 34 | + | func (Welcome) serverMessage() {} | |
| 35 | + | func (ErrorMsg) serverMessage() {} | |
| 36 | + | func (Output) serverMessage() {} | |
| 37 | + | func (Detached) serverMessage() {} | |
| 38 | + | func (Pong) serverMessage() {} | |
| 39 | + | func (Sessions) serverMessage() {} | |
| 40 | + | func (CommandResult) serverMessage() {} | |
| 41 | + | func (NavigationDirective) serverMessage() {} | |
| 42 | + | func (AttachTarget) serverMessage() {} | |
| 43 | + | func (RemotePreview) serverMessage() {} | |
| 44 | + | func (CommittedRouteIdentity) serverMessage() {} | |
| 45 | + | func (RouteNavigationAction) serverMessage() {} | |
| 46 | + | func (RouteCreateSessionAction) serverMessage() {} | |
| 47 | + | func (RouteNavigationFailure) serverMessage() {} | |
| 48 | + | func (RoutePosition) serverMessage() {} | |
| 49 | + | func (SamePeerSwitchFailure) serverMessage() {} | |
| 50 | + | func (ParkedRouteResponse) serverMessage() {} | |
| 51 | + | func (UIReceipt) serverMessage() {} | |
| 52 | + | func (UIViewUpdate) serverMessage() {} | |
| 53 | + | func (NavigationInventoryResponse) serverMessage() {} | |
| 54 | + | func (NavigationInventoryDemand) serverMessage() {} | |
| 55 | + | func (NavigationInventorySelection) serverMessage() {} | |
M internal/protocol/navigation.go +2 -1
| @@ -7,6 +7,7 @@ type NavigationCapabilities uint8 | |||
| 7 | 7 | const ( | |
| 8 | 8 | NavigationCapabilityHomePicker NavigationCapabilities = 1 << iota | |
| 9 | 9 | NavigationCapabilityBack | |
| 10 | + | NavigationCapabilityInventory | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | type StartupOverlay uint8 | |
| @@ -65,7 +66,7 @@ type ParkedRouteResponse struct { | |||
| 65 | 66 | } | |
| 66 | 67 | ||
| 67 | 68 | func validNavigationCapabilities(capabilities NavigationCapabilities) bool { | |
| 68 | - | return capabilities&^(NavigationCapabilityHomePicker|NavigationCapabilityBack) == 0 | |
| 69 | + | return capabilities&^(NavigationCapabilityHomePicker|NavigationCapabilityBack|NavigationCapabilityInventory) == 0 | |
| 69 | 70 | } | |
| 70 | 71 | ||
| 71 | 72 | func validStartupOverlay(overlay StartupOverlay) bool { | |
A internal/protocol/navigation_inventory.go +369 -0
| @@ -0,0 +1,369 @@ | |||
| 1 | + | package protocol | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "unicode/utf8" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Navigation inventory bounds. These are export limits for UI listings, not | |
| 10 | + | // limits on stored or running sessions. | |
| 11 | + | const ( | |
| 12 | + | NavigationInventoryMaxSourceGroups = 65 | |
| 13 | + | NavigationInventoryMaxLocalEntries = 4096 | |
| 14 | + | NavigationInventoryMaxRemoteEntriesPerSource = 256 | |
| 15 | + | NavigationInventoryMaxDisplayBytes = 256 | |
| 16 | + | NavigationInventoryMaxKeyBytes = 128 | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // NavigationInventoryLocalSourceKey identifies the control daemon's own | |
| 20 | + | // sessions inside inventory groups. | |
| 21 | + | const NavigationInventoryLocalSourceKey = "local" | |
| 22 | + | ||
| 23 | + | // NavigationInventoryRemoteSourcePrefix namespaces remote source keys so | |
| 24 | + | // relayed identifiers never expose raw configured endpoints and never | |
| 25 | + | // collide with the reserved local source. | |
| 26 | + | const NavigationInventoryRemoteSourcePrefix = "remote/" | |
| 27 | + | ||
| 28 | + | // NavigationInventoryOperation selects the control-source query. | |
| 29 | + | type NavigationInventoryOperation uint8 | |
| 30 | + | ||
| 31 | + | const ( | |
| 32 | + | NavigationInventorySnapshot NavigationInventoryOperation = 1 | |
| 33 | + | NavigationInventoryResolve NavigationInventoryOperation = 2 | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | // NavigationInventoryStatus is the overall source-control outcome. | |
| 37 | + | type NavigationInventoryStatus uint8 | |
| 38 | + | ||
| 39 | + | const ( | |
| 40 | + | NavigationInventoryOK NavigationInventoryStatus = 1 | |
| 41 | + | NavigationInventoryUnavailable NavigationInventoryStatus = 2 | |
| 42 | + | NavigationInventoryInvalid NavigationInventoryStatus = 3 | |
| 43 | + | NavigationInventoryVersionMismatch NavigationInventoryStatus = 4 | |
| 44 | + | ) | |
| 45 | + | ||
| 46 | + | // NavigationInventorySourceStatus is the per-source outcome. Healthy sources | |
| 47 | + | // survive another source's size or error failure. | |
| 48 | + | type NavigationInventorySourceStatus uint8 | |
| 49 | + | ||
| 50 | + | const ( | |
| 51 | + | NavigationInventorySourceOK NavigationInventorySourceStatus = 1 | |
| 52 | + | NavigationInventorySourceUnavailable NavigationInventorySourceStatus = 2 | |
| 53 | + | NavigationInventorySourceTooLarge NavigationInventorySourceStatus = 3 | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | // NavigationInventoryFailureCode classifies resolve/selection failures. | |
| 57 | + | type NavigationInventoryFailureCode uint8 | |
| 58 | + | ||
| 59 | + | const ( | |
| 60 | + | NavigationInventoryStaleIdentity NavigationInventoryFailureCode = 1 | |
| 61 | + | NavigationInventorySourceGone NavigationInventoryFailureCode = 2 | |
| 62 | + | NavigationInventoryIncompatible NavigationInventoryFailureCode = 3 | |
| 63 | + | NavigationInventoryInvalidTarget NavigationInventoryFailureCode = 4 | |
| 64 | + | NavigationInventoryNavigationFailed NavigationInventoryFailureCode = 5 | |
| 65 | + | NavigationInventoryRestoreFailed NavigationInventoryFailureCode = 6 | |
| 66 | + | ) | |
| 67 | + | ||
| 68 | + | // NavigationInventoryEntry is one sanitized public row. It carries display | |
| 69 | + | // facts only, never exact endpoints, connection parameters, CWD, | |
| 70 | + | // environment, preview, or pane content. | |
| 71 | + | type NavigationInventoryEntry struct { | |
| 72 | + | SourceKey string | |
| 73 | + | EntryKey string | |
| 74 | + | Name string | |
| 75 | + | DisplayOrigin string | |
| 76 | + | State string | |
| 77 | + | Reason string | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // NavigationInventorySourceGroup is one packed source with its own status. | |
| 81 | + | type NavigationInventorySourceGroup struct { | |
| 82 | + | SourceKey string | |
| 83 | + | Status NavigationInventorySourceStatus | |
| 84 | + | Entries []NavigationInventoryEntry | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // NavigationInventoryRequest queries a source control connection without | |
| 88 | + | // creating an attachment or Hello session. | |
| 89 | + | type NavigationInventoryRequest struct { | |
| 90 | + | Version uint16 | |
| 91 | + | RequestID uint64 | |
| 92 | + | Operation NavigationInventoryOperation | |
| 93 | + | SourceKey string | |
| 94 | + | EntryKey string | |
| 95 | + | Registration domain.RemoteRegistration | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // NavigationInventoryResponse answers a control query. Snapshot carries | |
| 99 | + | // source groups; resolve carries a non-mutating AttachTarget; never both. | |
| 100 | + | // Version-mismatch and malformed responses never carry a target. | |
| 101 | + | type NavigationInventoryResponse struct { | |
| 102 | + | RequestID uint64 | |
| 103 | + | Operation NavigationInventoryOperation | |
| 104 | + | Status NavigationInventoryStatus | |
| 105 | + | Groups []NavigationInventorySourceGroup | |
| 106 | + | Resolved *AttachTarget | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // NavigationInventoryDemand is serving daemon → client. | |
| 110 | + | type NavigationInventoryDemand struct { | |
| 111 | + | InteractionGeneration uint64 | |
| 112 | + | Open bool | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // NavigationInventoryPublication is client → serving daemon. | |
| 116 | + | type NavigationInventoryPublication struct { | |
| 117 | + | InteractionGeneration uint64 | |
| 118 | + | PublicationGeneration uint64 | |
| 119 | + | Groups []NavigationInventorySourceGroup | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // NavigationInventorySelection is serving daemon → client. No endpoint or | |
| 123 | + | // name is supplied as authority; the key resolves through the source. | |
| 124 | + | type NavigationInventorySelection struct { | |
| 125 | + | CauseActionID uint64 | |
| 126 | + | InteractionGeneration uint64 | |
| 127 | + | PublicationGeneration uint64 | |
| 128 | + | SourceKey string | |
| 129 | + | EntryKey string | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // NavigationInventoryFailure is client → serving daemon. | |
| 133 | + | type NavigationInventoryFailure struct { | |
| 134 | + | CauseActionID uint64 | |
| 135 | + | InteractionGeneration uint64 | |
| 136 | + | SourceKey string | |
| 137 | + | EntryKey string | |
| 138 | + | Code NavigationInventoryFailureCode | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func validNavigationInventoryOperation(op NavigationInventoryOperation) bool { | |
| 142 | + | return op == NavigationInventorySnapshot || op == NavigationInventoryResolve | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func validNavigationInventoryStatus(status NavigationInventoryStatus) bool { | |
| 146 | + | switch status { | |
| 147 | + | case NavigationInventoryOK, | |
| 148 | + | NavigationInventoryUnavailable, | |
| 149 | + | NavigationInventoryInvalid, | |
| 150 | + | NavigationInventoryVersionMismatch: | |
| 151 | + | return true | |
| 152 | + | default: | |
| 153 | + | return false | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func validNavigationInventorySourceStatus(status NavigationInventorySourceStatus) bool { | |
| 158 | + | switch status { | |
| 159 | + | case NavigationInventorySourceOK, | |
| 160 | + | NavigationInventorySourceUnavailable, | |
| 161 | + | NavigationInventorySourceTooLarge: | |
| 162 | + | return true | |
| 163 | + | default: | |
| 164 | + | return false | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func validNavigationInventoryFailureCode(code NavigationInventoryFailureCode) bool { | |
| 169 | + | switch code { | |
| 170 | + | case NavigationInventoryStaleIdentity, | |
| 171 | + | NavigationInventorySourceGone, | |
| 172 | + | NavigationInventoryIncompatible, | |
| 173 | + | NavigationInventoryInvalidTarget, | |
| 174 | + | NavigationInventoryNavigationFailed, | |
| 175 | + | NavigationInventoryRestoreFailed: | |
| 176 | + | return true | |
| 177 | + | default: | |
| 178 | + | return false | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func validInventoryKey(value string) bool { | |
| 183 | + | if value == "" || len(value) > NavigationInventoryMaxKeyBytes { | |
| 184 | + | return false | |
| 185 | + | } | |
| 186 | + | if !utf8.ValidString(value) { | |
| 187 | + | return false | |
| 188 | + | } | |
| 189 | + | for _, r := range value { | |
| 190 | + | if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { | |
| 191 | + | return false | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | return true | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func validInventoryDisplay(value string) bool { | |
| 198 | + | if len(value) > NavigationInventoryMaxDisplayBytes { | |
| 199 | + | return false | |
| 200 | + | } | |
| 201 | + | if !utf8.ValidString(value) { | |
| 202 | + | return false | |
| 203 | + | } | |
| 204 | + | for _, r := range value { | |
| 205 | + | if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { | |
| 206 | + | return false | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | return true | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // ValidateNavigationInventoryRequest enforces nonzero IDs, operation-dependent | |
| 213 | + | // unions, key shape, registration identity for resolve, and display safety. | |
| 214 | + | // Local resolve carries a zero registration (the source is the control daemon | |
| 215 | + | // itself); remote resolve carries the configured registration to fence ABA | |
| 216 | + | // re-registration. | |
| 217 | + | func ValidateNavigationInventoryRequest(request NavigationInventoryRequest) error { | |
| 218 | + | if request.RequestID == 0 || !validNavigationInventoryOperation(request.Operation) { | |
| 219 | + | return ErrInvalidNavigation | |
| 220 | + | } | |
| 221 | + | if request.Operation == NavigationInventorySnapshot { | |
| 222 | + | if request.SourceKey != "" || request.EntryKey != "" || !request.Registration.IsZero() { | |
| 223 | + | return ErrInvalidNavigation | |
| 224 | + | } | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | if !validInventoryKey(request.SourceKey) || !validInventoryKey(request.EntryKey) { | |
| 228 | + | return ErrInvalidNavigation | |
| 229 | + | } | |
| 230 | + | if request.SourceKey == NavigationInventoryLocalSourceKey { | |
| 231 | + | if !request.Registration.IsZero() { | |
| 232 | + | return ErrInvalidNavigation | |
| 233 | + | } | |
| 234 | + | return nil | |
| 235 | + | } | |
| 236 | + | if request.Registration.Validate() != nil { | |
| 237 | + | return ErrInvalidNavigation | |
| 238 | + | } | |
| 239 | + | return nil | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // ValidateNavigationInventoryResponse enforces ID/operation echo, status | |
| 243 | + | // codes, union shape, unique keys, and display safety. Targets are rejected | |
| 244 | + | // on version-mismatch or malformed outcomes. | |
| 245 | + | func ValidateNavigationInventoryResponse(response NavigationInventoryResponse) error { | |
| 246 | + | if response.RequestID == 0 || !validNavigationInventoryOperation(response.Operation) { | |
| 247 | + | return ErrInvalidNavigation | |
| 248 | + | } | |
| 249 | + | if !validNavigationInventoryStatus(response.Status) { | |
| 250 | + | return ErrInvalidNavigation | |
| 251 | + | } | |
| 252 | + | if response.Status == NavigationInventoryVersionMismatch { | |
| 253 | + | if response.Resolved != nil || len(response.Groups) != 0 { | |
| 254 | + | return ErrInvalidNavigation | |
| 255 | + | } | |
| 256 | + | return nil | |
| 257 | + | } | |
| 258 | + | if response.Operation == NavigationInventorySnapshot { | |
| 259 | + | if response.Resolved != nil { | |
| 260 | + | return ErrInvalidNavigation | |
| 261 | + | } | |
| 262 | + | return validateNavigationInventoryGroups(response.Groups, true) | |
| 263 | + | } | |
| 264 | + | if len(response.Groups) != 0 { | |
| 265 | + | return ErrInvalidNavigation | |
| 266 | + | } | |
| 267 | + | if response.Status != NavigationInventoryOK { | |
| 268 | + | if response.Resolved != nil { | |
| 269 | + | return ErrInvalidNavigation | |
| 270 | + | } | |
| 271 | + | return nil | |
| 272 | + | } | |
| 273 | + | if response.Resolved == nil { | |
| 274 | + | return ErrInvalidNavigation | |
| 275 | + | } | |
| 276 | + | return ValidateAttachTarget(*response.Resolved) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func validateNavigationInventoryGroups(groups []NavigationInventorySourceGroup, snapshot bool) error { | |
| 280 | + | if len(groups) > NavigationInventoryMaxSourceGroups { | |
| 281 | + | return ErrInvalidNavigation | |
| 282 | + | } | |
| 283 | + | sources := make(map[string]struct{}, len(groups)) | |
| 284 | + | for _, group := range groups { | |
| 285 | + | if !validInventoryKey(group.SourceKey) || !validNavigationInventorySourceStatus(group.Status) { | |
| 286 | + | return ErrInvalidNavigation | |
| 287 | + | } | |
| 288 | + | // Row limits are source-specific: the local source streams live | |
| 289 | + | // state, remote sources mirror bounded directory caches. | |
| 290 | + | limit := NavigationInventoryMaxRemoteEntriesPerSource | |
| 291 | + | if group.SourceKey == NavigationInventoryLocalSourceKey { | |
| 292 | + | limit = NavigationInventoryMaxLocalEntries | |
| 293 | + | } | |
| 294 | + | if len(group.Entries) > limit { | |
| 295 | + | return ErrInvalidNavigation | |
| 296 | + | } | |
| 297 | + | if _, dup := sources[group.SourceKey]; dup { | |
| 298 | + | return ErrInvalidNavigation | |
| 299 | + | } | |
| 300 | + | sources[group.SourceKey] = struct{}{} | |
| 301 | + | if group.Status != NavigationInventorySourceOK && len(group.Entries) != 0 { | |
| 302 | + | return ErrInvalidNavigation | |
| 303 | + | } | |
| 304 | + | entries := make(map[string]struct{}, len(group.Entries)) | |
| 305 | + | for _, entry := range group.Entries { | |
| 306 | + | if entry.SourceKey != group.SourceKey || !validInventoryKey(entry.EntryKey) { | |
| 307 | + | return ErrInvalidNavigation | |
| 308 | + | } | |
| 309 | + | if _, dup := entries[entry.EntryKey]; dup { | |
| 310 | + | return ErrInvalidNavigation | |
| 311 | + | } | |
| 312 | + | entries[entry.EntryKey] = struct{}{} | |
| 313 | + | if !validInventoryDisplay(entry.Name) || !validInventoryDisplay(entry.DisplayOrigin) || | |
| 314 | + | !validInventoryDisplay(entry.State) || !validInventoryDisplay(entry.Reason) { | |
| 315 | + | return ErrInvalidNavigation | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | if !snapshot && len(group.Entries) == 0 { | |
| 319 | + | return ErrInvalidNavigation | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | return nil | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | // ValidateNavigationInventoryDemand requires a nonzero interaction. | |
| 326 | + | func ValidateNavigationInventoryDemand(demand NavigationInventoryDemand) error { | |
| 327 | + | if demand.InteractionGeneration == 0 { | |
| 328 | + | return ErrInvalidNavigation | |
| 329 | + | } | |
| 330 | + | return nil | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // ValidateNavigationInventoryPublication requires nonzero generations and | |
| 334 | + | // sanitized groups. | |
| 335 | + | func ValidateNavigationInventoryPublication(publication NavigationInventoryPublication) error { | |
| 336 | + | if publication.InteractionGeneration == 0 || publication.PublicationGeneration == 0 { | |
| 337 | + | return ErrInvalidNavigation | |
| 338 | + | } | |
| 339 | + | return validateNavigationInventoryGroups(publication.Groups, true) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // ValidateNavigationInventorySelection rejects unknown keys only at resolve | |
| 343 | + | // time; wire validation enforces shape, nonzero generations, and key | |
| 344 | + | // safety. The cause correlates input when present and stays zero for | |
| 345 | + | // ordinary keyboard input, matching the optional UI-cause contract of | |
| 346 | + | // other navigation actions. | |
| 347 | + | func ValidateNavigationInventorySelection(selection NavigationInventorySelection) error { | |
| 348 | + | if selection.InteractionGeneration == 0 || selection.PublicationGeneration == 0 { | |
| 349 | + | return ErrInvalidNavigation | |
| 350 | + | } | |
| 351 | + | if !validInventoryKey(selection.SourceKey) || !validInventoryKey(selection.EntryKey) { | |
| 352 | + | return ErrInvalidNavigation | |
| 353 | + | } | |
| 354 | + | return nil | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | // ValidateNavigationInventoryFailure enforces matching IDs and bounded codes. | |
| 358 | + | func ValidateNavigationInventoryFailure(failure NavigationInventoryFailure) error { | |
| 359 | + | if failure.InteractionGeneration == 0 { | |
| 360 | + | return ErrInvalidNavigation | |
| 361 | + | } | |
| 362 | + | if !validInventoryKey(failure.SourceKey) || !validInventoryKey(failure.EntryKey) { | |
| 363 | + | return ErrInvalidNavigation | |
| 364 | + | } | |
| 365 | + | if !validNavigationInventoryFailureCode(failure.Code) { | |
| 366 | + | return ErrInvalidNavigation | |
| 367 | + | } | |
| 368 | + | return nil | |
| 369 | + | } | |
A internal/protocol/navigation_inventory_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package protocol | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestValidateNavigationInventoryRequest(t *testing.T) { | |
| 11 | + | registration, err := domain.NewRemoteRegistration("user@arch", [16]byte{1}) | |
| 12 | + | if err != nil { | |
| 13 | + | t.Fatalf("NewRemoteRegistration: %v", err) | |
| 14 | + | } | |
| 15 | + | tests := []struct { | |
| 16 | + | name string | |
| 17 | + | request NavigationInventoryRequest | |
| 18 | + | wantErr bool | |
| 19 | + | }{ | |
| 20 | + | {name: "snapshot ok", request: NavigationInventoryRequest{Version: Version, RequestID: 1, Operation: NavigationInventorySnapshot}}, | |
| 21 | + | {name: "zero request", request: NavigationInventoryRequest{Operation: NavigationInventorySnapshot}, wantErr: true}, | |
| 22 | + | {name: "bad operation", request: NavigationInventoryRequest{Version: Version, RequestID: 1, Operation: 9}, wantErr: true}, | |
| 23 | + | {name: "snapshot with keys", request: NavigationInventoryRequest{Version: Version, RequestID: 1, Operation: NavigationInventorySnapshot, SourceKey: "local"}, wantErr: true}, | |
| 24 | + | {name: "resolve ok", request: NavigationInventoryRequest{Version: Version, RequestID: 2, Operation: NavigationInventoryResolve, SourceKey: "remote-a", EntryKey: "abc", Registration: registration}}, | |
| 25 | + | {name: "resolve local ok", request: NavigationInventoryRequest{Version: Version, RequestID: 3, Operation: NavigationInventoryResolve, SourceKey: NavigationInventoryLocalSourceKey, EntryKey: "abc"}}, | |
| 26 | + | {name: "resolve local with registration", request: NavigationInventoryRequest{Version: Version, RequestID: 3, Operation: NavigationInventoryResolve, SourceKey: NavigationInventoryLocalSourceKey, EntryKey: "abc", Registration: registration}, wantErr: true}, | |
| 27 | + | {name: "resolve missing registration", request: NavigationInventoryRequest{Version: Version, RequestID: 2, Operation: NavigationInventoryResolve, SourceKey: "remote-a", EntryKey: "abc"}, wantErr: true}, | |
| 28 | + | {name: "resolve bad key", request: NavigationInventoryRequest{Version: Version, RequestID: 2, Operation: NavigationInventoryResolve, SourceKey: "a\x1b", EntryKey: "abc", Registration: registration}, wantErr: true}, | |
| 29 | + | } | |
| 30 | + | for _, tt := range tests { | |
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |
| 32 | + | err := ValidateNavigationInventoryRequest(tt.request) | |
| 33 | + | if (err != nil) != tt.wantErr { | |
| 34 | + | t.Fatalf("ValidateNavigationInventoryRequest() = %v, wantErr %t", err, tt.wantErr) | |
| 35 | + | } | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestValidateNavigationInventoryResponseUnions(t *testing.T) { | |
| 41 | + | group := NavigationInventorySourceGroup{SourceKey: "local", Status: NavigationInventorySourceOK, Entries: []NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: "one"}}} | |
| 42 | + | tests := []struct { | |
| 43 | + | name string | |
| 44 | + | response NavigationInventoryResponse | |
| 45 | + | wantErr bool | |
| 46 | + | }{ | |
| 47 | + | {name: "snapshot ok", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: []NavigationInventorySourceGroup{group}}}, | |
| 48 | + | {name: "snapshot with target", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: []NavigationInventorySourceGroup{group}, Resolved: &AttachTarget{}}, wantErr: true}, | |
| 49 | + | {name: "duplicate source", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: []NavigationInventorySourceGroup{group, group}}, wantErr: true}, | |
| 50 | + | {name: "too many groups", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: make([]NavigationInventorySourceGroup, NavigationInventoryMaxSourceGroups+1)}, wantErr: true}, | |
| 51 | + | {name: "control char label", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: []NavigationInventorySourceGroup{{SourceKey: "local", Status: NavigationInventorySourceOK, Entries: []NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: "bad\x1b"}}}}}, wantErr: true}, | |
| 52 | + | {name: "oversize label", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: []NavigationInventorySourceGroup{{SourceKey: "local", Status: NavigationInventorySourceOK, Entries: []NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: strings.Repeat("x", NavigationInventoryMaxDisplayBytes+1)}}}}}, wantErr: true}, | |
| 53 | + | {name: "version mismatch clean", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryVersionMismatch}}, | |
| 54 | + | {name: "version mismatch with target", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryVersionMismatch, Groups: []NavigationInventorySourceGroup{group}}, wantErr: true}, | |
| 55 | + | } | |
| 56 | + | for _, tt := range tests { | |
| 57 | + | t.Run(tt.name, func(t *testing.T) { | |
| 58 | + | err := ValidateNavigationInventoryResponse(tt.response) | |
| 59 | + | if (err != nil) != tt.wantErr { | |
| 60 | + | t.Fatalf("ValidateNavigationInventoryResponse() = %v, wantErr %t", err, tt.wantErr) | |
| 61 | + | } | |
| 62 | + | }) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestNavigationCapabilityInventoryValid(t *testing.T) { | |
| 67 | + | if err := ValidateNavigation(NavigationCapabilityInventory, StartupOverlayNone, false); err != nil { | |
| 68 | + | t.Fatalf("ValidateNavigation(inventory) = %v", err) | |
| 69 | + | } | |
| 70 | + | } | |
M internal/protocol/session.go +1 -1
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // Version is the negotiated vev session protocol version. | |
| 16 | - | const Version uint16 = 41 | |
| 16 | + | const Version uint16 = 42 | |
| 17 | 17 | ||
| 18 | 18 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 19 | 19 | // first committed publication. | |
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(41), Version) | |
| 13 | + | require.Equal(t, uint16(42), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
M internal/protocol/wire/frame.go +48 -42
| @@ -8,50 +8,56 @@ const MaxFrameLen = 16 << 20 | |||
| 8 | 8 | type MsgType uint8 | |
| 9 | 9 | ||
| 10 | 10 | // Frame message types use non-contiguous allocations: the legacy client range | |
| 11 | - | // is 1–13 and 15, and newer client controls use 32–33, 35, and 37. Server-originated | |
| 12 | - | // messages occupy 16–23, 25–31, 34, 36, and 38. Values 14 and 24 remain reserved for | |
| 13 | - | // future extensions. | |
| 11 | + | // is 1–13 and 15, and newer client controls use 32–33, 35, 37, 39, 42, 45, | |
| 12 | + | // and 47. Server-originated messages occupy 16–23, 25–31, 34, 36, 38, 40–41, | |
| 13 | + | // and 43–44 and 46. Values 14 and 24 remain reserved for future extensions. | |
| 14 | 14 | const ( | |
| 15 | - | MsgHello MsgType = 1 | |
| 16 | - | MsgInput MsgType = 2 | |
| 17 | - | MsgResize MsgType = 3 | |
| 18 | - | MsgDetach MsgType = 4 | |
| 19 | - | MsgPing MsgType = 5 | |
| 20 | - | MsgList MsgType = 6 | |
| 21 | - | MsgKill MsgType = 7 | |
| 22 | - | MsgTheme MsgType = 8 | |
| 23 | - | MsgAck MsgType = 9 | |
| 24 | - | MsgImagePush MsgType = 10 | |
| 25 | - | MsgClientNotice MsgType = 11 | |
| 26 | - | MsgCommand MsgType = 12 | |
| 27 | - | MsgOutputResetRequest MsgType = 13 | |
| 28 | - | MsgRemotePreviewRequest MsgType = 15 | |
| 29 | - | MsgRouteAttentionSubscription MsgType = 32 | |
| 30 | - | MsgSamePeerSwitchRequest MsgType = 33 | |
| 31 | - | MsgParkedRouteRequest MsgType = 35 | |
| 32 | - | MsgSessionCreationFailure MsgType = 37 | |
| 33 | - | MsgUIFence MsgType = 39 | |
| 15 | + | MsgHello MsgType = 1 | |
| 16 | + | MsgInput MsgType = 2 | |
| 17 | + | MsgResize MsgType = 3 | |
| 18 | + | MsgDetach MsgType = 4 | |
| 19 | + | MsgPing MsgType = 5 | |
| 20 | + | MsgList MsgType = 6 | |
| 21 | + | MsgKill MsgType = 7 | |
| 22 | + | MsgTheme MsgType = 8 | |
| 23 | + | MsgAck MsgType = 9 | |
| 24 | + | MsgImagePush MsgType = 10 | |
| 25 | + | MsgClientNotice MsgType = 11 | |
| 26 | + | MsgCommand MsgType = 12 | |
| 27 | + | MsgOutputResetRequest MsgType = 13 | |
| 28 | + | MsgRemotePreviewRequest MsgType = 15 | |
| 29 | + | MsgRouteAttentionSubscription MsgType = 32 | |
| 30 | + | MsgSamePeerSwitchRequest MsgType = 33 | |
| 31 | + | MsgParkedRouteRequest MsgType = 35 | |
| 32 | + | MsgSessionCreationFailure MsgType = 37 | |
| 33 | + | MsgUIFence MsgType = 39 | |
| 34 | + | MsgNavigationInventoryRequest MsgType = 42 | |
| 35 | + | MsgNavigationInventoryPublication MsgType = 45 | |
| 36 | + | MsgNavigationInventoryFailure MsgType = 47 | |
| 34 | 37 | ||
| 35 | - | MsgWelcome MsgType = 16 | |
| 36 | - | MsgError MsgType = 17 | |
| 37 | - | MsgOutput MsgType = 18 | |
| 38 | - | MsgDetached MsgType = 19 | |
| 39 | - | MsgPong MsgType = 20 | |
| 40 | - | MsgSessions MsgType = 21 | |
| 41 | - | MsgCommandResult MsgType = 22 | |
| 42 | - | MsgNavigationAction MsgType = 23 | |
| 43 | - | MsgAttachTarget MsgType = 25 | |
| 44 | - | MsgRemotePreviewResponse MsgType = 26 | |
| 45 | - | MsgCommittedRouteIdentity MsgType = 27 | |
| 46 | - | MsgRecentRouteSnapshot MsgType = 28 | |
| 47 | - | MsgNavigateRecentRoute MsgType = 29 | |
| 48 | - | MsgRouteNavigationFailure MsgType = 30 | |
| 49 | - | MsgRoutePosition MsgType = 31 | |
| 50 | - | MsgSamePeerSwitchFailure MsgType = 34 | |
| 51 | - | MsgParkedRouteResponse MsgType = 36 | |
| 52 | - | MsgRouteCreateSession MsgType = 38 | |
| 53 | - | MsgUIReceipt MsgType = 40 | |
| 54 | - | MsgUIViewUpdate MsgType = 41 | |
| 38 | + | MsgWelcome MsgType = 16 | |
| 39 | + | MsgError MsgType = 17 | |
| 40 | + | MsgOutput MsgType = 18 | |
| 41 | + | MsgDetached MsgType = 19 | |
| 42 | + | MsgPong MsgType = 20 | |
| 43 | + | MsgSessions MsgType = 21 | |
| 44 | + | MsgCommandResult MsgType = 22 | |
| 45 | + | MsgNavigationAction MsgType = 23 | |
| 46 | + | MsgAttachTarget MsgType = 25 | |
| 47 | + | MsgRemotePreviewResponse MsgType = 26 | |
| 48 | + | MsgCommittedRouteIdentity MsgType = 27 | |
| 49 | + | MsgRecentRouteSnapshot MsgType = 28 | |
| 50 | + | MsgNavigateRecentRoute MsgType = 29 | |
| 51 | + | MsgRouteNavigationFailure MsgType = 30 | |
| 52 | + | MsgRoutePosition MsgType = 31 | |
| 53 | + | MsgSamePeerSwitchFailure MsgType = 34 | |
| 54 | + | MsgParkedRouteResponse MsgType = 36 | |
| 55 | + | MsgRouteCreateSession MsgType = 38 | |
| 56 | + | MsgUIReceipt MsgType = 40 | |
| 57 | + | MsgUIViewUpdate MsgType = 41 | |
| 58 | + | MsgNavigationInventoryResponse MsgType = 43 | |
| 59 | + | MsgNavigationInventoryDemand MsgType = 44 | |
| 60 | + | MsgNavigationInventorySelection MsgType = 46 | |
| 55 | 61 | ) | |
| 56 | 62 | ||
| 57 | 63 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
M internal/protocol/wire/frame_test.go +2 -2
| @@ -7,8 +7,8 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | func TestProtocolVersion(t *testing.T) { | |
| 10 | - | if protocol.Version != 41 { | |
| 11 | - | t.Fatalf("ProtocolVersion = %d, want 41", protocol.Version) | |
| 10 | + | if protocol.Version != 42 { | |
| 11 | + | t.Fatalf("ProtocolVersion = %d, want 42", protocol.Version) | |
| 12 | 12 | } | |
| 13 | 13 | } | |
| 14 | 14 | ||
A internal/protocol/wire/navigation_inventory.go +456 -0
| @@ -0,0 +1,456 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Navigation inventory wire bounds. Complete encoded response/publication | |
| 11 | + | // stays below MaxFrameLen; 4 MiB is the documented export budget. | |
| 12 | + | const navigationInventoryMaxEncodedBytes = 4 << 20 | |
| 13 | + | ||
| 14 | + | // PeekNavigationInventoryVersion returns the leading protocol version from a | |
| 15 | + | // NavigationInventoryRequest payload, mirroring the Hello/Command peekers. | |
| 16 | + | func PeekNavigationInventoryVersion(b []byte) (uint16, bool) { | |
| 17 | + | if len(b) < 2 { | |
| 18 | + | return 0, false | |
| 19 | + | } | |
| 20 | + | return binary.BigEndian.Uint16(b), true | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // PeekNavigationInventoryRequestID returns the request ID following the | |
| 24 | + | // leading version in a NavigationInventoryRequest payload. | |
| 25 | + | func PeekNavigationInventoryRequestID(b []byte) (uint64, bool) { | |
| 26 | + | if len(b) < 10 { | |
| 27 | + | return 0, false | |
| 28 | + | } | |
| 29 | + | return binary.BigEndian.Uint64(b[2:10]), true | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func marshalInventoryRegistration(w *payloadWriter, registration domain.RemoteRegistration) { | |
| 33 | + | w.putString(registration.Endpoint) | |
| 34 | + | w.putBytes(registration.Incarnation[:]) | |
| 35 | + | w.putUint64(uint64(registration.Generation)) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func unmarshalInventoryRegistration(r *payloadReader) (domain.RemoteRegistration, error) { | |
| 39 | + | var registration domain.RemoteRegistration | |
| 40 | + | var err error | |
| 41 | + | if registration.Endpoint, err = r.getString(); err != nil { | |
| 42 | + | return domain.RemoteRegistration{}, err | |
| 43 | + | } | |
| 44 | + | incarnation, err := r.getBytes(16) | |
| 45 | + | if err != nil { | |
| 46 | + | return domain.RemoteRegistration{}, err | |
| 47 | + | } | |
| 48 | + | copy(registration.Incarnation[:], incarnation) | |
| 49 | + | generation, err := r.getUint64() | |
| 50 | + | if err != nil { | |
| 51 | + | return domain.RemoteRegistration{}, err | |
| 52 | + | } | |
| 53 | + | registration.Generation = domain.RemoteGeneration(generation) | |
| 54 | + | // Zero registrations decode structurally: local resolve carries no | |
| 55 | + | // registration, and the semantic union validator enforces the | |
| 56 | + | // operation/source coupling afterward. | |
| 57 | + | if !registration.IsZero() { | |
| 58 | + | if err := registration.Validate(); err != nil { | |
| 59 | + | return domain.RemoteRegistration{}, err | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | return registration, nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func marshalInventoryGroups(w *payloadWriter, groups []protocol.NavigationInventorySourceGroup) bool { | |
| 66 | + | if len(groups) > protocol.NavigationInventoryMaxSourceGroups { | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | w.putUint16(uint16(len(groups))) | |
| 70 | + | for _, group := range groups { | |
| 71 | + | w.putString(group.SourceKey) | |
| 72 | + | w.putUint8(uint8(group.Status)) | |
| 73 | + | if group.Status != protocol.NavigationInventorySourceOK { | |
| 74 | + | w.putUint32(0) | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | if len(group.Entries) > inventoryGroupEntryLimit(group.SourceKey) { | |
| 78 | + | return false | |
| 79 | + | } | |
| 80 | + | w.putUint32(uint32(len(group.Entries))) | |
| 81 | + | for _, entry := range group.Entries { | |
| 82 | + | w.putString(entry.EntryKey) | |
| 83 | + | w.putString(entry.Name) | |
| 84 | + | w.putString(entry.DisplayOrigin) | |
| 85 | + | w.putString(entry.State) | |
| 86 | + | w.putString(entry.Reason) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | return true | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // inventoryGroupEntryLimit mirrors semantic validation: the local source | |
| 93 | + | // streams live state, remote sources mirror bounded directory caches. | |
| 94 | + | func inventoryGroupEntryLimit(sourceKey string) int { | |
| 95 | + | if sourceKey == protocol.NavigationInventoryLocalSourceKey { | |
| 96 | + | return protocol.NavigationInventoryMaxLocalEntries | |
| 97 | + | } | |
| 98 | + | return protocol.NavigationInventoryMaxRemoteEntriesPerSource | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func unmarshalInventoryGroups(r *payloadReader) ([]protocol.NavigationInventorySourceGroup, error) { | |
| 102 | + | count, err := r.getUint16() | |
| 103 | + | if err != nil { | |
| 104 | + | return nil, err | |
| 105 | + | } | |
| 106 | + | if int(count) > protocol.NavigationInventoryMaxSourceGroups { | |
| 107 | + | return nil, protocol.ErrInvalidNavigation | |
| 108 | + | } | |
| 109 | + | groups := make([]protocol.NavigationInventorySourceGroup, 0, int(count)) | |
| 110 | + | for range int(count) { | |
| 111 | + | var group protocol.NavigationInventorySourceGroup | |
| 112 | + | if group.SourceKey, err = r.getString(); err != nil { | |
| 113 | + | return nil, err | |
| 114 | + | } | |
| 115 | + | status, err := r.getUint8() | |
| 116 | + | if err != nil { | |
| 117 | + | return nil, err | |
| 118 | + | } | |
| 119 | + | group.Status = protocol.NavigationInventorySourceStatus(status) | |
| 120 | + | entryCount, err := r.getUint32() | |
| 121 | + | if err != nil { | |
| 122 | + | return nil, err | |
| 123 | + | } | |
| 124 | + | if entryCount > uint32(inventoryGroupEntryLimit(group.SourceKey)) { | |
| 125 | + | return nil, protocol.ErrInvalidNavigation | |
| 126 | + | } | |
| 127 | + | if group.Status != protocol.NavigationInventorySourceOK && entryCount != 0 { | |
| 128 | + | return nil, protocol.ErrInvalidNavigation | |
| 129 | + | } | |
| 130 | + | if entryCount > 0 { | |
| 131 | + | group.Entries = make([]protocol.NavigationInventoryEntry, 0, int(entryCount)) | |
| 132 | + | for range int(entryCount) { | |
| 133 | + | var entry protocol.NavigationInventoryEntry | |
| 134 | + | entry.SourceKey = group.SourceKey | |
| 135 | + | if entry.EntryKey, err = r.getString(); err != nil { | |
| 136 | + | return nil, err | |
| 137 | + | } | |
| 138 | + | if entry.Name, err = r.getString(); err != nil { | |
| 139 | + | return nil, err | |
| 140 | + | } | |
| 141 | + | if entry.DisplayOrigin, err = r.getString(); err != nil { | |
| 142 | + | return nil, err | |
| 143 | + | } | |
| 144 | + | if entry.State, err = r.getString(); err != nil { | |
| 145 | + | return nil, err | |
| 146 | + | } | |
| 147 | + | if entry.Reason, err = r.getString(); err != nil { | |
| 148 | + | return nil, err | |
| 149 | + | } | |
| 150 | + | group.Entries = append(group.Entries, entry) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | groups = append(groups, group) | |
| 154 | + | } | |
| 155 | + | return groups, nil | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // MarshalNavigationInventoryRequest encodes a control-source query. Version | |
| 159 | + | // stays first so the version peeker works. | |
| 160 | + | func MarshalNavigationInventoryRequest(request protocol.NavigationInventoryRequest) []byte { | |
| 161 | + | if protocol.ValidateNavigationInventoryRequest(request) != nil { | |
| 162 | + | return nil | |
| 163 | + | } | |
| 164 | + | w := payloadWriter{} | |
| 165 | + | w.putUint16(request.Version) | |
| 166 | + | w.putUint64(request.RequestID) | |
| 167 | + | w.putUint8(uint8(request.Operation)) | |
| 168 | + | if request.Operation == protocol.NavigationInventoryResolve { | |
| 169 | + | w.putString(request.SourceKey) | |
| 170 | + | w.putString(request.EntryKey) | |
| 171 | + | marshalInventoryRegistration(&w, request.Registration) | |
| 172 | + | } | |
| 173 | + | if len(w.b) > navigationInventoryMaxEncodedBytes { | |
| 174 | + | return nil | |
| 175 | + | } | |
| 176 | + | return w.b | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // UnmarshalNavigationInventoryRequest decodes a strict control-source query. | |
| 180 | + | func UnmarshalNavigationInventoryRequest(data []byte) (protocol.NavigationInventoryRequest, error) { | |
| 181 | + | r := payloadReader{b: data} | |
| 182 | + | var request protocol.NavigationInventoryRequest | |
| 183 | + | var err error | |
| 184 | + | if request.Version, err = r.getUint16(); err != nil { | |
| 185 | + | return protocol.NavigationInventoryRequest{}, err | |
| 186 | + | } | |
| 187 | + | if request.RequestID, err = r.getUint64(); err != nil { | |
| 188 | + | return protocol.NavigationInventoryRequest{}, err | |
| 189 | + | } | |
| 190 | + | operation, err := r.getUint8() | |
| 191 | + | if err != nil { | |
| 192 | + | return protocol.NavigationInventoryRequest{}, err | |
| 193 | + | } | |
| 194 | + | request.Operation = protocol.NavigationInventoryOperation(operation) | |
| 195 | + | if request.Operation == protocol.NavigationInventoryResolve { | |
| 196 | + | if request.SourceKey, err = r.getString(); err != nil { | |
| 197 | + | return protocol.NavigationInventoryRequest{}, err | |
| 198 | + | } | |
| 199 | + | if request.EntryKey, err = r.getString(); err != nil { | |
| 200 | + | return protocol.NavigationInventoryRequest{}, err | |
| 201 | + | } | |
| 202 | + | if request.Registration, err = unmarshalInventoryRegistration(&r); err != nil { | |
| 203 | + | return protocol.NavigationInventoryRequest{}, protocol.ErrInvalidNavigation | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | if err := r.done(); err != nil { | |
| 207 | + | return protocol.NavigationInventoryRequest{}, err | |
| 208 | + | } | |
| 209 | + | if err := protocol.ValidateNavigationInventoryRequest(request); err != nil { | |
| 210 | + | return protocol.NavigationInventoryRequest{}, err | |
| 211 | + | } | |
| 212 | + | return request, nil | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // MarshalNavigationInventoryResponse encodes a source answer. Snapshot | |
| 216 | + | // carries groups; resolve carries an AttachTarget; never both. | |
| 217 | + | func MarshalNavigationInventoryResponse(response protocol.NavigationInventoryResponse) []byte { | |
| 218 | + | if protocol.ValidateNavigationInventoryResponse(response) != nil { | |
| 219 | + | return nil | |
| 220 | + | } | |
| 221 | + | w := payloadWriter{} | |
| 222 | + | w.putUint64(response.RequestID) | |
| 223 | + | w.putUint8(uint8(response.Operation)) | |
| 224 | + | w.putUint8(uint8(response.Status)) | |
| 225 | + | if response.Operation == protocol.NavigationInventorySnapshot && response.Status != protocol.NavigationInventoryVersionMismatch { | |
| 226 | + | if !marshalInventoryGroups(&w, response.Groups) { | |
| 227 | + | return nil | |
| 228 | + | } | |
| 229 | + | } else if response.Operation == protocol.NavigationInventoryResolve && response.Status == protocol.NavigationInventoryOK && response.Resolved != nil { | |
| 230 | + | inner := MarshalAttachTarget(*response.Resolved) | |
| 231 | + | if inner == nil { | |
| 232 | + | return nil | |
| 233 | + | } | |
| 234 | + | w.putLongBytes(inner) | |
| 235 | + | } | |
| 236 | + | if len(w.b) > navigationInventoryMaxEncodedBytes { | |
| 237 | + | return nil | |
| 238 | + | } | |
| 239 | + | return w.b | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // UnmarshalNavigationInventoryResponse decodes a strict source answer. | |
| 243 | + | func UnmarshalNavigationInventoryResponse(data []byte) (protocol.NavigationInventoryResponse, error) { | |
| 244 | + | if len(data) > navigationInventoryMaxEncodedBytes { | |
| 245 | + | return protocol.NavigationInventoryResponse{}, protocol.ErrInvalidNavigation | |
| 246 | + | } | |
| 247 | + | r := payloadReader{b: data} | |
| 248 | + | var response protocol.NavigationInventoryResponse | |
| 249 | + | var err error | |
| 250 | + | if response.RequestID, err = r.getUint64(); err != nil { | |
| 251 | + | return protocol.NavigationInventoryResponse{}, err | |
| 252 | + | } | |
| 253 | + | operation, err := r.getUint8() | |
| 254 | + | if err != nil { | |
| 255 | + | return protocol.NavigationInventoryResponse{}, err | |
| 256 | + | } | |
| 257 | + | response.Operation = protocol.NavigationInventoryOperation(operation) | |
| 258 | + | status, err := r.getUint8() | |
| 259 | + | if err != nil { | |
| 260 | + | return protocol.NavigationInventoryResponse{}, err | |
| 261 | + | } | |
| 262 | + | response.Status = protocol.NavigationInventoryStatus(status) | |
| 263 | + | if response.Status == protocol.NavigationInventoryVersionMismatch { | |
| 264 | + | if err := r.done(); err != nil { | |
| 265 | + | return protocol.NavigationInventoryResponse{}, err | |
| 266 | + | } | |
| 267 | + | if err := protocol.ValidateNavigationInventoryResponse(response); err != nil { | |
| 268 | + | return protocol.NavigationInventoryResponse{}, err | |
| 269 | + | } | |
| 270 | + | return response, nil | |
| 271 | + | } | |
| 272 | + | if response.Operation == protocol.NavigationInventorySnapshot { | |
| 273 | + | if response.Groups, err = unmarshalInventoryGroups(&r); err != nil { | |
| 274 | + | return protocol.NavigationInventoryResponse{}, err | |
| 275 | + | } | |
| 276 | + | } else if response.Status == protocol.NavigationInventoryOK { | |
| 277 | + | inner, err := r.getLongBytes() | |
| 278 | + | if err != nil { | |
| 279 | + | return protocol.NavigationInventoryResponse{}, err | |
| 280 | + | } | |
| 281 | + | target, err := UnmarshalAttachTarget(inner) | |
| 282 | + | if err != nil { | |
| 283 | + | return protocol.NavigationInventoryResponse{}, protocol.ErrInvalidNavigation | |
| 284 | + | } | |
| 285 | + | response.Resolved = &target | |
| 286 | + | } | |
| 287 | + | if err := r.done(); err != nil { | |
| 288 | + | return protocol.NavigationInventoryResponse{}, err | |
| 289 | + | } | |
| 290 | + | if err := protocol.ValidateNavigationInventoryResponse(response); err != nil { | |
| 291 | + | return protocol.NavigationInventoryResponse{}, err | |
| 292 | + | } | |
| 293 | + | return response, nil | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // MarshalNavigationInventoryDemand encodes a serving-daemon open/close flag. | |
| 297 | + | func MarshalNavigationInventoryDemand(demand protocol.NavigationInventoryDemand) []byte { | |
| 298 | + | if protocol.ValidateNavigationInventoryDemand(demand) != nil { | |
| 299 | + | return nil | |
| 300 | + | } | |
| 301 | + | w := payloadWriter{} | |
| 302 | + | w.putUint64(demand.InteractionGeneration) | |
| 303 | + | w.putBool(demand.Open) | |
| 304 | + | return w.b | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // UnmarshalNavigationInventoryDemand decodes a strict demand message. | |
| 308 | + | func UnmarshalNavigationInventoryDemand(data []byte) (protocol.NavigationInventoryDemand, error) { | |
| 309 | + | r := payloadReader{b: data} | |
| 310 | + | var demand protocol.NavigationInventoryDemand | |
| 311 | + | var err error | |
| 312 | + | if demand.InteractionGeneration, err = r.getUint64(); err != nil { | |
| 313 | + | return protocol.NavigationInventoryDemand{}, err | |
| 314 | + | } | |
| 315 | + | if demand.Open, err = r.getBool(); err != nil { | |
| 316 | + | return protocol.NavigationInventoryDemand{}, err | |
| 317 | + | } | |
| 318 | + | if err := r.done(); err != nil { | |
| 319 | + | return protocol.NavigationInventoryDemand{}, err | |
| 320 | + | } | |
| 321 | + | if err := protocol.ValidateNavigationInventoryDemand(demand); err != nil { | |
| 322 | + | return protocol.NavigationInventoryDemand{}, err | |
| 323 | + | } | |
| 324 | + | return demand, nil | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // MarshalNavigationInventoryPublication encodes sanitized source groups. | |
| 328 | + | func MarshalNavigationInventoryPublication(publication protocol.NavigationInventoryPublication) []byte { | |
| 329 | + | if protocol.ValidateNavigationInventoryPublication(publication) != nil { | |
| 330 | + | return nil | |
| 331 | + | } | |
| 332 | + | w := payloadWriter{} | |
| 333 | + | w.putUint64(publication.InteractionGeneration) | |
| 334 | + | w.putUint64(publication.PublicationGeneration) | |
| 335 | + | if !marshalInventoryGroups(&w, publication.Groups) { | |
| 336 | + | return nil | |
| 337 | + | } | |
| 338 | + | if len(w.b) > navigationInventoryMaxEncodedBytes { | |
| 339 | + | return nil | |
| 340 | + | } | |
| 341 | + | return w.b | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // UnmarshalNavigationInventoryPublication decodes a strict publication. | |
| 345 | + | func UnmarshalNavigationInventoryPublication(data []byte) (protocol.NavigationInventoryPublication, error) { | |
| 346 | + | if len(data) > navigationInventoryMaxEncodedBytes { | |
| 347 | + | return protocol.NavigationInventoryPublication{}, protocol.ErrInvalidNavigation | |
| 348 | + | } | |
| 349 | + | r := payloadReader{b: data} | |
| 350 | + | var publication protocol.NavigationInventoryPublication | |
| 351 | + | var err error | |
| 352 | + | if publication.InteractionGeneration, err = r.getUint64(); err != nil { | |
| 353 | + | return protocol.NavigationInventoryPublication{}, err | |
| 354 | + | } | |
| 355 | + | if publication.PublicationGeneration, err = r.getUint64(); err != nil { | |
| 356 | + | return protocol.NavigationInventoryPublication{}, err | |
| 357 | + | } | |
| 358 | + | if publication.Groups, err = unmarshalInventoryGroups(&r); err != nil { | |
| 359 | + | return protocol.NavigationInventoryPublication{}, err | |
| 360 | + | } | |
| 361 | + | if err := r.done(); err != nil { | |
| 362 | + | return protocol.NavigationInventoryPublication{}, err | |
| 363 | + | } | |
| 364 | + | if err := protocol.ValidateNavigationInventoryPublication(publication); err != nil { | |
| 365 | + | return protocol.NavigationInventoryPublication{}, err | |
| 366 | + | } | |
| 367 | + | return publication, nil | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | // MarshalNavigationInventorySelection encodes a key-only selection. | |
| 371 | + | func MarshalNavigationInventorySelection(selection protocol.NavigationInventorySelection) []byte { | |
| 372 | + | if protocol.ValidateNavigationInventorySelection(selection) != nil { | |
| 373 | + | return nil | |
| 374 | + | } | |
| 375 | + | w := payloadWriter{} | |
| 376 | + | w.putUint64(selection.CauseActionID) | |
| 377 | + | w.putUint64(selection.InteractionGeneration) | |
| 378 | + | w.putUint64(selection.PublicationGeneration) | |
| 379 | + | w.putString(selection.SourceKey) | |
| 380 | + | w.putString(selection.EntryKey) | |
| 381 | + | return w.b | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // UnmarshalNavigationInventorySelection decodes a strict selection. | |
| 385 | + | func UnmarshalNavigationInventorySelection(data []byte) (protocol.NavigationInventorySelection, error) { | |
| 386 | + | r := payloadReader{b: data} | |
| 387 | + | var selection protocol.NavigationInventorySelection | |
| 388 | + | var err error | |
| 389 | + | if selection.CauseActionID, err = r.getUint64(); err != nil { | |
| 390 | + | return protocol.NavigationInventorySelection{}, err | |
| 391 | + | } | |
| 392 | + | if selection.InteractionGeneration, err = r.getUint64(); err != nil { | |
| 393 | + | return protocol.NavigationInventorySelection{}, err | |
| 394 | + | } | |
| 395 | + | if selection.PublicationGeneration, err = r.getUint64(); err != nil { | |
| 396 | + | return protocol.NavigationInventorySelection{}, err | |
| 397 | + | } | |
| 398 | + | if selection.SourceKey, err = r.getString(); err != nil { | |
| 399 | + | return protocol.NavigationInventorySelection{}, err | |
| 400 | + | } | |
| 401 | + | if selection.EntryKey, err = r.getString(); err != nil { | |
| 402 | + | return protocol.NavigationInventorySelection{}, err | |
| 403 | + | } | |
| 404 | + | if err := r.done(); err != nil { | |
| 405 | + | return protocol.NavigationInventorySelection{}, err | |
| 406 | + | } | |
| 407 | + | if err := protocol.ValidateNavigationInventorySelection(selection); err != nil { | |
| 408 | + | return protocol.NavigationInventorySelection{}, err | |
| 409 | + | } | |
| 410 | + | return selection, nil | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | // MarshalNavigationInventoryFailure encodes a bounded failure code. | |
| 414 | + | func MarshalNavigationInventoryFailure(failure protocol.NavigationInventoryFailure) []byte { | |
| 415 | + | if protocol.ValidateNavigationInventoryFailure(failure) != nil { | |
| 416 | + | return nil | |
| 417 | + | } | |
| 418 | + | w := payloadWriter{} | |
| 419 | + | w.putUint64(failure.CauseActionID) | |
| 420 | + | w.putUint64(failure.InteractionGeneration) | |
| 421 | + | w.putString(failure.SourceKey) | |
| 422 | + | w.putString(failure.EntryKey) | |
| 423 | + | w.putUint8(uint8(failure.Code)) | |
| 424 | + | return w.b | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | // UnmarshalNavigationInventoryFailure decodes a strict failure message. | |
| 428 | + | func UnmarshalNavigationInventoryFailure(data []byte) (protocol.NavigationInventoryFailure, error) { | |
| 429 | + | r := payloadReader{b: data} | |
| 430 | + | var failure protocol.NavigationInventoryFailure | |
| 431 | + | var err error | |
| 432 | + | if failure.CauseActionID, err = r.getUint64(); err != nil { | |
| 433 | + | return protocol.NavigationInventoryFailure{}, err | |
| 434 | + | } | |
| 435 | + | if failure.InteractionGeneration, err = r.getUint64(); err != nil { | |
| 436 | + | return protocol.NavigationInventoryFailure{}, err | |
| 437 | + | } | |
| 438 | + | if failure.SourceKey, err = r.getString(); err != nil { | |
| 439 | + | return protocol.NavigationInventoryFailure{}, err | |
| 440 | + | } | |
| 441 | + | if failure.EntryKey, err = r.getString(); err != nil { | |
| 442 | + | return protocol.NavigationInventoryFailure{}, err | |
| 443 | + | } | |
| 444 | + | code, err := r.getUint8() | |
| 445 | + | if err != nil { | |
| 446 | + | return protocol.NavigationInventoryFailure{}, err | |
| 447 | + | } | |
| 448 | + | failure.Code = protocol.NavigationInventoryFailureCode(code) | |
| 449 | + | if err := r.done(); err != nil { | |
| 450 | + | return protocol.NavigationInventoryFailure{}, err | |
| 451 | + | } | |
| 452 | + | if err := protocol.ValidateNavigationInventoryFailure(failure); err != nil { | |
| 453 | + | return protocol.NavigationInventoryFailure{}, err | |
| 454 | + | } | |
| 455 | + | return failure, nil | |
| 456 | + | } | |
A internal/protocol/wire/navigation_inventory_test.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strconv" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func inventoryRegistrationForTest() domain.RemoteRegistration { | |
| 13 | + | registration, err := domain.NewRemoteRegistration("user@arch", [16]byte{1, 2, 3}) | |
| 14 | + | if err != nil { | |
| 15 | + | panic(err) | |
| 16 | + | } | |
| 17 | + | return registration | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestNavigationInventoryRequestRoundTrip(t *testing.T) { | |
| 21 | + | tests := []struct { | |
| 22 | + | name string | |
| 23 | + | value protocol.NavigationInventoryRequest | |
| 24 | + | }{ | |
| 25 | + | {name: "snapshot", value: protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventorySnapshot}}, | |
| 26 | + | {name: "resolve", value: protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 2, Operation: protocol.NavigationInventoryResolve, SourceKey: "remote-a", EntryKey: "abc", Registration: inventoryRegistrationForTest()}}, | |
| 27 | + | {name: "local resolve carries zero registration", value: protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 3, Operation: protocol.NavigationInventoryResolve, SourceKey: "local", EntryKey: "abc"}}, | |
| 28 | + | } | |
| 29 | + | for _, tt := range tests { | |
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |
| 31 | + | payload := MarshalNavigationInventoryRequest(tt.value) | |
| 32 | + | if payload == nil { | |
| 33 | + | t.Fatal("MarshalNavigationInventoryRequest returned nil") | |
| 34 | + | } | |
| 35 | + | if version, ok := PeekNavigationInventoryVersion(payload); !ok || version != tt.value.Version { | |
| 36 | + | t.Fatalf("PeekNavigationInventoryVersion() = %d, %v; want %d, true", version, ok, tt.value.Version) | |
| 37 | + | } | |
| 38 | + | if id, ok := PeekNavigationInventoryRequestID(payload); !ok || id != tt.value.RequestID { | |
| 39 | + | t.Fatalf("PeekNavigationInventoryRequestID() = %d, %v; want %d, true", id, ok, tt.value.RequestID) | |
| 40 | + | } | |
| 41 | + | decoded, err := UnmarshalNavigationInventoryRequest(payload) | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatalf("UnmarshalNavigationInventoryRequest() error = %v", err) | |
| 44 | + | } | |
| 45 | + | if decoded != tt.value { | |
| 46 | + | t.Fatalf("UnmarshalNavigationInventoryRequest() = %#v, want %#v", decoded, tt.value) | |
| 47 | + | } | |
| 48 | + | assertAllPrefixesFail(t, payload, UnmarshalNavigationInventoryRequest) | |
| 49 | + | assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventoryRequest) | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | if MarshalNavigationInventoryRequest(protocol.NavigationInventoryRequest{}) != nil { | |
| 53 | + | t.Fatal("MarshalNavigationInventoryRequest accepted zero request") | |
| 54 | + | } | |
| 55 | + | if MarshalNavigationInventoryRequest(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventorySnapshot, SourceKey: "local"}) != nil { | |
| 56 | + | t.Fatal("MarshalNavigationInventoryRequest accepted snapshot with keys") | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestNavigationInventoryResponseRoundTrip(t *testing.T) { | |
| 61 | + | group := protocol.NavigationInventorySourceGroup{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: "one", DisplayOrigin: "local", State: "up"}}} | |
| 62 | + | target := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach} | |
| 63 | + | tests := []struct { | |
| 64 | + | name string | |
| 65 | + | value protocol.NavigationInventoryResponse | |
| 66 | + | }{ | |
| 67 | + | {name: "snapshot", value: protocol.NavigationInventoryResponse{RequestID: 1, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: []protocol.NavigationInventorySourceGroup{group}}}, | |
| 68 | + | {name: "unavailable", value: protocol.NavigationInventoryResponse{RequestID: 2, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryUnavailable, Groups: []protocol.NavigationInventorySourceGroup{{SourceKey: "remote", Status: protocol.NavigationInventorySourceUnavailable}}}}, | |
| 69 | + | {name: "resolve", value: protocol.NavigationInventoryResponse{RequestID: 3, Operation: protocol.NavigationInventoryResolve, Status: protocol.NavigationInventoryOK, Resolved: &target}}, | |
| 70 | + | {name: "version mismatch", value: protocol.NavigationInventoryResponse{RequestID: 4, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryVersionMismatch}}, | |
| 71 | + | } | |
| 72 | + | for _, tt := range tests { | |
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |
| 74 | + | payload := MarshalNavigationInventoryResponse(tt.value) | |
| 75 | + | if payload == nil { | |
| 76 | + | t.Fatal("MarshalNavigationInventoryResponse returned nil") | |
| 77 | + | } | |
| 78 | + | decoded, err := UnmarshalNavigationInventoryResponse(payload) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatalf("UnmarshalNavigationInventoryResponse() error = %v", err) | |
| 81 | + | } | |
| 82 | + | if decoded.RequestID != tt.value.RequestID || decoded.Operation != tt.value.Operation || decoded.Status != tt.value.Status { | |
| 83 | + | t.Fatalf("UnmarshalNavigationInventoryResponse() = %#v, want %#v", decoded, tt.value) | |
| 84 | + | } | |
| 85 | + | assertAllPrefixesFail(t, payload, UnmarshalNavigationInventoryResponse) | |
| 86 | + | assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventoryResponse) | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | bad := protocol.NavigationInventoryResponse{RequestID: 1, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: []protocol.NavigationInventorySourceGroup{{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: "bad\x1b"}}}}} | |
| 90 | + | if MarshalNavigationInventoryResponse(bad) != nil { | |
| 91 | + | t.Fatal("MarshalNavigationInventoryResponse accepted control character") | |
| 92 | + | } | |
| 93 | + | oversize := protocol.NavigationInventoryResponse{RequestID: 1, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: []protocol.NavigationInventorySourceGroup{{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: strings.Repeat("x", protocol.NavigationInventoryMaxDisplayBytes+1)}}}}} | |
| 94 | + | if MarshalNavigationInventoryResponse(oversize) != nil { | |
| 95 | + | t.Fatal("MarshalNavigationInventoryResponse accepted oversize label") | |
| 96 | + | } | |
| 97 | + | remoteEntries := func(n int) []protocol.NavigationInventoryEntry { | |
| 98 | + | entries := make([]protocol.NavigationInventoryEntry, 0, n) | |
| 99 | + | for i := 0; i < n; i++ { | |
| 100 | + | entries = append(entries, protocol.NavigationInventoryEntry{SourceKey: "remote-a", EntryKey: "e/" + strconv.Itoa(i), Name: "n"}) | |
| 101 | + | } | |
| 102 | + | return entries | |
| 103 | + | } | |
| 104 | + | atLimit := protocol.NavigationInventoryResponse{RequestID: 5, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: []protocol.NavigationInventorySourceGroup{{SourceKey: "remote-a", Status: protocol.NavigationInventorySourceOK, Entries: remoteEntries(protocol.NavigationInventoryMaxRemoteEntriesPerSource)}}} | |
| 105 | + | if payload := MarshalNavigationInventoryResponse(atLimit); payload == nil { | |
| 106 | + | t.Fatal("MarshalNavigationInventoryResponse rejected a remote group at its row limit") | |
| 107 | + | } else if _, err := UnmarshalNavigationInventoryResponse(payload); err != nil { | |
| 108 | + | t.Fatalf("UnmarshalNavigationInventoryResponse() error = %v", err) | |
| 109 | + | } | |
| 110 | + | overLimit := protocol.NavigationInventoryResponse{RequestID: 6, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: []protocol.NavigationInventorySourceGroup{{SourceKey: "remote-a", Status: protocol.NavigationInventorySourceOK, Entries: remoteEntries(protocol.NavigationInventoryMaxRemoteEntriesPerSource + 1)}}} | |
| 111 | + | if MarshalNavigationInventoryResponse(overLimit) != nil { | |
| 112 | + | t.Fatal("MarshalNavigationInventoryResponse accepted a remote group past its row limit") | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestNavigationInventoryDemandPublicationSelectionFailureRoundTrip(t *testing.T) { | |
| 117 | + | group := protocol.NavigationInventorySourceGroup{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: "one"}}} | |
| 118 | + | demand := protocol.NavigationInventoryDemand{InteractionGeneration: 3, Open: true} | |
| 119 | + | if payload := MarshalNavigationInventoryDemand(demand); payload == nil { | |
| 120 | + | t.Fatal("MarshalNavigationInventoryDemand returned nil") | |
| 121 | + | } else { | |
| 122 | + | decoded, err := UnmarshalNavigationInventoryDemand(payload) | |
| 123 | + | if err != nil || decoded != demand { | |
| 124 | + | t.Fatalf("UnmarshalNavigationInventoryDemand() = %#v, %v; want %#v", decoded, err, demand) | |
| 125 | + | } | |
| 126 | + | assertAllPrefixesFail(t, payload, UnmarshalNavigationInventoryDemand) | |
| 127 | + | assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventoryDemand) | |
| 128 | + | } | |
| 129 | + | publication := protocol.NavigationInventoryPublication{InteractionGeneration: 3, PublicationGeneration: 1, Groups: []protocol.NavigationInventorySourceGroup{group}} | |
| 130 | + | if payload := MarshalNavigationInventoryPublication(publication); payload == nil { | |
| 131 | + | t.Fatal("MarshalNavigationInventoryPublication returned nil") | |
| 132 | + | } else { | |
| 133 | + | decoded, err := UnmarshalNavigationInventoryPublication(payload) | |
| 134 | + | if err != nil { | |
| 135 | + | t.Fatalf("UnmarshalNavigationInventoryPublication() error = %v", err) | |
| 136 | + | } | |
| 137 | + | if decoded.InteractionGeneration != publication.InteractionGeneration || decoded.PublicationGeneration != publication.PublicationGeneration { | |
| 138 | + | t.Fatalf("UnmarshalNavigationInventoryPublication() = %#v, want %#v", decoded, publication) | |
| 139 | + | } | |
| 140 | + | assertAllPrefixesFail(t, payload, UnmarshalNavigationInventoryPublication) | |
| 141 | + | assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventoryPublication) | |
| 142 | + | } | |
| 143 | + | selection := protocol.NavigationInventorySelection{CauseActionID: 9, InteractionGeneration: 3, PublicationGeneration: 1, SourceKey: "local", EntryKey: "a"} | |
| 144 | + | if payload := MarshalNavigationInventorySelection(selection); payload == nil { | |
| 145 | + | t.Fatal("MarshalNavigationInventorySelection returned nil") | |
| 146 | + | } else { | |
| 147 | + | decoded, err := UnmarshalNavigationInventorySelection(payload) | |
| 148 | + | if err != nil || decoded != selection { | |
| 149 | + | t.Fatalf("UnmarshalNavigationInventorySelection() = %#v, %v; want %#v", decoded, err, selection) | |
| 150 | + | } | |
| 151 | + | assertAllPrefixesFail(t, payload, UnmarshalNavigationInventorySelection) | |
| 152 | + | assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventorySelection) | |
| 153 | + | } | |
| 154 | + | failure := protocol.NavigationInventoryFailure{CauseActionID: 9, InteractionGeneration: 3, SourceKey: "local", EntryKey: "a", Code: protocol.NavigationInventoryStaleIdentity} | |
| 155 | + | if payload := MarshalNavigationInventoryFailure(failure); payload == nil { | |
| 156 | + | t.Fatal("MarshalNavigationInventoryFailure returned nil") | |
| 157 | + | } else { | |
| 158 | + | decoded, err := UnmarshalNavigationInventoryFailure(payload) | |
| 159 | + | if err != nil || decoded != failure { | |
| 160 | + | t.Fatalf("UnmarshalNavigationInventoryFailure() = %#v, %v; want %#v", decoded, err, failure) | |
| 161 | + | } | |
| 162 | + | assertAllPrefixesFail(t, payload, UnmarshalNavigationInventoryFailure) | |
| 163 | + | assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventoryFailure) | |
| 164 | + | } | |
| 165 | + | } | |
M internal/protocol/wire/navigation_test.go +2 -2
| @@ -153,10 +153,10 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 153 | 153 | {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | |
| 154 | 154 | {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 155 | 155 | {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 156 | - | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002902" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 156 | + | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002a02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 157 | 157 | {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker}, | |
| 158 | 158 | {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | |
| 159 | - | {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| 159 | + | {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 8}, valid: false}, | |
| 160 | 160 | {name: "back without picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false}, | |
| 161 | 161 | {name: "picker without back", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | |
| 162 | 162 | } | |
M internal/protocol/wire/wire_final_test.go +3 -3
| @@ -12,8 +12,8 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 15 | - | if protocol.Version != 41 { | |
| 16 | - | t.Fatalf("ProtocolVersion = %d, want 41", protocol.Version) | |
| 15 | + | if protocol.Version != 42 { | |
| 16 | + | t.Fatalf("ProtocolVersion = %d, want 42", protocol.Version) | |
| 17 | 17 | } | |
| 18 | 18 | payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 19 | 19 | if len(payload) < 2 { | |
| @@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 27 | 27 | ||
| 28 | 28 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 29 | 29 | msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 30 | - | want := append([]byte{0, 41, 2}, make([]byte, 16+8)...) | |
| 30 | + | want := append([]byte{0, 42, 2}, make([]byte, 16+8)...) | |
| 31 | 31 | 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, 0, 0, 0, 0) | |
| 32 | 32 | want = append(want, 0, 0) | |
| 33 | 33 | got := MarshalHello(msg) | |
M internal/protocol/wire/wire_test.go +6 -6
| @@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 136 | 136 | } | |
| 137 | 137 | got := MarshalHello(msg) | |
| 138 | 138 | want := []byte{ | |
| 139 | - | 0x00, 0x29, 0x00, // version, intent, first client ID byte | |
| 139 | + | 0x00, 0x2a, 0x00, // version, intent, first client ID byte | |
| 140 | 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 141 | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 142 | 142 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 367 | 367 | } | |
| 368 | 368 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 369 | 369 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 370 | - | require.Equal(t, uint16(41), protocol.Version) | |
| 370 | + | require.Equal(t, uint16(42), protocol.Version) | |
| 371 | 371 | } | |
| 372 | 372 | ||
| 373 | 373 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 528 | 528 | name: "minimal", | |
| 529 | 529 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"}, | |
| 530 | 530 | want: []byte{ | |
| 531 | - | 0x00, 0x29, | |
| 531 | + | 0x00, 0x2a, | |
| 532 | 532 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 533 | 533 | 0x00, // attached | |
| 534 | 534 | 0x00, // self | |
| @@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 551 | 551 | JSON: true, | |
| 552 | 552 | }, | |
| 553 | 553 | want: []byte{ | |
| 554 | - | 0x00, 0x29, | |
| 554 | + | 0x00, 0x2a, | |
| 555 | 555 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 556 | 556 | 0x00, // attached | |
| 557 | 557 | 0x01, // self | |
| @@ -999,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 999 | 999 | name: "attached", | |
| 1000 | 1000 | msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 1001 | 1001 | want: []byte{ | |
| 1002 | - | 0x00, 0x29, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 1002 | + | 0x00, 0x2a, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 1003 | 1003 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 1004 | 1004 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 1005 | 1005 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -1009,7 +1009,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 1009 | 1009 | name: "control", | |
| 1010 | 1010 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"}, | |
| 1011 | 1011 | want: []byte{ | |
| 1012 | - | 0x00, 0x29, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1012 | + | 0x00, 0x2a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1013 | 1013 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 1014 | 1014 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1015 | 1015 | }, | |
M internal/usecase/client/client.go +405 -70
| @@ -169,6 +169,14 @@ type Dependencies struct { | |||
| 169 | 169 | // AttachHandoff keeps one Runner and one terminal/input ownership across a | |
| 170 | 170 | // local daemon's structured remote handoff. It is nil for direct CLI attach. | |
| 171 | 171 | AttachHandoff AttachHandoffFunc | |
| 172 | + | // LocalControlDialer is an optional dial-only control source for the | |
| 173 | + | // local daemon inventory. It must never start the daemon, create an | |
| 174 | + | // attachment, or call the CLI: a missing socket reports local control | |
| 175 | + | // unavailability. The relay enables it only after this client has a | |
| 176 | + | // committed explicitly local route and while its serving attachment is | |
| 177 | + | // remote; local-only and direct remote-only clients leave it nil and | |
| 178 | + | // perform zero control dials. | |
| 179 | + | LocalControlDialer ports.ClientDialer | |
| 172 | 180 | // Remote selects client-side carriage presentation only; it never enters | |
| 173 | 181 | // the daemon-facing session request. | |
| 174 | 182 | Remote bool | |
| @@ -214,6 +222,12 @@ type attachRoute struct { | |||
| 214 | 222 | type attachHandoff struct { | |
| 215 | 223 | target protocol.AttachTarget | |
| 216 | 224 | source attachRoute | |
| 225 | + | // Run captures inventory recovery from its rebased serving request. | |
| 226 | + | inventory bool | |
| 227 | + | // inventorySelection identifies the serving palette selection behind | |
| 228 | + | // an inventory handoff. Run retains it so destination failure can | |
| 229 | + | // report NavigationInventoryNavigationFailed after restoration. | |
| 230 | + | inventorySelection protocol.NavigationInventorySelection | |
| 217 | 231 | } | |
| 218 | 232 | ||
| 219 | 233 | func bindAttachHandoff(target protocol.AttachTarget, source attachRoute) *attachHandoff { | |
| @@ -232,10 +246,16 @@ type Runner struct { | |||
| 232 | 246 | remoteHostLearner ports.RemoteHostLearner | |
| 233 | 247 | attachHandoff AttachHandoffFunc | |
| 234 | 248 | remote bool | |
| 235 | - | origin protocol.RouteOrigin | |
| 236 | - | ledger *routeLedger | |
| 237 | - | routeFailure *protocol.RouteNavigationFailure | |
| 238 | - | creationFailure *protocol.SessionCreationFailure | |
| 249 | + | // localControlDialer is the dial-only inventory source. It never | |
| 250 | + | // starts the daemon; the relay is its sole consumer. | |
| 251 | + | localControlDialer ports.ClientDialer | |
| 252 | + | origin protocol.RouteOrigin | |
| 253 | + | ledger *routeLedger | |
| 254 | + | routeFailure *protocol.RouteNavigationFailure | |
| 255 | + | creationFailure *protocol.SessionCreationFailure | |
| 256 | + | // inventoryFailure reports a failed inventory handoff to the restored | |
| 257 | + | // serving daemon on the next successful handshake. | |
| 258 | + | inventoryFailure *protocol.NavigationInventoryFailure | |
| 239 | 259 | ||
| 240 | 260 | inputMu sync.Mutex | |
| 241 | 261 | input *terminalInputPump | |
| @@ -252,19 +272,20 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 252 | 272 | log = slog.Default() | |
| 253 | 273 | } | |
| 254 | 274 | return &Runner{ | |
| 255 | - | ui: deps.UI, | |
| 256 | - | dialer: deps.Dialer, | |
| 257 | - | term: deps.Terminal, | |
| 258 | - | clock: deps.Clock, | |
| 259 | - | clipboard: deps.Clipboard, | |
| 260 | - | logger: log, | |
| 261 | - | runtimeObserver: deps.RuntimeObserver, | |
| 262 | - | remoteHostLearner: deps.RemoteHostLearner, | |
| 263 | - | attachHandoff: deps.AttachHandoff, | |
| 264 | - | remote: deps.Remote, | |
| 265 | - | origin: normalizeRouteOrigin(deps.Origin, deps.Remote), | |
| 266 | - | probeCapabilities: !deps.DisableCapabilityProbe, | |
| 267 | - | ledger: newRouteLedger(), | |
| 275 | + | ui: deps.UI, | |
| 276 | + | dialer: deps.Dialer, | |
| 277 | + | term: deps.Terminal, | |
| 278 | + | clock: deps.Clock, | |
| 279 | + | clipboard: deps.Clipboard, | |
| 280 | + | logger: log, | |
| 281 | + | runtimeObserver: deps.RuntimeObserver, | |
| 282 | + | remoteHostLearner: deps.RemoteHostLearner, | |
| 283 | + | attachHandoff: deps.AttachHandoff, | |
| 284 | + | remote: deps.Remote, | |
| 285 | + | localControlDialer: deps.LocalControlDialer, | |
| 286 | + | origin: normalizeRouteOrigin(deps.Origin, deps.Remote), | |
| 287 | + | probeCapabilities: !deps.DisableCapabilityProbe, | |
| 288 | + | ledger: newRouteLedger(), | |
| 268 | 289 | } | |
| 269 | 290 | } | |
| 270 | 291 | ||
| @@ -496,16 +517,20 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 496 | 517 | returnResumeFallback := false | |
| 497 | 518 | homeNavigationPending := false | |
| 498 | 519 | returnNavigationPending := false | |
| 499 | - | routeNavigationPending := false | |
| 520 | + | // navTransition owns creation and recent-route fallback lifecycle: | |
| 521 | + | // the captured prior route, selected identity, and settle-once state. | |
| 522 | + | // routeNavigationSelection and routeNavigationAction remain as the | |
| 523 | + | // in-attempt payload consumed by attachAttempt (commit input and | |
| 524 | + | // failure matching), not as cross-attempt fallback authority. | |
| 525 | + | // routeNavigationResumeFallback stays as the transport-level exact-attach | |
| 526 | + | // retry flag. Home-picker return state stays distinct per its overlay | |
| 527 | + | // semantics. | |
| 528 | + | var navTransition navigationTransition | |
| 500 | 529 | routeNavigationResumeFallback := false | |
| 501 | 530 | var routeNavigationSelection *routeNavigationSelection | |
| 502 | 531 | var killedSelection *killedRouteSelection | |
| 503 | 532 | killedResumeFallback := false | |
| 504 | - | var routeNavigationFallback *attachRoute | |
| 505 | 533 | var routeNavigationAction *protocol.RouteNavigationAction | |
| 506 | - | creationPending := false | |
| 507 | - | var creationFallback *attachRoute | |
| 508 | - | var creationRequestID uint64 | |
| 509 | 534 | backoff := defaultReconnectBackoff.initial | |
| 510 | 535 | themeState := &terminalThemeState{} | |
| 511 | 536 | var rememberOnce sync.Once | |
| @@ -605,6 +630,27 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 605 | 630 | } | |
| 606 | 631 | return nil | |
| 607 | 632 | } | |
| 633 | + | // restorePendingInventory recovers the serving route of an unsettled | |
| 634 | + | // inventory handoff whose destination dial failed. It reports whether a | |
| 635 | + | // restore happened; callers redial the return route on true and fail | |
| 636 | + | // with the dial error when the transition itself is broken. | |
| 637 | + | restorePendingInventory := func() bool { | |
| 638 | + | if !navTransition.pendingInventory() { | |
| 639 | + | return false | |
| 640 | + | } | |
| 641 | + | failure, hasFailure := navTransition.inventoryNavigationFailure() | |
| 642 | + | route, ok := navTransition.restore() | |
| 643 | + | if !ok { | |
| 644 | + | return false | |
| 645 | + | } | |
| 646 | + | navTransition.settleFailure() | |
| 647 | + | if hasFailure { | |
| 648 | + | pending := failure | |
| 649 | + | r.inventoryFailure = &pending | |
| 650 | + | } | |
| 651 | + | restoreReturnRoute(route) | |
| 652 | + | return true | |
| 653 | + | } | |
| 608 | 654 | ||
| 609 | 655 | runTransientHomePicker := func(attemptCtx context.Context) attachResult { | |
| 610 | 656 | if homeRoute == nil { | |
| @@ -674,16 +720,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 674 | 720 | if killedSelection != nil { | |
| 675 | 721 | return errors.Join(err, r.ledger.retireKilled(*killedSelection)) | |
| 676 | 722 | } | |
| 677 | - | if creationPending && creationFallback != nil && ctx.Err() == nil { | |
| 678 | - | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(err)} | |
| 679 | - | route := *creationFallback | |
| 680 | - | creationFallback = nil | |
| 681 | - | creationPending = false | |
| 682 | - | creationRequestID = 0 | |
| 723 | + | if navTransition.pendingCreation() && ctx.Err() == nil { | |
| 724 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: routeFailureCode(err)} | |
| 725 | + | route, ok := navTransition.restore() | |
| 726 | + | if !ok { | |
| 727 | + | return err | |
| 728 | + | } | |
| 729 | + | navTransition.settleFailure() | |
| 683 | 730 | returnNavigationPending = true | |
| 684 | 731 | restoreReturnRoute(route) | |
| 685 | 732 | continue | |
| 686 | 733 | } | |
| 734 | + | if navTransition.pendingInventory() { | |
| 735 | + | if !restorePendingInventory() { | |
| 736 | + | return err | |
| 737 | + | } | |
| 738 | + | continue | |
| 739 | + | } | |
| 687 | 740 | if resumeToken == 0 || ctx.Err() != nil { | |
| 688 | 741 | if clearErr := reconnect.clear(); clearErr != nil { | |
| 689 | 742 | return errors.Join(err, clearErr) | |
| @@ -715,16 +768,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 715 | 768 | if killedSelection != nil { | |
| 716 | 769 | return errors.Join(err, r.ledger.retireKilled(*killedSelection)) | |
| 717 | 770 | } | |
| 718 | - | if creationPending && creationFallback != nil { | |
| 719 | - | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable} | |
| 720 | - | route := *creationFallback | |
| 721 | - | creationFallback = nil | |
| 722 | - | creationPending = false | |
| 723 | - | creationRequestID = 0 | |
| 771 | + | if navTransition.pendingCreation() { | |
| 772 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: protocol.RouteFailureUnavailable} | |
| 773 | + | route, ok := navTransition.restore() | |
| 774 | + | if !ok { | |
| 775 | + | return err | |
| 776 | + | } | |
| 777 | + | navTransition.settleFailure() | |
| 724 | 778 | returnNavigationPending = true | |
| 725 | 779 | restoreReturnRoute(route) | |
| 726 | 780 | continue | |
| 727 | 781 | } | |
| 782 | + | if navTransition.pendingInventory() { | |
| 783 | + | if !restorePendingInventory() { | |
| 784 | + | return err | |
| 785 | + | } | |
| 786 | + | continue | |
| 787 | + | } | |
| 728 | 788 | return err | |
| 729 | 789 | } | |
| 730 | 790 | stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport) | |
| @@ -736,16 +796,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 736 | 796 | stopHandshakeTransport() | |
| 737 | 797 | finishHandshake() | |
| 738 | 798 | _ = transport.Close() | |
| 739 | - | if creationPending && creationFallback != nil { | |
| 740 | - | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable} | |
| 741 | - | route := *creationFallback | |
| 742 | - | creationFallback = nil | |
| 743 | - | creationPending = false | |
| 744 | - | creationRequestID = 0 | |
| 799 | + | if navTransition.pendingCreation() { | |
| 800 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: protocol.RouteFailureUnavailable} | |
| 801 | + | route, ok := navTransition.restore() | |
| 802 | + | if !ok { | |
| 803 | + | return err | |
| 804 | + | } | |
| 805 | + | navTransition.settleFailure() | |
| 745 | 806 | returnNavigationPending = true | |
| 746 | 807 | restoreReturnRoute(route) | |
| 747 | 808 | continue | |
| 748 | 809 | } | |
| 810 | + | if navTransition.pendingInventory() { | |
| 811 | + | if !restorePendingInventory() { | |
| 812 | + | return err | |
| 813 | + | } | |
| 814 | + | continue | |
| 815 | + | } | |
| 749 | 816 | return err | |
| 750 | 817 | } | |
| 751 | 818 | ||
| @@ -778,7 +845,14 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 778 | 845 | terminalInput: func() *terminalInputPump { | |
| 779 | 846 | return input | |
| 780 | 847 | }, | |
| 781 | - | openHomePicker: runTransientHomePicker, | |
| 848 | + | openHomePicker: runTransientHomePicker, | |
| 849 | + | inventoryHome: homeRoute, | |
| 850 | + | inventoryDialer: r.localControlDialer, | |
| 851 | + | onInventoryCommitted: func() { | |
| 852 | + | if navTransition.pendingInventory() { | |
| 853 | + | navTransition.settleSuccess() | |
| 854 | + | } | |
| 855 | + | }, | |
| 782 | 856 | }).run(ctx) | |
| 783 | 857 | stopHandshakeTransport() | |
| 784 | 858 | finishHandshake() | |
| @@ -816,22 +890,22 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 816 | 890 | attemptRequest.PreferredTabID = result.routePosition.ActiveTabID | |
| 817 | 891 | attemptRequest.RemoteTarget = nil | |
| 818 | 892 | } | |
| 819 | - | if result.err == nil && result.welcomed && creationPending { | |
| 820 | - | creationPending = false | |
| 821 | - | creationFallback = nil | |
| 822 | - | creationRequestID = 0 | |
| 893 | + | if result.err == nil && result.welcomed && navTransition.pendingCreation() { | |
| 894 | + | navTransition.settleSuccess() | |
| 823 | 895 | } | |
| 824 | 896 | if result.err == nil && result.welcomed && killedSelection != nil { | |
| 825 | 897 | killedSelection = nil | |
| 826 | 898 | killedResumeFallback = false | |
| 827 | 899 | } | |
| 828 | - | if result.err == nil && result.welcomed && routeNavigationPending { | |
| 829 | - | routeNavigationPending = false | |
| 900 | + | if result.err == nil && result.welcomed && navTransition.pendingRecent() { | |
| 901 | + | navTransition.settleSuccess() | |
| 830 | 902 | routeNavigationSelection = nil | |
| 831 | 903 | routeNavigationResumeFallback = false | |
| 832 | - | routeNavigationFallback = nil | |
| 833 | 904 | routeNavigationAction = nil | |
| 834 | 905 | } | |
| 906 | + | if result.err == nil && result.welcomed && navTransition.pendingInventory() { | |
| 907 | + | navTransition.settleSuccess() | |
| 908 | + | } | |
| 835 | 909 | if result.routeCreateAction != nil { | |
| 836 | 910 | action := *result.routeCreateAction | |
| 837 | 911 | source := attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken} | |
| @@ -848,7 +922,8 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 848 | 922 | restoreReturnRoute(source) | |
| 849 | 923 | continue | |
| 850 | 924 | } | |
| 851 | - | creationFallback = &attachRoute{dialer: selection.prior.dialer, request: cloneAttachRequest(selection.prior.request), resumeToken: selection.prior.resumeToken} | |
| 925 | + | priorRoute := attachRoute{dialer: selection.prior.dialer, request: cloneAttachRequest(selection.prior.request), resumeToken: selection.prior.resumeToken} | |
| 926 | + | navTransition.beginCreation(priorRoute, action.RequestID) | |
| 852 | 927 | dialer = selection.selected.dialer | |
| 853 | 928 | attemptRequest = cloneAttachRequest(selection.selected.request) | |
| 854 | 929 | attemptRequest.Intent = protocol.IntentNew | |
| @@ -860,8 +935,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 860 | 935 | attemptRequest.NavigationCapabilities = 0 | |
| 861 | 936 | attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned | |
| 862 | 937 | resumeToken = 0 | |
| 863 | - | creationPending = true | |
| 864 | - | creationRequestID = action.RequestID | |
| 865 | 938 | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | |
| 866 | 939 | backoff = defaultReconnectBackoff.initial | |
| 867 | 940 | continue | |
| @@ -879,7 +952,8 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 879 | 952 | } | |
| 880 | 953 | action := *result.routeAction | |
| 881 | 954 | routeNavigationAction = &action | |
| 882 | - | routeNavigationFallback = &attachRoute{dialer: selection.prior.dialer, request: selection.prior.request, resumeToken: selection.prior.resumeToken} | |
| 955 | + | priorRoute := attachRoute{dialer: selection.prior.dialer, request: selection.prior.request, resumeToken: selection.prior.resumeToken} | |
| 956 | + | navTransition.beginRecent(priorRoute) | |
| 883 | 957 | dialer = selection.selected.dialer | |
| 884 | 958 | attemptRequest = cloneAttachRequest(selection.selected.request) | |
| 885 | 959 | if selection.selected.resumeToken != 0 { | |
| @@ -896,7 +970,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 896 | 970 | attemptRequest.NavigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 897 | 971 | } | |
| 898 | 972 | resumeToken = selection.selected.resumeToken | |
| 899 | - | routeNavigationPending = true | |
| 900 | 973 | routeNavigationSelection = &selection | |
| 901 | 974 | routeNavigationResumeFallback = selection.selected.resumeToken != 0 | |
| 902 | 975 | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | |
| @@ -1015,10 +1088,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 1015 | 1088 | return errors.New("vev: route handoff returned nil dialer") | |
| 1016 | 1089 | } | |
| 1017 | 1090 | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |
| 1018 | - | fallback := source | |
| 1019 | - | creationFallback = &fallback | |
| 1020 | - | creationPending = true | |
| 1021 | - | creationRequestID = target.RequestID | |
| 1091 | + | navTransition.beginCreation(source, target.RequestID) | |
| 1092 | + | } | |
| 1093 | + | if result.handoff != nil && result.handoff.inventory { | |
| 1094 | + | navTransition.beginInventory(attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken}, result.handoff.inventorySelection) | |
| 1022 | 1095 | } | |
| 1023 | 1096 | dialer = nextDialer | |
| 1024 | 1097 | attemptRequest = nextRequest | |
| @@ -1065,9 +1138,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 1065 | 1138 | returnRoute = nil | |
| 1066 | 1139 | homeNavigationPending = false | |
| 1067 | 1140 | returnNavigationPending = false | |
| 1068 | - | routeNavigationPending = false | |
| 1141 | + | if navTransition.operation == navigationOperationRecent { | |
| 1142 | + | navTransition.clear() | |
| 1143 | + | } | |
| 1069 | 1144 | routeNavigationSelection = nil | |
| 1070 | - | routeNavigationFallback = nil | |
| 1071 | 1145 | routeNavigationAction = nil | |
| 1072 | 1146 | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | |
| 1073 | 1147 | backoff = defaultReconnectBackoff.initial | |
| @@ -1093,31 +1167,40 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 1093 | 1167 | restoreReturnRoute(route) | |
| 1094 | 1168 | continue | |
| 1095 | 1169 | } | |
| 1096 | - | if creationPending && creationFallback != nil { | |
| 1097 | - | r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(result.err)} | |
| 1098 | - | route := *creationFallback | |
| 1099 | - | creationFallback = nil | |
| 1100 | - | creationPending = false | |
| 1101 | - | creationRequestID = 0 | |
| 1170 | + | if navTransition.pendingCreation() { | |
| 1171 | + | r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: routeFailureCode(result.err)} | |
| 1172 | + | route, ok := navTransition.restore() | |
| 1173 | + | if !ok { | |
| 1174 | + | return result.err | |
| 1175 | + | } | |
| 1176 | + | navTransition.settleFailure() | |
| 1102 | 1177 | returnNavigationPending = true | |
| 1103 | 1178 | restoreReturnRoute(route) | |
| 1104 | 1179 | continue | |
| 1105 | 1180 | } | |
| 1106 | - | if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) { | |
| 1181 | + | if navTransition.pendingInventory() { | |
| 1182 | + | if !restorePendingInventory() { | |
| 1183 | + | return result.err | |
| 1184 | + | } | |
| 1185 | + | continue | |
| 1186 | + | } | |
| 1187 | + | if navTransition.pendingRecent() && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) { | |
| 1107 | 1188 | attemptRequest.Intent = protocol.IntentAttach | |
| 1108 | 1189 | resumeToken = 0 | |
| 1109 | 1190 | routeNavigationResumeFallback = false | |
| 1110 | 1191 | continue | |
| 1111 | 1192 | } | |
| 1112 | - | if routeNavigationPending && routeNavigationFallback != nil { | |
| 1193 | + | if navTransition.pendingRecent() { | |
| 1113 | 1194 | if routeNavigationAction != nil { | |
| 1114 | 1195 | action := *routeNavigationAction | |
| 1115 | 1196 | r.routeFailure = &protocol.RouteNavigationFailure{Key: action.Key, Generation: action.Generation, Code: routeFailureCode(result.err)} | |
| 1116 | 1197 | routeNavigationAction = nil | |
| 1117 | 1198 | } | |
| 1118 | - | route := *routeNavigationFallback | |
| 1119 | - | routeNavigationFallback = nil | |
| 1120 | - | routeNavigationPending = false | |
| 1199 | + | route, ok := navTransition.restore() | |
| 1200 | + | if !ok { | |
| 1201 | + | return result.err | |
| 1202 | + | } | |
| 1203 | + | navTransition.settleFailure() | |
| 1121 | 1204 | routeNavigationSelection = nil | |
| 1122 | 1205 | routeNavigationResumeFallback = false | |
| 1123 | 1206 | returnNavigationPending = true | |
| @@ -1317,6 +1400,20 @@ type attachAttempt struct { | |||
| 1317 | 1400 | rememberRemoteHost func() | |
| 1318 | 1401 | terminalInput func() *terminalInputPump | |
| 1319 | 1402 | openHomePicker func(context.Context) attachResult | |
| 1403 | + | // inventoryHome is the committed local route that authorises the | |
| 1404 | + | // navigation-inventory relay and its handoffs. Nil disables the relay: | |
| 1405 | + | // local-only and ledger-less attachments never dial control sources. | |
| 1406 | + | inventoryHome *attachRoute | |
| 1407 | + | // inventoryDialer is the dial-only control source for relay queries. | |
| 1408 | + | // It never starts the daemon; home.dialer stays the sole attachment | |
| 1409 | + | // authority. | |
| 1410 | + | inventoryDialer ports.ClientDialer | |
| 1411 | + | // onInventoryCommitted settles the inventory transition when the | |
| 1412 | + | // destination's initial publications commit. attachAttempt.run lives | |
| 1413 | + | // for the attachment lifetime, so the outer loop cannot settle there: | |
| 1414 | + | // a later transport failure must reconnect the active destination, | |
| 1415 | + | // not restore the old serving route. | |
| 1416 | + | onInventoryCommitted func() | |
| 1320 | 1417 | } | |
| 1321 | 1418 | ||
| 1322 | 1419 | type attachResult struct { | |
| @@ -1392,6 +1489,17 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1392 | 1489 | transition := a.transition | |
| 1393 | 1490 | linkEvents := a.linkEvents | |
| 1394 | 1491 | remote := a.remote || a.request.Remote | |
| 1492 | + | // The inventory relay serves the serving daemon's palette from the | |
| 1493 | + | // dial-only local control source while this attachment is remote. It | |
| 1494 | + | // owns no goroutine: the attempt loop drives polling and publishes | |
| 1495 | + | // deltas, and bounded query workers report through the poll box and | |
| 1496 | + | // resolve channel. Advertising the capability in Hello is what lets | |
| 1497 | + | // the serving palette send demands at all. | |
| 1498 | + | var inventory *inventoryRelay | |
| 1499 | + | if home := a.inventoryHome; home != nil && inventoryRelayEnabled(true, remote && !home.request.Remote, a.inventoryDialer) { | |
| 1500 | + | inventory = newInventoryRelay(clk, a.inventoryDialer) | |
| 1501 | + | request.NavigationCapabilities |= protocol.NavigationCapabilityInventory | |
| 1502 | + | } | |
| 1395 | 1503 | clipboard := a.runner.clipboard | |
| 1396 | 1504 | log := a.runner.logger | |
| 1397 | 1505 | observer := a.runner.runtimeObserver | |
| @@ -1586,6 +1694,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1586 | 1694 | } | |
| 1587 | 1695 | a.runner.routeFailure = nil | |
| 1588 | 1696 | } | |
| 1697 | + | if a.runner.inventoryFailure != nil { | |
| 1698 | + | if err := sendHandshake(func() error { | |
| 1699 | + | return transport.SendClient(*a.runner.inventoryFailure) | |
| 1700 | + | }); err != nil { | |
| 1701 | + | return welcomedResult(fmt.Errorf("vev: publishing inventory navigation failure: %w", err)) | |
| 1702 | + | } | |
| 1703 | + | a.runner.inventoryFailure = nil | |
| 1704 | + | } | |
| 1589 | 1705 | ||
| 1590 | 1706 | // 3. Enter raw mode after Welcome; Run owns restoration. | |
| 1591 | 1707 | if err := enterRaw(); err != nil { | |
| @@ -1597,6 +1713,63 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1597 | 1713 | // coordinator owner. | |
| 1598 | 1714 | loopCtx, cancel := context.WithCancel(ctx) | |
| 1599 | 1715 | defer cancel() | |
| 1716 | + | // Poll and resolve workers report through the box and channel below; | |
| 1717 | + | // completions carry their originating interaction and query identity. | |
| 1718 | + | // The relay itself is created before Hello so the capability advertises. | |
| 1719 | + | inventoryBox := newInventoryPollBox() | |
| 1720 | + | var inventoryResolveC chan inventoryResolveOutcome | |
| 1721 | + | inventoryResolvePending := false | |
| 1722 | + | var nextInventoryRequestID uint64 = 1 | |
| 1723 | + | inventoryOpen := false | |
| 1724 | + | var inventoryInteraction uint64 | |
| 1725 | + | var inventoryTimer ports.Timer | |
| 1726 | + | var inventoryTickC <-chan time.Time | |
| 1727 | + | stopInventoryTimer := func() { | |
| 1728 | + | if inventoryTimer != nil { | |
| 1729 | + | inventoryTimer.Stop() | |
| 1730 | + | inventoryTimer = nil | |
| 1731 | + | } | |
| 1732 | + | inventoryTickC = nil | |
| 1733 | + | } | |
| 1734 | + | defer stopInventoryTimer() | |
| 1735 | + | armInventoryPoll := func() { | |
| 1736 | + | if inventory == nil || !inventoryOpen { | |
| 1737 | + | stopInventoryTimer() | |
| 1738 | + | return | |
| 1739 | + | } | |
| 1740 | + | if inventoryTimer != nil { | |
| 1741 | + | inventoryTimer.Stop() | |
| 1742 | + | } | |
| 1743 | + | inventoryTimer = clk.NewTimer(inventoryPollInterval) | |
| 1744 | + | inventoryTickC = inventoryTimer.C() | |
| 1745 | + | } | |
| 1746 | + | var cancelInventoryPoll context.CancelFunc | |
| 1747 | + | stopInventoryPoll := func() { | |
| 1748 | + | if cancelInventoryPoll != nil { | |
| 1749 | + | cancelInventoryPoll() | |
| 1750 | + | cancelInventoryPoll = nil | |
| 1751 | + | } | |
| 1752 | + | } | |
| 1753 | + | defer stopInventoryPoll() | |
| 1754 | + | startInventoryPoll := func() { | |
| 1755 | + | if inventory == nil || !inventoryOpen { | |
| 1756 | + | return | |
| 1757 | + | } | |
| 1758 | + | interaction := inventoryInteraction | |
| 1759 | + | query, ok := inventory.beginPoll() | |
| 1760 | + | if !ok { | |
| 1761 | + | return | |
| 1762 | + | } | |
| 1763 | + | queryID := nextInventoryRequestID | |
| 1764 | + | nextInventoryRequestID++ | |
| 1765 | + | queryCtx, cancel := context.WithCancel(loopCtx) | |
| 1766 | + | cancelInventoryPoll = cancel | |
| 1767 | + | go func() { | |
| 1768 | + | defer cancel() | |
| 1769 | + | response, err := inventory.querySnapshot(queryCtx, queryID) | |
| 1770 | + | inventoryBox.offer(inventoryPollOutcome{interaction: interaction, query: query, response: response, err: err}) | |
| 1771 | + | }() | |
| 1772 | + | } | |
| 1600 | 1773 | input := (*terminalInputPump)(nil) | |
| 1601 | 1774 | if a.terminalInput != nil { | |
| 1602 | 1775 | input = a.terminalInput() | |
| @@ -1774,6 +1947,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1774 | 1947 | // handshake deadline must not own the long-lived runtime transport: a quiet | |
| 1775 | 1948 | // or unchanged screen may legitimately produce no initial Output frame. | |
| 1776 | 1949 | endHandshake() | |
| 1950 | + | if a.onInventoryCommitted != nil { | |
| 1951 | + | a.onInventoryCommitted() | |
| 1952 | + | } | |
| 1777 | 1953 | senderStarted = true | |
| 1778 | 1954 | go runSender(loopCtx, cancel, transport, controlCh, barrierCh, sendCh, inputGate, ackQueue, sendErrCh, log) | |
| 1779 | 1955 | ||
| @@ -2069,6 +2245,106 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2069 | 2245 | case <-parkedFullC(): | |
| 2070 | 2246 | clearParkedFull() | |
| 2071 | 2247 | return welcomedResult(errors.New("vev: timed out waiting for parked-route full output")) | |
| 2248 | + | case <-inventoryTickC: | |
| 2249 | + | startInventoryPoll() | |
| 2250 | + | armInventoryPoll() | |
| 2251 | + | case <-inventoryBox.wake: | |
| 2252 | + | pollOutcome, ok := inventoryBox.take() | |
| 2253 | + | if !ok { | |
| 2254 | + | continue | |
| 2255 | + | } | |
| 2256 | + | stopInventoryPoll() | |
| 2257 | + | if inventory != nil { | |
| 2258 | + | inventory.endPoll(pollOutcome.query) | |
| 2259 | + | } | |
| 2260 | + | if pollOutcome.interaction != inventoryInteraction { | |
| 2261 | + | startInventoryPoll() | |
| 2262 | + | } | |
| 2263 | + | if pollOutcome.err != nil || inventory == nil || !inventoryOpen || | |
| 2264 | + | pollOutcome.interaction != inventoryInteraction { | |
| 2265 | + | armInventoryPoll() | |
| 2266 | + | continue | |
| 2267 | + | } | |
| 2268 | + | if groups, generation, changed := inventory.preparePublication(pollOutcome.response.Groups); changed { | |
| 2269 | + | publication := protocol.NavigationInventoryPublication{ | |
| 2270 | + | InteractionGeneration: inventoryInteraction, | |
| 2271 | + | PublicationGeneration: generation, Groups: groups, | |
| 2272 | + | } | |
| 2273 | + | if err := protocol.ValidateNavigationInventoryPublication(publication); err != nil { | |
| 2274 | + | log.Debug("dropping invalid inventory publication", "err", err) | |
| 2275 | + | armInventoryPoll() | |
| 2276 | + | continue | |
| 2277 | + | } | |
| 2278 | + | select { | |
| 2279 | + | case controlCh <- publication: | |
| 2280 | + | case <-loopCtx.Done(): | |
| 2281 | + | return loopCanceledResult() | |
| 2282 | + | } | |
| 2283 | + | } | |
| 2284 | + | armInventoryPoll() | |
| 2285 | + | case resolveOutcome := <-inventoryResolveC: | |
| 2286 | + | inventoryResolveC = nil | |
| 2287 | + | inventoryResolvePending = false | |
| 2288 | + | if inventory == nil { | |
| 2289 | + | continue | |
| 2290 | + | } | |
| 2291 | + | if resolveOutcome.err != nil { | |
| 2292 | + | // The serving palette already closed on selection; report | |
| 2293 | + | // the failure so its feedback names the dead entry. | |
| 2294 | + | code := protocol.NavigationInventoryStaleIdentity | |
| 2295 | + | if errors.Is(resolveOutcome.err, errInventorySourceUnavailable) { | |
| 2296 | + | code = protocol.NavigationInventorySourceGone | |
| 2297 | + | } | |
| 2298 | + | failure := protocol.NavigationInventoryFailure{ | |
| 2299 | + | CauseActionID: resolveOutcome.selection.CauseActionID, | |
| 2300 | + | InteractionGeneration: resolveOutcome.selection.InteractionGeneration, | |
| 2301 | + | SourceKey: resolveOutcome.selection.SourceKey, | |
| 2302 | + | EntryKey: resolveOutcome.selection.EntryKey, | |
| 2303 | + | Code: code, | |
| 2304 | + | } | |
| 2305 | + | if err := protocol.ValidateNavigationInventoryFailure(failure); err == nil { | |
| 2306 | + | select { | |
| 2307 | + | case controlCh <- failure: | |
| 2308 | + | case <-loopCtx.Done(): | |
| 2309 | + | return loopCanceledResult() | |
| 2310 | + | } | |
| 2311 | + | } | |
| 2312 | + | continue | |
| 2313 | + | } | |
| 2314 | + | if err := inventory.validateSelection(resolveOutcome.selection); err != nil { | |
| 2315 | + | // The publication retired the key while the resolve was in | |
| 2316 | + | // flight. The serving palette already closed on selection, | |
| 2317 | + | // so report the stale identity instead of dropping it. | |
| 2318 | + | failure := protocol.NavigationInventoryFailure{ | |
| 2319 | + | CauseActionID: resolveOutcome.selection.CauseActionID, | |
| 2320 | + | InteractionGeneration: resolveOutcome.selection.InteractionGeneration, | |
| 2321 | + | SourceKey: resolveOutcome.selection.SourceKey, | |
| 2322 | + | EntryKey: resolveOutcome.selection.EntryKey, | |
| 2323 | + | Code: protocol.NavigationInventoryStaleIdentity, | |
| 2324 | + | } | |
| 2325 | + | if err := protocol.ValidateNavigationInventoryFailure(failure); err == nil { | |
| 2326 | + | select { | |
| 2327 | + | case controlCh <- failure: | |
| 2328 | + | case <-loopCtx.Done(): | |
| 2329 | + | return loopCanceledResult() | |
| 2330 | + | } | |
| 2331 | + | } | |
| 2332 | + | continue | |
| 2333 | + | } | |
| 2334 | + | home := a.inventoryHome | |
| 2335 | + | if home == nil { | |
| 2336 | + | continue | |
| 2337 | + | } | |
| 2338 | + | if ui != nil { | |
| 2339 | + | ui.follow(uiGeneration, resolveOutcome.selection.CauseActionID) | |
| 2340 | + | publishUIStatus(ports.UIStatusTransitioning) | |
| 2341 | + | } | |
| 2342 | + | handoff := bindAttachHandoff(resolveOutcome.target, *home) | |
| 2343 | + | handoff.inventory = true | |
| 2344 | + | handoff.inventorySelection = resolveOutcome.selection | |
| 2345 | + | result := welcomedResult(nil) | |
| 2346 | + | result.handoff = handoff | |
| 2347 | + | return result | |
| 2072 | 2348 | case ev, ok := <-linkEvents: | |
| 2073 | 2349 | if !ok { | |
| 2074 | 2350 | linkEvents = nil | |
| @@ -2380,6 +2656,65 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2380 | 2656 | navigation := welcomedResult(nil) | |
| 2381 | 2657 | navigation.action = directive.Action | |
| 2382 | 2658 | return navigation | |
| 2659 | + | case protocol.NavigationInventoryDemand: | |
| 2660 | + | demand := message | |
| 2661 | + | if inventory == nil { | |
| 2662 | + | continue | |
| 2663 | + | } | |
| 2664 | + | if !demand.Open || demand.InteractionGeneration != inventoryInteraction { | |
| 2665 | + | stopInventoryPoll() | |
| 2666 | + | } | |
| 2667 | + | inventory.setOpen(demand.Open, demand.InteractionGeneration) | |
| 2668 | + | inventoryOpen = demand.Open | |
| 2669 | + | if demand.Open { | |
| 2670 | + | inventoryInteraction = demand.InteractionGeneration | |
| 2671 | + | startInventoryPoll() | |
| 2672 | + | } | |
| 2673 | + | armInventoryPoll() | |
| 2674 | + | continue | |
| 2675 | + | case protocol.NavigationInventorySelection: | |
| 2676 | + | selection := message | |
| 2677 | + | if inventory == nil || inventoryResolvePending { | |
| 2678 | + | continue | |
| 2679 | + | } | |
| 2680 | + | if err := inventory.validateSelection(selection); err != nil { | |
| 2681 | + | // The serving daemon rendered the row before the newer | |
| 2682 | + | // removal publication arrived. Its palette already | |
| 2683 | + | // closed on selection, so report the stale identity | |
| 2684 | + | // instead of leaving it without feedback. | |
| 2685 | + | failure := protocol.NavigationInventoryFailure{ | |
| 2686 | + | CauseActionID: selection.CauseActionID, | |
| 2687 | + | InteractionGeneration: selection.InteractionGeneration, | |
| 2688 | + | SourceKey: selection.SourceKey, | |
| 2689 | + | EntryKey: selection.EntryKey, | |
| 2690 | + | Code: protocol.NavigationInventoryStaleIdentity, | |
| 2691 | + | } | |
| 2692 | + | if verr := protocol.ValidateNavigationInventoryFailure(failure); verr == nil { | |
| 2693 | + | select { | |
| 2694 | + | case controlCh <- failure: | |
| 2695 | + | case <-loopCtx.Done(): | |
| 2696 | + | return loopCanceledResult() | |
| 2697 | + | } | |
| 2698 | + | } | |
| 2699 | + | continue | |
| 2700 | + | } | |
| 2701 | + | inventoryResolvePending = true | |
| 2702 | + | inventoryResolveC = make(chan inventoryResolveOutcome, 1) | |
| 2703 | + | resolveID := nextInventoryRequestID | |
| 2704 | + | nextInventoryRequestID++ | |
| 2705 | + | go func() { | |
| 2706 | + | outcome := inventoryResolveOutcome{selection: selection} | |
| 2707 | + | outcome.target, outcome.err = inventory.resolveEntry(loopCtx, resolveID, selection.SourceKey, selection.EntryKey, domain.RemoteRegistration{}) | |
| 2708 | + | select { | |
| 2709 | + | case inventoryResolveC <- outcome: | |
| 2710 | + | case <-loopCtx.Done(): | |
| 2711 | + | } | |
| 2712 | + | }() | |
| 2713 | + | continue | |
| 2714 | + | case protocol.NavigationInventoryResponse: | |
| 2715 | + | // Overlay-owned liveness. The relay never waits on it: | |
| 2716 | + | // publications carry the only state it tracks. | |
| 2717 | + | continue | |
| 2383 | 2718 | case protocol.RouteCreateSessionAction: | |
| 2384 | 2719 | action := message | |
| 2385 | 2720 | if action.Validate() != nil { | |
M internal/usecase/client/client_test.go +2 -1
| @@ -551,7 +551,8 @@ func TestKilledSessionReturnsToPreviousLocalRoute(t *testing.T) { | |||
| 551 | 551 | require.NoError(t, err) | |
| 552 | 552 | require.Equal(t, int32(2), dialer.calls.Load()) | |
| 553 | 553 | hello := helloFromSend(t, previous) | |
| 554 | - | require.Equal(t, protocol.IntentResume, hello.Intent) | |
| 554 | + | require.Equal(t, protocol.IntentAttach, hello.Intent) | |
| 555 | + | require.Zero(t, hello.ResumeToken) | |
| 555 | 556 | require.Equal(t, firstTarget, *hello.ExactTarget) | |
| 556 | 557 | require.Equal(t, int32(1), term.restoreCount.Load()) | |
| 557 | 558 | } | |
A internal/usecase/client/inventory_attempt.go +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/protocol" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // inventoryPollOutcome is one finished control-source snapshot query. The | |
| 10 | + | // interaction and query identity bind the completion to its originating | |
| 11 | + | // namespace: stale results from a closed interaction drop. | |
| 12 | + | type inventoryPollOutcome struct { | |
| 13 | + | interaction uint64 | |
| 14 | + | query uint64 | |
| 15 | + | response protocol.NavigationInventoryResponse | |
| 16 | + | err error | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // inventoryPollBox holds the latest finished poll outcome while the attempt | |
| 20 | + | // loop is busy. Queries never block on a slow loop: the newest outcome wins | |
| 21 | + | // and older ones drop, matching the single in-flight plus one coalesced | |
| 22 | + | // update budget. | |
| 23 | + | type inventoryPollBox struct { | |
| 24 | + | mu sync.Mutex | |
| 25 | + | outcome inventoryPollOutcome | |
| 26 | + | has bool | |
| 27 | + | wake chan struct{} | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func newInventoryPollBox() *inventoryPollBox { | |
| 31 | + | return &inventoryPollBox{wake: make(chan struct{}, 1)} | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (b *inventoryPollBox) offer(outcome inventoryPollOutcome) { | |
| 35 | + | b.mu.Lock() | |
| 36 | + | // Keep the newest completion: a late outcome from an older query must | |
| 37 | + | // not overwrite a newer one, or the newer query's completion is lost | |
| 38 | + | // while its slot stays busy and polling stalls. | |
| 39 | + | if b.has && outcome.query < b.outcome.query { | |
| 40 | + | b.mu.Unlock() | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | b.outcome = outcome | |
| 44 | + | b.has = true | |
| 45 | + | b.mu.Unlock() | |
| 46 | + | select { | |
| 47 | + | case b.wake <- struct{}{}: | |
| 48 | + | default: | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (b *inventoryPollBox) take() (inventoryPollOutcome, bool) { | |
| 53 | + | b.mu.Lock() | |
| 54 | + | defer b.mu.Unlock() | |
| 55 | + | outcome, has := b.outcome, b.has | |
| 56 | + | b.outcome = inventoryPollOutcome{} | |
| 57 | + | b.has = false | |
| 58 | + | return outcome, has | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // inventoryResolveOutcome is one finished selection resolve: either the | |
| 62 | + | // non-mutating attach target bound to its validated selection or the | |
| 63 | + | // resolve error. Stale outcomes drop on receipt when the interaction moved. | |
| 64 | + | type inventoryResolveOutcome struct { | |
| 65 | + | selection protocol.NavigationInventorySelection | |
| 66 | + | target protocol.AttachTarget | |
| 67 | + | err error | |
| 68 | + | } | |
A internal/usecase/client/inventory_attempt_test.go +438 -0
| @@ -0,0 +1,438 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "log/slog" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | "github.com/bnema/vev/internal/protocol" | |
| 16 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // inventoryTestClock is a manually advanced clock. Firing advances past the | |
| 20 | + | // poll interval so relay ticks stay deterministic. | |
| 21 | + | type inventoryTestClock struct { | |
| 22 | + | mu sync.Mutex | |
| 23 | + | now time.Time | |
| 24 | + | timers []*inventoryTestTimer | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func newInventoryTestClock() *inventoryTestClock { | |
| 28 | + | return &inventoryTestClock{now: time.Unix(1000, 0)} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (c *inventoryTestClock) Now() time.Time { | |
| 32 | + | c.mu.Lock() | |
| 33 | + | defer c.mu.Unlock() | |
| 34 | + | return c.now | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (c *inventoryTestClock) NewTimer(time.Duration) ports.Timer { | |
| 38 | + | c.mu.Lock() | |
| 39 | + | defer c.mu.Unlock() | |
| 40 | + | timer := &inventoryTestTimer{ch: make(chan time.Time, 1)} | |
| 41 | + | c.timers = append(c.timers, timer) | |
| 42 | + | return timer | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (c *inventoryTestClock) timerCount() int { | |
| 46 | + | c.mu.Lock() | |
| 47 | + | defer c.mu.Unlock() | |
| 48 | + | return len(c.timers) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (c *inventoryTestClock) fire() { | |
| 52 | + | c.mu.Lock() | |
| 53 | + | c.now = c.now.Add(2 * inventoryPollInterval) | |
| 54 | + | timers := append([]*inventoryTestTimer(nil), c.timers...) | |
| 55 | + | c.mu.Unlock() | |
| 56 | + | for _, timer := range timers { | |
| 57 | + | select { | |
| 58 | + | case timer.ch <- c.Now(): | |
| 59 | + | default: | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | type inventoryTestTimer struct{ ch chan time.Time } | |
| 65 | + | ||
| 66 | + | func (t *inventoryTestTimer) C() <-chan time.Time { return t.ch } | |
| 67 | + | func (t *inventoryTestTimer) Reset(time.Duration) bool { return false } | |
| 68 | + | func (t *inventoryTestTimer) Stop() bool { return true } | |
| 69 | + | ||
| 70 | + | // inventoryScript selects the serving-transport script after the publication. | |
| 71 | + | type inventoryScript int | |
| 72 | + | ||
| 73 | + | const ( | |
| 74 | + | inventoryScriptClose inventoryScript = iota | |
| 75 | + | inventoryScriptSelect | |
| 76 | + | // inventoryScriptSilent models an old daemon that never sends | |
| 77 | + | // demands: the relay stays idle and dials nothing. | |
| 78 | + | inventoryScriptSilent | |
| 79 | + | // inventoryScriptLocalIgnore models demands on a local attachment: | |
| 80 | + | // open and close cross back to back with no publication gate, so the | |
| 81 | + | // single-threaded loop provably processes both before detach. | |
| 82 | + | inventoryScriptLocalIgnore | |
| 83 | + | ) | |
| 84 | + | ||
| 85 | + | // inventoryAttemptTransport scripts a remote serving daemon: welcome, open | |
| 86 | + | // demand, then either a close demand or a valid selection once the relay | |
| 87 | + | // publishes. Raw sends record publications for assertions. | |
| 88 | + | type inventoryAttemptTransport struct { | |
| 89 | + | mu sync.Mutex | |
| 90 | + | calls int | |
| 91 | + | sent []wire.Frame | |
| 92 | + | publications []protocol.NavigationInventoryPublication | |
| 93 | + | published chan struct{} | |
| 94 | + | publishedOnce sync.Once | |
| 95 | + | release chan struct{} | |
| 96 | + | detach chan struct{} | |
| 97 | + | releaseClose chan struct{} | |
| 98 | + | script inventoryScript | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (t *inventoryAttemptTransport) Send(frame wire.Frame) error { | |
| 102 | + | var publication protocol.NavigationInventoryPublication | |
| 103 | + | if frame.Type == wire.MsgNavigationInventoryPublication { | |
| 104 | + | var err error | |
| 105 | + | publication, err = wire.UnmarshalNavigationInventoryPublication(frame.Payload) | |
| 106 | + | if err != nil { | |
| 107 | + | return err | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | t.mu.Lock() | |
| 111 | + | t.sent = append(t.sent, frame) | |
| 112 | + | if frame.Type == wire.MsgNavigationInventoryPublication { | |
| 113 | + | t.publications = append(t.publications, publication) | |
| 114 | + | } | |
| 115 | + | t.mu.Unlock() | |
| 116 | + | if frame.Type == wire.MsgNavigationInventoryPublication { | |
| 117 | + | t.publishedOnce.Do(func() { close(t.published) }) | |
| 118 | + | } | |
| 119 | + | return nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (t *inventoryAttemptTransport) Recv() (wire.Frame, error) { | |
| 123 | + | t.mu.Lock() | |
| 124 | + | t.calls++ | |
| 125 | + | call := t.calls | |
| 126 | + | t.mu.Unlock() | |
| 127 | + | switch call { | |
| 128 | + | case 1: | |
| 129 | + | return wire.Frame{Type: wire.MsgWelcome, Payload: wire.MarshalWelcome(protocol.Welcome{SessionID: "s"})}, nil | |
| 130 | + | case 2: | |
| 131 | + | if t.script == inventoryScriptSilent { | |
| 132 | + | select { | |
| 133 | + | case <-t.detach: | |
| 134 | + | return wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})}, nil | |
| 135 | + | case <-t.release: | |
| 136 | + | return wire.Frame{}, io.EOF | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | return wire.Frame{Type: wire.MsgNavigationInventoryDemand, Payload: wire.MarshalNavigationInventoryDemand(protocol.NavigationInventoryDemand{InteractionGeneration: 7, Open: true})}, nil | |
| 140 | + | case 3: | |
| 141 | + | if t.script == inventoryScriptLocalIgnore { | |
| 142 | + | return wire.Frame{Type: wire.MsgNavigationInventoryDemand, Payload: wire.MarshalNavigationInventoryDemand(protocol.NavigationInventoryDemand{InteractionGeneration: 7})}, nil | |
| 143 | + | } | |
| 144 | + | select { | |
| 145 | + | case <-t.published: | |
| 146 | + | case <-t.detach: | |
| 147 | + | return wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})}, nil | |
| 148 | + | case <-t.release: | |
| 149 | + | return wire.Frame{}, io.EOF | |
| 150 | + | } | |
| 151 | + | // The close script holds the close demand until the test releases | |
| 152 | + | // it, so polling assertions run against an open interaction. | |
| 153 | + | if t.script == inventoryScriptClose { | |
| 154 | + | select { | |
| 155 | + | case <-t.releaseClose: | |
| 156 | + | case <-t.release: | |
| 157 | + | return wire.Frame{}, io.EOF | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | if t.script == inventoryScriptSelect { | |
| 161 | + | return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: wire.MarshalNavigationInventorySelection(protocol.NavigationInventorySelection{ | |
| 162 | + | CauseActionID: 9, InteractionGeneration: 7, PublicationGeneration: 1, | |
| 163 | + | SourceKey: "local", EntryKey: "aaa/alpha", | |
| 164 | + | })}, nil | |
| 165 | + | } | |
| 166 | + | return wire.Frame{Type: wire.MsgNavigationInventoryDemand, Payload: wire.MarshalNavigationInventoryDemand(protocol.NavigationInventoryDemand{InteractionGeneration: 7})}, nil | |
| 167 | + | default: | |
| 168 | + | select { | |
| 169 | + | case <-t.detach: | |
| 170 | + | return wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})}, nil | |
| 171 | + | case <-t.release: | |
| 172 | + | return wire.Frame{}, io.EOF | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (t *inventoryAttemptTransport) SendClient(m protocol.ClientMessage) error { | |
| 178 | + | f, err := testClientFrame(m) | |
| 179 | + | if err != nil { | |
| 180 | + | return err | |
| 181 | + | } | |
| 182 | + | return t.Send(f) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (t *inventoryAttemptTransport) ReceiveServer() (protocol.ServerMessage, error) { | |
| 186 | + | f, err := t.Recv() | |
| 187 | + | if err != nil { | |
| 188 | + | return nil, err | |
| 189 | + | } | |
| 190 | + | return testServerMessage(f) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (t *inventoryAttemptTransport) Capabilities() protocol.ConnectionCapabilities { | |
| 194 | + | return testClientCapabilities(t) | |
| 195 | + | } | |
| 196 | + | func (t *inventoryAttemptTransport) LinkState() ports.LinkState { return ports.LinkStateConnected } | |
| 197 | + | func (t *inventoryAttemptTransport) LinkEvents() <-chan ports.LinkEvent { return nil } | |
| 198 | + | func (t *inventoryAttemptTransport) Close() error { return nil } | |
| 199 | + | ||
| 200 | + | func (t *inventoryAttemptTransport) publicationCount() int { | |
| 201 | + | t.mu.Lock() | |
| 202 | + | defer t.mu.Unlock() | |
| 203 | + | return len(t.publications) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (t *inventoryAttemptTransport) hello() protocol.Hello { | |
| 207 | + | t.mu.Lock() | |
| 208 | + | defer t.mu.Unlock() | |
| 209 | + | for _, frame := range t.sent { | |
| 210 | + | if frame.Type == wire.MsgHello { | |
| 211 | + | hello, err := wire.UnmarshalHello(frame.Payload) | |
| 212 | + | if err != nil { | |
| 213 | + | panic(err) | |
| 214 | + | } | |
| 215 | + | return hello | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | return protocol.Hello{} | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // inventoryTestControlConn answers one snapshot or resolve query from canned | |
| 222 | + | // groups without Hello, session creation, or attachment. | |
| 223 | + | type inventoryTestControlConn struct { | |
| 224 | + | request protocol.NavigationInventoryRequest | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func (c *inventoryTestControlConn) SendClient(message protocol.ClientMessage) error { | |
| 228 | + | request, ok := message.(protocol.NavigationInventoryRequest) | |
| 229 | + | if !ok { | |
| 230 | + | return io.ErrUnexpectedEOF | |
| 231 | + | } | |
| 232 | + | c.request = request | |
| 233 | + | return nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (c *inventoryTestControlConn) ReceiveServer() (protocol.ServerMessage, error) { | |
| 237 | + | switch c.request.Operation { | |
| 238 | + | case protocol.NavigationInventorySnapshot: | |
| 239 | + | return protocol.NavigationInventoryResponse{ | |
| 240 | + | RequestID: c.request.RequestID, Operation: protocol.NavigationInventorySnapshot, | |
| 241 | + | Status: protocol.NavigationInventoryOK, | |
| 242 | + | Groups: []protocol.NavigationInventorySourceGroup{{ | |
| 243 | + | SourceKey: "local", Status: protocol.NavigationInventorySourceOK, | |
| 244 | + | Entries: []protocol.NavigationInventoryEntry{ | |
| 245 | + | {SourceKey: "local", EntryKey: "aaa/alpha", Name: "alpha", DisplayOrigin: "local", State: "up"}, | |
| 246 | + | }, | |
| 247 | + | }}, | |
| 248 | + | }, nil | |
| 249 | + | case protocol.NavigationInventoryResolve: | |
| 250 | + | lifecycle := domain.SessionLifecycleID{7} | |
| 251 | + | return protocol.NavigationInventoryResponse{ | |
| 252 | + | RequestID: c.request.RequestID, Operation: protocol.NavigationInventoryResolve, | |
| 253 | + | Status: protocol.NavigationInventoryOK, | |
| 254 | + | Resolved: &protocol.AttachTarget{ | |
| 255 | + | Session: "alpha", Intent: protocol.IntentAttach, | |
| 256 | + | ExactTarget: &protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "alpha"}, | |
| 257 | + | }, | |
| 258 | + | }, nil | |
| 259 | + | default: | |
| 260 | + | return nil, io.ErrUnexpectedEOF | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (c *inventoryTestControlConn) Capabilities() protocol.ConnectionCapabilities { | |
| 265 | + | return protocol.ConnectionCapabilities{} | |
| 266 | + | } | |
| 267 | + | func (c *inventoryTestControlConn) LinkState() ports.LinkState { return ports.LinkStateConnected } | |
| 268 | + | func (c *inventoryTestControlConn) LinkEvents() <-chan ports.LinkEvent { return nil } | |
| 269 | + | func (c *inventoryTestControlConn) Close() error { return nil } | |
| 270 | + | ||
| 271 | + | type inventoryTestDialer struct { | |
| 272 | + | mu sync.Mutex | |
| 273 | + | dials int | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func (d *inventoryTestDialer) Dial(context.Context) (ports.ClientConnection, error) { | |
| 277 | + | d.mu.Lock() | |
| 278 | + | d.dials++ | |
| 279 | + | d.mu.Unlock() | |
| 280 | + | return &inventoryTestControlConn{}, nil | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func (d *inventoryTestDialer) count() int { | |
| 284 | + | d.mu.Lock() | |
| 285 | + | defer d.mu.Unlock() | |
| 286 | + | return d.dials | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func runInventoryAttempt(t *testing.T, clock *inventoryTestClock, transport *inventoryAttemptTransport, remote bool, dialer *inventoryTestDialer) attachResult { | |
| 290 | + | t.Helper() | |
| 291 | + | input := newPaletteAttachReader(nil) | |
| 292 | + | t.Cleanup(input.close) | |
| 293 | + | term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)} | |
| 294 | + | runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)} | |
| 295 | + | attempt := &attachAttempt{ | |
| 296 | + | runner: runner, dialer: dialer, transport: transport, | |
| 297 | + | request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote-work"}, | |
| 298 | + | remote: remote, | |
| 299 | + | inventoryHome: &attachRoute{ | |
| 300 | + | dialer: dialer, | |
| 301 | + | request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "home"}, | |
| 302 | + | }, | |
| 303 | + | inventoryDialer: dialer, | |
| 304 | + | milestones: msForInventoryTest(), themeState: &terminalThemeState{}, | |
| 305 | + | enterRaw: func() error { return nil }, | |
| 306 | + | reconnect: &reconnectUI{term: term, rawEntered: new(bool)}, | |
| 307 | + | } | |
| 308 | + | return attempt.run(context.Background()) | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func msForInventoryTest() *milestones { return &milestones{} } | |
| 312 | + | ||
| 313 | + | // TestInventoryPollBoxKeepsNewestCompletion pins out-of-order delivery: | |
| 314 | + | // a late outcome from an older query must not overwrite the newer one, | |
| 315 | + | // or the newer completion is lost while its slot stays busy. | |
| 316 | + | func TestInventoryPollBoxKeepsNewestCompletion(t *testing.T) { | |
| 317 | + | box := newInventoryPollBox() | |
| 318 | + | box.offer(inventoryPollOutcome{interaction: 2, query: 2}) | |
| 319 | + | box.offer(inventoryPollOutcome{interaction: 2, query: 1}) | |
| 320 | + | outcome, ok := box.take() | |
| 321 | + | require.True(t, ok) | |
| 322 | + | require.Equal(t, uint64(2), outcome.query) | |
| 323 | + | if _, ok := box.take(); ok { | |
| 324 | + | t.Fatal("take must drain the single coalesced outcome") | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // TestInventoryAttemptPublishesAndStops pins the relay poll loop through a | |
| 329 | + | // scripted remote serving daemon: the open demand triggers an immediate | |
| 330 | + | // snapshot query and a delta publication, the tick re-polls without | |
| 331 | + | // republishing unchanged inventory, and the close demand stops polling. | |
| 332 | + | func TestInventoryAttemptPublishesAndStops(t *testing.T) { | |
| 333 | + | clock := newInventoryTestClock() | |
| 334 | + | dialer := &inventoryTestDialer{} | |
| 335 | + | transport := &inventoryAttemptTransport{ | |
| 336 | + | published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), releaseClose: make(chan struct{}), script: inventoryScriptClose, | |
| 337 | + | } | |
| 338 | + | t.Cleanup(func() { close(transport.release) }) | |
| 339 | + | ||
| 340 | + | done := make(chan attachResult, 1) | |
| 341 | + | go func() { done <- runInventoryAttempt(t, clock, transport, true, dialer) }() | |
| 342 | + | ||
| 343 | + | select { | |
| 344 | + | case <-transport.published: | |
| 345 | + | case <-time.After(5 * time.Second): | |
| 346 | + | t.Fatal("relay did not publish after the open demand") | |
| 347 | + | } | |
| 348 | + | // Fire until the tick lands: pushes buffer on every known timer, so a | |
| 349 | + | // fire racing the post-publication re-arm is retried instead of lost | |
| 350 | + | // on the discarded open timer. | |
| 351 | + | require.Eventually(t, func() bool { | |
| 352 | + | clock.fire() | |
| 353 | + | return dialer.count() >= 2 | |
| 354 | + | }, 5*time.Second, 10*time.Millisecond, "the tick must drive a second poll") | |
| 355 | + | require.Eventually(t, func() bool { return transport.publicationCount() == 1 }, 5*time.Second, 10*time.Millisecond, | |
| 356 | + | "unchanged inventory must not republish, only release the poll slot") | |
| 357 | + | close(transport.releaseClose) | |
| 358 | + | close(transport.detach) | |
| 359 | + | ||
| 360 | + | result := <-done | |
| 361 | + | require.NoError(t, result.err) | |
| 362 | + | require.Nil(t, result.handoff) | |
| 363 | + | require.GreaterOrEqual(t, dialer.count(), 2, "the tick must drive a second poll that publishes nothing") | |
| 364 | + | require.Len(t, transport.publications, 1) | |
| 365 | + | publication := transport.publications[0] | |
| 366 | + | require.Equal(t, uint64(7), publication.InteractionGeneration) | |
| 367 | + | require.Equal(t, uint64(1), publication.PublicationGeneration) | |
| 368 | + | require.Len(t, publication.Groups, 1) | |
| 369 | + | require.Equal(t, "aaa/alpha", publication.Groups[0].Entries[0].EntryKey) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // TestInventoryAttemptResolvesSelectionToHandoff pins the selection path: | |
| 373 | + | // the serving daemon's selection validates against published state, resolves | |
| 374 | + | // through the local control dialer, and returns a handoff bound to the home | |
| 375 | + | // authority; Run captures the serving recovery route after rebasing metadata. | |
| 376 | + | func TestInventoryAttemptResolvesSelectionToHandoff(t *testing.T) { | |
| 377 | + | clock := newInventoryTestClock() | |
| 378 | + | transport := &inventoryAttemptTransport{ | |
| 379 | + | published: make(chan struct{}), release: make(chan struct{}), script: inventoryScriptSelect, | |
| 380 | + | } | |
| 381 | + | t.Cleanup(func() { close(transport.release) }) | |
| 382 | + | ||
| 383 | + | result := runInventoryAttempt(t, clock, transport, true, &inventoryTestDialer{}) | |
| 384 | + | require.NoError(t, result.err) | |
| 385 | + | hello := transport.hello() | |
| 386 | + | require.NotEqual(t, uint16(0), uint16(hello.NavigationCapabilities&protocol.NavigationCapabilityInventory), | |
| 387 | + | "the attempt must advertise inventory in Hello so the serving palette sends demands") | |
| 388 | + | require.NotNil(t, result.handoff) | |
| 389 | + | require.Empty(t, result.handoff.target.Endpoint) | |
| 390 | + | require.Equal(t, "alpha", result.handoff.target.Session) | |
| 391 | + | require.NotNil(t, result.handoff.target.ExactTarget) | |
| 392 | + | require.True(t, result.handoff.inventory) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | // TestInventoryAttemptIdleWithoutDemands pins the no-demand pairing: without | |
| 396 | + | // demands the relay stays idle, dials nothing, and publishes nothing, and | |
| 397 | + | // the attachment lifecycle is unaffected. | |
| 398 | + | func TestInventoryAttemptIdleWithoutDemands(t *testing.T) { | |
| 399 | + | clock := newInventoryTestClock() | |
| 400 | + | dialer := &inventoryTestDialer{} | |
| 401 | + | transport := &inventoryAttemptTransport{ | |
| 402 | + | published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), script: inventoryScriptSilent, | |
| 403 | + | } | |
| 404 | + | t.Cleanup(func() { close(transport.release) }) | |
| 405 | + | ||
| 406 | + | done := make(chan attachResult, 1) | |
| 407 | + | go func() { done <- runInventoryAttempt(t, clock, transport, true, dialer) }() | |
| 408 | + | clock.fire() | |
| 409 | + | close(transport.detach) | |
| 410 | + | ||
| 411 | + | result := <-done | |
| 412 | + | require.NoError(t, result.err) | |
| 413 | + | require.Nil(t, result.handoff) | |
| 414 | + | require.Zero(t, dialer.count(), "no demand must trigger zero control dials") | |
| 415 | + | require.Zero(t, transport.publicationCount()) | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // TestInventoryAttemptIgnoresDemandsWhenLocal pins the local-attachment | |
| 419 | + | // pairing: demands on a local serving attachment never enable the relay, | |
| 420 | + | // so no control dial crosses even though the frames decode. | |
| 421 | + | func TestInventoryAttemptIgnoresDemandsWhenLocal(t *testing.T) { | |
| 422 | + | clock := newInventoryTestClock() | |
| 423 | + | dialer := &inventoryTestDialer{} | |
| 424 | + | transport := &inventoryAttemptTransport{ | |
| 425 | + | published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), script: inventoryScriptLocalIgnore, | |
| 426 | + | } | |
| 427 | + | t.Cleanup(func() { close(transport.release) }) | |
| 428 | + | ||
| 429 | + | done := make(chan attachResult, 1) | |
| 430 | + | go func() { done <- runInventoryAttempt(t, clock, transport, false, dialer) }() | |
| 431 | + | close(transport.detach) | |
| 432 | + | ||
| 433 | + | result := <-done | |
| 434 | + | require.NoError(t, result.err) | |
| 435 | + | require.Nil(t, result.handoff) | |
| 436 | + | require.Zero(t, dialer.count()) | |
| 437 | + | require.Zero(t, transport.publicationCount()) | |
| 438 | + | } | |
A internal/usecase/client/inventory_cancellation_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestInventoryReopenRetainsPhysicalQuerySlotUntilCancellationCompletes(t *testing.T) { | |
| 17 | + | clock := newInventoryTestClock() | |
| 18 | + | closed := make(chan struct{}) | |
| 19 | + | entered := make(chan struct{}) | |
| 20 | + | var once sync.Once | |
| 21 | + | conn := portsmocks.NewMockClientConnection(t) | |
| 22 | + | conn.EXPECT().SendClient(mock.Anything).Return(nil).Once() | |
| 23 | + | conn.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) { close(entered); <-closed; return nil, io.EOF }).Once() | |
| 24 | + | conn.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe() | |
| 25 | + | dialer := portsmocks.NewMockClientDialer(t) | |
| 26 | + | dialer.EXPECT().Dial(mock.Anything).Return(conn, nil).Once() | |
| 27 | + | relay := newInventoryRelay(clock, dialer) | |
| 28 | + | relay.setOpen(true, 1) | |
| 29 | + | query, ok := relay.beginPoll() | |
| 30 | + | require.True(t, ok) | |
| 31 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 32 | + | defer cancel() | |
| 33 | + | done := make(chan error, 1) | |
| 34 | + | go func() { _, err := relay.querySnapshot(ctx, 1); done <- err }() | |
| 35 | + | select { | |
| 36 | + | case <-entered: | |
| 37 | + | case <-time.After(time.Second): | |
| 38 | + | t.Fatal("query never started") | |
| 39 | + | } | |
| 40 | + | for interaction := uint64(2); interaction < 6; interaction++ { | |
| 41 | + | relay.setOpen(false, interaction-1) | |
| 42 | + | cancel() | |
| 43 | + | relay.setOpen(true, interaction) | |
| 44 | + | _, ok := relay.beginPoll() | |
| 45 | + | require.False(t, ok, "close/reopen cannot release an unaccounted worker") | |
| 46 | + | } | |
| 47 | + | select { | |
| 48 | + | case err := <-done: | |
| 49 | + | require.Error(t, err) | |
| 50 | + | case <-time.After(time.Second): | |
| 51 | + | t.Fatal("cancellation did not close exact connection") | |
| 52 | + | } | |
| 53 | + | relay.endPoll(query) | |
| 54 | + | _, ok = relay.beginPoll() | |
| 55 | + | require.True(t, ok, "latest interaction starts immediately after old worker completes") | |
| 56 | + | } | |
A internal/usecase/client/inventory_recovery_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package client_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestInventoryFailureRestoresLatestRemoteIdentityAfterResumeRejection(t *testing.T) { | |
| 19 | + | term := newRunTerminal() | |
| 20 | + | defer term.in.unblock() | |
| 21 | + | local := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"} | |
| 22 | + | first := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "remote-first"} | |
| 23 | + | current := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "remote-current"} | |
| 24 | + | welcome := func(target protocol.ExactSessionTarget) wire.Frame { | |
| 25 | + | return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: target.SessionName, SessionName: target.SessionName, ResumeToken: 17, Capabilities: protocol.CapabilityResume, CommittedIdentity: &protocol.CommittedRouteIdentity{Target: target}})) | |
| 26 | + | } | |
| 27 | + | rejected := func() wire.Frame { | |
| 28 | + | return frameOf(wire.MsgError, wire.MarshalErrorMsg(protocol.ErrorMsg{Code: protocol.ErrNoSuchTarget, Text: "unavailable"})) | |
| 29 | + | } | |
| 30 | + | initial := &recordingTransport{recvs: []recvItem{{f: welcome(local)}, {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{Endpoint: "remote", Session: first.SessionName, Intent: protocol.IntentAttach, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}))}}} | |
| 31 | + | published := make(chan struct{}) | |
| 32 | + | var once sync.Once | |
| 33 | + | selection := protocol.NavigationInventorySelection{InteractionGeneration: 1, PublicationGeneration: 1, SourceKey: "local", EntryKey: "entry"} | |
| 34 | + | remote := &recordingTransport{recvs: []recvItem{ | |
| 35 | + | {f: welcome(first)}, | |
| 36 | + | {f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{Target: current}))}, | |
| 37 | + | {f: frameOf(wire.MsgRoutePosition, mustMarshalRoutePosition(protocol.RoutePosition{Target: current, ActiveTabID: "remembered-tab"}))}, | |
| 38 | + | {f: frameOf(wire.MsgNavigationInventoryDemand, wire.MarshalNavigationInventoryDemand(protocol.NavigationInventoryDemand{InteractionGeneration: 1, Open: true}))}, | |
| 39 | + | {f: frameOf(wire.MsgNavigationInventorySelection, wire.MarshalNavigationInventorySelection(selection)), wait: published}, | |
| 40 | + | }, stall: make(chan struct{})} | |
| 41 | + | release := make(chan struct{}) | |
| 42 | + | remote.stall = release | |
| 43 | + | remote.onClose = func() { | |
| 44 | + | select { | |
| 45 | + | case <-release: | |
| 46 | + | default: | |
| 47 | + | close(release) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | remote.onSend = func(f wire.Frame) { | |
| 51 | + | if f.Type == wire.MsgNavigationInventoryPublication { | |
| 52 | + | once.Do(func() { close(published) }) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | failed := &recordingTransport{recvs: []recvItem{{f: rejected()}}} | |
| 56 | + | resume := &recordingTransport{recvs: []recvItem{{f: rejected()}}} | |
| 57 | + | restored := &recordingTransport{recvs: []recvItem{{f: welcome(current)}, {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}}} | |
| 58 | + | localDialer := &sequenceDialer{trs: []wire.Transport{initial, failed}} | |
| 59 | + | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote, resume, restored}} | |
| 60 | + | control := portsmocks.NewMockClientDialer(t) | |
| 61 | + | control.EXPECT().Dial(mock.Anything).RunAndReturn(func(context.Context) (ports.ClientConnection, error) { | |
| 62 | + | conn := portsmocks.NewMockClientConnection(t) | |
| 63 | + | var request protocol.NavigationInventoryRequest | |
| 64 | + | conn.EXPECT().SendClient(mock.Anything).RunAndReturn(func(m protocol.ClientMessage) error { request = m.(protocol.NavigationInventoryRequest); return nil }).Once() | |
| 65 | + | conn.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) { | |
| 66 | + | response := protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.NavigationInventoryOK} | |
| 67 | + | if request.Operation == protocol.NavigationInventorySnapshot { | |
| 68 | + | response.Groups = []protocol.NavigationInventorySourceGroup{{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: "entry", Name: "local", DisplayOrigin: "local", State: "up"}}}} | |
| 69 | + | } else { | |
| 70 | + | response.Resolved = &protocol.AttachTarget{Session: local.SessionName, Intent: protocol.IntentAttach, ExactTarget: &local, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned} | |
| 71 | + | } | |
| 72 | + | return response, nil | |
| 73 | + | }).Once() | |
| 74 | + | conn.EXPECT().Close().Return(nil).Maybe() | |
| 75 | + | return conn, nil | |
| 76 | + | }).Twice() | |
| 77 | + | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |
| 78 | + | deps.LocalControlDialer = control | |
| 79 | + | deps.AttachHandoff = func(protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |
| 80 | + | return remoteDialer, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: first.SessionName, Remote: true, Origin: protocol.RouteOriginRemote, OriginKey: "remote", HostLabel: "remote", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, nil | |
| 81 | + | } | |
| 82 | + | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: local.SessionName, Origin: protocol.RouteOriginLocal, OriginKey: "local"})) | |
| 83 | + | for _, tr := range []*recordingTransport{resume, restored} { | |
| 84 | + | hello := helloFromSend(t, tr) | |
| 85 | + | require.Equal(t, ¤t, hello.ExactTarget) | |
| 86 | + | require.Equal(t, current.SessionName, hello.Name) | |
| 87 | + | require.Equal(t, domain.TabStableID("remembered-tab"), hello.PreferredTabID) | |
| 88 | + | } | |
| 89 | + | require.Equal(t, protocol.IntentAttach, helloFromSend(t, restored).Intent) | |
| 90 | + | } | |
A internal/usecase/client/navigation_fallback_capture_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // TestNavigationSelectionCaptureIsLedgerDetached pins the fallback-capture | |
| 12 | + | // contract the Run loop relies on: prior and selected routes returned for a | |
| 13 | + | // creation or recent-route handoff are copies detached from ledger state, so | |
| 14 | + | // later request rebasing (committed identity, cursor, tab memory) cannot | |
| 15 | + | // corrupt the recorded return route. P3 migrates the bookkeeping to a shared | |
| 16 | + | // transition but must preserve these detachment facts. | |
| 17 | + | func TestNavigationSelectionCaptureIsLedgerDetached(t *testing.T) { | |
| 18 | + | setup := func(t *testing.T) (*routeLedger, routeIdentity, routeGeneration) { | |
| 19 | + | t.Helper() | |
| 20 | + | ledger := newRouteLedger() | |
| 21 | + | first, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal)) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | second, err := ledger.commit(routeTestCandidate(2, protocol.RouteOriginLocal)) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | require.NotEqual(t, first, second) | |
| 26 | + | generation := routeGeneration(ledger.snapshot().Generation) | |
| 27 | + | return ledger, first, generation | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | tests := []struct { | |
| 31 | + | name string | |
| 32 | + | capture func(t *testing.T, ledger *routeLedger, selected routeIdentity, generation routeGeneration) (prior, current routeRecord) | |
| 33 | + | }{ | |
| 34 | + | {name: "recent", capture: func(t *testing.T, ledger *routeLedger, selected routeIdentity, generation routeGeneration) (routeRecord, routeRecord) { | |
| 35 | + | t.Helper() | |
| 36 | + | selection, ok := ledger.navigationSelection(selected.wireNavigationAction(generation)) | |
| 37 | + | require.True(t, ok) | |
| 38 | + | require.False(t, selection.noOp) | |
| 39 | + | return selection.prior, selection.selected | |
| 40 | + | }}, | |
| 41 | + | {name: "creation", capture: func(t *testing.T, ledger *routeLedger, selected routeIdentity, generation routeGeneration) (routeRecord, routeRecord) { | |
| 42 | + | t.Helper() | |
| 43 | + | selection, ok := ledger.creationSelection(protocol.RouteCreateSessionAction{ | |
| 44 | + | RequestID: 9, SnapshotGeneration: uint64(generation), | |
| 45 | + | Key: uint64(selected.key), Generation: uint64(selected.generation), SessionName: "example", | |
| 46 | + | }) | |
| 47 | + | require.True(t, ok) | |
| 48 | + | return selection.prior, selection.selected | |
| 49 | + | }}, | |
| 50 | + | } | |
| 51 | + | for _, tt := range tests { | |
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |
| 53 | + | ledger, selected, generation := setup(t) | |
| 54 | + | before := ledger.snapshot() | |
| 55 | + | prior, current := tt.capture(t, ledger, selected, generation) | |
| 56 | + | ||
| 57 | + | prior.request.SessionName = "mutated-prior" | |
| 58 | + | prior.request.Environment = append(prior.request.Environment, "MUTATED=1") | |
| 59 | + | current.request.SessionName = "mutated-selected" | |
| 60 | + | require.Equal(t, before, ledger.snapshot(), "captured fallback routes must not alias ledger state") | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // TestNavigationSelectionRejectsStaleCapture pins the prevalidation contract: | |
| 66 | + | // a selection against a superseded snapshot generation is rejected before | |
| 67 | + | // any fallback is captured, so a stale palette action keeps the current | |
| 68 | + | // attachment intact. | |
| 69 | + | func TestNavigationSelectionRejectsStaleCapture(t *testing.T) { | |
| 70 | + | ledger := newRouteLedger() | |
| 71 | + | selected, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal)) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | staleGeneration := routeGeneration(ledger.snapshot().Generation) | |
| 74 | + | _, err = ledger.commit(routeTestCandidate(2, protocol.RouteOriginLocal)) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | ||
| 77 | + | _, ok := ledger.navigationSelection(selected.wireNavigationAction(staleGeneration)) | |
| 78 | + | require.False(t, ok) | |
| 79 | + | _, ok = ledger.creationSelection(protocol.RouteCreateSessionAction{ | |
| 80 | + | RequestID: 9, SnapshotGeneration: uint64(staleGeneration), | |
| 81 | + | Key: uint64(selected.key), Generation: uint64(selected.generation), SessionName: "example", | |
| 82 | + | }) | |
| 83 | + | require.False(t, ok) | |
| 84 | + | } | |
A internal/usecase/client/navigation_inventory.go +346 -0
| @@ -0,0 +1,346 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sort" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // inventoryPollInterval bounds control-source refresh while the palette is | |
| 16 | + | // open. inventoryQueryTimeout bounds each complete dial/query; the exact | |
| 17 | + | // connection closes on cancellation so blocked operations unwind. | |
| 18 | + | const ( | |
| 19 | + | inventoryPollInterval = time.Second | |
| 20 | + | inventoryQueryTimeout = 5 * time.Second | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // inventoryRelayEnabled gates the concrete local source independently of | |
| 24 | + | // server demand: only after this client has a committed explicitly local | |
| 25 | + | // route and while its serving attachment is remote. Home labels and remote | |
| 26 | + | // home routes are not proof. Local-only and direct remote-only perform zero | |
| 27 | + | // control dials, including when a remote daemon sends an unsolicited demand. | |
| 28 | + | func inventoryRelayEnabled(homeCommitted, servingRemote bool, dialer ports.ClientDialer) bool { | |
| 29 | + | return homeCommitted && servingRemote && dialer != nil | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // inventoryEntryID is the opaque selection identity: source plus entry key. | |
| 33 | + | type inventoryEntryID struct { | |
| 34 | + | source string | |
| 35 | + | entry string | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // inventoryRelay is the per-attachment navigation-inventory relay state. It | |
| 39 | + | // owns no goroutine, cache, or persistent publication: the owning attempt | |
| 40 | + | // drives polling through pollDue/beginPoll/endPoll and publishes through | |
| 41 | + | // preparePublication. All work binds to attachment/transport identity plus | |
| 42 | + | // the interaction generation; late results cannot affect a new attachment. | |
| 43 | + | type inventoryRelay struct { | |
| 44 | + | clock ports.Clock | |
| 45 | + | dialer ports.ClientDialer | |
| 46 | + | ||
| 47 | + | open bool | |
| 48 | + | interaction uint64 | |
| 49 | + | publication uint64 | |
| 50 | + | lastPoll time.Time | |
| 51 | + | // inFlight is the outstanding query identity, or zero when the slot | |
| 52 | + | // is free. Identities bind completions to their originating | |
| 53 | + | // interaction: a late result from a closed interaction drops instead | |
| 54 | + | // of polluting the new namespace or clearing its slot. | |
| 55 | + | inFlight uint64 | |
| 56 | + | pollSeq uint64 | |
| 57 | + | ||
| 58 | + | published []protocol.NavigationInventorySourceGroup | |
| 59 | + | admitted map[inventoryEntryID]uint64 | |
| 60 | + | retired map[inventoryEntryID]struct{} | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func newInventoryRelay(clock ports.Clock, dialer ports.ClientDialer) *inventoryRelay { | |
| 64 | + | return &inventoryRelay{clock: clock, dialer: dialer, admitted: make(map[inventoryEntryID]uint64), retired: make(map[inventoryEntryID]struct{})} | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // setOpen starts or stops polling for one palette interaction. Opening keeps | |
| 68 | + | // a fresh interaction namespace: admitted keys and retirements never cross | |
| 69 | + | // interactions, and reappearing targets earn new keys through new lifecycles. | |
| 70 | + | func (r *inventoryRelay) setOpen(open bool, interaction uint64) { | |
| 71 | + | if r == nil { | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | r.open = open | |
| 75 | + | if open && interaction != r.interaction { | |
| 76 | + | r.lastPoll = time.Time{} | |
| 77 | + | r.interaction = interaction | |
| 78 | + | r.publication = 0 | |
| 79 | + | r.published = nil | |
| 80 | + | r.admitted = make(map[inventoryEntryID]uint64) | |
| 81 | + | r.retired = make(map[inventoryEntryID]struct{}) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // pollDue reports whether a refresh query may start: open, none in flight, | |
| 86 | + | // and at least one interval since the last poll. | |
| 87 | + | func (r *inventoryRelay) pollDue() bool { | |
| 88 | + | if r == nil || !r.open || r.inFlight != 0 || r.clock == nil { | |
| 89 | + | return false | |
| 90 | + | } | |
| 91 | + | return !r.clock.Now().Before(r.lastPoll.Add(inventoryPollInterval)) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // beginPoll claims the single in-flight query slot, returning the query | |
| 95 | + | // identity the completion must present to endPoll. | |
| 96 | + | func (r *inventoryRelay) beginPoll() (uint64, bool) { | |
| 97 | + | if r == nil || !r.pollDue() { | |
| 98 | + | return 0, false | |
| 99 | + | } | |
| 100 | + | r.pollSeq++ | |
| 101 | + | r.inFlight = r.pollSeq | |
| 102 | + | r.lastPoll = r.clock.Now() | |
| 103 | + | return r.inFlight, true | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // endPoll releases the in-flight slot, but only for the query that owns | |
| 107 | + | // it. Stale completions from a closed interaction drop without touching | |
| 108 | + | // the new namespace. | |
| 109 | + | func (r *inventoryRelay) endPoll(query uint64) { | |
| 110 | + | if r == nil || query == 0 || r.inFlight != query { | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | r.inFlight = 0 | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // cancel stops polling and drops queued updates on close, reconnect, | |
| 117 | + | // handoff, attachment replacement, or process cancellation. | |
| 118 | + | func (r *inventoryRelay) cancel() { | |
| 119 | + | if r == nil { | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | r.open = false | |
| 123 | + | r.inFlight = 0 | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // canonicalInventoryGroups sorts groups and entries by key so change | |
| 127 | + | // comparison ignores transport order. Poll timestamps and request IDs never | |
| 128 | + | // reach this form, so idle unchanged inventory compares equal. | |
| 129 | + | func canonicalInventoryGroups(groups []protocol.NavigationInventorySourceGroup) []protocol.NavigationInventorySourceGroup { | |
| 130 | + | out := make([]protocol.NavigationInventorySourceGroup, 0, len(groups)) | |
| 131 | + | for _, group := range groups { | |
| 132 | + | entries := make([]protocol.NavigationInventoryEntry, len(group.Entries)) | |
| 133 | + | copy(entries, group.Entries) | |
| 134 | + | sort.Slice(entries, func(i, j int) bool { return entries[i].EntryKey < entries[j].EntryKey }) | |
| 135 | + | out = append(out, protocol.NavigationInventorySourceGroup{SourceKey: group.SourceKey, Status: group.Status, Entries: entries}) | |
| 136 | + | } | |
| 137 | + | sort.Slice(out, func(i, j int) bool { return out[i].SourceKey < out[j].SourceKey }) | |
| 138 | + | return out | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func inventoryGroupsEqual(a, b []protocol.NavigationInventorySourceGroup) bool { | |
| 142 | + | if len(a) != len(b) { | |
| 143 | + | return false | |
| 144 | + | } | |
| 145 | + | for i := range a { | |
| 146 | + | if a[i].SourceKey != b[i].SourceKey || a[i].Status != b[i].Status || len(a[i].Entries) != len(b[i].Entries) { | |
| 147 | + | return false | |
| 148 | + | } | |
| 149 | + | for j := range a[i].Entries { | |
| 150 | + | if a[i].Entries[j] != b[i].Entries[j] { | |
| 151 | + | return false | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | return true | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // preparePublication admits a fresh snapshot and reports whether it carries | |
| 159 | + | // a semantic change worth publishing remotely. Unchanged polls produce no | |
| 160 | + | // new publication. Removed targets retire their keys for the interaction and | |
| 161 | + | // never reuse them; admitted keys record the publication generation that | |
| 162 | + | // first carried them so older displayed selections stay valid while current. | |
| 163 | + | func (r *inventoryRelay) preparePublication(groups []protocol.NavigationInventorySourceGroup) ([]protocol.NavigationInventorySourceGroup, uint64, bool) { | |
| 164 | + | if r == nil { | |
| 165 | + | return nil, 0, false | |
| 166 | + | } | |
| 167 | + | canonical := canonicalInventoryGroups(groups) | |
| 168 | + | if inventoryGroupsEqual(canonical, r.published) { | |
| 169 | + | return nil, 0, false | |
| 170 | + | } | |
| 171 | + | r.publication++ | |
| 172 | + | seen := make(map[inventoryEntryID]struct{}, len(canonical)) | |
| 173 | + | for _, group := range canonical { | |
| 174 | + | for _, entry := range group.Entries { | |
| 175 | + | id := inventoryEntryID{source: entry.SourceKey, entry: entry.EntryKey} | |
| 176 | + | seen[id] = struct{}{} | |
| 177 | + | if _, ok := r.admitted[id]; !ok { | |
| 178 | + | r.admitted[id] = r.publication | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | for id := range r.admitted { | |
| 183 | + | if _, ok := seen[id]; !ok { | |
| 184 | + | if _, retired := r.retired[id]; !retired { | |
| 185 | + | r.retired[id] = struct{}{} | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | r.published = canonical | |
| 190 | + | return canonical, r.publication, true | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // validateSelection admits a serving-daemon selection against published | |
| 194 | + | // state. Generations are monotonic: future generations, keys not yet | |
| 195 | + | // published at the selected generation, and retired or unknown keys reject. | |
| 196 | + | // An unchanged key survives refresh lag: selection from an older displayed | |
| 197 | + | // publication stays valid while the key is still current. | |
| 198 | + | func (r *inventoryRelay) validateSelection(selection protocol.NavigationInventorySelection) error { | |
| 199 | + | if r == nil { | |
| 200 | + | return errors.New("vev: inventory relay unavailable") | |
| 201 | + | } | |
| 202 | + | if err := protocol.ValidateNavigationInventorySelection(selection); err != nil { | |
| 203 | + | return err | |
| 204 | + | } | |
| 205 | + | // Remote-source resolution needs source registration the relay never | |
| 206 | + | // carries; remote vision stays off until the E4 slice wires it. Local | |
| 207 | + | // sources resolve registration-free on the control connection. | |
| 208 | + | if selection.SourceKey != protocol.NavigationInventoryLocalSourceKey { | |
| 209 | + | return errors.New("vev: inventory selection for a remote source is unavailable") | |
| 210 | + | } | |
| 211 | + | if selection.InteractionGeneration != r.interaction { | |
| 212 | + | return errors.New("vev: inventory selection for a closed interaction") | |
| 213 | + | } | |
| 214 | + | if selection.PublicationGeneration > r.publication { | |
| 215 | + | return errors.New("vev: inventory selection from a future publication") | |
| 216 | + | } | |
| 217 | + | id := inventoryEntryID{source: selection.SourceKey, entry: selection.EntryKey} | |
| 218 | + | if _, retired := r.retired[id]; retired { | |
| 219 | + | return errors.New("vev: inventory selection for a retired entry") | |
| 220 | + | } | |
| 221 | + | admittedAt, ok := r.admitted[id] | |
| 222 | + | if !ok || admittedAt > selection.PublicationGeneration { | |
| 223 | + | return errors.New("vev: inventory selection for an unpublished entry") | |
| 224 | + | } | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | var errInventorySourceUnavailable = errors.New("vev: local inventory source unavailable") | |
| 229 | + | ||
| 230 | + | // watchQueryConn closes the exact connection when the attempt cancels or | |
| 231 | + | // the clock-driven query budget expires. Established IPC connections do | |
| 232 | + | // not retain the dial context, so without this a wedged source parks the | |
| 233 | + | // worker past teardown. The returned stop func releases the watcher. | |
| 234 | + | func (r *inventoryRelay) watchQueryConn(ctx context.Context, closeConn func()) func() { | |
| 235 | + | noop := func() {} | |
| 236 | + | if r == nil || closeConn == nil { | |
| 237 | + | return noop | |
| 238 | + | } | |
| 239 | + | done := make(chan struct{}) | |
| 240 | + | var timer ports.Timer | |
| 241 | + | var timerC <-chan time.Time | |
| 242 | + | if r.clock != nil { | |
| 243 | + | timer = r.clock.NewTimer(inventoryQueryTimeout) | |
| 244 | + | timerC = timer.C() | |
| 245 | + | } | |
| 246 | + | go func() { | |
| 247 | + | select { | |
| 248 | + | case <-ctx.Done(): | |
| 249 | + | closeConn() | |
| 250 | + | case <-timerC: | |
| 251 | + | closeConn() | |
| 252 | + | case <-done: | |
| 253 | + | } | |
| 254 | + | }() | |
| 255 | + | return func() { | |
| 256 | + | close(done) | |
| 257 | + | if timer != nil { | |
| 258 | + | timer.Stop() | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // querySnapshot performs one bounded dial-only snapshot query without Hello, | |
| 264 | + | // session creation, attachment, or daemon startup. A nil dialer or dial | |
| 265 | + | // failure reports source unavailability; the serving attachment is | |
| 266 | + | // unaffected and native commands remain usable. | |
| 267 | + | func (r *inventoryRelay) querySnapshot(ctx context.Context, requestID uint64) (protocol.NavigationInventoryResponse, error) { | |
| 268 | + | if r == nil || r.dialer == nil { | |
| 269 | + | return protocol.NavigationInventoryResponse{}, errInventorySourceUnavailable | |
| 270 | + | } | |
| 271 | + | ctx, cancel := context.WithTimeout(ctx, inventoryQueryTimeout) | |
| 272 | + | defer cancel() | |
| 273 | + | conn, err := r.dialer.Dial(ctx) | |
| 274 | + | if err != nil { | |
| 275 | + | return protocol.NavigationInventoryResponse{}, errInventorySourceUnavailable | |
| 276 | + | } | |
| 277 | + | var closeOnce sync.Once | |
| 278 | + | closeConn := func() { closeOnce.Do(func() { _ = conn.Close() }) } | |
| 279 | + | defer closeConn() | |
| 280 | + | stopWatch := r.watchQueryConn(ctx, closeConn) | |
| 281 | + | defer stopWatch() | |
| 282 | + | request := protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.NavigationInventorySnapshot} | |
| 283 | + | if err := protocol.ValidateNavigationInventoryRequest(request); err != nil { | |
| 284 | + | return protocol.NavigationInventoryResponse{}, err | |
| 285 | + | } | |
| 286 | + | if err := conn.SendClient(request); err != nil { | |
| 287 | + | return protocol.NavigationInventoryResponse{}, err | |
| 288 | + | } | |
| 289 | + | message, err := conn.ReceiveServer() | |
| 290 | + | if err != nil { | |
| 291 | + | return protocol.NavigationInventoryResponse{}, err | |
| 292 | + | } | |
| 293 | + | response, ok := message.(protocol.NavigationInventoryResponse) | |
| 294 | + | if !ok { | |
| 295 | + | return protocol.NavigationInventoryResponse{}, errors.New("vev: unexpected inventory response type") | |
| 296 | + | } | |
| 297 | + | if err := protocol.ValidateNavigationInventoryResponse(response); err != nil { | |
| 298 | + | return protocol.NavigationInventoryResponse{}, err | |
| 299 | + | } | |
| 300 | + | if response.RequestID != requestID || response.Operation != protocol.NavigationInventorySnapshot { | |
| 301 | + | return protocol.NavigationInventoryResponse{}, errors.New("vev: inventory response identity mismatch") | |
| 302 | + | } | |
| 303 | + | return response, nil | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // resolveEntry revalidates one selected key against the source and returns | |
| 307 | + | // the non-mutating attach target. Registration fencing happens source-side; | |
| 308 | + | // the relay only checks response identity before admitting any data. | |
| 309 | + | func (r *inventoryRelay) resolveEntry(ctx context.Context, requestID uint64, source, entry string, registration domain.RemoteRegistration) (protocol.AttachTarget, error) { | |
| 310 | + | if r == nil || r.dialer == nil { | |
| 311 | + | return protocol.AttachTarget{}, errInventorySourceUnavailable | |
| 312 | + | } | |
| 313 | + | ctx, cancel := context.WithTimeout(ctx, inventoryQueryTimeout) | |
| 314 | + | defer cancel() | |
| 315 | + | conn, err := r.dialer.Dial(ctx) | |
| 316 | + | if err != nil { | |
| 317 | + | return protocol.AttachTarget{}, errInventorySourceUnavailable | |
| 318 | + | } | |
| 319 | + | var closeOnce sync.Once | |
| 320 | + | closeConn := func() { closeOnce.Do(func() { _ = conn.Close() }) } | |
| 321 | + | defer closeConn() | |
| 322 | + | stopWatch := r.watchQueryConn(ctx, closeConn) | |
| 323 | + | defer stopWatch() | |
| 324 | + | request := protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.NavigationInventoryResolve, SourceKey: source, EntryKey: entry, Registration: registration} | |
| 325 | + | if err := protocol.ValidateNavigationInventoryRequest(request); err != nil { | |
| 326 | + | return protocol.AttachTarget{}, err | |
| 327 | + | } | |
| 328 | + | if err := conn.SendClient(request); err != nil { | |
| 329 | + | return protocol.AttachTarget{}, err | |
| 330 | + | } | |
| 331 | + | message, err := conn.ReceiveServer() | |
| 332 | + | if err != nil { | |
| 333 | + | return protocol.AttachTarget{}, err | |
| 334 | + | } | |
| 335 | + | response, ok := message.(protocol.NavigationInventoryResponse) | |
| 336 | + | if !ok { | |
| 337 | + | return protocol.AttachTarget{}, errors.New("vev: unexpected inventory response type") | |
| 338 | + | } | |
| 339 | + | if err := protocol.ValidateNavigationInventoryResponse(response); err != nil { | |
| 340 | + | return protocol.AttachTarget{}, err | |
| 341 | + | } | |
| 342 | + | if response.RequestID != requestID || response.Operation != protocol.NavigationInventoryResolve || response.Status != protocol.NavigationInventoryOK || response.Resolved == nil { | |
| 343 | + | return protocol.AttachTarget{}, errors.New("vev: inventory resolve rejected") | |
| 344 | + | } | |
| 345 | + | return *response.Resolved, nil | |
| 346 | + | } | |
A internal/usecase/client/navigation_inventory_test.go +330 -0
| @@ -0,0 +1,330 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // manualClock is a fake ports.Clock with explicit time control: no sleeps. | |
| 13 | + | type manualClock struct { | |
| 14 | + | now time.Time | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (c *manualClock) Now() time.Time { return c.now } | |
| 18 | + | ||
| 19 | + | func (c *manualClock) NewTimer(d time.Duration) ports.Timer { return manualTimer{at: c.now.Add(d)} } | |
| 20 | + | ||
| 21 | + | type manualTimer struct{ at time.Time } | |
| 22 | + | ||
| 23 | + | func (t manualTimer) C() <-chan time.Time { return nil } | |
| 24 | + | ||
| 25 | + | func (t manualTimer) Reset(time.Duration) bool { return false } | |
| 26 | + | ||
| 27 | + | func (t manualTimer) Stop() bool { return false } | |
| 28 | + | ||
| 29 | + | // inventoryFakeConnection scripts one control query: records sends, replays | |
| 30 | + | // queued server messages, counts closes. | |
| 31 | + | type inventoryFakeConnection struct { | |
| 32 | + | sent []protocol.ClientMessage | |
| 33 | + | replies []protocol.ServerMessage | |
| 34 | + | recvErr error | |
| 35 | + | closes int | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (c *inventoryFakeConnection) SendClient(message protocol.ClientMessage) error { | |
| 39 | + | c.sent = append(c.sent, message) | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (c *inventoryFakeConnection) ReceiveServer() (protocol.ServerMessage, error) { | |
| 44 | + | if c.recvErr != nil { | |
| 45 | + | return nil, c.recvErr | |
| 46 | + | } | |
| 47 | + | if len(c.replies) == 0 { | |
| 48 | + | return nil, context.DeadlineExceeded | |
| 49 | + | } | |
| 50 | + | message := c.replies[0] | |
| 51 | + | c.replies = c.replies[1:] | |
| 52 | + | return message, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (c *inventoryFakeConnection) Capabilities() protocol.ConnectionCapabilities { | |
| 56 | + | return protocol.ConnectionCapabilities{} | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (c *inventoryFakeConnection) LinkState() ports.LinkState { return ports.LinkStateConnected } | |
| 60 | + | ||
| 61 | + | func (c *inventoryFakeConnection) LinkEvents() <-chan ports.LinkEvent { return nil } | |
| 62 | + | ||
| 63 | + | func (c *inventoryFakeConnection) Close() error { | |
| 64 | + | c.closes++ | |
| 65 | + | return nil | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // inventoryFakeDialer counts dials and hands out one scripted connection. | |
| 69 | + | // Zero dials is asserted directly for local-only and remote-only modes. | |
| 70 | + | type inventoryFakeDialer struct { | |
| 71 | + | dials int | |
| 72 | + | conn *inventoryFakeConnection | |
| 73 | + | dialErr error | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (d *inventoryFakeDialer) Dial(context.Context) (ports.ClientConnection, error) { | |
| 77 | + | d.dials++ | |
| 78 | + | if d.dialErr != nil { | |
| 79 | + | return nil, d.dialErr | |
| 80 | + | } | |
| 81 | + | return d.conn, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func inventoryTestGroups() []protocol.NavigationInventorySourceGroup { | |
| 85 | + | return []protocol.NavigationInventorySourceGroup{{ | |
| 86 | + | SourceKey: protocol.NavigationInventoryLocalSourceKey, | |
| 87 | + | Status: protocol.NavigationInventorySourceOK, | |
| 88 | + | Entries: []protocol.NavigationInventoryEntry{ | |
| 89 | + | {SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "aaa/one", Name: "one"}, | |
| 90 | + | {SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "bbb/two", Name: "two"}, | |
| 91 | + | }, | |
| 92 | + | }} | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func inventoryTestResponse(requestID uint64) protocol.NavigationInventoryResponse { | |
| 96 | + | return protocol.NavigationInventoryResponse{ | |
| 97 | + | RequestID: requestID, Operation: protocol.NavigationInventorySnapshot, | |
| 98 | + | Status: protocol.NavigationInventoryOK, Groups: inventoryTestGroups(), | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestInventoryRelayEnabledModes(t *testing.T) { | |
| 103 | + | dialer := &inventoryFakeDialer{conn: &inventoryFakeConnection{}} | |
| 104 | + | tests := []struct { | |
| 105 | + | name string | |
| 106 | + | homeCommitted bool | |
| 107 | + | servingRemote bool | |
| 108 | + | dialer ports.ClientDialer | |
| 109 | + | want bool | |
| 110 | + | }{ | |
| 111 | + | {name: "hybrid remote serving", homeCommitted: true, servingRemote: true, dialer: dialer, want: true}, | |
| 112 | + | {name: "local only never dials", homeCommitted: true, servingRemote: false, dialer: dialer}, | |
| 113 | + | {name: "direct remote only never dials", servingRemote: true, dialer: dialer}, | |
| 114 | + | {name: "no dialer never dials", homeCommitted: true, servingRemote: true}, | |
| 115 | + | {name: "unsolicited demand without source never dials", servingRemote: true}, | |
| 116 | + | } | |
| 117 | + | for _, tt := range tests { | |
| 118 | + | t.Run(tt.name, func(t *testing.T) { | |
| 119 | + | if got := inventoryRelayEnabled(tt.homeCommitted, tt.servingRemote, tt.dialer); got != tt.want { | |
| 120 | + | t.Fatalf("inventoryRelayEnabled() = %t, want %t", got, tt.want) | |
| 121 | + | } | |
| 122 | + | }) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestInventoryRelayZeroDialsWithoutSource(t *testing.T) { | |
| 127 | + | for _, dialer := range []ports.ClientDialer{nil, &inventoryFakeDialer{conn: &inventoryFakeConnection{}}} { | |
| 128 | + | relay := newInventoryRelay(&manualClock{now: time.Unix(1_000, 0)}, nil) | |
| 129 | + | if _, err := relay.querySnapshot(context.Background(), 1); err == nil { | |
| 130 | + | t.Fatal("nil-dialer query must report source unavailability") | |
| 131 | + | } | |
| 132 | + | _ = dialer | |
| 133 | + | } | |
| 134 | + | relay := newInventoryRelay(&manualClock{now: time.Unix(1_000, 0)}, nil) | |
| 135 | + | relay.setOpen(true, 1) | |
| 136 | + | query, ok := relay.beginPoll() | |
| 137 | + | if !ok || query == 0 { | |
| 138 | + | t.Fatal("poll slot should open without a source") | |
| 139 | + | } | |
| 140 | + | relay.endPoll(query) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestInventoryRelaySnapshotAdmitsAndSuppressesUnchanged(t *testing.T) { | |
| 144 | + | clock := &manualClock{now: time.Unix(1_000, 0)} | |
| 145 | + | conn := &inventoryFakeConnection{replies: []protocol.ServerMessage{inventoryTestResponse(7), inventoryTestResponse(8)}} | |
| 146 | + | dialer := &inventoryFakeDialer{conn: conn} | |
| 147 | + | relay := newInventoryRelay(clock, dialer) | |
| 148 | + | relay.setOpen(true, 3) | |
| 149 | + | first, ok := relay.beginPoll() | |
| 150 | + | if !ok || first == 0 { | |
| 151 | + | t.Fatal("first poll should start") | |
| 152 | + | } | |
| 153 | + | response, err := relay.querySnapshot(context.Background(), 7) | |
| 154 | + | if err != nil { | |
| 155 | + | t.Fatalf("querySnapshot() error = %v", err) | |
| 156 | + | } | |
| 157 | + | relay.endPoll(first) | |
| 158 | + | if dialer.dials != 1 || conn.closes != 1 { | |
| 159 | + | t.Fatalf("dials = %d, closes = %d; want exactly one bounded query", dialer.dials, conn.closes) | |
| 160 | + | } | |
| 161 | + | groups, generation, changed := relay.preparePublication(response.Groups) | |
| 162 | + | if !changed || generation != 1 || len(groups) != 1 { | |
| 163 | + | t.Fatalf("first snapshot must publish once, got changed=%t generation=%d", changed, generation) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | clock.now = clock.now.Add(2 * time.Second) | |
| 167 | + | second, ok := relay.beginPoll() | |
| 168 | + | if !ok || second == 0 || second == first { | |
| 169 | + | t.Fatal("second poll should start after the interval with a new identity") | |
| 170 | + | } | |
| 171 | + | response, err = relay.querySnapshot(context.Background(), 8) | |
| 172 | + | if err != nil { | |
| 173 | + | t.Fatalf("second querySnapshot() error = %v", err) | |
| 174 | + | } | |
| 175 | + | relay.endPoll(second) | |
| 176 | + | if _, _, changed := relay.preparePublication(response.Groups); changed { | |
| 177 | + | t.Fatal("unchanged inventory must not republish remotely") | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestInventoryRelaySelectionSurvivesRefreshLag(t *testing.T) { | |
| 182 | + | clock := &manualClock{now: time.Unix(1_000, 0)} | |
| 183 | + | groups := inventoryTestGroups() | |
| 184 | + | relay := newInventoryRelay(clock, &inventoryFakeDialer{}) | |
| 185 | + | relay.setOpen(true, 5) | |
| 186 | + | if _, _, changed := relay.preparePublication(groups); !changed { | |
| 187 | + | t.Fatal("initial publication must report changed") | |
| 188 | + | } | |
| 189 | + | // Two further unchanged refreshes: no new publication, same keys. | |
| 190 | + | clock.now = clock.now.Add(3 * time.Second) | |
| 191 | + | if _, _, changed := relay.preparePublication(groups); changed { | |
| 192 | + | t.Fatal("unchanged refresh must stay silent") | |
| 193 | + | } | |
| 194 | + | clock.now = clock.now.Add(3 * time.Second) | |
| 195 | + | if _, _, changed := relay.preparePublication(groups); changed { | |
| 196 | + | t.Fatal("unchanged refresh must stay silent") | |
| 197 | + | } | |
| 198 | + | // Selection from the older displayed publication stays valid: the key is | |
| 199 | + | // still current and was already published at that generation. | |
| 200 | + | selection := protocol.NavigationInventorySelection{CauseActionID: 9, InteractionGeneration: 5, PublicationGeneration: 1, SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "aaa/one"} | |
| 201 | + | if err := relay.validateSelection(selection); err != nil { | |
| 202 | + | t.Fatalf("lagged selection for an unchanged key must stay valid: %v", err) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestInventoryRelaySelectionRejects(t *testing.T) { | |
| 207 | + | clock := &manualClock{now: time.Unix(1_000, 0)} | |
| 208 | + | relay := newInventoryRelay(clock, &inventoryFakeDialer{}) | |
| 209 | + | relay.setOpen(true, 5) | |
| 210 | + | groups, generation, changed := relay.preparePublication(inventoryTestGroups()) | |
| 211 | + | if !changed || generation != 1 { | |
| 212 | + | t.Fatal("initial publication must report changed") | |
| 213 | + | } | |
| 214 | + | _ = groups | |
| 215 | + | valid := protocol.NavigationInventorySelection{CauseActionID: 9, InteractionGeneration: 5, PublicationGeneration: 1, SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "aaa/one"} | |
| 216 | + | if err := relay.validateSelection(valid); err != nil { | |
| 217 | + | t.Fatalf("current selection must validate: %v", err) | |
| 218 | + | } | |
| 219 | + | tests := []struct { | |
| 220 | + | name string | |
| 221 | + | mutate func(*protocol.NavigationInventorySelection) | |
| 222 | + | wantError bool | |
| 223 | + | }{ | |
| 224 | + | {name: "future generation", mutate: func(s *protocol.NavigationInventorySelection) { s.PublicationGeneration = 2 }, wantError: true}, | |
| 225 | + | {name: "unknown key", mutate: func(s *protocol.NavigationInventorySelection) { s.EntryKey = "zzz/nope" }, wantError: true}, | |
| 226 | + | {name: "closed interaction", mutate: func(s *protocol.NavigationInventorySelection) { s.InteractionGeneration = 4 }, wantError: true}, | |
| 227 | + | {name: "zero publication", mutate: func(s *protocol.NavigationInventorySelection) { s.PublicationGeneration = 0 }, wantError: true}, | |
| 228 | + | {name: "zero cause stays valid for keyboard input", mutate: func(s *protocol.NavigationInventorySelection) { s.CauseActionID = 0 }, wantError: false}, | |
| 229 | + | } | |
| 230 | + | for _, tt := range tests { | |
| 231 | + | t.Run(tt.name, func(t *testing.T) { | |
| 232 | + | selection := valid | |
| 233 | + | tt.mutate(&selection) | |
| 234 | + | if err := relay.validateSelection(selection); (err != nil) != tt.wantError { | |
| 235 | + | t.Fatalf("validateSelection() = %v, wantError %t", err, tt.wantError) | |
| 236 | + | } | |
| 237 | + | }) | |
| 238 | + | } | |
| 239 | + | // Retire the key by removing it, then check rejection. | |
| 240 | + | thinned := []protocol.NavigationInventorySourceGroup{{ | |
| 241 | + | SourceKey: protocol.NavigationInventoryLocalSourceKey, Status: protocol.NavigationInventorySourceOK, | |
| 242 | + | Entries: []protocol.NavigationInventoryEntry{ | |
| 243 | + | {SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "bbb/two", Name: "two"}, | |
| 244 | + | }, | |
| 245 | + | }} | |
| 246 | + | if _, _, changed := relay.preparePublication(thinned); !changed { | |
| 247 | + | t.Fatal("removal must publish") | |
| 248 | + | } | |
| 249 | + | if err := relay.validateSelection(valid); err == nil { | |
| 250 | + | t.Fatal("retired key must reject without same-name substitution") | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func TestInventoryRelayCancelAndPollGating(t *testing.T) { | |
| 255 | + | clock := &manualClock{now: time.Unix(1_000, 0)} | |
| 256 | + | relay := newInventoryRelay(clock, &inventoryFakeDialer{}) | |
| 257 | + | if relay.pollDue() { | |
| 258 | + | t.Fatal("closed relay must not poll") | |
| 259 | + | } | |
| 260 | + | if _, ok := relay.beginPoll(); ok { | |
| 261 | + | t.Fatal("closed relay must not claim the slot") | |
| 262 | + | } | |
| 263 | + | relay.setOpen(true, 2) | |
| 264 | + | if !relay.pollDue() { | |
| 265 | + | t.Fatal("open relay must poll") | |
| 266 | + | } | |
| 267 | + | first, ok := relay.beginPoll() | |
| 268 | + | if !ok { | |
| 269 | + | t.Fatal("open relay must claim the slot") | |
| 270 | + | } | |
| 271 | + | if _, ok := relay.beginPoll(); ok { | |
| 272 | + | t.Fatal("at most one query in flight") | |
| 273 | + | } | |
| 274 | + | relay.endPoll(first) | |
| 275 | + | clock.now = clock.now.Add(inventoryPollInterval) | |
| 276 | + | if !relay.pollDue() { | |
| 277 | + | t.Fatal("poll due again after the interval") | |
| 278 | + | } | |
| 279 | + | relay.cancel() | |
| 280 | + | if relay.pollDue() { | |
| 281 | + | t.Fatal("cancelled relay must not poll") | |
| 282 | + | } | |
| 283 | + | if _, ok := relay.beginPoll(); ok { | |
| 284 | + | t.Fatal("cancelled relay must not claim the slot") | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestInventoryRelayStaleCompletionDropsWithoutTouchingSlot(t *testing.T) { | |
| 289 | + | clock := &manualClock{now: time.Unix(1_000, 0)} | |
| 290 | + | relay := newInventoryRelay(clock, &inventoryFakeDialer{}) | |
| 291 | + | relay.setOpen(true, 2) | |
| 292 | + | stale, ok := relay.beginPoll() | |
| 293 | + | if !ok { | |
| 294 | + | t.Fatal("first poll should start") | |
| 295 | + | } | |
| 296 | + | // Closing for a new interaction releases the slot without waiting | |
| 297 | + | // for the outstanding worker. | |
| 298 | + | relay.setOpen(true, 3) | |
| 299 | + | relay.endPoll(stale) | |
| 300 | + | if relay.inFlight != 0 { | |
| 301 | + | t.Fatal("stale completion must not clear the new namespace slot") | |
| 302 | + | } | |
| 303 | + | clock.now = clock.now.Add(inventoryPollInterval) | |
| 304 | + | fresh, ok := relay.beginPoll() | |
| 305 | + | if !ok || fresh == stale { | |
| 306 | + | t.Fatal("new interaction must claim a fresh slot identity") | |
| 307 | + | } | |
| 308 | + | relay.endPoll(fresh + 99) | |
| 309 | + | if relay.inFlight != fresh { | |
| 310 | + | t.Fatal("foreign identity must not release the slot") | |
| 311 | + | } | |
| 312 | + | relay.endPoll(fresh) | |
| 313 | + | if relay.inFlight != 0 { | |
| 314 | + | t.Fatal("owning identity must release the slot") | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestInventoryRelayQueryRejectsIdentityMismatch(t *testing.T) { | |
| 319 | + | clock := &manualClock{now: time.Unix(1_000, 0)} | |
| 320 | + | mismatched := inventoryTestResponse(999) | |
| 321 | + | conn := &inventoryFakeConnection{replies: []protocol.ServerMessage{mismatched}} | |
| 322 | + | dialer := &inventoryFakeDialer{conn: conn} | |
| 323 | + | relay := newInventoryRelay(clock, dialer) | |
| 324 | + | if _, err := relay.querySnapshot(context.Background(), 7); err == nil { | |
| 325 | + | t.Fatal("response ID mismatch must not admit data") | |
| 326 | + | } | |
| 327 | + | if len(relay.admitted) != 0 { | |
| 328 | + | t.Fatal("mismatched response must admit nothing") | |
| 329 | + | } | |
| 330 | + | } | |
M internal/usecase/client/navigation_test.go +1 -1
| @@ -42,7 +42,7 @@ func TestValidateAttachRequestNavigationTable(t *testing.T) { | |||
| 42 | 42 | {name: "home picker on client-owned remote new route", request: AttachRequest{Intent: protocol.IntentNew, SessionName: "example", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true}, | |
| 43 | 43 | {name: "back on client-owned route", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: true}, | |
| 44 | 44 | {name: "back on remote-target route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", RemoteTarget: remoteRoute, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false}, | |
| 45 | - | {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 4}, valid: false}, | |
| 45 | + | {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 8}, valid: false}, | |
| 46 | 46 | {name: "back without startup picker", request: AttachRequest{NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false}, | |
| 47 | 47 | {name: "startup picker without back", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | |
| 48 | 48 | } | |
A internal/usecase/client/navigation_transition.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/protocol" | |
| 4 | + | ||
| 5 | + | // Home-picker overlays retain their separate navigation lifecycle. | |
| 6 | + | type navigationOperation uint8 | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | navigationOperationNone navigationOperation = iota | |
| 10 | + | navigationOperationCreation | |
| 11 | + | navigationOperationRecent | |
| 12 | + | navigationOperationInventory | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // navigationTransition retains recovery until destination publication. Target | |
| 16 | + | // transport authority belongs to attachHandoff.source; history belongs to the | |
| 17 | + | // route ledger. Neither is duplicated here. | |
| 18 | + | type navigationTransition struct { | |
| 19 | + | operation navigationOperation | |
| 20 | + | returnRoute attachRoute | |
| 21 | + | requestID uint64 | |
| 22 | + | // inventorySelection identifies the serving palette selection that | |
| 23 | + | // caused the handoff. It survives destination dial/handshake failure | |
| 24 | + | // so recovery can report NavigationInventoryNavigationFailed to the | |
| 25 | + | // restored serving daemon. | |
| 26 | + | inventorySelection protocol.NavigationInventorySelection | |
| 27 | + | hasInventorySelection bool | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (t *navigationTransition) active() bool { | |
| 31 | + | return t != nil && t.operation != navigationOperationNone | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (t *navigationTransition) pendingCreation() bool { | |
| 35 | + | return t != nil && t.operation == navigationOperationCreation | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (t *navigationTransition) pendingRecent() bool { | |
| 39 | + | return t != nil && t.operation == navigationOperationRecent | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (t *navigationTransition) pendingInventory() bool { | |
| 43 | + | return t != nil && t.operation == navigationOperationInventory | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (t *navigationTransition) beginCreation(returnRoute attachRoute, requestID uint64) { | |
| 47 | + | *t = navigationTransition{operation: navigationOperationCreation, returnRoute: returnRoute, requestID: requestID} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (t *navigationTransition) beginRecent(returnRoute attachRoute) { | |
| 51 | + | *t = navigationTransition{operation: navigationOperationRecent, returnRoute: returnRoute} | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (t *navigationTransition) beginInventory(returnRoute attachRoute, selection protocol.NavigationInventorySelection) { | |
| 55 | + | *t = navigationTransition{operation: navigationOperationInventory, returnRoute: returnRoute, inventorySelection: selection, hasInventorySelection: true} | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // inventoryNavigationFailure reports the failed handoff to the restored | |
| 59 | + | // serving daemon. It returns false when no selection was retained or the | |
| 60 | + | // failure does not validate. | |
| 61 | + | func (t *navigationTransition) inventoryNavigationFailure() (protocol.NavigationInventoryFailure, bool) { | |
| 62 | + | if t == nil || !t.hasInventorySelection { | |
| 63 | + | return protocol.NavigationInventoryFailure{}, false | |
| 64 | + | } | |
| 65 | + | failure := protocol.NavigationInventoryFailure{ | |
| 66 | + | CauseActionID: t.inventorySelection.CauseActionID, | |
| 67 | + | InteractionGeneration: t.inventorySelection.InteractionGeneration, | |
| 68 | + | SourceKey: t.inventorySelection.SourceKey, | |
| 69 | + | EntryKey: t.inventorySelection.EntryKey, | |
| 70 | + | Code: protocol.NavigationInventoryNavigationFailed, | |
| 71 | + | } | |
| 72 | + | if err := protocol.ValidateNavigationInventoryFailure(failure); err != nil { | |
| 73 | + | return protocol.NavigationInventoryFailure{}, false | |
| 74 | + | } | |
| 75 | + | return failure, true | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (t *navigationTransition) restore() (attachRoute, bool) { | |
| 79 | + | if !t.active() { | |
| 80 | + | return attachRoute{}, false | |
| 81 | + | } | |
| 82 | + | return t.returnRoute, true | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Both outcomes retire the same pending recovery record. The route ledger | |
| 86 | + | // independently commits successful destinations. | |
| 87 | + | func (t *navigationTransition) settleSuccess() { t.clear() } | |
| 88 | + | func (t *navigationTransition) settleFailure() { t.clear() } | |
| 89 | + | ||
| 90 | + | func (t *navigationTransition) clear() { | |
| 91 | + | if t != nil { | |
| 92 | + | *t = navigationTransition{} | |
| 93 | + | } | |
| 94 | + | } | |
A internal/usecase/client/navigation_transition_test.go +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/protocol" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func testAttachRoute(name string) attachRoute { | |
| 11 | + | return attachRoute{request: AttachRequest{SessionName: name}} | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func TestNavigationTransitionRetiresRecoveryOnce(t *testing.T) { | |
| 15 | + | for _, tc := range []struct { | |
| 16 | + | name string | |
| 17 | + | begin func(*navigationTransition) | |
| 18 | + | }{ | |
| 19 | + | {"creation", func(n *navigationTransition) { n.beginCreation(testAttachRoute("return"), 7) }}, | |
| 20 | + | {"recent", func(n *navigationTransition) { n.beginRecent(testAttachRoute("return")) }}, | |
| 21 | + | {"inventory", func(n *navigationTransition) { | |
| 22 | + | n.beginInventory(testAttachRoute("return"), protocol.NavigationInventorySelection{ | |
| 23 | + | CauseActionID: 1, | |
| 24 | + | InteractionGeneration: 2, | |
| 25 | + | SourceKey: protocol.NavigationInventoryLocalSourceKey, | |
| 26 | + | EntryKey: "entry", | |
| 27 | + | }) | |
| 28 | + | }}, | |
| 29 | + | } { | |
| 30 | + | t.Run(tc.name, func(t *testing.T) { | |
| 31 | + | for _, success := range []bool{false, true} { | |
| 32 | + | var n navigationTransition | |
| 33 | + | tc.begin(&n) | |
| 34 | + | require.True(t, n.active()) | |
| 35 | + | route, ok := n.restore() | |
| 36 | + | require.True(t, ok) | |
| 37 | + | require.Equal(t, "return", route.request.SessionName) | |
| 38 | + | if success { | |
| 39 | + | n.settleSuccess() | |
| 40 | + | } else { | |
| 41 | + | n.settleFailure() | |
| 42 | + | } | |
| 43 | + | require.False(t, n.active()) | |
| 44 | + | _, ok = n.restore() | |
| 45 | + | require.False(t, ok) | |
| 46 | + | n.clear() | |
| 47 | + | require.False(t, n.active()) | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestNavigationTransitionInventoryFailure(t *testing.T) { | |
| 54 | + | for _, tc := range []struct { | |
| 55 | + | name string | |
| 56 | + | begin func(*navigationTransition) | |
| 57 | + | wantCode protocol.NavigationInventoryFailureCode | |
| 58 | + | wantValid bool | |
| 59 | + | }{ | |
| 60 | + | { | |
| 61 | + | name: "inventory retains selection for failed handoff", | |
| 62 | + | begin: func(n *navigationTransition) { | |
| 63 | + | n.beginInventory(testAttachRoute("return"), protocol.NavigationInventorySelection{ | |
| 64 | + | CauseActionID: 9, | |
| 65 | + | InteractionGeneration: 3, | |
| 66 | + | SourceKey: protocol.NavigationInventoryLocalSourceKey, | |
| 67 | + | EntryKey: "entry", | |
| 68 | + | }) | |
| 69 | + | }, | |
| 70 | + | wantCode: protocol.NavigationInventoryNavigationFailed, | |
| 71 | + | wantValid: true, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | name: "creation carries no inventory failure", | |
| 75 | + | begin: func(n *navigationTransition) { n.beginCreation(testAttachRoute("return"), 7) }, | |
| 76 | + | wantValid: false, | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | name: "recent carries no inventory failure", | |
| 80 | + | begin: func(n *navigationTransition) { n.beginRecent(testAttachRoute("return")) }, | |
| 81 | + | wantValid: false, | |
| 82 | + | }, | |
| 83 | + | } { | |
| 84 | + | t.Run(tc.name, func(t *testing.T) { | |
| 85 | + | var n navigationTransition | |
| 86 | + | tc.begin(&n) | |
| 87 | + | failure, ok := n.inventoryNavigationFailure() | |
| 88 | + | require.Equal(t, tc.wantValid, ok) | |
| 89 | + | if !tc.wantValid { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | require.Equal(t, tc.wantCode, failure.Code) | |
| 93 | + | require.Equal(t, uint64(9), failure.CauseActionID) | |
| 94 | + | require.Equal(t, uint64(3), failure.InteractionGeneration) | |
| 95 | + | require.Equal(t, protocol.NavigationInventoryLocalSourceKey, failure.SourceKey) | |
| 96 | + | require.Equal(t, "entry", failure.EntryKey) | |
| 97 | + | require.NoError(t, protocol.ValidateNavigationInventoryFailure(failure)) | |
| 98 | + | }) | |
| 99 | + | } | |
| 100 | + | } | |
M internal/usecase/client/routes.go +11 -0
| @@ -435,6 +435,17 @@ func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, err | |||
| 435 | 435 | resumeToken: candidate.resumeToken, | |
| 436 | 436 | home: isHome, | |
| 437 | 437 | } | |
| 438 | + | // Resume credentials belong to attachments, which can move between | |
| 439 | + | // sessions on one daemon. Only the newly committed route may retain | |
| 440 | + | // this credential; older routes must use their exact attach targets. | |
| 441 | + | if candidate.resumeToken != 0 { | |
| 442 | + | for i := range l.entries { | |
| 443 | + | previous := &l.entries[i] | |
| 444 | + | if previous.origin == candidate.origin && previous.originKey == candidate.originKey && previous.resumeToken == candidate.resumeToken { | |
| 445 | + | previous.resumeToken = 0 | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | } | |
| 438 | 449 | oldActive := l.active | |
| 439 | 450 | l.moveToFrontLocked(index, entry) | |
| 440 | 451 | if !oldActive.empty() && oldActive.key != identity.key { | |
A internal/usecase/client/routes_resume_identity_test.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestRouteLedgerNavigationAfterSamePeerSwitchDoesNotResumePreviousSessionCredential(t *testing.T) { | |
| 12 | + | ledger := newRouteLedger() | |
| 13 | + | first := routeTestCandidate(0, protocol.RouteOriginLocal) | |
| 14 | + | first.resumeToken = 42 | |
| 15 | + | _, err := ledger.commit(first) | |
| 16 | + | require.NoError(t, err) | |
| 17 | + | second := routeTestTarget(1) | |
| 18 | + | _, err = ledger.commitCommittedIdentity(protocol.CommittedRouteIdentity{Target: second}) | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | remote := routeTestCandidate(2, protocol.RouteOriginRemote) | |
| 21 | + | remote.originKey = "remote" | |
| 22 | + | _, err = ledger.commit(remote) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | snapshot := ledger.snapshot() | |
| 25 | + | for _, entry := range snapshot.Entries { | |
| 26 | + | if entry.Target != first.target { | |
| 27 | + | continue | |
| 28 | + | } | |
| 29 | + | connector := &routeTestConnector{resumeErr: errRouteResumeUnavailable} | |
| 30 | + | err = ledger.navigate(context.Background(), protocol.RouteNavigationAction{SnapshotGeneration: snapshot.Generation, Key: entry.Key, Generation: entry.Generation}, connector) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.Zero(t, connector.resumeCalls, "a credential follows its attachment to the new session; old history must attach exactly") | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | t.Fatal("previous session missing from history") | |
| 36 | + | } | |
M internal/usecase/client/typed_connection_external_test.go +8 -0
| @@ -140,6 +140,10 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) { | |||
| 140 | 140 | case protocol.SessionCreationFailure: | |
| 141 | 141 | p, e := wire.MarshalSessionCreationFailure(x) | |
| 142 | 142 | return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: p}, e | |
| 143 | + | case protocol.NavigationInventoryPublication: | |
| 144 | + | return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: wire.MarshalNavigationInventoryPublication(x)}, nil | |
| 145 | + | case protocol.NavigationInventoryFailure: | |
| 146 | + | return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: wire.MarshalNavigationInventoryFailure(x)}, nil | |
| 143 | 147 | default: | |
| 144 | 148 | return wire.Frame{}, errors.New("test client: unsupported message") | |
| 145 | 149 | } | |
| @@ -148,6 +152,10 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) { | |||
| 148 | 152 | switch f.Type { | |
| 149 | 153 | case wire.MsgWelcome: | |
| 150 | 154 | return wire.UnmarshalWelcome(f.Payload) | |
| 155 | + | case wire.MsgNavigationInventoryDemand: | |
| 156 | + | return wire.UnmarshalNavigationInventoryDemand(f.Payload) | |
| 157 | + | case wire.MsgNavigationInventorySelection: | |
| 158 | + | return wire.UnmarshalNavigationInventorySelection(f.Payload) | |
| 151 | 159 | case wire.MsgError: | |
| 152 | 160 | return wire.UnmarshalErrorMsg(f.Payload) | |
| 153 | 161 | case wire.MsgOutput: | |
M internal/usecase/client/typed_connection_test.go +24 -0
| @@ -136,6 +136,24 @@ func testClientFrame(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 136 | 136 | case protocol.RouteNavigationFailure: | |
| 137 | 137 | p, e := wire.MarshalRouteNavigationFailure(m) | |
| 138 | 138 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e | |
| 139 | + | case protocol.NavigationInventoryRequest: | |
| 140 | + | p := wire.MarshalNavigationInventoryRequest(m) | |
| 141 | + | if p == nil { | |
| 142 | + | return wire.Frame{}, errors.New("test client connection: invalid inventory request") | |
| 143 | + | } | |
| 144 | + | return wire.Frame{Type: wire.MsgNavigationInventoryRequest, Payload: p}, nil | |
| 145 | + | case protocol.NavigationInventoryPublication: | |
| 146 | + | p := wire.MarshalNavigationInventoryPublication(m) | |
| 147 | + | if p == nil { | |
| 148 | + | return wire.Frame{}, errors.New("test client connection: invalid inventory publication") | |
| 149 | + | } | |
| 150 | + | return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: p}, nil | |
| 151 | + | case protocol.NavigationInventoryFailure: | |
| 152 | + | p := wire.MarshalNavigationInventoryFailure(m) | |
| 153 | + | if p == nil { | |
| 154 | + | return wire.Frame{}, errors.New("test client connection: invalid inventory failure") | |
| 155 | + | } | |
| 156 | + | return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: p}, nil | |
| 139 | 157 | default: | |
| 140 | 158 | return wire.Frame{}, errors.New("test client connection: unsupported client message") | |
| 141 | 159 | } | |
| @@ -175,6 +193,12 @@ func testServerMessage(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 175 | 193 | return wire.UnmarshalSamePeerSwitchFailure(frame.Payload) | |
| 176 | 194 | case wire.MsgParkedRouteResponse: | |
| 177 | 195 | return wire.UnmarshalParkedRouteResponse(frame.Payload) | |
| 196 | + | case wire.MsgNavigationInventoryResponse: | |
| 197 | + | return wire.UnmarshalNavigationInventoryResponse(frame.Payload) | |
| 198 | + | case wire.MsgNavigationInventoryDemand: | |
| 199 | + | return wire.UnmarshalNavigationInventoryDemand(frame.Payload) | |
| 200 | + | case wire.MsgNavigationInventorySelection: | |
| 201 | + | return wire.UnmarshalNavigationInventorySelection(frame.Payload) | |
| 178 | 202 | default: | |
| 179 | 203 | return nil, errors.New("test client connection: unsupported server frame") | |
| 180 | 204 | } | |
A internal/usecase/daemon/activation_policy_test.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // TestActivationPolicyTable drives the D5 contract through the picker | |
| 16 | + | // projection (not a pure helper): broken and incompatible rows are not | |
| 17 | + | // activatable but keep their diagnostic representation, while known valid | |
| 18 | + | // cached targets with stale or failed observations stay attemptable with a | |
| 19 | + | // presentation reason. Destination attach still validates exactly. | |
| 20 | + | func TestActivationPolicyTable(t *testing.T) { | |
| 21 | + | now := time.Unix(1_000, 0) | |
| 22 | + | lifecycle := domain.SessionLifecycleID{21} | |
| 23 | + | tabs := []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell"}} | |
| 24 | + | ||
| 25 | + | tests := []struct { | |
| 26 | + | name string | |
| 27 | + | host ports.RemoteHostSnapshot | |
| 28 | + | session catalogue.RemoteCatalogSession | |
| 29 | + | activation picker.RemoteActivation | |
| 30 | + | reason string | |
| 31 | + | rowPresent bool | |
| 32 | + | }{ | |
| 33 | + | { | |
| 34 | + | name: "live reachable attaches", | |
| 35 | + | host: reachableDirectoryHost("user@arch", now), | |
| 36 | + | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 37 | + | activation: picker.RemoteAttach, | |
| 38 | + | rowPresent: true, | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "stopped reachable restarts", | |
| 42 | + | host: reachableDirectoryHost("user@arch", now), | |
| 43 | + | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown, Tabs: tabs}, | |
| 44 | + | activation: picker.RemoteRestart, | |
| 45 | + | rowPresent: true, | |
| 46 | + | }, | |
| 47 | + | { | |
| 48 | + | name: "broken is diagnostic only", | |
| 49 | + | host: reachableDirectoryHost("user@arch", now), | |
| 50 | + | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionBroken, Tabs: tabs}, | |
| 51 | + | activation: picker.RemoteUnavailable, | |
| 52 | + | reason: domain.RemoteReasonSessionBroken, | |
| 53 | + | rowPresent: true, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "incompatible is diagnostic only", | |
| 57 | + | host: ports.RemoteHostSnapshot{ | |
| 58 | + | Endpoint: "user@arch", Availability: domain.RemoteAvailabilityIncompatible, | |
| 59 | + | LastSuccess: now, InventoryKnown: true, | |
| 60 | + | }, | |
| 61 | + | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 62 | + | activation: picker.RemoteUnavailable, | |
| 63 | + | reason: domain.RemoteReasonVersionMismatch, | |
| 64 | + | rowPresent: true, | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | name: "stale inventory stays attemptable", | |
| 68 | + | host: func() ports.RemoteHostSnapshot { | |
| 69 | + | host := reachableDirectoryHost("user@arch", now.Add(-time.Hour)) | |
| 70 | + | return host | |
| 71 | + | }(), | |
| 72 | + | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 73 | + | activation: picker.RemoteAttach, | |
| 74 | + | rowPresent: true, | |
| 75 | + | }, | |
| 76 | + | { | |
| 77 | + | name: "unreachable cached stays attemptable with reason", | |
| 78 | + | host: ports.RemoteHostSnapshot{ | |
| 79 | + | Endpoint: "user@arch", Availability: domain.RemoteAvailabilityUnreachable, | |
| 80 | + | LastSuccess: now.Add(-time.Hour), InventoryKnown: true, | |
| 81 | + | }, | |
| 82 | + | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 83 | + | activation: picker.RemoteAttach, | |
| 84 | + | reason: domain.RemoteReasonHostUnreachable, | |
| 85 | + | rowPresent: true, | |
| 86 | + | }, | |
| 87 | + | { | |
| 88 | + | name: "auth failure stays attemptable with reason", | |
| 89 | + | host: ports.RemoteHostSnapshot{ | |
| 90 | + | Endpoint: "user@arch", Availability: domain.RemoteAvailabilityAuthFailed, | |
| 91 | + | LastSuccess: now.Add(-time.Hour), InventoryKnown: true, | |
| 92 | + | }, | |
| 93 | + | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 94 | + | activation: picker.RemoteAttach, | |
| 95 | + | reason: domain.RemoteReasonAuthFailure, | |
| 96 | + | rowPresent: true, | |
| 97 | + | }, | |
| 98 | + | { | |
| 99 | + | name: "malformed catalog stays attemptable with reason", | |
| 100 | + | host: ports.RemoteHostSnapshot{ | |
| 101 | + | Endpoint: "user@arch", Availability: domain.RemoteAvailabilityInvalidResponse, | |
| 102 | + | LastSuccess: now.Add(-time.Hour), InventoryKnown: true, | |
| 103 | + | }, | |
| 104 | + | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 105 | + | activation: picker.RemoteAttach, | |
| 106 | + | reason: domain.RemoteReasonMalformed, | |
| 107 | + | rowPresent: true, | |
| 108 | + | }, | |
| 109 | + | } | |
| 110 | + | for _, tt := range tests { | |
| 111 | + | t.Run(tt.name, func(t *testing.T) { | |
| 112 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 113 | + | current := addControlSession(d, "current", "tab-1", "pane-1") | |
| 114 | + | current.ephemeral = false | |
| 115 | + | host := tt.host | |
| 116 | + | host.Sessions = []catalogue.RemoteCatalogSession{tt.session} | |
| 117 | + | seedRemoteDirectory(t, d, host) | |
| 118 | + | ||
| 119 | + | views, _ := d.pickerViews(current, nil) | |
| 120 | + | var found *picker.SessionView | |
| 121 | + | for i, view := range views { | |
| 122 | + | if view.RemoteHost == "user@arch" && view.Name == "work@arch" { | |
| 123 | + | found = &views[i] | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | require.NotNil(t, found, "row must be present for exact-origin diagnosis") | |
| 127 | + | require.Equal(t, tt.activation, found.RemoteActivation) | |
| 128 | + | if tt.reason != "" { | |
| 129 | + | require.Equal(t, tt.reason, found.RemoteReason) | |
| 130 | + | } | |
| 131 | + | }) | |
| 132 | + | } | |
| 133 | + | } | |
M internal/usecase/daemon/attachment_effect_gate.go +3 -0
| @@ -217,6 +217,9 @@ func (t *attachmentEffect) sendControl(message protocol.ServerMessage) error { | |||
| 217 | 217 | case protocol.RouteCreateSessionAction: | |
| 218 | 218 | navigation.CauseActionID = t.uiActionID | |
| 219 | 219 | message = navigation | |
| 220 | + | case protocol.NavigationInventorySelection: | |
| 221 | + | navigation.CauseActionID = t.uiActionID | |
| 222 | + | message = navigation | |
| 220 | 223 | } | |
| 221 | 224 | err := t.transport.transport.SendServer(message) | |
| 222 | 225 | if err != nil { | |
M internal/usecase/daemon/client_frame_routing.go +36 -0
| @@ -193,6 +193,42 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability, | |||
| 193 | 193 | return true | |
| 194 | 194 | } | |
| 195 | 195 | resultEffect.End() | |
| 196 | + | case protocol.NavigationInventoryPublication: | |
| 197 | + | overlays := effect.ac.overlays | |
| 198 | + | if overlays == nil { | |
| 199 | + | break | |
| 200 | + | } | |
| 201 | + | overlays.paletteMu.Lock() | |
| 202 | + | admitted := admitPaletteInventoryPublication(overlays, message) | |
| 203 | + | overlays.paletteMu.Unlock() | |
| 204 | + | if admitted { | |
| 205 | + | d.refreshPalette(effect.ac) | |
| 206 | + | d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication") | |
| 207 | + | } | |
| 208 | + | case protocol.NavigationInventoryFailure: | |
| 209 | + | overlays := effect.ac.overlays | |
| 210 | + | if overlays == nil { | |
| 211 | + | break | |
| 212 | + | } | |
| 213 | + | overlays.paletteMu.Lock() | |
| 214 | + | // Failures correlate to the pending selection, which survives the | |
| 215 | + | // selection close; the next open clears it. Unmatched failures | |
| 216 | + | // from older interactions drop. An open palette for the same | |
| 217 | + | // interaction also shows contextual feedback; the durable notice | |
| 218 | + | // always records so a closed palette still surfaces the cause. | |
| 219 | + | matched := overlays.paletteInventorySelected != nil && | |
| 220 | + | overlays.paletteInventorySelected.InteractionGeneration == message.InteractionGeneration && | |
| 221 | + | overlays.paletteInventorySelected.SourceKey == message.SourceKey && | |
| 222 | + | overlays.paletteInventorySelected.EntryKey == message.EntryKey | |
| 223 | + | if overlays.palette != nil && overlays.paletteInventoryOpen && | |
| 224 | + | overlays.paletteInventoryInteraction == message.InteractionGeneration { | |
| 225 | + | overlays.paletteFeedback = inventoryFailureNotice(message.Code) | |
| 226 | + | } | |
| 227 | + | overlays.paletteMu.Unlock() | |
| 228 | + | if matched { | |
| 229 | + | d.notify(effect.sess, domain.NoticeError, domain.NoticeNavigationInventory, inventoryFailureNotice(message.Code), nil) | |
| 230 | + | } | |
| 231 | + | d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-failure") | |
| 196 | 232 | case protocol.Ping: | |
| 197 | 233 | if err := effect.sendControl(serverPong()); err != nil { | |
| 198 | 234 | effect.End() | |
M internal/usecase/daemon/control.go +6 -14
| @@ -702,21 +702,13 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 702 | 702 | if !asJSON { | |
| 703 | 703 | return "", command.ErrInvalidArguments | |
| 704 | 704 | } | |
| 705 | - | e.d.mu.Lock() | |
| 706 | - | sessions := sessionsSnapshot(e.d.sessions) | |
| 707 | - | stopped := make([]inactiveSession, 0, len(e.d.inactive)) | |
| 708 | - | for _, entry := range e.d.inactive { | |
| 709 | - | if entry.visible() { | |
| 710 | - | stopped = append(stopped, entry) | |
| 711 | - | } | |
| 712 | - | } | |
| 713 | - | e.d.mu.Unlock() | |
| 705 | + | inv := e.d.captureSessionInventory(viewOptions{tabDetails: true, focusedTitles: true}, true) | |
| 706 | + | stopped := inv.visibleStopped() | |
| 714 | 707 | ||
| 715 | - | rows := make([]catalogue.RemoteCatalogSession, 0, len(sessions)+len(stopped)) | |
| 716 | - | liveNames := make(map[string]struct{}, len(sessions)) | |
| 717 | - | for _, sess := range sessions { | |
| 718 | - | e.d.refreshSessionFocusedTitles(sess) | |
| 719 | - | snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false}) | |
| 708 | + | rows := make([]catalogue.RemoteCatalogSession, 0, len(inv.live)+len(stopped)) | |
| 709 | + | liveNames := make(map[string]struct{}, len(inv.live)) | |
| 710 | + | for _, item := range inv.live { | |
| 711 | + | snap := item.view | |
| 720 | 712 | tabs, err := remoteCatalogTabs(snap) | |
| 721 | 713 | if err != nil { | |
| 722 | 714 | return "", err | |
M internal/usecase/daemon/daemon.go +13 -0
| @@ -1141,6 +1141,10 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) { | |||
| 1141 | 1141 | if err := d.handleRemotePreview(tr, message); err != nil { | |
| 1142 | 1142 | d.log.Warn("remote preview handler failed", "err", err) | |
| 1143 | 1143 | } | |
| 1144 | + | case protocol.NavigationInventoryRequest: | |
| 1145 | + | stopTransport() | |
| 1146 | + | finishHandshake() | |
| 1147 | + | d.handleNavigationInventory(tr, message) | |
| 1144 | 1148 | case protocol.Kill: | |
| 1145 | 1149 | stopTransport() | |
| 1146 | 1150 | finishHandshake() | |
| @@ -1182,6 +1186,15 @@ func (d *Daemon) handleInitialDecodeFailure(ctx context.Context, tr ports.Server | |||
| 1182 | 1186 | send(serverError(protocol.ErrInternal, "malformed kill request")) | |
| 1183 | 1187 | case protocol.DecodeMessageRemotePreview: | |
| 1184 | 1188 | send(protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewMalformed}) | |
| 1189 | + | case protocol.DecodeMessageNavigationInventory: | |
| 1190 | + | if !failure.HasRequestID { | |
| 1191 | + | return | |
| 1192 | + | } | |
| 1193 | + | status := protocol.NavigationInventoryInvalid | |
| 1194 | + | if failure.Version != 0 && failure.Version != protocol.Version { | |
| 1195 | + | status = protocol.NavigationInventoryVersionMismatch | |
| 1196 | + | } | |
| 1197 | + | send(protocol.NavigationInventoryResponse{RequestID: failure.RequestID, Operation: protocol.NavigationInventorySnapshot, Status: status}) | |
| 1185 | 1198 | default: | |
| 1186 | 1199 | send(serverError(protocol.ErrInternal, "expected hello")) | |
| 1187 | 1200 | } | |
M internal/usecase/daemon/input.go +2 -1
| @@ -464,7 +464,8 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 464 | 464 | } | |
| 465 | 465 | switch action { | |
| 466 | 466 | case keys.ActionOpenPalette: | |
| 467 | - | h.d.enterPalette(sess, h.ac) | |
| 467 | + | interaction := h.d.enterPalette(sess, h.ac) | |
| 468 | + | h.d.sendPaletteInventoryDemand(h.ac, effect, true, interaction) | |
| 468 | 469 | case keys.ActionJumpAttention: | |
| 469 | 470 | if effect == nil { | |
| 470 | 471 | if err := h.d.jumpAttention(sess, h.ac); err != nil { | |
A internal/usecase/daemon/navigation_inventory.go +292 -0
| @@ -0,0 +1,292 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/hex" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // navigationInventoryEntryKey derives a stable opaque key for an unchanged | |
| 14 | + | // private exact target (authority, lifecycle, name). Presentation-only | |
| 15 | + | // updates keep the key; a changed or removed target retires it and a | |
| 16 | + | // reappearing target gets a new key through its new lifecycle. | |
| 17 | + | func navigationInventoryEntryKey(lifecycle domain.SessionLifecycleID, name string) string { | |
| 18 | + | return hex.EncodeToString(lifecycle[:]) + "/" + name | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // handleNavigationInventory answers a one-shot source-control query without a | |
| 22 | + | // Hello session, attachment, startup, PTY, or geometry effect. It closes the | |
| 23 | + | // exact connection on every exit so blocked operations unwind. | |
| 24 | + | func (d *Daemon) handleNavigationInventory(tr ports.ServerConnection, request protocol.NavigationInventoryRequest) { | |
| 25 | + | defer func() { _ = tr.Close() }() | |
| 26 | + | response := d.answerNavigationInventory(request) | |
| 27 | + | _ = d.boundedControlSend(tr, response) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // answerNavigationInventory builds the typed response for a validated or | |
| 31 | + | // rejected inventory request. | |
| 32 | + | func (d *Daemon) answerNavigationInventory(request protocol.NavigationInventoryRequest) protocol.NavigationInventoryResponse { | |
| 33 | + | if err := protocol.ValidateNavigationInventoryRequest(request); err != nil { | |
| 34 | + | return protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.NavigationInventoryInvalid} | |
| 35 | + | } | |
| 36 | + | if request.Version != protocol.Version { | |
| 37 | + | return protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.NavigationInventoryVersionMismatch} | |
| 38 | + | } | |
| 39 | + | if request.Operation == protocol.NavigationInventoryResolve { | |
| 40 | + | return d.resolveNavigationInventory(request) | |
| 41 | + | } | |
| 42 | + | return d.snapshotNavigationInventory(request.RequestID) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // snapshotNavigationInventory packs complete source groups, local first, then | |
| 46 | + | // deterministic host order. A source exceeding its row limit becomes an | |
| 47 | + | // explicit too_large row with no activatable entries; it never invalidates | |
| 48 | + | // other complete groups. Encoded-byte overflow degrades the largest group to | |
| 49 | + | // status-only instead of silently truncating. | |
| 50 | + | func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.NavigationInventoryResponse { | |
| 51 | + | inv := d.captureSessionInventory(viewOptions{}, false) | |
| 52 | + | groups := make([]protocol.NavigationInventorySourceGroup, 0, len(inv.hosts)+1) | |
| 53 | + | groups = append(groups, d.localInventoryGroup(inv)) | |
| 54 | + | for _, host := range inv.hosts { | |
| 55 | + | groups = append(groups, remoteInventoryGroup(host)) | |
| 56 | + | } | |
| 57 | + | if len(groups) > protocol.NavigationInventoryMaxSourceGroups { | |
| 58 | + | groups = groups[:protocol.NavigationInventoryMaxSourceGroups] | |
| 59 | + | } | |
| 60 | + | response := protocol.NavigationInventoryResponse{RequestID: requestID, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: groups} | |
| 61 | + | return fitNavigationInventoryResponse(response) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // localInventoryGroup projects live named sessions and resumable stopped | |
| 65 | + | // sessions. Observation failures do not exist for local state; broken | |
| 66 | + | // records are excluded here because they are never resumable. | |
| 67 | + | func (d *Daemon) localInventoryGroup(inv sessionInventory) protocol.NavigationInventorySourceGroup { | |
| 68 | + | group := protocol.NavigationInventorySourceGroup{SourceKey: protocol.NavigationInventoryLocalSourceKey, Status: protocol.NavigationInventorySourceOK} | |
| 69 | + | for _, item := range inv.live { | |
| 70 | + | view := item.view | |
| 71 | + | if view.name == "" || view.ephemeral { | |
| 72 | + | continue | |
| 73 | + | } | |
| 74 | + | group.Entries = append(group.Entries, protocol.NavigationInventoryEntry{ | |
| 75 | + | SourceKey: group.SourceKey, | |
| 76 | + | EntryKey: navigationInventoryEntryKey(view.incarnation, view.name), | |
| 77 | + | Name: view.name, | |
| 78 | + | State: "up", | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | for _, entry := range inv.resumableStopped() { | |
| 82 | + | group.Entries = append(group.Entries, protocol.NavigationInventoryEntry{ | |
| 83 | + | SourceKey: group.SourceKey, | |
| 84 | + | EntryKey: navigationInventoryEntryKey(entry.incarnation, entry.name), | |
| 85 | + | Name: entry.name, | |
| 86 | + | State: "stopped", | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | if len(group.Entries) > protocol.NavigationInventoryMaxLocalEntries { | |
| 90 | + | return protocol.NavigationInventorySourceGroup{SourceKey: group.SourceKey, Status: protocol.NavigationInventorySourceTooLarge} | |
| 91 | + | } | |
| 92 | + | return group | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // remoteInventoryGroup projects one configured remote source with its own | |
| 96 | + | // status. Broken sessions keep a diagnostic row; stale, unreachable, | |
| 97 | + | // auth-failed, and malformed observations stay attemptable with a | |
| 98 | + | // presentation reason, per the shared activation policy. | |
| 99 | + | func remoteInventoryGroup(host ports.RemoteHostSnapshot) protocol.NavigationInventorySourceGroup { | |
| 100 | + | if host.Endpoint == "" { | |
| 101 | + | return protocol.NavigationInventorySourceGroup{Status: protocol.NavigationInventorySourceUnavailable} | |
| 102 | + | } | |
| 103 | + | // Remote source keys are opaque identifiers, never raw endpoints: the | |
| 104 | + | // relay forwards them to the serving daemon, which only ever sees | |
| 105 | + | // display origins. The endpoint hash keeps keys stable across polls | |
| 106 | + | // (so refreshes do not churn admissions) while a host literally named | |
| 107 | + | // "local" cannot collide with the reserved local source. Resolve maps | |
| 108 | + | // back through the request registration, never the key. | |
| 109 | + | group := protocol.NavigationInventorySourceGroup{SourceKey: remoteInventorySourceKey(host.Endpoint), Status: protocol.NavigationInventorySourceOK} | |
| 110 | + | eligible := 0 | |
| 111 | + | for _, session := range host.Sessions { | |
| 112 | + | if session.Ephemeral { | |
| 113 | + | continue | |
| 114 | + | } | |
| 115 | + | eligible++ | |
| 116 | + | } | |
| 117 | + | if eligible > protocol.NavigationInventoryMaxRemoteEntriesPerSource { | |
| 118 | + | return protocol.NavigationInventorySourceGroup{SourceKey: group.SourceKey, Status: protocol.NavigationInventorySourceTooLarge} | |
| 119 | + | } | |
| 120 | + | for _, session := range host.Sessions { | |
| 121 | + | if session.Ephemeral { | |
| 122 | + | continue | |
| 123 | + | } | |
| 124 | + | key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}, session) | |
| 125 | + | if key.Validate() != nil { | |
| 126 | + | continue | |
| 127 | + | } | |
| 128 | + | reason := directorySessionReason(host, session, target) | |
| 129 | + | state := string(session.State) | |
| 130 | + | if session.State == catalogue.RemoteCatalogSessionBroken || target.Validate() != nil { | |
| 131 | + | state = string(catalogue.RemoteCatalogSessionBroken) | |
| 132 | + | } | |
| 133 | + | group.Entries = append(group.Entries, protocol.NavigationInventoryEntry{ | |
| 134 | + | SourceKey: group.SourceKey, | |
| 135 | + | EntryKey: navigationInventoryEntryKey(session.LifecycleID, session.Name), | |
| 136 | + | Name: session.Name, | |
| 137 | + | DisplayOrigin: key.DisplayOrigin, | |
| 138 | + | State: state, | |
| 139 | + | Reason: reason, | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | if host.Availability != domain.RemoteAvailabilityReachable && len(group.Entries) == 0 { | |
| 143 | + | group.Status = protocol.NavigationInventorySourceUnavailable | |
| 144 | + | } | |
| 145 | + | return group | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // remoteInventorySourceKey derives the stable opaque identifier for a | |
| 149 | + | // configured remote endpoint. The hash domain-separates inventory keys so | |
| 150 | + | // relayed identifiers disclose nothing about local SSH configuration. | |
| 151 | + | func remoteInventorySourceKey(endpoint string) string { | |
| 152 | + | sum := sha256.Sum256([]byte("vev-inventory-remote\x00" + endpoint)) | |
| 153 | + | return protocol.NavigationInventoryRemoteSourcePrefix + hex.EncodeToString(sum[:12]) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // navigationInventoryEncodedBudget mirrors the wire export budget without | |
| 157 | + | // importing the codec layer. The daemon never encodes frames itself; the | |
| 158 | + | // estimate below upper-bounds the real payload so fit degrades early rather | |
| 159 | + | // than emitting an oversized frame. | |
| 160 | + | const navigationInventoryEncodedBudget = 4 << 20 | |
| 161 | + | ||
| 162 | + | func navigationInventorySnapshotSize(response protocol.NavigationInventoryResponse) int { | |
| 163 | + | size := 10 + 2 | |
| 164 | + | for _, group := range response.Groups { | |
| 165 | + | size += 2 + len(group.SourceKey) + 1 + 4 | |
| 166 | + | for _, entry := range group.Entries { | |
| 167 | + | size += 2 + len(entry.EntryKey) + 2 + len(entry.Name) + 2 + len(entry.DisplayOrigin) + 2 + len(entry.State) + 2 + len(entry.Reason) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | return size | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // fitNavigationInventoryResponse degrades the largest complete group to | |
| 174 | + | // status-only until the response fits the encoded budget. It never emits a | |
| 175 | + | // silently truncated group or a claim of complete inventory. | |
| 176 | + | func fitNavigationInventoryResponse(response protocol.NavigationInventoryResponse) protocol.NavigationInventoryResponse { | |
| 177 | + | if protocol.ValidateNavigationInventoryResponse(response) != nil { | |
| 178 | + | response.Groups = nil | |
| 179 | + | response.Status = protocol.NavigationInventoryInvalid | |
| 180 | + | return response | |
| 181 | + | } | |
| 182 | + | for range len(response.Groups) + 1 { | |
| 183 | + | if navigationInventorySnapshotSize(response) <= navigationInventoryEncodedBudget { | |
| 184 | + | return response | |
| 185 | + | } | |
| 186 | + | largest := -1 | |
| 187 | + | for i, group := range response.Groups { | |
| 188 | + | if group.Status != protocol.NavigationInventorySourceOK || len(group.Entries) == 0 { | |
| 189 | + | continue | |
| 190 | + | } | |
| 191 | + | if largest == -1 || len(group.Entries) > len(response.Groups[largest].Entries) { | |
| 192 | + | largest = i | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | if largest == -1 { | |
| 196 | + | response.Groups = nil | |
| 197 | + | response.Status = protocol.NavigationInventoryUnavailable | |
| 198 | + | return response | |
| 199 | + | } | |
| 200 | + | source := response.Groups[largest].SourceKey | |
| 201 | + | response.Groups[largest] = protocol.NavigationInventorySourceGroup{SourceKey: source, Status: protocol.NavigationInventorySourceTooLarge} | |
| 202 | + | } | |
| 203 | + | response.Groups = nil | |
| 204 | + | response.Status = protocol.NavigationInventoryUnavailable | |
| 205 | + | return response | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // resolveNavigationInventory revalidates the selected key against a fresh | |
| 209 | + | // capture and returns a non-mutating AttachTarget. Registration is checked at | |
| 210 | + | // resolve: a removed or re-registered source rejects without attaching a | |
| 211 | + | // same-name replacement. | |
| 212 | + | func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventoryRequest) protocol.NavigationInventoryResponse { | |
| 213 | + | response := protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: protocol.NavigationInventoryResolve} | |
| 214 | + | if request.SourceKey == protocol.NavigationInventoryLocalSourceKey { | |
| 215 | + | target, ok := d.resolveLocalInventoryEntry(request.EntryKey) | |
| 216 | + | if !ok { | |
| 217 | + | response.Status = protocol.NavigationInventoryUnavailable | |
| 218 | + | return response | |
| 219 | + | } | |
| 220 | + | response.Status = protocol.NavigationInventoryOK | |
| 221 | + | resolved := protocol.AttachTarget{Session: target.SessionName, Intent: protocol.IntentAttach, ExactTarget: &target} | |
| 222 | + | response.Resolved = &resolved | |
| 223 | + | return response | |
| 224 | + | } | |
| 225 | + | target, ok := d.resolveRemoteInventoryEntry(request) | |
| 226 | + | if !ok { | |
| 227 | + | response.Status = protocol.NavigationInventoryUnavailable | |
| 228 | + | return response | |
| 229 | + | } | |
| 230 | + | if target.Validate() != nil { | |
| 231 | + | response.Status = protocol.NavigationInventoryInvalid | |
| 232 | + | return response | |
| 233 | + | } | |
| 234 | + | response.Status = protocol.NavigationInventoryOK | |
| 235 | + | resolved := protocol.AttachTarget{ | |
| 236 | + | Endpoint: request.Registration.Endpoint, Session: target.SessionName, | |
| 237 | + | Intent: protocol.IntentAttach, RemoteTarget: &target, | |
| 238 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 239 | + | } | |
| 240 | + | response.Resolved = &resolved | |
| 241 | + | return response | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // resolveLocalInventoryEntry matches the opaque key against fresh live and | |
| 245 | + | // resumable stopped state. A stopped target that is now live with the same | |
| 246 | + | // lifecycle and selector stays attachable; a changed lifecycle rejects. | |
| 247 | + | func (d *Daemon) resolveLocalInventoryEntry(entryKey string) (protocol.ExactSessionTarget, bool) { | |
| 248 | + | inv := d.captureSessionInventory(viewOptions{}, false) | |
| 249 | + | for _, item := range inv.live { | |
| 250 | + | view := item.view | |
| 251 | + | if view.name == "" || view.ephemeral { | |
| 252 | + | continue | |
| 253 | + | } | |
| 254 | + | if navigationInventoryEntryKey(view.incarnation, view.name) != entryKey { | |
| 255 | + | continue | |
| 256 | + | } | |
| 257 | + | return protocol.ExactSessionTarget{LifecycleID: view.incarnation, SessionName: view.name}, true | |
| 258 | + | } | |
| 259 | + | for _, entry := range inv.resumableStopped() { | |
| 260 | + | if navigationInventoryEntryKey(entry.incarnation, entry.name) != entryKey { | |
| 261 | + | continue | |
| 262 | + | } | |
| 263 | + | return protocol.ExactSessionTarget{LifecycleID: entry.incarnation, SessionName: entry.name}, true | |
| 264 | + | } | |
| 265 | + | return protocol.ExactSessionTarget{}, false | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // resolveRemoteInventoryEntry checks the stored registration identity, then | |
| 269 | + | // matches the opaque key against the source's current sessions. Known valid | |
| 270 | + | // cached targets with stale or failed observations stay attemptable; | |
| 271 | + | // destination attach still validates exactly. | |
| 272 | + | func (d *Daemon) resolveRemoteInventoryEntry(request protocol.NavigationInventoryRequest) (domain.RemoteSessionTarget, bool) { | |
| 273 | + | snapshot := d.remoteDirectorySnapshot() | |
| 274 | + | host, ok := snapshot.Find(request.Registration.Endpoint) | |
| 275 | + | if !ok || !host.Registration.Equal(request.Registration) { | |
| 276 | + | return domain.RemoteSessionTarget{}, false | |
| 277 | + | } | |
| 278 | + | for _, session := range host.Sessions { | |
| 279 | + | if session.Ephemeral { | |
| 280 | + | continue | |
| 281 | + | } | |
| 282 | + | if navigationInventoryEntryKey(session.LifecycleID, session.Name) != request.EntryKey { | |
| 283 | + | continue | |
| 284 | + | } | |
| 285 | + | _, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}, session) | |
| 286 | + | if session.State == catalogue.RemoteCatalogSessionBroken { | |
| 287 | + | return domain.RemoteSessionTarget{}, false | |
| 288 | + | } | |
| 289 | + | return target, true | |
| 290 | + | } | |
| 291 | + | return domain.RemoteSessionTarget{}, false | |
| 292 | + | } | |
A internal/usecase/daemon/navigation_inventory_test.go +298 -0
| @@ -0,0 +1,298 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // addInventorySession inserts a minimal live session without PTY allocation. | |
| 15 | + | // Snapshot paths only read registry fields, so no tab or PTY is needed. | |
| 16 | + | func addInventorySession(d *Daemon, name string, lifecycle domain.SessionLifecycleID, ephemeral bool) { | |
| 17 | + | d.mu.Lock() | |
| 18 | + | defer d.mu.Unlock() | |
| 19 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, incarnation: lifecycle}} | |
| 20 | + | sess.mruAt.Store(1) | |
| 21 | + | if d.sessions == nil { | |
| 22 | + | d.sessions = make(map[domain.SessionID]*session) | |
| 23 | + | } | |
| 24 | + | d.sessions[sess.id] = sess | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func inventorySnapshot(t *testing.T, d *Daemon, requestID uint64) protocol.NavigationInventoryResponse { | |
| 28 | + | t.Helper() | |
| 29 | + | response := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.NavigationInventorySnapshot}) | |
| 30 | + | require.Equal(t, protocol.NavigationInventoryOK, response.Status) | |
| 31 | + | require.NoError(t, protocol.ValidateNavigationInventoryResponse(response)) | |
| 32 | + | return response | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestNavigationInventorySnapshotHasNoSideEffects(t *testing.T) { | |
| 36 | + | now := time.Unix(1_000, 0) | |
| 37 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 38 | + | current := addControlSession(d, "current", "tab-1", "pane-1") | |
| 39 | + | current.ephemeral = false | |
| 40 | + | current.incarnation = domain.SessionLifecycleID{11} | |
| 41 | + | other := addControlSession(d, "other", "tab-2", "pane-2") | |
| 42 | + | other.ephemeral = false | |
| 43 | + | other.incarnation = domain.SessionLifecycleID{12} | |
| 44 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ | |
| 45 | + | LifecycleID: domain.SessionLifecycleID{21}, Name: "shared", State: catalogue.RemoteCatalogSessionUp, | |
| 46 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, | |
| 47 | + | ActiveTabID: "tab-remote", | |
| 48 | + | })) | |
| 49 | + | ||
| 50 | + | before := len(d.sessions) | |
| 51 | + | response := inventorySnapshot(t, d, 1) | |
| 52 | + | require.Len(t, response.Groups, 2) | |
| 53 | + | require.Equal(t, protocol.NavigationInventoryLocalSourceKey, response.Groups[0].SourceKey) | |
| 54 | + | require.Equal(t, remoteInventorySourceKey("user@arch"), response.Groups[1].SourceKey) | |
| 55 | + | require.Len(t, d.sessions, before, "snapshot must not create sessions") | |
| 56 | + | require.NotNil(t, current) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestNavigationInventoryRemoteSourceKeysNeverCollideWithLocal(t *testing.T) { | |
| 60 | + | now := time.Unix(1_000, 0) | |
| 61 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 62 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("local", now, catalogue.RemoteCatalogSession{ | |
| 63 | + | LifecycleID: domain.SessionLifecycleID{31}, Name: "doppel", State: catalogue.RemoteCatalogSessionUp, | |
| 64 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-x", Index: 0, Name: "shell"}}, | |
| 65 | + | ActiveTabID: "tab-x", | |
| 66 | + | })) | |
| 67 | + | ||
| 68 | + | response := inventorySnapshot(t, d, 1) | |
| 69 | + | require.NoError(t, protocol.ValidateNavigationInventoryResponse(response)) | |
| 70 | + | seen := map[string]int{} | |
| 71 | + | for _, group := range response.Groups { | |
| 72 | + | seen[group.SourceKey]++ | |
| 73 | + | if group.SourceKey != protocol.NavigationInventoryLocalSourceKey { | |
| 74 | + | require.NotContains(t, group.SourceKey, "local", "relayed keys must not expose raw endpoints") | |
| 75 | + | require.Equal(t, remoteInventorySourceKey("local"), group.SourceKey, "keys stay stable across snapshots") | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | for key, count := range seen { | |
| 79 | + | require.Equal(t, 1, count, "source key %q must be unique", key) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestNavigationInventoryLocalBeyondRemoteBound(t *testing.T) { | |
| 84 | + | now := time.Unix(1_000, 0) | |
| 85 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 86 | + | for i := range 257 { | |
| 87 | + | name := string(rune('a'+i%26)) + string(rune('a'+(i/26)%26)) + string(rune('0'+i/676)) | |
| 88 | + | addInventorySession(d, name, domain.SessionLifecycleID{byte(i), byte(i >> 8)}, false) | |
| 89 | + | } | |
| 90 | + | remoteSessions := make([]catalogue.RemoteCatalogSession, 0, 257) | |
| 91 | + | for i := range 257 { | |
| 92 | + | remoteSessions = append(remoteSessions, catalogue.RemoteCatalogSession{ | |
| 93 | + | LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, Name: "remote-session", | |
| 94 | + | State: catalogue.RemoteCatalogSessionUp, | |
| 95 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab", Index: 0, Name: "shell"}}, | |
| 96 | + | }) | |
| 97 | + | } | |
| 98 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", now, remoteSessions...)) | |
| 99 | + | ||
| 100 | + | response := inventorySnapshot(t, d, 2) | |
| 101 | + | require.Equal(t, protocol.NavigationInventoryOK, response.Status) | |
| 102 | + | local := response.Groups[0] | |
| 103 | + | require.Equal(t, protocol.NavigationInventoryLocalSourceKey, local.SourceKey) | |
| 104 | + | require.Equal(t, protocol.NavigationInventorySourceOK, local.Status) | |
| 105 | + | require.Len(t, local.Entries, 257, "257 local sessions are not rejected by the remote 256 bound") | |
| 106 | + | remote := response.Groups[1] | |
| 107 | + | require.Equal(t, protocol.NavigationInventorySourceTooLarge, remote.Status) | |
| 108 | + | require.Empty(t, remote.Entries) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestNavigationInventoryResolveLocalAndStale(t *testing.T) { | |
| 112 | + | now := time.Unix(1_000, 0) | |
| 113 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 114 | + | addInventorySession(d, "work", domain.SessionLifecycleID{5}, false) | |
| 115 | + | ||
| 116 | + | response := inventorySnapshot(t, d, 3) | |
| 117 | + | var entryKey string | |
| 118 | + | for _, entry := range response.Groups[0].Entries { | |
| 119 | + | if entry.Name == "work" { | |
| 120 | + | entryKey = entry.EntryKey | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | require.NotEmpty(t, entryKey) | |
| 124 | + | ||
| 125 | + | resolved := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 4, Operation: protocol.NavigationInventoryResolve, SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: entryKey}) | |
| 126 | + | require.Equal(t, protocol.NavigationInventoryOK, resolved.Status) | |
| 127 | + | require.NotNil(t, resolved.Resolved) | |
| 128 | + | require.Equal(t, "work", resolved.Resolved.Session) | |
| 129 | + | require.NotNil(t, resolved.Resolved.ExactTarget) | |
| 130 | + | require.Equal(t, domain.SessionLifecycleID{5}, resolved.Resolved.ExactTarget.LifecycleID) | |
| 131 | + | ||
| 132 | + | unknown := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 5, Operation: protocol.NavigationInventoryResolve, SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "deadbeef/missing"}) | |
| 133 | + | require.NotEqual(t, protocol.NavigationInventoryOK, unknown.Status) | |
| 134 | + | require.Nil(t, unknown.Resolved, "stale keys never resolve to a same-name replacement") | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestNavigationInventoryResolveRegistrationFencesReRegistration(t *testing.T) { | |
| 138 | + | now := time.Unix(1_000, 0) | |
| 139 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 140 | + | lifecycle := domain.SessionLifecycleID{21} | |
| 141 | + | registration, err := domain.NewRemoteRegistration("user@arch", [16]byte{9}) | |
| 142 | + | require.NoError(t, err) | |
| 143 | + | host := reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ | |
| 144 | + | LifecycleID: lifecycle, Name: "shared", State: catalogue.RemoteCatalogSessionUp, | |
| 145 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, | |
| 146 | + | ActiveTabID: "tab-remote", | |
| 147 | + | }) | |
| 148 | + | host.Registration = registration | |
| 149 | + | seedRemoteDirectory(t, d, host) | |
| 150 | + | ||
| 151 | + | response := inventorySnapshot(t, d, 6) | |
| 152 | + | var entryKey string | |
| 153 | + | for _, group := range response.Groups { | |
| 154 | + | if group.SourceKey != remoteInventorySourceKey("user@arch") { | |
| 155 | + | continue | |
| 156 | + | } | |
| 157 | + | for _, entry := range group.Entries { | |
| 158 | + | if entry.Name == "shared" { | |
| 159 | + | entryKey = entry.EntryKey | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | require.NotEmpty(t, entryKey) | |
| 164 | + | ||
| 165 | + | good := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 7, Operation: protocol.NavigationInventoryResolve, SourceKey: remoteInventorySourceKey("user@arch"), EntryKey: entryKey, Registration: registration}) | |
| 166 | + | require.Equal(t, protocol.NavigationInventoryOK, good.Status) | |
| 167 | + | require.NotNil(t, good.Resolved) | |
| 168 | + | require.Equal(t, "user@arch", good.Resolved.Endpoint) | |
| 169 | + | require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, good.Resolved.EnvironmentPolicy) | |
| 170 | + | ||
| 171 | + | // Removal before resolve rejects. | |
| 172 | + | seedRemoteDirectory(t, d) | |
| 173 | + | gone := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 8, Operation: protocol.NavigationInventoryResolve, SourceKey: remoteInventorySourceKey("user@arch"), EntryKey: entryKey, Registration: registration}) | |
| 174 | + | require.NotEqual(t, protocol.NavigationInventoryOK, gone.Status) | |
| 175 | + | require.Nil(t, gone.Resolved) | |
| 176 | + | ||
| 177 | + | // Re-adding the endpoint mints a new incarnation; the old registration rejects. | |
| 178 | + | fresh, err := domain.NewRemoteRegistration("user@arch", [16]byte{10}) | |
| 179 | + | require.NoError(t, err) | |
| 180 | + | readded := reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ | |
| 181 | + | LifecycleID: lifecycle, Name: "shared", State: catalogue.RemoteCatalogSessionUp, | |
| 182 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, | |
| 183 | + | ActiveTabID: "tab-remote", | |
| 184 | + | }) | |
| 185 | + | readded.Registration = fresh | |
| 186 | + | seedRemoteDirectory(t, d, readded) | |
| 187 | + | stale := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 9, Operation: protocol.NavigationInventoryResolve, SourceKey: remoteInventorySourceKey("user@arch"), EntryKey: entryKey, Registration: registration}) | |
| 188 | + | require.NotEqual(t, protocol.NavigationInventoryOK, stale.Status) | |
| 189 | + | require.Nil(t, stale.Resolved) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestNavigationInventoryStaleObservationStaysAttemptable(t *testing.T) { | |
| 193 | + | now := time.Unix(1_000, 0) | |
| 194 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 195 | + | lifecycle := domain.SessionLifecycleID{21} | |
| 196 | + | registration, err := domain.NewRemoteRegistration("user@arch", [16]byte{9}) | |
| 197 | + | require.NoError(t, err) | |
| 198 | + | host := reachableDirectoryHost("user@arch", now.Add(-time.Hour), catalogue.RemoteCatalogSession{ | |
| 199 | + | LifecycleID: lifecycle, Name: "cached", State: catalogue.RemoteCatalogSessionUp, | |
| 200 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell"}}, | |
| 201 | + | ActiveTabID: "tab-1", | |
| 202 | + | }) | |
| 203 | + | host.Registration = registration | |
| 204 | + | host.Availability = domain.RemoteAvailabilityUnreachable | |
| 205 | + | host.InventoryKnown = true | |
| 206 | + | seedRemoteDirectory(t, d, host) | |
| 207 | + | ||
| 208 | + | response := inventorySnapshot(t, d, 10) | |
| 209 | + | var entryKey, reason string | |
| 210 | + | for _, group := range response.Groups { | |
| 211 | + | if group.SourceKey != remoteInventorySourceKey("user@arch") { | |
| 212 | + | continue | |
| 213 | + | } | |
| 214 | + | for _, entry := range group.Entries { | |
| 215 | + | if entry.Name == "cached" { | |
| 216 | + | entryKey, reason = entry.EntryKey, entry.Reason | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | require.NotEmpty(t, entryKey, "stale observations keep their rows") | |
| 221 | + | require.NotEmpty(t, reason) | |
| 222 | + | ||
| 223 | + | resolved := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 11, Operation: protocol.NavigationInventoryResolve, SourceKey: remoteInventorySourceKey("user@arch"), EntryKey: entryKey, Registration: registration}) | |
| 224 | + | require.Equal(t, protocol.NavigationInventoryOK, resolved.Status, "known valid cached targets stay attemptable") | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestNavigationInventoryStoppedBecomingLiveResolves(t *testing.T) { | |
| 228 | + | now := time.Unix(1_000, 0) | |
| 229 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 230 | + | lifecycle := domain.SessionLifecycleID{7} | |
| 231 | + | d.mu.Lock() | |
| 232 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: lifecycle, state: protocol.SessionDown} | |
| 233 | + | d.mu.Unlock() | |
| 234 | + | ||
| 235 | + | response := inventorySnapshot(t, d, 12) | |
| 236 | + | var entryKey string | |
| 237 | + | for _, entry := range response.Groups[0].Entries { | |
| 238 | + | if entry.Name == "work" { | |
| 239 | + | entryKey = entry.EntryKey | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | require.NotEmpty(t, entryKey) | |
| 243 | + | ||
| 244 | + | // The stopped target becomes live with the same lifecycle and selector. | |
| 245 | + | addInventorySession(d, "work", lifecycle, false) | |
| 246 | + | d.mu.Lock() | |
| 247 | + | delete(d.inactive, "work") | |
| 248 | + | d.mu.Unlock() | |
| 249 | + | ||
| 250 | + | resolved := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 13, Operation: protocol.NavigationInventoryResolve, SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: entryKey}) | |
| 251 | + | require.Equal(t, protocol.NavigationInventoryOK, resolved.Status) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func TestNavigationInventoryVersionMismatchAndMalformed(t *testing.T) { | |
| 255 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 256 | + | mismatch := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version + 1, RequestID: 1, Operation: protocol.NavigationInventorySnapshot}) | |
| 257 | + | require.Equal(t, protocol.NavigationInventoryVersionMismatch, mismatch.Status) | |
| 258 | + | require.Nil(t, mismatch.Resolved) | |
| 259 | + | require.Empty(t, mismatch.Groups) | |
| 260 | + | ||
| 261 | + | malformed := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Operation: protocol.NavigationInventorySnapshot}) | |
| 262 | + | require.Equal(t, protocol.NavigationInventoryInvalid, malformed.Status) | |
| 263 | + | require.Nil(t, malformed.Resolved) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // TestNeverVisitedLocalSessionRequiresRelay is the P0.2 known-red product | |
| 267 | + | // case: a palette served by daemon A cannot list daemon L's sessions, so a | |
| 268 | + | // local session the client never visited is unreachable from remote. It | |
| 269 | + | // currently asserts the deficiency (absence); the P4 relay must invert it | |
| 270 | + | // to presence plus successful local commit. No pass is claimed here. | |
| 271 | + | func TestNeverVisitedLocalSessionRequiresRelay(t *testing.T) { | |
| 272 | + | local := newTestDaemon(t, nil, stubClock{}) | |
| 273 | + | addInventorySession(local, "local-unvisited", domain.SessionLifecycleID{33}, false) | |
| 274 | + | ||
| 275 | + | remote := newTestDaemon(t, nil, stubClock{}) | |
| 276 | + | serving := addControlSession(remote, "remote-one", "tab-1", "pane-1") | |
| 277 | + | serving.ephemeral = false | |
| 278 | + | serving.incarnation = domain.SessionLifecycleID{44} | |
| 279 | + | ||
| 280 | + | results := remote.paletteResults(serving, nil, protocol.RecentRouteSnapshot{}) | |
| 281 | + | for _, result := range results { | |
| 282 | + | if name, ok := result.SessionName(); ok { | |
| 283 | + | require.NotEqual(t, "local-unvisited", name, "cross-daemon leak would be a privacy violation, not the fix") | |
| 284 | + | } | |
| 285 | + | if _, ok := result.RemoteSessionTarget(); ok { | |
| 286 | + | continue | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | localGroups := local.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventorySnapshot}) | |
| 290 | + | require.Equal(t, protocol.NavigationInventoryOK, localGroups.Status) | |
| 291 | + | found := false | |
| 292 | + | for _, entry := range localGroups.Groups[0].Entries { | |
| 293 | + | if entry.Name == "local-unvisited" { | |
| 294 | + | found = true | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | require.True(t, found, "the source control daemon exports the session; only the relay to the serving daemon is missing") | |
| 298 | + | } | |
M internal/usecase/daemon/overlay_runtime.go +12 -0
| @@ -50,6 +50,18 @@ type overlayRuntime struct { | |||
| 50 | 50 | palettePreview string | |
| 51 | 51 | paletteFeedback string | |
| 52 | 52 | palettePending []byte | |
| 53 | + | // paletteInventory* carries the serving-daemon side of navigation | |
| 54 | + | // inventory, guarded by paletteMu alongside the palette model. Open | |
| 55 | + | // tracks the current interaction namespace; publication is the latest | |
| 56 | + | // admitted publication generation (older/duplicates discard); groups | |
| 57 | + | // holds the sanitized relayed rows; selected freezes the chosen row | |
| 58 | + | // across select-close; demandSent records open-demand emission. | |
| 59 | + | paletteInventoryOpen bool | |
| 60 | + | paletteInventoryInteraction uint64 | |
| 61 | + | paletteInventoryPublication uint64 | |
| 62 | + | paletteInventoryGroups []protocol.NavigationInventorySourceGroup | |
| 63 | + | paletteInventorySelected *protocol.NavigationInventorySelection | |
| 64 | + | paletteInventoryDemandSent bool | |
| 53 | 65 | ||
| 54 | 66 | promptMu sync.Mutex | |
| 55 | 67 | prompt *promptui.Model | |
M internal/usecase/daemon/palette.go +107 -50
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | 10 | "github.com/bnema/vev/internal/ports" | |
| 11 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 12 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 13 | 12 | "github.com/bnema/vev/internal/usecase/command" | |
| 14 | 13 | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | 14 | "github.com/bnema/vev/internal/usecase/palette" | |
| @@ -41,7 +40,7 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal { | |||
| 41 | 40 | return modal | |
| 42 | 41 | } | |
| 43 | 42 | ||
| 44 | - | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |
| 43 | + | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) uint64 { | |
| 45 | 44 | // Capture the client-owned route snapshot before taking paletteMu. The | |
| 46 | 45 | // snapshot is immutable for this palette interaction and the daemon never | |
| 47 | 46 | // consults its own session history for recent-route commands. | |
| @@ -57,8 +56,10 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 57 | 56 | ac.overlays.palettePreview = "" | |
| 58 | 57 | ac.overlays.paletteFeedback = "" | |
| 59 | 58 | ac.overlays.palettePending = nil | |
| 59 | + | interaction := openPaletteInventory(ac.overlays) | |
| 60 | 60 | ac.overlays.paletteMu.Unlock() | |
| 61 | 61 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 62 | + | return interaction | |
| 62 | 63 | } | |
| 63 | 64 | ||
| 64 | 65 | type paletteSessionIdentity struct { | |
| @@ -170,32 +171,18 @@ func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemon | |||
| 170 | 171 | paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin | |
| 171 | 172 | } | |
| 172 | 173 | ||
| 173 | - | func remotePaletteUnavailableReason(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession) string { | |
| 174 | - | if session.State == catalogue.RemoteCatalogSessionBroken { | |
| 175 | - | return domain.RemoteReasonSessionBroken | |
| 176 | - | } | |
| 177 | - | return directorySessionReason(host, session, mustRemotePaletteTarget(host.Endpoint, session)) | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | func mustRemotePaletteTarget(endpoint string, session catalogue.RemoteCatalogSession) domain.RemoteSessionTarget { | |
| 181 | - | _, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: endpoint, Name: session.Name}, session) | |
| 182 | - | return target | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | // paletteResults captures eligible named sessions before paletteMu so the | |
| 186 | - | // palette has an immutable lifecycle target without violating lock ordering. | |
| 174 | + | // paletteResults projects the shared daemon inventory for the palette. It keeps | |
| 175 | + | // quick-switch exclusions, compact labels, commands, and history behavior; | |
| 176 | + | // discovery, exact-target construction, and attemptability facts stay shared | |
| 177 | + | // with the picker through remoteCatalogSessionTarget/directorySessionReason. | |
| 187 | 178 | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot protocol.RecentRouteSnapshot) []palette.Result { | |
| 188 | - | d.mu.Lock() | |
| 189 | - | sessions := d.sessionsSnapshotLocked() | |
| 190 | - | stopped := make([]inactiveSession, 0, len(d.inactive)) | |
| 191 | - | represented := make(map[paletteSessionIdentity]struct{}, len(sessions)+len(d.inactive)+len(routeSnapshot.Entries)) | |
| 192 | - | for _, entry := range d.inactive { | |
| 179 | + | inv := d.captureSessionInventory(viewOptions{}, false) | |
| 180 | + | hosts := inv.hosts | |
| 181 | + | stopped := inv.resumableStopped() | |
| 182 | + | represented := make(map[paletteSessionIdentity]struct{}, len(inv.live)+len(inv.stopped)+len(routeSnapshot.Entries)) | |
| 183 | + | for _, entry := range inv.stopped { | |
| 193 | 184 | represented[localPaletteSessionIdentity(entry.incarnation)] = struct{}{} | |
| 194 | - | if entry.canResume() { | |
| 195 | - | stopped = append(stopped, entry) | |
| 196 | - | } | |
| 197 | 185 | } | |
| 198 | - | d.mu.Unlock() | |
| 199 | 186 | var currentLifecycle domain.SessionLifecycleID | |
| 200 | 187 | if current != nil { | |
| 201 | 188 | current.mu.Lock() | |
| @@ -203,29 +190,33 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 203 | 190 | current.mu.Unlock() | |
| 204 | 191 | } | |
| 205 | 192 | daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) | |
| 193 | + | hybrid := len(hosts) > 0 || daemonDisplayOrigin != "" | |
| 194 | + | for _, entry := range routeSnapshot.Entries { | |
| 195 | + | hybrid = hybrid || entry.Kind == protocol.RouteKindRemote | |
| 196 | + | } | |
| 197 | + | if daemonDisplayOrigin == "" && hybrid { | |
| 198 | + | daemonDisplayOrigin = "local" | |
| 199 | + | } | |
| 206 | 200 | ||
| 207 | - | directory := d.remoteDirectorySnapshot() | |
| 208 | - | hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...) | |
| 209 | - | sortDirectoryHosts(hosts) | |
| 210 | 201 | remoteSessionCount := 0 | |
| 211 | 202 | for _, host := range hosts { | |
| 212 | 203 | remoteSessionCount += len(host.Sessions) | |
| 213 | 204 | } | |
| 214 | 205 | ||
| 215 | 206 | destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, hosts) | |
| 216 | - | results := make([]palette.Result, 0, len(commands)+len(destinations)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries)) | |
| 207 | + | results := make([]palette.Result, 0, len(commands)+len(destinations)+len(inv.live)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries)) | |
| 217 | 208 | for _, cmd := range commands { | |
| 218 | 209 | results = append(results, palette.NewCommandResult(cmd)) | |
| 219 | 210 | } | |
| 220 | 211 | results = append(results, destinations...) | |
| 221 | - | active := make([]palette.Result, 0, len(sessions)) | |
| 222 | - | for _, candidate := range sessions { | |
| 223 | - | snap := candidate.snapshotView(viewOptions{}) | |
| 212 | + | active := make([]palette.Result, 0, len(inv.live)) | |
| 213 | + | for _, candidate := range inv.live { | |
| 214 | + | snap := candidate.view | |
| 224 | 215 | if snap.name == "" || snap.ephemeral { | |
| 225 | 216 | continue | |
| 226 | 217 | } | |
| 227 | 218 | represented[localPaletteSessionIdentity(snap.incarnation)] = struct{}{} | |
| 228 | - | if candidate == current { | |
| 219 | + | if candidate.sess == current { | |
| 229 | 220 | continue | |
| 230 | 221 | } | |
| 231 | 222 | target := protocol.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | |
| @@ -266,20 +257,23 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 266 | 257 | } | |
| 267 | 258 | discovered = append(discovered, discoveredRemote{ | |
| 268 | 259 | identity: identity, | |
| 269 | - | result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host, session)), | |
| 260 | + | result: palette.NewRemoteSessionResult(key, target, directorySessionReason(host, session, target)), | |
| 270 | 261 | }) | |
| 271 | 262 | discoveredByPresentation[presentation] = append(discoveredByPresentation[presentation], identity) | |
| 272 | 263 | } | |
| 273 | 264 | } | |
| 274 | - | formattedRoutes := formatRecentRouteSnapshot(routeSnapshot) | |
| 275 | - | for i, entry := range routeSnapshot.Entries { | |
| 276 | - | if i >= len(formattedRoutes) { | |
| 277 | - | break | |
| 278 | - | } | |
| 279 | - | label := formattedRoutes[i].name | |
| 280 | - | if label == "" { | |
| 265 | + | for _, entry := range routeSnapshot.Entries { | |
| 266 | + | if entry.Name == "" { | |
| 281 | 267 | continue | |
| 282 | 268 | } | |
| 269 | + | origin := "" | |
| 270 | + | if hybrid { | |
| 271 | + | origin = "local" | |
| 272 | + | } | |
| 273 | + | if entry.Kind == protocol.RouteKindRemote { | |
| 274 | + | origin = paletteRemoteDisplayOrigin(entry.HostLabel) | |
| 275 | + | } | |
| 276 | + | label := palette.SessionDisplay(entry.Name, origin) | |
| 283 | 277 | identity := localPaletteSessionIdentity(entry.Target.LifecycleID) | |
| 284 | 278 | if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) { | |
| 285 | 279 | continue | |
| @@ -332,6 +326,7 @@ func (d *Daemon) refreshPalette(ac *attachedClient) { | |||
| 332 | 326 | } | |
| 333 | 327 | ||
| 334 | 328 | results := d.paletteResults(sess, d.paletteCommands(), routeSnapshot) | |
| 329 | + | results = appendImportedResults(results, ac) | |
| 335 | 330 | rt.paletteMu.Lock() | |
| 336 | 331 | defer rt.paletteMu.Unlock() | |
| 337 | 332 | if rt.paletteGeneration != generation || rt.palette != model { | |
| @@ -416,6 +411,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 416 | 411 | var remoteKey domain.RemoteSessionKey | |
| 417 | 412 | var remoteUnavailableReason string | |
| 418 | 413 | var hasRemoteTarget bool | |
| 414 | + | var importedSourceKey, importedEntryKey string | |
| 415 | + | var hasImportedTarget bool | |
| 419 | 416 | var routeTarget protocol.RouteNavigationAction | |
| 420 | 417 | var hasRouteTarget bool | |
| 421 | 418 | var createDestination palette.Result | |
| @@ -424,6 +421,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 424 | 421 | var routeSnapshot protocol.RecentRouteSnapshot | |
| 425 | 422 | var generation uint64 | |
| 426 | 423 | var rawQuery string | |
| 424 | + | var cancelInventoryInteraction uint64 | |
| 425 | + | var hasCancelInventory bool | |
| 427 | 426 | changed, cancel, execute, chooseDestination := false, false, false, false | |
| 428 | 427 | var effect *attachmentEffect | |
| 429 | 428 | if len(effects) != 0 { | |
| @@ -507,6 +506,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 507 | 506 | remoteKey = key | |
| 508 | 507 | remoteUnavailableReason, _ = selected.RemoteSessionUnavailableReason() | |
| 509 | 508 | hasRemoteTarget = true | |
| 509 | + | } else if sourceKey, entryKey, ok := selected.ImportedSessionKey(); ok { | |
| 510 | + | importedSourceKey = sourceKey | |
| 511 | + | importedEntryKey = entryKey | |
| 512 | + | hasImportedTarget = true | |
| 510 | 513 | } else if action, ok := selected.RouteNavigationAction(); ok { | |
| 511 | 514 | routeTarget = action | |
| 512 | 515 | hasRouteTarget = true | |
| @@ -546,10 +549,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 546 | 549 | } | |
| 547 | 550 | } | |
| 548 | 551 | if cancel { | |
| 552 | + | cancelInventoryInteraction, hasCancelInventory = takePaletteInventoryClose(ac.overlays) | |
| 549 | 553 | ac.clearPaletteLocked() | |
| 550 | 554 | } | |
| 551 | 555 | ac.overlays.paletteMu.Unlock() | |
| 552 | 556 | if cancel { | |
| 557 | + | if hasCancelInventory { | |
| 558 | + | d.sendPaletteInventoryDemand(ac, effect, false, cancelInventoryInteraction) | |
| 559 | + | } | |
| 553 | 560 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 554 | 561 | return | |
| 555 | 562 | } | |
| @@ -568,7 +575,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 568 | 575 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 569 | 576 | return | |
| 570 | 577 | } | |
| 571 | - | if !d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 578 | + | if !d.closeExecutedPalette(ac, effect, generation, rawQuery) { | |
| 572 | 579 | return | |
| 573 | 580 | } | |
| 574 | 581 | if name == "" { | |
| @@ -602,7 +609,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 602 | 609 | } | |
| 603 | 610 | err := d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{}, "palette-remote-session") | |
| 604 | 611 | if err == nil { | |
| 605 | - | d.closeExecutedPalette(ac, generation, rawQuery) | |
| 612 | + | d.closeExecutedPalette(ac, effect, generation, rawQuery) | |
| 606 | 613 | return | |
| 607 | 614 | } | |
| 608 | 615 | if !errors.Is(err, errAttachmentTransition) { | |
| @@ -612,6 +619,49 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 612 | 619 | return | |
| 613 | 620 | } | |
| 614 | 621 | ||
| 622 | + | if hasImportedTarget { | |
| 623 | + | if effect == nil { | |
| 624 | + | ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable") | |
| 625 | + | d.invalidateRender(entry, ac, true, "palette.go") | |
| 626 | + | return | |
| 627 | + | } | |
| 628 | + | if importedSourceKey != protocol.NavigationInventoryLocalSourceKey { | |
| 629 | + | // Remote vision is read-only: imported remote routes surface | |
| 630 | + | // availability but never attach. | |
| 631 | + | ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached") | |
| 632 | + | d.invalidateRender(entry, ac, true, "palette.go") | |
| 633 | + | return | |
| 634 | + | } | |
| 635 | + | ac.overlays.paletteMu.Lock() | |
| 636 | + | open := ac.overlays.paletteInventoryOpen | |
| 637 | + | selection := protocol.NavigationInventorySelection{ | |
| 638 | + | CauseActionID: effect.uiActionID, | |
| 639 | + | InteractionGeneration: ac.overlays.paletteInventoryInteraction, | |
| 640 | + | PublicationGeneration: ac.overlays.paletteInventoryPublication, | |
| 641 | + | SourceKey: importedSourceKey, | |
| 642 | + | EntryKey: importedEntryKey, | |
| 643 | + | } | |
| 644 | + | if open { | |
| 645 | + | freezePaletteInventorySelection(ac.overlays, selection) | |
| 646 | + | } | |
| 647 | + | ac.overlays.paletteMu.Unlock() | |
| 648 | + | if !open || protocol.ValidateNavigationInventorySelection(selection) != nil { | |
| 649 | + | ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable") | |
| 650 | + | d.invalidateRender(entry, ac, true, "palette.go") | |
| 651 | + | return | |
| 652 | + | } | |
| 653 | + | // Selection crosses before the close demand on the guarded serving | |
| 654 | + | // connection; closing after selection must not cancel the client's | |
| 655 | + | // pending navigation operation. | |
| 656 | + | if err := effect.sendControl(selection); err != nil { | |
| 657 | + | ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable") | |
| 658 | + | d.invalidateRender(entry, ac, true, "palette.go") | |
| 659 | + | return | |
| 660 | + | } | |
| 661 | + | d.closeExecutedPalette(ac, effect, generation, rawQuery) | |
| 662 | + | return | |
| 663 | + | } | |
| 664 | + | ||
| 615 | 665 | if hasRouteTarget { | |
| 616 | 666 | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect} | |
| 617 | 667 | if err := exec.NavigateRecentRoute(routeTarget); err != nil { | |
| @@ -622,7 +672,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 622 | 672 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 623 | 673 | return | |
| 624 | 674 | } | |
| 625 | - | if d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 675 | + | if d.closeExecutedPalette(ac, effect, generation, rawQuery) { | |
| 626 | 676 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 627 | 677 | } | |
| 628 | 678 | return | |
| @@ -651,7 +701,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 651 | 701 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 652 | 702 | return | |
| 653 | 703 | } | |
| 654 | - | if d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 704 | + | if d.closeExecutedPalette(ac, effect, generation, rawQuery) { | |
| 655 | 705 | if current := ac.currentAttachmentSession(); current != nil { | |
| 656 | 706 | d.invalidateRender(current, ac, true, "palette.go") | |
| 657 | 707 | } | |
| @@ -675,7 +725,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 675 | 725 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 676 | 726 | return | |
| 677 | 727 | } | |
| 678 | - | if d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 728 | + | if d.closeExecutedPalette(ac, effect, generation, rawQuery) { | |
| 679 | 729 | d.recordPaletteUse(cmd.Code) | |
| 680 | 730 | if current := ac.currentAttachmentSession(); current != nil { | |
| 681 | 731 | d.invalidateRender(current, ac, true, "palette.go") | |
| @@ -684,7 +734,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 684 | 734 | return | |
| 685 | 735 | } | |
| 686 | 736 | attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach" | |
| 687 | - | if !attachmentHandoff && !d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 737 | + | if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) { | |
| 688 | 738 | return | |
| 689 | 739 | } | |
| 690 | 740 | sess.dispatchMu.Lock() | |
| @@ -694,14 +744,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 694 | 744 | if current := ac.currentSession(); current != nil { | |
| 695 | 745 | currentToken := current.captureAttachmentCapability(ac, ac.transport()) | |
| 696 | 746 | fresh, admitted := ac.beginAttachmentEffect(currentToken) | |
| 697 | - | if d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 747 | + | if d.closeExecutedPalette(ac, effect, generation, rawQuery) { | |
| 698 | 748 | d.invalidateRender(current, ac, true, "palette.go") | |
| 699 | 749 | } | |
| 700 | 750 | if admitted { | |
| 701 | 751 | fresh.End() | |
| 702 | 752 | } | |
| 703 | 753 | } else { | |
| 704 | - | d.closeExecutedPalette(ac, generation, rawQuery) | |
| 754 | + | d.closeExecutedPalette(ac, effect, generation, rawQuery) | |
| 705 | 755 | } | |
| 706 | 756 | } | |
| 707 | 757 | if errors.Is(err, errAttachmentTransition) { | |
| @@ -738,6 +788,7 @@ func (ac *attachedClient) clearPaletteLocked() { | |||
| 738 | 788 | ac.overlays.palettePreview = "" | |
| 739 | 789 | ac.overlays.paletteFeedback = "" | |
| 740 | 790 | ac.overlays.palettePending = nil | |
| 791 | + | closePaletteInventory(ac.overlays) | |
| 741 | 792 | } | |
| 742 | 793 | ||
| 743 | 794 | func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) { | |
| @@ -749,14 +800,20 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s | |||
| 749 | 800 | ac.overlays.paletteFeedback = feedback | |
| 750 | 801 | } | |
| 751 | 802 | ||
| 752 | - | func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool { | |
| 803 | + | func (d *Daemon) closeExecutedPalette(ac *attachedClient, effect *attachmentEffect, generation uint64, rawQuery string) bool { | |
| 753 | 804 | ac.overlays.paletteMu.Lock() | |
| 754 | 805 | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | |
| 755 | 806 | ac.overlays.paletteMu.Unlock() | |
| 756 | 807 | return false | |
| 757 | 808 | } | |
| 809 | + | // Every successful close stops relay polling: take the close demand | |
| 810 | + | // before clearing so no execute path leaks a 1/s poll loop. | |
| 811 | + | closeInteraction, hasClose := takePaletteInventoryClose(ac.overlays) | |
| 758 | 812 | ac.clearPaletteLocked() | |
| 759 | 813 | ac.overlays.paletteMu.Unlock() | |
| 814 | + | if hasClose { | |
| 815 | + | d.sendPaletteInventoryDemand(ac, effect, false, closeInteraction) | |
| 816 | + | } | |
| 760 | 817 | return true | |
| 761 | 818 | } | |
| 762 | 819 | ||
A internal/usecase/daemon/palette_inventory.go +195 -0
| @@ -0,0 +1,195 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/protocol" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // This file owns the serving-daemon side of navigation inventory: demand | |
| 9 | + | // emission, publication admission, and imported-row selection. All overlay | |
| 10 | + | // fields below are guarded by paletteMu alongside the palette model; no | |
| 11 | + | // lock is held across sends. Helpers never lock internally: callers hold | |
| 12 | + | // paletteMu for state transitions and release it before any sendControl. | |
| 13 | + | ||
| 14 | + | // inventoryDemandEnabled reports whether this attachment may receive | |
| 15 | + | // inventory demand. The serving daemon emits demand only for the advertised | |
| 16 | + | // capability; the client independently checks its concrete local source. | |
| 17 | + | func inventoryDemandEnabled(ac *attachedClient) bool { | |
| 18 | + | return ac != nil && ac.navigationCapabilities&protocol.NavigationCapabilityInventory != 0 | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // openPaletteInventory starts one palette interaction namespace. Admitted | |
| 22 | + | // keys and retirements never cross interactions. | |
| 23 | + | func openPaletteInventory(rt *overlayRuntime) uint64 { | |
| 24 | + | rt.paletteInventoryOpen = true | |
| 25 | + | rt.paletteInventoryInteraction++ | |
| 26 | + | rt.paletteInventoryPublication = 0 | |
| 27 | + | rt.paletteInventoryGroups = nil | |
| 28 | + | rt.paletteInventorySelected = nil | |
| 29 | + | rt.paletteInventoryDemandSent = false | |
| 30 | + | return rt.paletteInventoryInteraction | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // closePaletteInventory ends the interaction and drops relayed rows. | |
| 34 | + | // Imported rows vanish with the palette; a reopen starts a new namespace. | |
| 35 | + | // The pending selection survives the close so a late resolve failure still | |
| 36 | + | // correlates to the interaction that chose it; the next open clears it. | |
| 37 | + | func closePaletteInventory(rt *overlayRuntime) { | |
| 38 | + | rt.paletteInventoryOpen = false | |
| 39 | + | rt.paletteInventoryGroups = nil | |
| 40 | + | rt.paletteInventoryDemandSent = false | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // takePaletteInventoryClose snapshots a pending close-demand: the overlay | |
| 44 | + | // must be open for the interaction. Callers send Demand{open:false} after | |
| 45 | + | // releasing paletteMu, then close through clearPaletteLocked. | |
| 46 | + | func takePaletteInventoryClose(rt *overlayRuntime) (uint64, bool) { | |
| 47 | + | if !rt.paletteInventoryOpen { | |
| 48 | + | return 0, false | |
| 49 | + | } | |
| 50 | + | return rt.paletteInventoryInteraction, true | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // sendPaletteInventoryDemand emits an open or close demand on the guarded | |
| 54 | + | // serving connection. It locks paletteMu only for the emission check, never | |
| 55 | + | // across the send. A nil effect skips the send while the overlay state still | |
| 56 | + | // transitions; those paths coincide with attachment teardown or replacement | |
| 57 | + | // where the client-side relay is cancelled independently. Stale close | |
| 58 | + | // demands (superseded by a reopen) skip so generations stay ordered. | |
| 59 | + | func (d *Daemon) sendPaletteInventoryDemand(ac *attachedClient, effect *attachmentEffect, open bool, interaction uint64) { | |
| 60 | + | if !inventoryDemandEnabled(ac) || effect == nil || interaction == 0 { | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | ac.overlays.paletteMu.Lock() | |
| 64 | + | if open { | |
| 65 | + | if !ac.overlays.paletteInventoryOpen || ac.overlays.paletteInventoryInteraction != interaction || ac.overlays.paletteInventoryDemandSent { | |
| 66 | + | ac.overlays.paletteMu.Unlock() | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | ac.overlays.paletteInventoryDemandSent = true | |
| 70 | + | } else if ac.overlays.paletteInventoryInteraction != interaction { | |
| 71 | + | ac.overlays.paletteMu.Unlock() | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | ac.overlays.paletteMu.Unlock() | |
| 75 | + | demand := protocol.NavigationInventoryDemand{InteractionGeneration: interaction, Open: open} | |
| 76 | + | if protocol.ValidateNavigationInventoryDemand(demand) != nil { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | _ = effect.sendControl(demand) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // admitPaletteInventoryPublication validates and stores one client | |
| 83 | + | // publication. Older or duplicate publication generations discard: the | |
| 84 | + | // daemon never steps its display backward. Stale interactions (palette | |
| 85 | + | // closed or reopened since) drop silently. | |
| 86 | + | func admitPaletteInventoryPublication(rt *overlayRuntime, message protocol.NavigationInventoryPublication) bool { | |
| 87 | + | if protocol.ValidateNavigationInventoryPublication(message) != nil { | |
| 88 | + | return false | |
| 89 | + | } | |
| 90 | + | if !rt.paletteInventoryOpen || message.InteractionGeneration != rt.paletteInventoryInteraction { | |
| 91 | + | return false | |
| 92 | + | } | |
| 93 | + | if message.PublicationGeneration <= rt.paletteInventoryPublication { | |
| 94 | + | return false | |
| 95 | + | } | |
| 96 | + | rt.paletteInventoryPublication = message.PublicationGeneration | |
| 97 | + | rt.paletteInventoryGroups = append([]protocol.NavigationInventorySourceGroup(nil), message.Groups...) | |
| 98 | + | return true | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // copyPaletteInventoryGroups snapshots the admitted relayed groups for | |
| 102 | + | // projection. It copies under paletteMu and releases it before any capture: | |
| 103 | + | // callers must never nest it inside registry locks. A closed palette | |
| 104 | + | // yields nil: imported rows vanish with the overlay. | |
| 105 | + | func copyPaletteInventoryGroups(ac *attachedClient) []protocol.NavigationInventorySourceGroup { | |
| 106 | + | if ac == nil || ac.overlays == nil { | |
| 107 | + | return nil | |
| 108 | + | } | |
| 109 | + | ac.overlays.paletteMu.Lock() | |
| 110 | + | defer ac.overlays.paletteMu.Unlock() | |
| 111 | + | if !ac.overlays.paletteInventoryOpen { | |
| 112 | + | return nil | |
| 113 | + | } | |
| 114 | + | return append([]protocol.NavigationInventorySourceGroup(nil), ac.overlays.paletteInventoryGroups...) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // importedPaletteResults projects admitted sanitized groups as palette rows | |
| 118 | + | // with explicit origin qualification. Homonyms across sources stay distinct | |
| 119 | + | // rows; selection resolves through opaque keys, never labels. | |
| 120 | + | func importedPaletteResults(groups []protocol.NavigationInventorySourceGroup) []palette.Result { | |
| 121 | + | var results []palette.Result | |
| 122 | + | for _, group := range groups { | |
| 123 | + | if group.Status != protocol.NavigationInventorySourceOK { | |
| 124 | + | continue | |
| 125 | + | } | |
| 126 | + | for _, entry := range group.Entries { | |
| 127 | + | origin := entry.DisplayOrigin | |
| 128 | + | if origin == "" { | |
| 129 | + | origin = group.SourceKey | |
| 130 | + | } | |
| 131 | + | results = append(results, palette.NewImportedSessionResult(group.SourceKey, entry.EntryKey, entry.Name, origin, entry.State, entry.Reason)) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | return results | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // appendImportedResults projects admitted relayed rows after native | |
| 138 | + | // results. Enter starts native-only (fresh namespace); refreshes pick up | |
| 139 | + | // streamed publications. Stable opaque keys preserve cursor and query | |
| 140 | + | // across ReplaceResults. | |
| 141 | + | func appendImportedResults(results []palette.Result, ac *attachedClient) []palette.Result { | |
| 142 | + | groups := copyPaletteInventoryGroups(ac) | |
| 143 | + | if ac == nil || ac.overlays == nil { | |
| 144 | + | return results | |
| 145 | + | } | |
| 146 | + | localKeys := make(map[string]bool) | |
| 147 | + | for _, group := range groups { | |
| 148 | + | if group.SourceKey == protocol.NavigationInventoryLocalSourceKey && group.Status == protocol.NavigationInventorySourceOK { | |
| 149 | + | for _, entry := range group.Entries { | |
| 150 | + | localKeys[entry.EntryKey] = true | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ac.overlays.paletteMu.Lock() | |
| 155 | + | snapshot := ac.overlays.paletteRouteSnapshot | |
| 156 | + | ac.overlays.paletteMu.Unlock() | |
| 157 | + | filtered := results[:0] | |
| 158 | + | for _, result := range results { | |
| 159 | + | if action, ok := result.RouteNavigationAction(); ok { | |
| 160 | + | entry, found := routeEntryForRef(snapshot, protocol.RouteRef{Key: action.Key, Generation: action.Generation}) | |
| 161 | + | if found && entry.Kind == protocol.RouteKindLocal && localKeys[navigationInventoryEntryKey(entry.Target.LifecycleID, entry.Target.SessionName)] { | |
| 162 | + | continue | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | filtered = append(filtered, result) | |
| 166 | + | } | |
| 167 | + | return append(filtered, importedPaletteResults(groups)...) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // inventoryFailureNotice maps a bounded relay failure code to palette | |
| 171 | + | // feedback through existing conventions. Native commands stay available: | |
| 172 | + | // only the contextual feedback line changes. | |
| 173 | + | func inventoryFailureNotice(code protocol.NavigationInventoryFailureCode) string { | |
| 174 | + | switch code { | |
| 175 | + | case protocol.NavigationInventoryStaleIdentity, protocol.NavigationInventoryInvalidTarget: | |
| 176 | + | return "that destination is no longer available" | |
| 177 | + | case protocol.NavigationInventorySourceGone: | |
| 178 | + | return "local inventory source unavailable" | |
| 179 | + | case protocol.NavigationInventoryIncompatible: | |
| 180 | + | return "incompatible session version" | |
| 181 | + | case protocol.NavigationInventoryNavigationFailed: | |
| 182 | + | return "couldn't switch sessions" | |
| 183 | + | case protocol.NavigationInventoryRestoreFailed: | |
| 184 | + | return "couldn't restore the original session" | |
| 185 | + | default: | |
| 186 | + | return "inventory selection unavailable" | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // freezePaletteInventorySelection records the chosen imported row before its | |
| 191 | + | // Selection crosses the wire. Closing after selection must not cancel the | |
| 192 | + | // client's pending navigation operation. | |
| 193 | + | func freezePaletteInventorySelection(rt *overlayRuntime, selection protocol.NavigationInventorySelection) { | |
| 194 | + | rt.paletteInventorySelected = &selection | |
| 195 | + | } | |
A internal/usecase/daemon/palette_inventory_test.go +291 -0
| @@ -0,0 +1,291 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // TestPaletteInventoryNamespaceLifecycle pins the overlay interaction | |
| 15 | + | // namespace: open bumps the generation and resets admit state, close drops | |
| 16 | + | // relayed rows, and a reopen starts a new namespace. | |
| 17 | + | func TestPaletteInventoryNamespaceLifecycle(t *testing.T) { | |
| 18 | + | rt := &overlayRuntime{} | |
| 19 | + | first := openPaletteInventory(rt) | |
| 20 | + | require.Equal(t, uint64(1), first) | |
| 21 | + | rt.paletteInventoryPublication = 3 | |
| 22 | + | rt.paletteInventoryGroups = []protocol.NavigationInventorySourceGroup{{SourceKey: "local"}} | |
| 23 | + | ||
| 24 | + | interaction, ok := takePaletteInventoryClose(rt) | |
| 25 | + | require.True(t, ok) | |
| 26 | + | require.Equal(t, first, interaction) | |
| 27 | + | ||
| 28 | + | closePaletteInventory(rt) | |
| 29 | + | if _, ok := takePaletteInventoryClose(rt); ok { | |
| 30 | + | t.Fatal("closed palette must not offer a close-demand") | |
| 31 | + | } | |
| 32 | + | require.Empty(t, rt.paletteInventoryGroups) | |
| 33 | + | ||
| 34 | + | second := openPaletteInventory(rt) | |
| 35 | + | require.Equal(t, first+1, second) | |
| 36 | + | require.Zero(t, rt.paletteInventoryPublication) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // TestAdmitPaletteInventoryPublication pins admission: newer generations | |
| 40 | + | // store, older and duplicate generations discard, stale interactions and | |
| 41 | + | // closed palettes drop, malformed messages never store. | |
| 42 | + | func TestAdmitPaletteInventoryPublication(t *testing.T) { | |
| 43 | + | groups := func() []protocol.NavigationInventorySourceGroup { | |
| 44 | + | return []protocol.NavigationInventorySourceGroup{{ | |
| 45 | + | SourceKey: "local", Status: protocol.NavigationInventorySourceOK, | |
| 46 | + | Entries: []protocol.NavigationInventoryEntry{ | |
| 47 | + | {SourceKey: "local", EntryKey: "aaa/one", Name: "one"}, | |
| 48 | + | }, | |
| 49 | + | }} | |
| 50 | + | } | |
| 51 | + | publication := func(interaction, generation uint64) protocol.NavigationInventoryPublication { | |
| 52 | + | return protocol.NavigationInventoryPublication{InteractionGeneration: interaction, PublicationGeneration: generation, Groups: groups()} | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | rt := &overlayRuntime{} | |
| 56 | + | interaction := openPaletteInventory(rt) | |
| 57 | + | ||
| 58 | + | require.True(t, admitPaletteInventoryPublication(rt, publication(interaction, 1))) | |
| 59 | + | require.Len(t, rt.paletteInventoryGroups, 1) | |
| 60 | + | require.False(t, admitPaletteInventoryPublication(rt, publication(interaction, 1)), "duplicate generation must discard") | |
| 61 | + | require.False(t, admitPaletteInventoryPublication(rt, publication(interaction, 0)), "older generation must discard") | |
| 62 | + | require.True(t, admitPaletteInventoryPublication(rt, publication(interaction, 2))) | |
| 63 | + | require.Equal(t, uint64(2), rt.paletteInventoryPublication) | |
| 64 | + | require.False(t, admitPaletteInventoryPublication(rt, publication(interaction+7, 3)), "stale interaction must drop") | |
| 65 | + | ||
| 66 | + | closePaletteInventory(rt) | |
| 67 | + | require.False(t, admitPaletteInventoryPublication(rt, publication(interaction, 3)), "closed palette must drop") | |
| 68 | + | ||
| 69 | + | other := &overlayRuntime{} | |
| 70 | + | openPaletteInventory(other) | |
| 71 | + | malformed := publication(other.paletteInventoryInteraction, 1) | |
| 72 | + | malformed.Groups = nil | |
| 73 | + | malformed.PublicationGeneration = 0 | |
| 74 | + | require.False(t, admitPaletteInventoryPublication(other, malformed)) | |
| 75 | + | require.Empty(t, other.paletteInventoryGroups) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // TestPaletteInventoryDemandAndSelectionFlow drives the serving-daemon side | |
| 79 | + | // end to end through a capturing transport: capability-gated open demand, | |
| 80 | + | // publication admission with imported-row projection, Enter resolving | |
| 81 | + | // through opaque keys with bound cause, Selection before close-demand wire | |
| 82 | + | // order, and overlay teardown that preserves the frozen selection. | |
| 83 | + | func TestPaletteInventoryDemandAndSelectionFlow(t *testing.T) { | |
| 84 | + | p, release := newBlockingPTY(t) | |
| 85 | + | defer release() | |
| 86 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |
| 87 | + | ac.navigationCapabilities = protocol.NavigationCapabilityInventory | |
| 88 | + | effect := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 89 | + | // No automation action ID: ordinary keyboard input carries cause zero, | |
| 90 | + | // which must still select. | |
| 91 | + | ||
| 92 | + | interaction := d.enterPalette(current, ac) | |
| 93 | + | d.sendPaletteInventoryDemand(ac, effect, true, interaction) | |
| 94 | + | demandFrame := awaitFrame(t, sends, wire.MsgNavigationInventoryDemand) | |
| 95 | + | demand, err := wire.UnmarshalNavigationInventoryDemand(demandFrame.Payload) | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | require.True(t, demand.Open) | |
| 98 | + | require.Equal(t, interaction, demand.InteractionGeneration) | |
| 99 | + | ||
| 100 | + | published := protocol.NavigationInventoryPublication{ | |
| 101 | + | InteractionGeneration: interaction, PublicationGeneration: 1, | |
| 102 | + | Groups: []protocol.NavigationInventorySourceGroup{{ | |
| 103 | + | SourceKey: "local", Status: protocol.NavigationInventorySourceOK, | |
| 104 | + | Entries: []protocol.NavigationInventoryEntry{ | |
| 105 | + | {SourceKey: "local", EntryKey: "aaa/zzqimported", Name: "zzqimported", DisplayOrigin: "local", State: "up"}, | |
| 106 | + | }, | |
| 107 | + | }}, | |
| 108 | + | } | |
| 109 | + | ac.overlays.paletteMu.Lock() | |
| 110 | + | require.True(t, admitPaletteInventoryPublication(ac.overlays, published)) | |
| 111 | + | ac.overlays.paletteMu.Unlock() | |
| 112 | + | d.refreshPalette(ac) | |
| 113 | + | ||
| 114 | + | d.handlePaletteInput(ac, []byte("zzqimported\r"), effect) | |
| 115 | + | ||
| 116 | + | selectionFrame := awaitFrame(t, sends, wire.MsgNavigationInventorySelection) | |
| 117 | + | selection, err := wire.UnmarshalNavigationInventorySelection(selectionFrame.Payload) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | require.Equal(t, uint64(0), selection.CauseActionID, "keyboard input carries no automation cause") | |
| 120 | + | require.Equal(t, interaction, selection.InteractionGeneration) | |
| 121 | + | require.Equal(t, uint64(1), selection.PublicationGeneration) | |
| 122 | + | require.Equal(t, "local", selection.SourceKey) | |
| 123 | + | require.Equal(t, "aaa/zzqimported", selection.EntryKey) | |
| 124 | + | ||
| 125 | + | closeFrame := awaitFrame(t, sends, wire.MsgNavigationInventoryDemand) | |
| 126 | + | closeDemand, err := wire.UnmarshalNavigationInventoryDemand(closeFrame.Payload) | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | require.False(t, closeDemand.Open) | |
| 129 | + | require.Equal(t, interaction, closeDemand.InteractionGeneration) | |
| 130 | + | // Input handling sends controls synchronously; no second close may be queued. | |
| 131 | + | for len(sends) > 0 { | |
| 132 | + | frame := <-sends | |
| 133 | + | require.NotEqual(t, wire.MsgNavigationInventoryDemand, frame.Type, "selection emits exactly one close") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | require.False(t, ac.overlays.paletteActive(), "select closes the overlay") | |
| 137 | + | ac.overlays.paletteMu.Lock() | |
| 138 | + | frozen := ac.overlays.paletteInventorySelected | |
| 139 | + | ac.overlays.paletteMu.Unlock() | |
| 140 | + | require.NotNil(t, frozen, "the pending selection survives the close so a late failure correlates") | |
| 141 | + | require.Equal(t, interaction, frozen.InteractionGeneration) | |
| 142 | + | require.Equal(t, "aaa/zzqimported", frozen.EntryKey) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // TestPaletteInventoryRemoteEnterNeverAttaches pins read-only remote | |
| 146 | + | // vision: Enter on a remote-origin row reports feedback, keeps the palette | |
| 147 | + | // open, and emits no Selection frame. | |
| 148 | + | func TestPaletteInventoryRemoteEnterNeverAttaches(t *testing.T) { | |
| 149 | + | p, release := newBlockingPTY(t) | |
| 150 | + | defer release() | |
| 151 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |
| 152 | + | ac.navigationCapabilities = protocol.NavigationCapabilityInventory | |
| 153 | + | effect := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 154 | + | effect.uiActionID = 123 | |
| 155 | + | ||
| 156 | + | interaction := d.enterPalette(current, ac) | |
| 157 | + | published := protocol.NavigationInventoryPublication{ | |
| 158 | + | InteractionGeneration: interaction, PublicationGeneration: 1, | |
| 159 | + | Groups: []protocol.NavigationInventorySourceGroup{ | |
| 160 | + | { | |
| 161 | + | SourceKey: "local", Status: protocol.NavigationInventorySourceOK, | |
| 162 | + | Entries: []protocol.NavigationInventoryEntry{ | |
| 163 | + | {SourceKey: "local", EntryKey: "aaa/zzqlocal", Name: "zzqlocal", DisplayOrigin: "local", State: "up"}, | |
| 164 | + | }, | |
| 165 | + | }, | |
| 166 | + | { | |
| 167 | + | SourceKey: "arch", Status: protocol.NavigationInventorySourceOK, | |
| 168 | + | Entries: []protocol.NavigationInventoryEntry{ | |
| 169 | + | {SourceKey: "arch", EntryKey: "aaa/zzqremote", Name: "zzqremote", DisplayOrigin: "arch", State: "down", Reason: "unreachable"}, | |
| 170 | + | }, | |
| 171 | + | }, | |
| 172 | + | }, | |
| 173 | + | } | |
| 174 | + | ac.overlays.paletteMu.Lock() | |
| 175 | + | require.True(t, admitPaletteInventoryPublication(ac.overlays, published)) | |
| 176 | + | ac.overlays.paletteMu.Unlock() | |
| 177 | + | d.refreshPalette(ac) | |
| 178 | + | ||
| 179 | + | d.handlePaletteInput(ac, []byte("zzqremote\r"), effect) | |
| 180 | + | ||
| 181 | + | ac.overlays.paletteMu.Lock() | |
| 182 | + | feedback := ac.overlays.paletteFeedback | |
| 183 | + | ac.overlays.paletteMu.Unlock() | |
| 184 | + | require.Equal(t, "remote sessions are visible but cannot be attached", feedback) | |
| 185 | + | require.True(t, ac.overlays.paletteActive(), "refused remote selection keeps the palette open") | |
| 186 | + | select { | |
| 187 | + | case frame := <-sends: | |
| 188 | + | require.NotEqual(t, wire.MsgNavigationInventorySelection, frame.Type, "remote Enter must not emit a selection") | |
| 189 | + | case <-time.After(200 * time.Millisecond): | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // TestPaletteInventoryFailureCorrelatesToPendingSelection pins durable | |
| 194 | + | // failure routing: a failure matching the frozen selection records a | |
| 195 | + | // notice even with the palette closed, shows contextual feedback while | |
| 196 | + | // the interaction is open, and drops when nothing matches. | |
| 197 | + | func TestPaletteInventoryFailureCorrelatesToPendingSelection(t *testing.T) { | |
| 198 | + | p, release := newBlockingPTY(t) | |
| 199 | + | defer release() | |
| 200 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 201 | + | ||
| 202 | + | interaction := d.enterPalette(current, ac) | |
| 203 | + | selection := protocol.NavigationInventorySelection{ | |
| 204 | + | InteractionGeneration: interaction, PublicationGeneration: 1, | |
| 205 | + | SourceKey: "local", EntryKey: "aaa/zzqimported", | |
| 206 | + | } | |
| 207 | + | ac.overlays.paletteMu.Lock() | |
| 208 | + | freezePaletteInventorySelection(ac.overlays, selection) | |
| 209 | + | ac.overlays.paletteMu.Unlock() | |
| 210 | + | d.closeExecutedPalette(ac, nil, 1, "") | |
| 211 | + | ||
| 212 | + | matched := protocol.NavigationInventoryFailure{ | |
| 213 | + | InteractionGeneration: interaction, SourceKey: "local", EntryKey: "aaa/zzqimported", | |
| 214 | + | Code: protocol.NavigationInventorySourceGone, | |
| 215 | + | } | |
| 216 | + | d.handleAttachmentClientMessage(captureAttachmentCapability(current, ac, ac.transport()), matched) | |
| 217 | + | require.True(t, hasInventoryNotice(t, ac), "matched failure must record a durable notice") | |
| 218 | + | ||
| 219 | + | stale := protocol.NavigationInventoryFailure{ | |
| 220 | + | InteractionGeneration: interaction + 1, SourceKey: "local", EntryKey: "aaa/zzqimported", | |
| 221 | + | Code: protocol.NavigationInventorySourceGone, | |
| 222 | + | } | |
| 223 | + | before := countInventoryNotices(t, ac) | |
| 224 | + | d.handleAttachmentClientMessage(captureAttachmentCapability(current, ac, ac.transport()), stale) | |
| 225 | + | require.Equal(t, before, countInventoryNotices(t, ac), "unmatched failure must drop") | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func hasInventoryNotice(t *testing.T, ac *attachedClient) bool { | |
| 229 | + | t.Helper() | |
| 230 | + | return countInventoryNotices(t, ac) != 0 | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func countInventoryNotices(t *testing.T, ac *attachedClient) int { | |
| 234 | + | t.Helper() | |
| 235 | + | ac.overlays.noticeMu.Lock() | |
| 236 | + | defer ac.overlays.noticeMu.Unlock() | |
| 237 | + | count := 0 | |
| 238 | + | for _, toast := range ac.overlays.noticeToasts { | |
| 239 | + | if toast.n.Code == domain.NoticeNavigationInventory { | |
| 240 | + | count++ | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | return count | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // TestPaletteInventoryEscapeSendsCloseDemand pins the plain-cancel path: a | |
| 247 | + | // bare Escape cancels the unselected interaction and still stops polling. | |
| 248 | + | func TestPaletteInventoryEscapeSendsCloseDemand(t *testing.T) { | |
| 249 | + | p, release := newBlockingPTY(t) | |
| 250 | + | defer release() | |
| 251 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |
| 252 | + | ac.navigationCapabilities = protocol.NavigationCapabilityInventory | |
| 253 | + | effect := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 254 | + | ||
| 255 | + | interaction := d.enterPalette(current, ac) | |
| 256 | + | d.sendPaletteInventoryDemand(ac, effect, true, interaction) | |
| 257 | + | awaitFrame(t, sends, wire.MsgNavigationInventoryDemand) | |
| 258 | + | ||
| 259 | + | d.handlePaletteInput(ac, []byte{0x1b}, effect) | |
| 260 | + | closeFrame := awaitFrame(t, sends, wire.MsgNavigationInventoryDemand) | |
| 261 | + | closeDemand, err := wire.UnmarshalNavigationInventoryDemand(closeFrame.Payload) | |
| 262 | + | require.NoError(t, err) | |
| 263 | + | require.False(t, closeDemand.Open) | |
| 264 | + | require.Equal(t, interaction, closeDemand.InteractionGeneration) | |
| 265 | + | require.False(t, ac.overlays.paletteActive()) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // TestPaletteInventoryDemandRequiresCapability pins the serving-side gate: | |
| 269 | + | // without the advertised capability bit no demand crosses, even with an | |
| 270 | + | // admitted effect, and the overlay namespace still opens natively. | |
| 271 | + | func TestPaletteInventoryDemandRequiresCapability(t *testing.T) { | |
| 272 | + | p, release := newBlockingPTY(t) | |
| 273 | + | defer release() | |
| 274 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |
| 275 | + | effect := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 276 | + | ||
| 277 | + | interaction := d.enterPalette(current, ac) | |
| 278 | + | d.sendPaletteInventoryDemand(ac, effect, true, interaction) | |
| 279 | + | deadline := time.After(time.Second) | |
| 280 | + | for { | |
| 281 | + | select { | |
| 282 | + | case frame := <-sends: | |
| 283 | + | if frame.Type == wire.MsgNavigationInventoryDemand { | |
| 284 | + | t.Fatalf("demand without capability must not send") | |
| 285 | + | } | |
| 286 | + | case <-deadline: | |
| 287 | + | require.True(t, ac.overlays.paletteActive()) | |
| 288 | + | return | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
A internal/usecase/daemon/palette_origin_test.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestPaletteLocalQualificationDependsOnConfiguredHosts(t *testing.T) { | |
| 14 | + | for _, tc := range []struct { | |
| 15 | + | name string | |
| 16 | + | hosts []ports.RemoteHostSnapshot | |
| 17 | + | want string | |
| 18 | + | }{ | |
| 19 | + | {name: "local only", want: "Switch to session sample"}, | |
| 20 | + | {name: "offline host without sessions", hosts: []ports.RemoteHostSnapshot{{Endpoint: "host-a"}}, want: "Switch to session sample@local"}, | |
| 21 | + | } { | |
| 22 | + | t.Run(tc.name, func(t *testing.T) { | |
| 23 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 24 | + | sess := addControlSession(d, "sample", "tab-1", "pane-1") | |
| 25 | + | sess.ephemeral = false | |
| 26 | + | sess.incarnation = domain.SessionLifecycleID{1} | |
| 27 | + | seedRemoteDirectory(t, d, tc.hosts...) | |
| 28 | + | results := d.paletteResults(nil, nil, protocol.RecentRouteSnapshot{}) | |
| 29 | + | var labels []string | |
| 30 | + | for _, result := range results { | |
| 31 | + | if _, ok := result.SessionTarget(); ok { | |
| 32 | + | labels = append(labels, result.DisplayText()) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | require.Equal(t, []string{tc.want}, labels) | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestPaletteHistoryUsesClientRelativeOrigin(t *testing.T) { | |
| 41 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 42 | + | snapshot := protocol.RecentRouteSnapshot{Generation: 1, Entries: []protocol.RecentRouteEntry{ | |
| 43 | + | {Key: 1, Generation: 1, Name: "sample", Kind: protocol.RouteKindLocal, Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "sample"}}, | |
| 44 | + | {Key: 2, Generation: 1, Name: "sample", Kind: protocol.RouteKindRemote, HostLabel: "host-a", Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "sample"}}, | |
| 45 | + | }} | |
| 46 | + | results := d.paletteResults(nil, nil, snapshot) | |
| 47 | + | require.Len(t, results, 2) | |
| 48 | + | require.Equal(t, "Switch to session sample@local", results[0].DisplayText()) | |
| 49 | + | require.Equal(t, "Switch to session sample@host-a", results[1].DisplayText()) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestPaletteImportedLocalSessionReplacesDuplicateHistoryRoute(t *testing.T) { | |
| 53 | + | target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "sample"} | |
| 54 | + | action := protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1} | |
| 55 | + | ac := &attachedClient{overlays: &overlayRuntime{ | |
| 56 | + | paletteInventoryOpen: true, | |
| 57 | + | paletteRouteSnapshot: protocol.RecentRouteSnapshot{Entries: []protocol.RecentRouteEntry{{Key: 1, Generation: 1, Kind: protocol.RouteKindLocal, Target: target}}}, | |
| 58 | + | paletteInventoryGroups: []protocol.NavigationInventorySourceGroup{{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: navigationInventoryEntryKey(target.LifecycleID, target.SessionName), Name: "sample", DisplayOrigin: "local", State: "up"}}}}, | |
| 59 | + | }} | |
| 60 | + | results := appendImportedResults([]palette.Result{palette.NewRecentRouteResult("sample", "sample", action)}, ac) | |
| 61 | + | require.Len(t, results, 1, "history and inventory represent one exact local destination") | |
| 62 | + | require.Equal(t, palette.ResultKindImportedSession, results[0].Kind()) | |
| 63 | + | ||
| 64 | + | for _, tc := range []struct { | |
| 65 | + | name string | |
| 66 | + | kind protocol.RouteKind | |
| 67 | + | target protocol.ExactSessionTarget | |
| 68 | + | }{ | |
| 69 | + | {"replacement lifecycle", protocol.RouteKindLocal, protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "sample"}}, | |
| 70 | + | {"remote homonym", protocol.RouteKindRemote, target}, | |
| 71 | + | } { | |
| 72 | + | t.Run(tc.name, func(t *testing.T) { | |
| 73 | + | ac.overlays.paletteRouteSnapshot.Entries[0].Kind = tc.kind | |
| 74 | + | ac.overlays.paletteRouteSnapshot.Entries[0].Target = tc.target | |
| 75 | + | results := appendImportedResults([]palette.Result{palette.NewRecentRouteResult("sample", "sample", action)}, ac) | |
| 76 | + | require.Len(t, results, 2, "presentation homonyms must not erase distinct identities") | |
| 77 | + | }) | |
| 78 | + | } | |
| 79 | + | } | |
M internal/usecase/daemon/palette_test.go +1 -1
| @@ -556,7 +556,7 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te | |||
| 556 | 556 | results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{}) | |
| 557 | 557 | var matching []palette.Result | |
| 558 | 558 | for _, result := range results { | |
| 559 | - | if result.DisplayText() == "Switch to session vev" || result.DisplayText() == "Switch to session vev@arch" { | |
| 559 | + | if result.DisplayText() == "Switch to session vev@local" || result.DisplayText() == "Switch to session vev@arch" { | |
| 560 | 560 | matching = append(matching, result) | |
| 561 | 561 | } | |
| 562 | 562 | } | |
M internal/usecase/daemon/picker.go +16 -42
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | renderer "github.com/bnema/vev-vt" | |
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | - | "github.com/bnema/vev/internal/ports" | |
| 13 | 12 | "github.com/bnema/vev/internal/protocol" | |
| 14 | 13 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 15 | 14 | "github.com/bnema/vev/internal/usecase/keys" | |
| @@ -87,48 +86,30 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||
| 87 | 86 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 88 | 87 | } | |
| 89 | 88 | ||
| 90 | - | // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally | |
| 91 | - | // knows nothing about picker intent; picker.New owns all destination policy. | |
| 89 | + | // pickerViews projects the shared daemon inventory for the picker. It keeps | |
| 90 | + | // current/ephemeral rows, tabs/previews, grouping, and move eligibility; | |
| 91 | + | // lifecycle and remote facts come from the common capture. | |
| 92 | 92 | func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) { | |
| 93 | - | d.mu.Lock() | |
| 94 | - | sessions := make([]*session, 0, len(d.sessions)) | |
| 95 | - | for _, entry := range d.sessions { | |
| 96 | - | if entry == nil { | |
| 97 | - | continue | |
| 98 | - | } | |
| 99 | - | sessions = append(sessions, entry) | |
| 100 | - | } | |
| 101 | - | stopped := make([]inactiveSession, 0, len(d.inactive)) | |
| 102 | - | for _, s := range d.inactive { | |
| 103 | - | if s.visible() { | |
| 104 | - | stopped = append(stopped, s) | |
| 105 | - | } | |
| 106 | - | } | |
| 107 | - | d.mu.Unlock() | |
| 108 | - | ||
| 109 | - | for _, entry := range sessions { | |
| 110 | - | if entry != nil { | |
| 111 | - | d.refreshSessionFocusedTitles(entry) | |
| 112 | - | } | |
| 93 | + | if cur != nil && ac != nil { | |
| 94 | + | cur.repairAttachmentView(ac) | |
| 113 | 95 | } | |
| 114 | 96 | opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle} | |
| 97 | + | inv := d.captureSessionInventory(opts, true) | |
| 98 | + | stopped := inv.visibleStopped() | |
| 99 | + | hosts := inv.hosts | |
| 100 | + | now := inv.now | |
| 101 | + | monitored := inv.monitored | |
| 102 | + | initialized := inv.initialized | |
| 115 | 103 | ||
| 116 | - | type liveSnapshot struct { | |
| 117 | - | entry *session | |
| 118 | - | view sessionView | |
| 119 | - | } | |
| 120 | 104 | // One snapshot per live session: sorting and view building read the same | |
| 121 | 105 | // capture, so comparators cannot observe a concurrent touchMRU or | |
| 122 | 106 | // renameSession mid-sort. Remote-catalog locks are released before sorting | |
| 123 | 107 | // and picker-row construction. | |
| 124 | - | live := make([]liveSnapshot, 0, len(sessions)) | |
| 108 | + | live := inv.live | |
| 125 | 109 | var current picker.SourceFilter | |
| 126 | - | for _, entry := range sessions { | |
| 127 | - | if entry == cur && ac != nil { | |
| 128 | - | entry.repairAttachmentView(ac) | |
| 129 | - | } | |
| 130 | - | snap := entry.snapshotView(opts) | |
| 131 | - | if entry == cur { | |
| 110 | + | for _, item := range live { | |
| 111 | + | snap := item.view | |
| 112 | + | if item.sess == cur { | |
| 132 | 113 | if ac != nil { | |
| 133 | 114 | view := ac.viewSnapshot() | |
| 134 | 115 | if view.tabID != "" { | |
| @@ -138,7 +119,6 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 138 | 119 | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id} | |
| 139 | 120 | } | |
| 140 | 121 | } | |
| 141 | - | live = append(live, liveSnapshot{entry: entry, view: snap}) | |
| 142 | 122 | } | |
| 143 | 123 | sort.Slice(live, func(i, j int) bool { | |
| 144 | 124 | if live[i].view.mruAt != live[j].view.mruAt { | |
| @@ -163,12 +143,6 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 163 | 143 | }) | |
| 164 | 144 | } | |
| 165 | 145 | ||
| 166 | - | directory := d.remoteDirectorySnapshot() | |
| 167 | - | monitored := d.remoteDirectory != nil | |
| 168 | - | hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...) | |
| 169 | - | sortDirectoryHosts(hosts) | |
| 170 | - | now := d.daemonNow() | |
| 171 | - | ||
| 172 | 146 | catalogRows := 0 | |
| 173 | 147 | for _, host := range hosts { | |
| 174 | 148 | catalogRows += len(host.Sessions) | |
| @@ -215,7 +189,7 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 215 | 189 | } | |
| 216 | 190 | // A nil directory means remote monitoring is not installed at all: | |
| 217 | 191 | // only an installed-but-unpublished monitor reads as "checking". | |
| 218 | - | if monitored && !directory.Initialized { | |
| 192 | + | if monitored && !initialized { | |
| 219 | 193 | view := remotePickerCheckingView() | |
| 220 | 194 | if grouped { | |
| 221 | 195 | view.Section = "REMOTE" | |
A internal/usecase/daemon/session_inventory.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // inventoriedLiveSession pairs a live session pointer with its immutable | |
| 10 | + | // snapshot view. The pointer preserves exact current-session identity while | |
| 11 | + | // the view carries all display/policy facts, so projections never re-lock | |
| 12 | + | // the registry to re-read the same session. | |
| 13 | + | type inventoriedLiveSession struct { | |
| 14 | + | sess *session | |
| 15 | + | view sessionView | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // sessionInventory is the daemon-owned common capture consumed by the | |
| 19 | + | // palette, picker, and catalog export projections. It copies active/inactive | |
| 20 | + | // records and remote-directory values once through this module; projections | |
| 21 | + | // keep only UI-specific filtering, sorting, and labeling. | |
| 22 | + | // | |
| 23 | + | // d.mu is released before any per-session snapshot, preserving the | |
| 24 | + | // established lock ordering. Copied values are coherent per exact target, | |
| 25 | + | // not a distributed atomic transaction; callers revalidate at selection. | |
| 26 | + | type sessionInventory struct { | |
| 27 | + | live []inventoriedLiveSession | |
| 28 | + | stopped []inactiveSession | |
| 29 | + | hosts []ports.RemoteHostSnapshot | |
| 30 | + | monitored bool | |
| 31 | + | initialized bool | |
| 32 | + | now time.Time | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // captureSessionInventory copies registry pointers and inactive records under | |
| 36 | + | // d.mu, then snapshots each live session without holding that lock. When | |
| 37 | + | // refreshTitles is true the focused-title refresh runs per session after the | |
| 38 | + | // registry lock is released, matching the historical picker/catalog cost; | |
| 39 | + | // palette passes false to avoid rich tab-title work it never displays. | |
| 40 | + | func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) sessionInventory { | |
| 41 | + | var sessions []*session | |
| 42 | + | var stopped []inactiveSession | |
| 43 | + | var monitored bool | |
| 44 | + | if d != nil { | |
| 45 | + | d.mu.Lock() | |
| 46 | + | sessions = d.sessionsSnapshotLocked() | |
| 47 | + | stopped = make([]inactiveSession, 0, len(d.inactive)) | |
| 48 | + | for _, entry := range d.inactive { | |
| 49 | + | stopped = append(stopped, entry) | |
| 50 | + | } | |
| 51 | + | monitored = d.remoteDirectory != nil | |
| 52 | + | d.mu.Unlock() | |
| 53 | + | } | |
| 54 | + | if refreshTitles && d != nil { | |
| 55 | + | for _, sess := range sessions { | |
| 56 | + | d.refreshSessionFocusedTitles(sess) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | live := make([]inventoriedLiveSession, 0, len(sessions)) | |
| 60 | + | for _, sess := range sessions { | |
| 61 | + | if sess == nil { | |
| 62 | + | continue | |
| 63 | + | } | |
| 64 | + | live = append(live, inventoriedLiveSession{sess: sess, view: sess.snapshotView(opts)}) | |
| 65 | + | } | |
| 66 | + | directory := d.remoteDirectorySnapshot() | |
| 67 | + | hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...) | |
| 68 | + | sortDirectoryHosts(hosts) | |
| 69 | + | return sessionInventory{ | |
| 70 | + | live: live, | |
| 71 | + | stopped: stopped, | |
| 72 | + | hosts: hosts, | |
| 73 | + | monitored: monitored, | |
| 74 | + | initialized: directory.Initialized, | |
| 75 | + | now: d.daemonNow(), | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // resumableStopped returns stopped sessions eligible for resume display, | |
| 80 | + | // preserving the palette's historical canResume projection. | |
| 81 | + | func (inv sessionInventory) resumableStopped() []inactiveSession { | |
| 82 | + | out := make([]inactiveSession, 0, len(inv.stopped)) | |
| 83 | + | for _, entry := range inv.stopped { | |
| 84 | + | if entry.canResume() { | |
| 85 | + | out = append(out, entry) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | return out | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // visibleStopped returns non-purging stopped sessions, preserving the | |
| 92 | + | // picker and catalog export projections. | |
| 93 | + | func (inv sessionInventory) visibleStopped() []inactiveSession { | |
| 94 | + | out := make([]inactiveSession, 0, len(inv.stopped)) | |
| 95 | + | for _, entry := range inv.stopped { | |
| 96 | + | if entry.visible() { | |
| 97 | + | out = append(out, entry) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | return out | |
| 101 | + | } | |
A internal/usecase/daemon/session_inventory_test.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // TestSessionInventorySharedCapture verifies the daemon-owned common capture | |
| 15 | + | // feeds palette, picker, and catalog export with identical lifecycle facts, | |
| 16 | + | // while preserving each projection's intentional cost and filtering policy. | |
| 17 | + | func TestSessionInventorySharedCapture(t *testing.T) { | |
| 18 | + | now := time.Unix(1_000, 0) | |
| 19 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 20 | + | current := addControlSession(d, "current", "tab-1", "pane-1") | |
| 21 | + | current.ephemeral = false | |
| 22 | + | current.incarnation = domain.SessionLifecycleID{11} | |
| 23 | + | other := addControlSession(d, "other", "tab-2", "pane-2") | |
| 24 | + | other.ephemeral = false | |
| 25 | + | other.incarnation = domain.SessionLifecycleID{12} | |
| 26 | + | remoteLifecycle := domain.SessionLifecycleID{21} | |
| 27 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ | |
| 28 | + | LifecycleID: remoteLifecycle, Name: "shared", State: catalogue.RemoteCatalogSessionUp, | |
| 29 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, | |
| 30 | + | ActiveTabID: "tab-remote", | |
| 31 | + | })) | |
| 32 | + | ||
| 33 | + | cheap := d.captureSessionInventory(viewOptions{}, false) | |
| 34 | + | rich := d.captureSessionInventory(viewOptions{tabDetails: true, focusedTitles: true}, true) | |
| 35 | + | ||
| 36 | + | require.Len(t, cheap.live, 2) | |
| 37 | + | require.Len(t, rich.live, 2) | |
| 38 | + | require.Len(t, cheap.hosts, 1) | |
| 39 | + | require.Equal(t, cheap.hosts[0].Endpoint, rich.hosts[0].Endpoint) | |
| 40 | + | // Cheap palette capture avoids tab details; rich picker/catalog capture includes them. | |
| 41 | + | require.Empty(t, cheap.live[0].view.tabs) | |
| 42 | + | require.NotEmpty(t, rich.live[0].view.tabs) | |
| 43 | + | ||
| 44 | + | results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{}) | |
| 45 | + | foundOther := false | |
| 46 | + | foundRemote := false | |
| 47 | + | for _, result := range results { | |
| 48 | + | if name, ok := result.SessionName(); ok && name == "other" { | |
| 49 | + | foundOther = true | |
| 50 | + | } | |
| 51 | + | if _, ok := result.RemoteSessionTarget(); ok { | |
| 52 | + | foundRemote = true | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | require.True(t, foundOther, "palette must project shared live sessions") | |
| 56 | + | require.True(t, foundRemote, "palette must project shared remote sessions") | |
| 57 | + | ||
| 58 | + | views, _ := d.pickerViews(current, nil) | |
| 59 | + | foundPickerOther := false | |
| 60 | + | foundPickerRemote := false | |
| 61 | + | for _, view := range views { | |
| 62 | + | if view.Name == "other" { | |
| 63 | + | foundPickerOther = true | |
| 64 | + | } | |
| 65 | + | if view.RemoteTarget != nil && view.RemoteHost == "user@arch" { | |
| 66 | + | foundPickerRemote = true | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | require.True(t, foundPickerOther) | |
| 70 | + | require.True(t, foundPickerRemote) | |
| 71 | + | ||
| 72 | + | catalog, err := controlExec{d: d}.RemoteCatalog(true) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | require.Contains(t, catalog, `"other"`) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // TestSessionInventoryStoppedFiltering documents the intentional projection | |
| 78 | + | // difference: palette shows only resumable sessions while picker and catalog | |
| 79 | + | // observe every visible stopped session through the shared capture. | |
| 80 | + | func TestSessionInventoryStoppedFiltering(t *testing.T) { | |
| 81 | + | tests := []struct { | |
| 82 | + | name string | |
| 83 | + | projection string | |
| 84 | + | }{ | |
| 85 | + | {name: "palette", projection: "palette"}, | |
| 86 | + | {name: "picker", projection: "picker"}, | |
| 87 | + | {name: "catalog", projection: "catalog"}, | |
| 88 | + | } | |
| 89 | + | for _, tt := range tests { | |
| 90 | + | t.Run(tt.name, func(t *testing.T) { | |
| 91 | + | now := time.Unix(2_000, 0) | |
| 92 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 93 | + | current := addControlSession(d, "current", "tab-1", "pane-1") | |
| 94 | + | current.ephemeral = false | |
| 95 | + | current.incarnation = domain.SessionLifecycleID{13} | |
| 96 | + | d.mu.Lock() | |
| 97 | + | d.inactive["resumable"] = inactiveSession{name: "resumable", cwd: "/tmp/resumable", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown} | |
| 98 | + | d.inactive["broken"] = inactiveSession{name: "broken", cwd: "/tmp/broken", createdAt: 2, incarnation: domain.IncarnationID{4}, state: protocol.SessionBroken} | |
| 99 | + | d.mu.Unlock() | |
| 100 | + | ||
| 101 | + | inv := d.captureSessionInventory(viewOptions{}, false) | |
| 102 | + | require.NotEmpty(t, inv.visibleStopped()) | |
| 103 | + | require.NotEmpty(t, inv.resumableStopped()) | |
| 104 | + | require.GreaterOrEqual(t, len(inv.visibleStopped()), len(inv.resumableStopped())) | |
| 105 | + | ||
| 106 | + | switch tt.projection { | |
| 107 | + | case "palette": | |
| 108 | + | results := d.paletteResults(current, nil, testRecentRouteSnapshot()) | |
| 109 | + | names := map[string]bool{} | |
| 110 | + | for _, result := range results { | |
| 111 | + | if name, ok := result.SessionName(); ok { | |
| 112 | + | names[name] = true | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | require.True(t, names["resumable"]) | |
| 116 | + | require.False(t, names["broken"]) | |
| 117 | + | case "picker": | |
| 118 | + | views, _ := d.pickerViews(current, nil) | |
| 119 | + | names := map[string]bool{} | |
| 120 | + | for _, view := range views { | |
| 121 | + | names[view.Name] = true | |
| 122 | + | } | |
| 123 | + | require.True(t, names["resumable"]) | |
| 124 | + | require.True(t, names["broken"]) | |
| 125 | + | case "catalog": | |
| 126 | + | out, err := controlExec{d: d}.RemoteCatalog(true) | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | require.Contains(t, out, `"resumable"`) | |
| 129 | + | require.Contains(t, out, `"broken"`) | |
| 130 | + | } | |
| 131 | + | }) | |
| 132 | + | } | |
| 133 | + | } | |
M internal/usecase/daemon/typed_connection_test.go +24 -0
| @@ -158,6 +158,12 @@ func testDecodeClientFrame(frame wire.Frame) (protocol.ClientMessage, error) { | |||
| 158 | 158 | return wire.UnmarshalSamePeerSwitchRequest(frame.Payload) | |
| 159 | 159 | case wire.MsgParkedRouteRequest: | |
| 160 | 160 | return wire.UnmarshalParkedRouteRequest(frame.Payload) | |
| 161 | + | case wire.MsgNavigationInventoryRequest: | |
| 162 | + | return wire.UnmarshalNavigationInventoryRequest(frame.Payload) | |
| 163 | + | case wire.MsgNavigationInventoryPublication: | |
| 164 | + | return wire.UnmarshalNavigationInventoryPublication(frame.Payload) | |
| 165 | + | case wire.MsgNavigationInventoryFailure: | |
| 166 | + | return wire.UnmarshalNavigationInventoryFailure(frame.Payload) | |
| 161 | 167 | case wire.MsgRecentRouteSnapshot: | |
| 162 | 168 | return wire.UnmarshalRecentRouteSnapshot(frame.Payload) | |
| 163 | 169 | case wire.MsgRouteNavigationFailure: | |
| @@ -316,6 +322,24 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 316 | 322 | return wire.Frame{Type: wire.MsgSamePeerSwitchFailure, Payload: payload}, err | |
| 317 | 323 | case protocol.ParkedRouteResponse: | |
| 318 | 324 | return wire.Frame{Type: wire.MsgParkedRouteResponse, Payload: wire.MarshalParkedRouteResponse(m)}, nil | |
| 325 | + | case protocol.NavigationInventoryResponse: | |
| 326 | + | payload := wire.MarshalNavigationInventoryResponse(m) | |
| 327 | + | if payload == nil { | |
| 328 | + | return wire.Frame{}, errors.New("test server connection: invalid inventory response") | |
| 329 | + | } | |
| 330 | + | return wire.Frame{Type: wire.MsgNavigationInventoryResponse, Payload: payload}, nil | |
| 331 | + | case protocol.NavigationInventoryDemand: | |
| 332 | + | payload := wire.MarshalNavigationInventoryDemand(m) | |
| 333 | + | if payload == nil { | |
| 334 | + | return wire.Frame{}, errors.New("test server connection: invalid inventory demand") | |
| 335 | + | } | |
| 336 | + | return wire.Frame{Type: wire.MsgNavigationInventoryDemand, Payload: payload}, nil | |
| 337 | + | case protocol.NavigationInventorySelection: | |
| 338 | + | payload := wire.MarshalNavigationInventorySelection(m) | |
| 339 | + | if payload == nil { | |
| 340 | + | return wire.Frame{}, errors.New("test server connection: invalid inventory selection") | |
| 341 | + | } | |
| 342 | + | return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: payload}, nil | |
| 319 | 343 | default: | |
| 320 | 344 | return wire.Frame{}, errors.New("test server connection: unsupported server message") | |
| 321 | 345 | } | |
M internal/usecase/palette/result.go +102 -8
| @@ -19,6 +19,7 @@ const ( | |||
| 19 | 19 | ResultKindRemoteSession | |
| 20 | 20 | ResultKindRecentRoute | |
| 21 | 21 | ResultKindCreateSessionDestination | |
| 22 | + | ResultKindImportedSession | |
| 22 | 23 | ) | |
| 23 | 24 | ||
| 24 | 25 | // CreateSessionDestinationKind identifies where a CNS result creates a session. | |
| @@ -31,12 +32,14 @@ const ( | |||
| 31 | 32 | ) | |
| 32 | 33 | ||
| 33 | 34 | // Result is an immutable palette target. Its kind is the sole discriminator | |
| 34 | - | // for its private command, local-session, remote-session, or route payload. | |
| 35 | + | // for its private command, local-session, remote-session, imported-session, | |
| 36 | + | // or route payload. | |
| 35 | 37 | type Result struct { | |
| 36 | 38 | kind ResultKind | |
| 37 | 39 | command command.Command | |
| 38 | 40 | session sessionPayload | |
| 39 | 41 | remoteSession remoteSessionPayload | |
| 42 | + | importedSession importedSessionPayload | |
| 40 | 43 | route routePayload | |
| 41 | 44 | createDestination createSessionDestinationPayload | |
| 42 | 45 | } | |
| @@ -53,6 +56,20 @@ type remoteSessionPayload struct { | |||
| 53 | 56 | unavailableReason string | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 59 | + | // importedSessionPayload is one sanitized navigation-inventory row relayed | |
| 60 | + | // from the client's local control source. It carries display facts and the | |
| 61 | + | // opaque selection keys only: no endpoint, connection parameters, CWD, | |
| 62 | + | // environment, preview, or pane content. Resolution happens source-side | |
| 63 | + | // through the keys. | |
| 64 | + | type importedSessionPayload struct { | |
| 65 | + | sourceKey string | |
| 66 | + | entryKey string | |
| 67 | + | name string | |
| 68 | + | displayOrigin string | |
| 69 | + | state string | |
| 70 | + | reason string | |
| 71 | + | } | |
| 72 | + | ||
| 56 | 73 | type routePayload struct { | |
| 57 | 74 | name string | |
| 58 | 75 | label string | |
| @@ -101,12 +118,24 @@ func NewStoppedSessionResultWithDisplayOrigin(target protocol.ExactSessionTarget | |||
| 101 | 118 | } | |
| 102 | 119 | } | |
| 103 | 120 | ||
| 104 | - | func newSessionPayload(target protocol.ExactSessionTarget, createdAt time.Time, displayOrigin string) sessionPayload { | |
| 105 | - | display := target.SessionName | |
| 106 | - | if displayOrigin != "" { | |
| 107 | - | display = domain.RemoteSessionDisplay(target.SessionName, displayOrigin) | |
| 121 | + | // SessionDisplay qualifies palette destinations relative to the client. | |
| 122 | + | func SessionDisplay(name, origin string) string { | |
| 123 | + | if origin == "" { | |
| 124 | + | return name | |
| 125 | + | } | |
| 126 | + | return name + "@" + domain.RemoteDisplayOrigin(origin) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func remoteSessionDisplay(key domain.RemoteSessionKey) string { | |
| 130 | + | origin := key.DisplayOrigin | |
| 131 | + | if origin == "" { | |
| 132 | + | origin = domain.RemoteDisplayOrigin(key.Host) | |
| 108 | 133 | } | |
| 109 | - | return sessionPayload{display: display, createdAt: createdAt, target: target} | |
| 134 | + | return SessionDisplay(key.Name, origin) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func newSessionPayload(target protocol.ExactSessionTarget, createdAt time.Time, displayOrigin string) sessionPayload { | |
| 138 | + | return sessionPayload{display: SessionDisplay(target.SessionName, displayOrigin), createdAt: createdAt, target: target} | |
| 110 | 139 | } | |
| 111 | 140 | ||
| 112 | 141 | // NewRemoteSessionResult creates an immutable catalog-backed remote target. | |
| @@ -119,6 +148,19 @@ func NewRemoteSessionResult(key domain.RemoteSessionKey, target domain.RemoteSes | |||
| 119 | 148 | } | |
| 120 | 149 | } | |
| 121 | 150 | ||
| 151 | + | // NewImportedSessionResult creates an immutable inventory-relayed row. | |
| 152 | + | // Labels are never parsed back into selection authority: Enter resolves | |
| 153 | + | // through the opaque source/entry keys. | |
| 154 | + | func NewImportedSessionResult(sourceKey, entryKey, name, displayOrigin, state, reason string) Result { | |
| 155 | + | return Result{ | |
| 156 | + | kind: ResultKindImportedSession, | |
| 157 | + | importedSession: importedSessionPayload{ | |
| 158 | + | sourceKey: sourceKey, entryKey: entryKey, name: name, | |
| 159 | + | displayOrigin: displayOrigin, state: state, reason: reason, | |
| 160 | + | }, | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 122 | 164 | // NewRecentRouteResult creates an immutable client-ledger route target. | |
| 123 | 165 | func NewRecentRouteResult(name, label string, action protocol.RouteNavigationAction) Result { | |
| 124 | 166 | return Result{kind: ResultKindRecentRoute, route: routePayload{name: name, label: label, action: action}} | |
| @@ -150,6 +192,8 @@ func (r Result) sameTarget(other Result) bool { | |||
| 150 | 192 | return r.session.target == other.session.target | |
| 151 | 193 | case ResultKindRemoteSession: | |
| 152 | 194 | return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target | |
| 195 | + | case ResultKindImportedSession: | |
| 196 | + | return r.importedSession.sourceKey == other.importedSession.sourceKey && r.importedSession.entryKey == other.importedSession.entryKey | |
| 153 | 197 | case ResultKindRecentRoute: | |
| 154 | 198 | return r.route.action == other.route.action | |
| 155 | 199 | case ResultKindCreateSessionDestination: | |
| @@ -167,7 +211,10 @@ func (r Result) DisplayText() string { | |||
| 167 | 211 | return r.command.Code | |
| 168 | 212 | } | |
| 169 | 213 | if r.kind == ResultKindRemoteSession { | |
| 170 | - | return activeSessionDisplayPrefix + r.remoteSession.key.Display() | |
| 214 | + | return activeSessionDisplayPrefix + remoteSessionDisplay(r.remoteSession.key) | |
| 215 | + | } | |
| 216 | + | if r.kind == ResultKindImportedSession { | |
| 217 | + | return activeSessionDisplayPrefix + r.importedSession.display() | |
| 171 | 218 | } | |
| 172 | 219 | if r.kind == ResultKindRecentRoute { | |
| 173 | 220 | return activeSessionDisplayPrefix + r.route.label | |
| @@ -213,7 +260,9 @@ func (r Result) searchTerms() (identity, label string, offset int, ok bool) { | |||
| 213 | 260 | case ResultKindActiveSession, ResultKindStoppedSession: | |
| 214 | 261 | return r.session.target.SessionName, r.session.display, utf8.RuneCountInString(r.sessionDisplayPrefix()), true | |
| 215 | 262 | case ResultKindRemoteSession: | |
| 216 | - | return r.remoteSession.key.Name, r.remoteSession.key.Display(), utf8.RuneCountInString(activeSessionDisplayPrefix), true | |
| 263 | + | return r.remoteSession.key.Name, remoteSessionDisplay(r.remoteSession.key), utf8.RuneCountInString(activeSessionDisplayPrefix), true | |
| 264 | + | case ResultKindImportedSession: | |
| 265 | + | return r.importedSession.name, r.importedSession.display(), utf8.RuneCountInString(activeSessionDisplayPrefix), true | |
| 217 | 266 | case ResultKindRecentRoute: | |
| 218 | 267 | return r.route.name, r.route.label, utf8.RuneCountInString(activeSessionDisplayPrefix), true | |
| 219 | 268 | default: | |
| @@ -260,6 +309,51 @@ func (r Result) RemoteSessionUnavailableReason() (string, bool) { | |||
| 260 | 309 | return r.remoteSession.unavailableReason, r.kind == ResultKindRemoteSession | |
| 261 | 310 | } | |
| 262 | 311 | ||
| 312 | + | // importedDisplay qualifies an inventory row with its explicit origin. | |
| 313 | + | // Unqualified names would collapse homonyms across local and remote | |
| 314 | + | // sources into the wrong selection. | |
| 315 | + | func (p importedSessionPayload) qualified() string { | |
| 316 | + | return SessionDisplay(p.name, p.displayOrigin) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func (p importedSessionPayload) display() string { | |
| 320 | + | base := p.qualified() | |
| 321 | + | // Remote vision is read-only availability tagging: availability | |
| 322 | + | // reasons surface independently of lifecycle state (a cached-up row | |
| 323 | + | // can still be unreachable), and any non-live state tags inline so | |
| 324 | + | // stale or broken entries never look attachable. Live rows without a | |
| 325 | + | // reason stay untagged. | |
| 326 | + | tag := p.state | |
| 327 | + | if tag == "" || tag == "up" { | |
| 328 | + | tag = "" | |
| 329 | + | } | |
| 330 | + | if p.reason != "" { | |
| 331 | + | if tag == "" { | |
| 332 | + | return base + " (" + p.reason + ")" | |
| 333 | + | } | |
| 334 | + | return base + " (" + tag + ": " + p.reason + ")" | |
| 335 | + | } | |
| 336 | + | if tag == "" { | |
| 337 | + | return base | |
| 338 | + | } | |
| 339 | + | return base + " (" + tag + ")" | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // ImportedSessionKey returns the opaque inventory selection keys only for | |
| 343 | + | // an imported session result. | |
| 344 | + | func (r Result) ImportedSessionKey() (sourceKey, entryKey string, ok bool) { | |
| 345 | + | return r.importedSession.sourceKey, r.importedSession.entryKey, r.kind == ResultKindImportedSession | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // ImportedSessionDisplay returns the qualified display label and reason only | |
| 349 | + | // for an imported session result. | |
| 350 | + | func (r Result) ImportedSessionDisplay() (display, reason string, ok bool) { | |
| 351 | + | if r.kind != ResultKindImportedSession { | |
| 352 | + | return "", "", false | |
| 353 | + | } | |
| 354 | + | return r.importedSession.qualified(), r.importedSession.reason, true | |
| 355 | + | } | |
| 356 | + | ||
| 263 | 357 | // RouteNavigationAction returns the exact client-ledger target only for a | |
| 264 | 358 | // recent-route result. | |
| 265 | 359 | func (r Result) RouteNavigationAction() (protocol.RouteNavigationAction, bool) { | |
A internal/usecase/palette/result_origin_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package palette | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSessionResultsAlwaysQualifyClientOrigin(t *testing.T) { | |
| 12 | + | for _, tc := range []struct { | |
| 13 | + | name string | |
| 14 | + | result Result | |
| 15 | + | want string | |
| 16 | + | }{ | |
| 17 | + | {"local only", NewActiveSessionResult(testExactTarget("sample", 1), time.Time{}), "Switch to session sample"}, | |
| 18 | + | {"hybrid local", NewActiveSessionResultWithDisplayOrigin(testExactTarget("sample", 1), time.Time{}, "local"), "Switch to session sample@local"}, | |
| 19 | + | {"serving remote", NewActiveSessionResultWithDisplayOrigin(testExactTarget("sample", 1), time.Time{}, "host-a"), "Switch to session sample@host-a"}, | |
| 20 | + | {"imported local", NewImportedSessionResult("local", "key", "sample", "local", "up", ""), "Switch to session sample@local"}, | |
| 21 | + | {"discovered remote", NewRemoteSessionResult(domain.RemoteSessionKey{Name: "sample", Host: "user@host-a", DisplayOrigin: "host-a"}, domain.RemoteSessionTarget{}, ""), "Switch to session sample@host-a"}, | |
| 22 | + | } { | |
| 23 | + | t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, tc.result.DisplayText()) }) | |
| 24 | + | } | |
| 25 | + | } | |
M internal/usecase/palette/result_test.go +36 -0
| @@ -14,6 +14,42 @@ func testExactTarget(name string, marker byte) protocol.ExactSessionTarget { | |||
| 14 | 14 | return protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name} | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | + | func TestImportedSessionResultKeepsOpaqueKeysAndQualifiedDisplay(t *testing.T) { | |
| 18 | + | local := NewImportedSessionResult("local", "aaa/one", "shared", "local", "up", "") | |
| 19 | + | remote := NewImportedSessionResult("user@arch", "bbb/shared", "shared", "arch", "up", "") | |
| 20 | + | ||
| 21 | + | require.Equal(t, ResultKindImportedSession, local.Kind()) | |
| 22 | + | require.Equal(t, "Switch to session shared@local", local.DisplayText()) | |
| 23 | + | require.Equal(t, "Switch to session shared@arch", remote.DisplayText()) | |
| 24 | + | ||
| 25 | + | source, entry, ok := local.ImportedSessionKey() | |
| 26 | + | require.True(t, ok) | |
| 27 | + | require.Equal(t, "local", source) | |
| 28 | + | require.Equal(t, "aaa/one", entry) | |
| 29 | + | display, reason, ok := remote.ImportedSessionDisplay() | |
| 30 | + | require.True(t, ok) | |
| 31 | + | require.Equal(t, "shared@arch", display) | |
| 32 | + | require.Empty(t, reason) | |
| 33 | + | ||
| 34 | + | // Homonyms across sources stay distinct: keys never collapse. | |
| 35 | + | require.False(t, local.sameTarget(remote)) | |
| 36 | + | require.True(t, local.sameTarget(NewImportedSessionResult("local", "aaa/one", "renamed", "local", "up", ""))) | |
| 37 | + | ||
| 38 | + | // Remote vision tags availability inline: non-live states surface so | |
| 39 | + | // stale or broken entries never look attachable. Live rows stay clean. | |
| 40 | + | down := NewImportedSessionResult("arch", "aaa/db", "db", "arch", "down", "unreachable") | |
| 41 | + | require.Equal(t, "Switch to session db@arch (down: unreachable)", down.DisplayText()) | |
| 42 | + | broken := NewImportedSessionResult("arch", "aaa/db", "db", "arch", "broken", "") | |
| 43 | + | require.Equal(t, "Switch to session db@arch (broken)", broken.DisplayText()) | |
| 44 | + | stopped := NewImportedSessionResult("local", "aaa/old", "old", "local", "stopped", "") | |
| 45 | + | require.Equal(t, "Switch to session old@local (stopped)", stopped.DisplayText()) | |
| 46 | + | ||
| 47 | + | // Other kinds expose no imported keys. | |
| 48 | + | if _, _, ok := NewCommandResult(command.Command{Code: "NT"}).ImportedSessionKey(); ok { | |
| 49 | + | t.Fatal("command results must not expose imported keys") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 17 | 53 | func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |
| 18 | 54 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | |
| 19 | 55 | commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"}) | |
D scripts/remote-picker-harness/Dockerfile +0 -48
| @@ -1,48 +0,0 @@ | |||
| 1 | - | ARG BASE_IMAGE=ubuntu:24.04 | |
| 2 | - | FROM ${BASE_IMAGE} | |
| 3 | - | ||
| 4 | - | ENV DEBIAN_FRONTEND=noninteractive \ | |
| 5 | - | XDG_STATE_HOME=/tmp/vev-state \ | |
| 6 | - | XDG_RUNTIME_DIR=/tmp/vev-runtime \ | |
| 7 | - | PATH=/home/test/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ | |
| 8 | - | TERM=xterm-256color \ | |
| 9 | - | COLORTERM=truecolor | |
| 10 | - | ||
| 11 | - | RUN apt-get update \ | |
| 12 | - | && apt-get install -y --no-install-recommends ca-certificates openssh-client openssh-server \ | |
| 13 | - | && rm -rf /var/lib/apt/lists/* \ | |
| 14 | - | && useradd --create-home --shell /bin/bash test \ | |
| 15 | - | && usermod --password x test \ | |
| 16 | - | && install -d -m 0755 /run/sshd /tmp/vev-state /tmp/vev-runtime /home/test/bin \ | |
| 17 | - | && chmod 0777 /tmp/vev-state /tmp/vev-runtime \ | |
| 18 | - | && install -d -m 0700 -o test -g test /home/test/.ssh | |
| 19 | - | ||
| 20 | - | COPY vev /usr/local/bin/vev.real | |
| 21 | - | COPY harness /usr/local/bin/remote-picker-harness | |
| 22 | - | COPY vev-wrapper /usr/local/bin/vev | |
| 23 | - | COPY id_ed25519.pub /home/test/.ssh/authorized_keys | |
| 24 | - | ||
| 25 | - | RUN chown test:test /home/test/.ssh/authorized_keys \ | |
| 26 | - | && chmod 0600 /home/test/.ssh/authorized_keys \ | |
| 27 | - | && chmod 0755 /usr/local/bin/vev /usr/local/bin/vev.real /usr/local/bin/remote-picker-harness \ | |
| 28 | - | && printf '%s\n' \ | |
| 29 | - | 'Host *' \ | |
| 30 | - | ' IdentityFile /home/test/.ssh/id_ed25519' \ | |
| 31 | - | ' StrictHostKeyChecking no' \ | |
| 32 | - | ' UserKnownHostsFile /dev/null' \ | |
| 33 | - | ' LogLevel ERROR' \ | |
| 34 | - | > /home/test/.ssh/config \ | |
| 35 | - | && chown test:test /home/test/.ssh/config \ | |
| 36 | - | && chmod 0600 /home/test/.ssh/config \ | |
| 37 | - | && printf '%s\n' \ | |
| 38 | - | 'PermitRootLogin no' \ | |
| 39 | - | 'PasswordAuthentication no' \ | |
| 40 | - | 'KbdInteractiveAuthentication no' \ | |
| 41 | - | 'PubkeyAuthentication yes' \ | |
| 42 | - | 'UsePAM no' \ | |
| 43 | - | 'AllowUsers test' \ | |
| 44 | - | 'PrintMotd no' \ | |
| 45 | - | > /etc/ssh/sshd_config.d/vev-harness.conf \ | |
| 46 | - | && ssh-keygen -A | |
| 47 | - | ||
| 48 | - | CMD ["/usr/sbin/sshd", "-D", "-e"] | |
M scripts/remote-picker-harness/README.md +20 -44
| @@ -1,44 +1,20 @@ | |||
| 1 | - | # Remote picker container acceptance | |
| 2 | - | ||
| 3 | - | Run the disposable two-container acceptance harness with: | |
| 4 | - | ||
| 5 | - | ```sh | |
| 6 | - | make remote-acceptance | |
| 7 | - | ``` | |
| 8 | - | ||
| 9 | - | The harness builds the current binary, starts a local client container and an | |
| 10 | - | SSH-enabled remote daemon container on a private Docker network, and removes | |
| 11 | - | all resources when it exits. Docker uses the normal CLI configuration, so | |
| 12 | - | `DOCKER_HOST` or the active context selects a rootless daemon; the harness does | |
| 13 | - | not assume a socket pathname. | |
| 14 | - | ||
| 15 | - | Environment overrides: | |
| 16 | - | ||
| 17 | - | - `VEV_HARNESS_BASE_IMAGE` selects the Docker base image used for both | |
| 18 | - | containers (default: `ubuntu:24.04`). | |
| 19 | - | - `VEV_HARNESS_TARGET` selects the SSH target passed to the harness binary | |
| 20 | - | (default: `test@remote`). | |
| 21 | - | - `VEV_HARNESS_ARTIFACT_DIR`, when set, writes a bounded | |
| 22 | - | `remote-picker-harness.json` containing output-state, checkpoint, and event | |
| 23 | - | metadata. It is unset by default; the artifact contains no terminal bytes, | |
| 24 | - | raw screen text, credentials, or target output. `run.sh` copies the report to | |
| 25 | - | that host directory before it removes the disposable containers. | |
| 26 | - | ||
| 27 | - | Each logical transport has one persistent `vt.Screen` and a client-equivalent | |
| 28 | - | output-state chain. The probe writes accepted full, incremental, and side-effect | |
| 29 | - | frames to that screen, ACKs only accepted state-bearing frames, and retains a | |
| 30 | - | bounded set of screen checkpoints plus non-sensitive event metadata. The local | |
| 31 | - | picker phase captures unified local/remote rows before selection, keeps the same | |
| 32 | - | local client transport while the daemon publishes proxied remote content, | |
| 33 | - | rejects direct `MsgAttachTarget` handoff and remote daemon chrome, verifies | |
| 34 | - | continued input, and captures the unified rows again from the remote view. | |
| 35 | - | ||
| 36 | - | The run exercises real SSH-stdio and UDP attachment, typed live catalog and | |
| 37 | - | preview requests, tab creation/removal fencing, exact lifecycle fencing after | |
| 38 | - | replacement, daemon restart resume, slow-preview cancellation, version | |
| 39 | - | mismatch, and unreachable-host handling. It checks environment values in | |
| 40 | - | memory without printing terminal viewport data. | |
| 41 | - | ||
| 42 | - | Stopped-selector restoration and daemon-owned environment assertions are also | |
| 43 | - | covered by the repository's daemon and route integration tests, where the | |
| 44 | - | fixture can deterministically hold restoration at the stopped state. | |
| 1 | + | # Remote navigation acceptance | |
| 2 | + | ||
| 3 | + | Run `make remote-acceptance` with Docker, Python 3 and `ssh-keygen` installed. | |
| 4 | + | ||
| 5 | + | The runner builds `scripts/demo/Dockerfile` from the current worktree and starts | |
| 6 | + | two disposable Arch containers on a private network. Per-run client and host | |
| 7 | + | keys authenticate SSH with host-key pinning. No host credentials, state or | |
| 8 | + | sockets are mounted. Containers, image, network and temporary keys are removed | |
| 9 | + | on success, failure or interruption. | |
| 10 | + | ||
| 11 | + | The real UI driver exercises local A → local B → remote → local A. It asserts | |
| 12 | + | qualified palette labels, exactly one imported local destination, the original | |
| 13 | + | session lifecycle at return, and a successfully committed navigation action. | |
| 14 | + | A failed assertion fails the command. This is a targeted regression, not an | |
| 15 | + | exhaustive transport or geometry matrix. | |
| 16 | + | ||
| 17 | + | `navigation_repro.py CLIENT_CONTAINER REMOTE_CONTAINER` also runs against | |
| 18 | + | already prepared disposable demo containers. The client needs a pinned `remote` | |
| 19 | + | SSH alias and `vev host add remote`. The script creates uniquely named sessions | |
| 20 | + | and detaches its clients; the container owner handles session cleanup. | |
D scripts/remote-picker-harness/main.go +0 -1037
| @@ -1,1037 +0,0 @@ | |||
| 1 | - | package main | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "log/slog" | |
| 10 | - | "os" | |
| 11 | - | "os/exec" | |
| 12 | - | "strings" | |
| 13 | - | "sync" | |
| 14 | - | "sync/atomic" | |
| 15 | - | "time" | |
| 16 | - | ||
| 17 | - | "github.com/bnema/vev/internal/adapters/dgram" | |
| 18 | - | "github.com/bnema/vev/internal/adapters/ipc" | |
| 19 | - | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 20 | - | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 21 | - | "github.com/bnema/vev/internal/domain" | |
| 22 | - | "github.com/bnema/vev/internal/ports" | |
| 23 | - | "github.com/bnema/vev/internal/protocol" | |
| 24 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 25 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 26 | - | ) | |
| 27 | - | ||
| 28 | - | const ( | |
| 29 | - | defaultTarget = "test@remote" | |
| 30 | - | sessionName = "harness" | |
| 31 | - | ) | |
| 32 | - | ||
| 33 | - | var requestID atomic.Uint64 | |
| 34 | - | ||
| 35 | - | func main() { | |
| 36 | - | if err := run(); err != nil { | |
| 37 | - | fmt.Fprintf(os.Stderr, "remote picker harness: %v\n", err) | |
| 38 | - | os.Exit(1) | |
| 39 | - | } | |
| 40 | - | fmt.Println("remote picker harness: PASS") | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | func run() (runErr error) { | |
| 44 | - | target := os.Getenv("VEV_HARNESS_TARGET") | |
| 45 | - | if target == "" { | |
| 46 | - | target = defaultTarget | |
| 47 | - | } | |
| 48 | - | ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) | |
| 49 | - | defer cancel() | |
| 50 | - | ||
| 51 | - | artifact := newHarnessArtifact(os.Getenv("VEV_HARNESS_ARTIFACT_DIR")) | |
| 52 | - | if artifact != nil { | |
| 53 | - | ctx = withHarnessArtifact(ctx, artifact) | |
| 54 | - | defer func() { | |
| 55 | - | if err := artifact.write(runErr == nil); err != nil { | |
| 56 | - | runErr = errors.Join(runErr, err) | |
| 57 | - | } | |
| 58 | - | }() | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | catalog := remoteadapter.NewCatalogClient() | |
| 62 | - | preview := remoteadapter.NewPreviewClient() | |
| 63 | - | if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil { | |
| 64 | - | return err | |
| 65 | - | } | |
| 66 | - | if err := runLiveStdioPhase(ctx, target, catalog, preview); err != nil { | |
| 67 | - | return err | |
| 68 | - | } | |
| 69 | - | resumedTarget, err := runRestartResumePhase(ctx, target, catalog, preview) | |
| 70 | - | if err != nil { | |
| 71 | - | return err | |
| 72 | - | } | |
| 73 | - | if err := runUDPPhase(ctx, target); err != nil { | |
| 74 | - | return err | |
| 75 | - | } | |
| 76 | - | if err := runCatalogFailurePhase(ctx, target, catalog); err != nil { | |
| 77 | - | return err | |
| 78 | - | } | |
| 79 | - | if _, err := catalog.List(ctx, "test@unreachable.invalid"); err == nil { | |
| 80 | - | return errors.New("unreachable catalog unexpectedly succeeded") | |
| 81 | - | } | |
| 82 | - | return runStaleTargetPhase(ctx, target, catalog, resumedTarget) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) error { | |
| 86 | - | stdio, err := openTransport(ctx, remoteadapter.TransportStdio, target) | |
| 87 | - | if err != nil { | |
| 88 | - | return fmt.Errorf("open stdio transport: %w", err) | |
| 89 | - | } | |
| 90 | - | defer func() { _ = stdio.Close() }() | |
| 91 | - | if err := attachAndCheck(ctx, stdio, protocol.Hello{ | |
| 92 | - | Intent: protocol.IntentNew, Name: sessionName, Env: localEnvironment(), Cwd: "/tmp/local-cwd", | |
| 93 | - | TermEnv: "xterm-256color", TrueColor: true, | |
| 94 | - | }); err != nil { | |
| 95 | - | return fmt.Errorf("stdio live attach: %w", err) | |
| 96 | - | } | |
| 97 | - | if err := sendInputAndAwait(ctx, stdio, "printf 'VEV_REMOTE_HARNESS_ENV=%s:%s:%s:%s\\n' \"$VEV_TEST_ENV\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$VEV\"", "local:/run/local:wayland-local:"); err != nil { | |
| 98 | - | return fmt.Errorf("direct client environment: %w", err) | |
| 99 | - | } | |
| 100 | - | live, err := waitForSession(ctx, catalog, target, sessionName, "up") | |
| 101 | - | if err != nil { | |
| 102 | - | return fmt.Errorf("live catalog: %w", err) | |
| 103 | - | } | |
| 104 | - | liveTarget, err := targetForSession(target, live, false) | |
| 105 | - | if err != nil { | |
| 106 | - | return fmt.Errorf("live target: %w", err) | |
| 107 | - | } | |
| 108 | - | if _, err := preview.Preview(ctx, liveTarget, 40, 8); err != nil { | |
| 109 | - | return fmt.Errorf("live preview: %w", err) | |
| 110 | - | } | |
| 111 | - | if err := sendCommand(ctx, stdio, "new-tab"); err != nil { | |
| 112 | - | return fmt.Errorf("create second tab: %w", err) | |
| 113 | - | } | |
| 114 | - | liveWithTabs, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "up", 2) | |
| 115 | - | if err != nil { | |
| 116 | - | return fmt.Errorf("multi-tab catalog: %w", err) | |
| 117 | - | } | |
| 118 | - | beforeTabs := catalogue.CatalogTabs(liveWithTabs) | |
| 119 | - | if err := sendCommand(ctx, stdio, "close-tab"); err != nil { | |
| 120 | - | return fmt.Errorf("remove tab: %w", err) | |
| 121 | - | } | |
| 122 | - | after, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "up", 1) | |
| 123 | - | if err != nil { | |
| 124 | - | return fmt.Errorf("removed-tab catalog: %w", err) | |
| 125 | - | } | |
| 126 | - | removedID, ok := removedCatalogTabID(beforeTabs, catalogue.CatalogTabs(after)) | |
| 127 | - | if !ok { | |
| 128 | - | return errors.New("removed-tab catalog did not identify exactly one removed tab") | |
| 129 | - | } | |
| 130 | - | removedSession := liveWithTabs | |
| 131 | - | removedSession.ActiveTabID = removedID | |
| 132 | - | removedTarget, err := targetForSession(target, removedSession, false) | |
| 133 | - | if err != nil { | |
| 134 | - | return fmt.Errorf("removed-tab target: %w", err) | |
| 135 | - | } | |
| 136 | - | if _, err := preview.Preview(ctx, removedTarget, 40, 8); err == nil { | |
| 137 | - | return errors.New("removed tab unexpectedly produced a preview") | |
| 138 | - | } | |
| 139 | - | survivingTarget, err := targetForSession(target, after, false) | |
| 140 | - | if err != nil { | |
| 141 | - | return fmt.Errorf("surviving-tab target: %w", err) | |
| 142 | - | } | |
| 143 | - | if _, err := preview.Preview(ctx, survivingTarget, 40, 8); err != nil { | |
| 144 | - | return fmt.Errorf("surviving tab preview: %w", err) | |
| 145 | - | } | |
| 146 | - | if err := detach(ctx, stdio); err != nil { | |
| 147 | - | return fmt.Errorf("stop session: %w", err) | |
| 148 | - | } | |
| 149 | - | return nil | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | // runLocalPickerUnitedPhase drives the actual local picker against the SSH | |
| 153 | - | // daemon container. It validates the seam that individual catalog, preview, | |
| 154 | - | // and attach tests cannot: unified rows before and after selection, remote | |
| 155 | - | // content publication on the same local client transport, local chrome | |
| 156 | - | // composition, and continued input forwarding. | |
| 157 | - | func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) { | |
| 158 | - | const ( | |
| 159 | - | localSession = "local-picker" | |
| 160 | - | remoteSession = "picker" | |
| 161 | - | previewMarker = "VEV_REMOTE_PICKER_ACTIVE" | |
| 162 | - | inputMarker = "VEV_REMOTE_PICKER_INPUT_OK" | |
| 163 | - | ) | |
| 164 | - | ||
| 165 | - | remote, err := openTransport(ctx, remoteadapter.TransportStdio, target) | |
| 166 | - | if err != nil { | |
| 167 | - | return fmt.Errorf("open remote picker source: %w", err) | |
| 168 | - | } | |
| 169 | - | remote.probe.configure("remote-picker-source") | |
| 170 | - | ownsRemoteSession := false | |
| 171 | - | defer func() { | |
| 172 | - | _ = remote.Close() | |
| 173 | - | if !ownsRemoteSession { | |
| 174 | - | return | |
| 175 | - | } | |
| 176 | - | cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 177 | - | defer cancel() | |
| 178 | - | if cleanupErr := runRemoteCommand(cleanup, target, "vev", "kill", remoteSession); cleanupErr != nil { | |
| 179 | - | err = errors.Join(err, fmt.Errorf("destroy remote picker session: %w", cleanupErr)) | |
| 180 | - | } | |
| 181 | - | }() | |
| 182 | - | if err := attachAndCheck(ctx, remote, protocol.Hello{ | |
| 183 | - | Intent: protocol.IntentNew, Name: remoteSession, Env: localEnvironment(), Cwd: "/tmp/remote-cwd", | |
| 184 | - | TermEnv: "xterm-256color", TrueColor: true, | |
| 185 | - | }); err != nil { | |
| 186 | - | return fmt.Errorf("attach remote picker source: %w", err) | |
| 187 | - | } | |
| 188 | - | ownsRemoteSession = true | |
| 189 | - | if err := sendCommand(ctx, remote, "new-tab"); err != nil { | |
| 190 | - | return fmt.Errorf("create remote picker tab: %w", err) | |
| 191 | - | } | |
| 192 | - | if err := sendCommand(ctx, remote, "next-tab"); err != nil { | |
| 193 | - | return fmt.Errorf("focus remote picker tab: %w", err) | |
| 194 | - | } | |
| 195 | - | if err := sendInputAndAwait(ctx, remote, "printf '"+previewMarker+"\\n'", previewMarker); err != nil { | |
| 196 | - | return fmt.Errorf("seed remote picker preview: %w", err) | |
| 197 | - | } | |
| 198 | - | remoteCatalog, err := waitForSessionWithTabCount(ctx, catalog, target, remoteSession, "up", 2) | |
| 199 | - | if err != nil { | |
| 200 | - | return fmt.Errorf("remote picker catalog: %w", err) | |
| 201 | - | } | |
| 202 | - | if remoteCatalog.ActiveTabID == "" { | |
| 203 | - | return errors.New("remote picker catalog omitted active tab") | |
| 204 | - | } | |
| 205 | - | tabs := catalogue.CatalogTabs(remoteCatalog) | |
| 206 | - | if len(tabs) != 2 { | |
| 207 | - | return errors.New("remote picker catalog did not retain both tabs") | |
| 208 | - | } | |
| 209 | - | selectedTarget, err := targetForSession(target, remoteCatalog, false) | |
| 210 | - | if err != nil { | |
| 211 | - | return fmt.Errorf("active remote picker target: %w", err) | |
| 212 | - | } | |
| 213 | - | if selectedTarget.LiveTabID != domain.TabStableID(remoteCatalog.ActiveTabID) { | |
| 214 | - | return errors.New("remote picker catalog active tab did not resolve to the selected target") | |
| 215 | - | } | |
| 216 | - | activeTabIndex := -1 | |
| 217 | - | for i, tab := range tabs { | |
| 218 | - | if domain.TabStableID(tab.ID) == selectedTarget.LiveTabID { | |
| 219 | - | activeTabIndex = i | |
| 220 | - | break | |
| 221 | - | } | |
| 222 | - | } | |
| 223 | - | if activeTabIndex < 0 { | |
| 224 | - | return errors.New("remote picker catalog active tab was absent from tab list") | |
| 225 | - | } | |
| 226 | - | selectedPreview, err := preview.Preview(ctx, selectedTarget, 55, 22) | |
| 227 | - | if err != nil { | |
| 228 | - | return fmt.Errorf("explicit remote tab preview: %w", err) | |
| 229 | - | } | |
| 230 | - | if !remotePreviewContains(selectedPreview, previewMarker) { | |
| 231 | - | return errors.New("explicit remote tab preview omitted marker") | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | if err := runLocalCommand(ctx, "vev", "host", "add", target); err != nil { | |
| 235 | - | return fmt.Errorf("register remote picker host: %w", err) | |
| 236 | - | } | |
| 237 | - | localDaemon, err := startLocalDaemon(ctx) | |
| 238 | - | if err != nil { | |
| 239 | - | return fmt.Errorf("start local picker daemon: %w", err) | |
| 240 | - | } | |
| 241 | - | defer stopLocalDaemon(localDaemon) | |
| 242 | - | ||
| 243 | - | local, err := openLocalTransport(ctx) | |
| 244 | - | if err != nil { | |
| 245 | - | return fmt.Errorf("open local picker transport: %w", err) | |
| 246 | - | } | |
| 247 | - | // This is the one client-equivalent connection for the proof. Remote | |
| 248 | - | // selection must replace its daemon-side owner without sending an endpoint | |
| 249 | - | // or MsgAttachTarget to this transport. | |
| 250 | - | local.probe.configure("local-picker") | |
| 251 | - | defer func() { _ = local.Close() }() | |
| 252 | - | if err := attachAndCheck(ctx, local, protocol.Hello{ | |
| 253 | - | Intent: protocol.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd", | |
| 254 | - | TermEnv: "xterm-256color", TrueColor: true, | |
| 255 | - | }); err != nil { | |
| 256 | - | return fmt.Errorf("attach local picker session: %w", err) | |
| 257 | - | } | |
| 258 | - | if err := sendRawInput(local, "\x1b "); err != nil { | |
| 259 | - | return fmt.Errorf("open command palette: %w", err) | |
| 260 | - | } | |
| 261 | - | if err := awaitOutputContains(ctx, local, "Commands"); err != nil { | |
| 262 | - | return fmt.Errorf("await command palette: %w", err) | |
| 263 | - | } | |
| 264 | - | if err := sendRawInput(local, "SSP\r"); err != nil { | |
| 265 | - | return fmt.Errorf("open session picker: %w", err) | |
| 266 | - | } | |
| 267 | - | if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil { | |
| 268 | - | return fmt.Errorf("await remote picker catalog row: %w", err) | |
| 269 | - | } | |
| 270 | - | beforeRows := capturePickerRows(local.probe) | |
| 271 | - | if err := assertUnifiedPickerRows(beforeRows, localSession, remoteSession); err != nil { | |
| 272 | - | return fmt.Errorf("capture unified picker rows before remote selection: %w", err) | |
| 273 | - | } | |
| 274 | - | // The local selected tab is first and the remote session header is not | |
| 275 | - | // selectable, so one down reaches the first remote tab. Derive the rest | |
| 276 | - | // from the catalog's stable active tab identity instead of assuming an | |
| 277 | - | // ordinal target. | |
| 278 | - | if err := sendRawInput(local, strings.Repeat("j", activeTabIndex+1)); err != nil { | |
| 279 | - | return fmt.Errorf("select catalog active remote tab: %w", err) | |
| 280 | - | } | |
| 281 | - | if err := awaitRemotePickerPreview(ctx, local, previewMarker); err != nil { | |
| 282 | - | return fmt.Errorf("remote picker preview: %w", err) | |
| 283 | - | } | |
| 284 | - | if err := sendRawInput(local, "\r"); err != nil { | |
| 285 | - | return fmt.Errorf("commit remote picker target: %w", err) | |
| 286 | - | } | |
| 287 | - | if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil { | |
| 288 | - | return fmt.Errorf("publish remote content on local transport: %w", err) | |
| 289 | - | } | |
| 290 | - | if !local.probe.contains(previewMarker) { | |
| 291 | - | return errors.New("published remote content omitted preview marker") | |
| 292 | - | } | |
| 293 | - | if err := assertNoDirectHandoff(local); err != nil { | |
| 294 | - | return err | |
| 295 | - | } | |
| 296 | - | if err := assertNoRemoteChrome(local.probe, remoteSession); err != nil { | |
| 297 | - | return fmt.Errorf("remote content chrome: %w", err) | |
| 298 | - | } | |
| 299 | - | // Split the marker so the terminal's echoed command line cannot satisfy the | |
| 300 | - | // awaited-output assertion before the shell executes it. | |
| 301 | - | if err := sendInputAndAwait(ctx, local, "printf '%s%s' 'VEV_REMOTE_PICKER' '_INPUT_OK'", inputMarker); err != nil { | |
| 302 | - | return fmt.Errorf("continued input on local transport: %w", err) | |
| 303 | - | } | |
| 304 | - | if err := assertNoDirectHandoff(local); err != nil { | |
| 305 | - | return err | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | // Reopen the picker through the same local connection while its owner is a | |
| 309 | - | // remote view. The navigation shell must remain local and retain both sides | |
| 310 | - | // of the unified session list. | |
| 311 | - | if err := sendRawInput(local, "\x1b "); err != nil { | |
| 312 | - | return fmt.Errorf("reopen command palette from remote content: %w", err) | |
| 313 | - | } | |
| 314 | - | if err := awaitOutputContains(ctx, local, "Commands"); err != nil { | |
| 315 | - | return fmt.Errorf("await reopened command palette: %w", err) | |
| 316 | - | } | |
| 317 | - | if err := sendRawInput(local, "SSP\r"); err != nil { | |
| 318 | - | return fmt.Errorf("reopen session picker from remote content: %w", err) | |
| 319 | - | } | |
| 320 | - | if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil { | |
| 321 | - | return fmt.Errorf("await reopened remote picker row: %w", err) | |
| 322 | - | } | |
| 323 | - | afterRows := capturePickerRows(local.probe) | |
| 324 | - | if err := assertUnifiedPickerRows(afterRows, localSession, remoteSession); err != nil { | |
| 325 | - | return fmt.Errorf("capture unified picker rows after remote selection: %w", err) | |
| 326 | - | } | |
| 327 | - | if err := assertNoDirectHandoff(local); err != nil { | |
| 328 | - | return err | |
| 329 | - | } | |
| 330 | - | if err := sendRawInput(local, "\x1b"); err != nil { | |
| 331 | - | return fmt.Errorf("close reopened session picker: %w", err) | |
| 332 | - | } | |
| 333 | - | return nil | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | func runRestartResumePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (domain.RemoteSessionTarget, error) { | |
| 337 | - | if err := runRemoteCommand(ctx, target, "vev", "kill", "--daemon"); err != nil { | |
| 338 | - | return domain.RemoteSessionTarget{}, fmt.Errorf("stop remote daemon: %w", err) | |
| 339 | - | } | |
| 340 | - | if err := runRemoteCommand(ctx, target, "vev", "--daemon-launcher"); err != nil { | |
| 341 | - | return domain.RemoteSessionTarget{}, fmt.Errorf("restart remote daemon: %w", err) | |
| 342 | - | } | |
| 343 | - | resumedCatalog, err := waitForSession(ctx, catalog, target, sessionName, "up") | |
| 344 | - | if err != nil { | |
| 345 | - | return domain.RemoteSessionTarget{}, fmt.Errorf("resumed catalog: %w", err) | |
| 346 | - | } | |
| 347 | - | resumedTarget, err := targetForSession(target, resumedCatalog, false) | |
| 348 | - | if err != nil { | |
| 349 | - | return domain.RemoteSessionTarget{}, fmt.Errorf("resumed target: %w", err) | |
| 350 | - | } | |
| 351 | - | resumed, err := openTransport(ctx, remoteadapter.TransportStdio, target) | |
| 352 | - | if err != nil { | |
| 353 | - | return domain.RemoteSessionTarget{}, fmt.Errorf("open restart stdio transport: %w", err) | |
| 354 | - | } | |
| 355 | - | defer func() { _ = resumed.Close() }() | |
| 356 | - | if err := attachAndCheck(ctx, resumed, protocol.Hello{ | |
| 357 | - | Intent: protocol.IntentResume, Name: sessionName, Env: localEnvironment(), Cwd: "/tmp/local-cwd", | |
| 358 | - | TermEnv: "xterm-256color", TrueColor: true, RemoteTarget: &resumedTarget, | |
| 359 | - | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 360 | - | }); err != nil { | |
| 361 | - | return domain.RemoteSessionTarget{}, fmt.Errorf("exact restart resume: %w", err) | |
| 362 | - | } | |
| 363 | - | if _, err := preview.Preview(ctx, resumedTarget, 40, 8); err != nil { | |
| 364 | - | return domain.RemoteSessionTarget{}, fmt.Errorf("restart preview: %w", err) | |
| 365 | - | } | |
| 366 | - | if err := touchRemoteFlag(ctx, target, "/tmp/vev-slow-preview"); err != nil { | |
| 367 | - | return domain.RemoteSessionTarget{}, fmt.Errorf("enable slow preview: %w", err) | |
| 368 | - | } | |
| 369 | - | slowCtx, slowCancel := context.WithTimeout(ctx, 150*time.Millisecond) | |
| 370 | - | _, slowErr := preview.Preview(slowCtx, resumedTarget, 40, 8) | |
| 371 | - | slowCancel() | |
| 372 | - | if err := touchRemoteFlag(ctx, target, "/tmp/vev-slow-preview-off"); err != nil { | |
| 373 | - | return domain.RemoteSessionTarget{}, fmt.Errorf("disable slow preview: %w", err) | |
| 374 | - | } | |
| 375 | - | if slowErr == nil { | |
| 376 | - | return domain.RemoteSessionTarget{}, errors.New("slow preview did not honor cancellation") | |
| 377 | - | } | |
| 378 | - | if err := detach(ctx, resumed); err != nil { | |
| 379 | - | return domain.RemoteSessionTarget{}, fmt.Errorf("stop resumed session: %w", err) | |
| 380 | - | } | |
| 381 | - | return resumedTarget, nil | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | func runUDPPhase(ctx context.Context, target string) error { | |
| 385 | - | udp, err := openTransport(ctx, remoteadapter.TransportUDP, target) | |
| 386 | - | if err != nil { | |
| 387 | - | return fmt.Errorf("open UDP transport: %w", err) | |
| 388 | - | } | |
| 389 | - | defer func() { _ = udp.Close() }() | |
| 390 | - | if err := attachAndCheck(ctx, udp, protocol.Hello{ | |
| 391 | - | Intent: protocol.IntentNew, Name: "udp-harness", Env: localEnvironment(), Cwd: "/tmp/local-cwd", | |
| 392 | - | TermEnv: "xterm-256color", TrueColor: true, | |
| 393 | - | }); err != nil { | |
| 394 | - | return fmt.Errorf("UDP attach: %w", err) | |
| 395 | - | } | |
| 396 | - | if err := detach(ctx, udp); err != nil { | |
| 397 | - | return fmt.Errorf("UDP detach: %w", err) | |
| 398 | - | } | |
| 399 | - | return nil | |
| 400 | - | } | |
| 401 | - | ||
| 402 | - | func runCatalogFailurePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient) error { | |
| 403 | - | if err := touchRemoteFlag(ctx, target, "/tmp/vev-bad-catalog"); err != nil { | |
| 404 | - | return fmt.Errorf("enable mismatched catalog: %w", err) | |
| 405 | - | } | |
| 406 | - | if _, err := catalog.List(ctx, target); err == nil { | |
| 407 | - | return errors.New("version-mismatched catalog unexpectedly decoded") | |
| 408 | - | } | |
| 409 | - | if err := touchRemoteFlag(ctx, target, "/tmp/vev-bad-catalog-off"); err != nil { | |
| 410 | - | return fmt.Errorf("disable mismatched catalog: %w", err) | |
| 411 | - | } | |
| 412 | - | return nil | |
| 413 | - | } | |
| 414 | - | ||
| 415 | - | func runStaleTargetPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, staleTarget domain.RemoteSessionTarget) error { | |
| 416 | - | if err := runRemoteCommand(ctx, target, "vev", "kill", sessionName); err != nil { | |
| 417 | - | return fmt.Errorf("destroy session for stale-target test: %w", err) | |
| 418 | - | } | |
| 419 | - | if err := waitForMissingSession(ctx, catalog, target, sessionName); err != nil { | |
| 420 | - | return fmt.Errorf("wait for destroyed session: %w", err) | |
| 421 | - | } | |
| 422 | - | stale, err := openTransport(ctx, remoteadapter.TransportStdio, target) | |
| 423 | - | if err != nil { | |
| 424 | - | return fmt.Errorf("open stale-target transport: %w", err) | |
| 425 | - | } | |
| 426 | - | defer func() { _ = stale.Close() }() | |
| 427 | - | if err := expectNoSuchTarget(ctx, stale, staleTarget); err != nil { | |
| 428 | - | return fmt.Errorf("stale target fence: %w", err) | |
| 429 | - | } | |
| 430 | - | return nil | |
| 431 | - | } | |
| 432 | - | ||
| 433 | - | func removedCatalogTabID(before, after []catalogue.RemoteCatalogTab) (string, bool) { | |
| 434 | - | remaining := make(map[string]struct{}, len(after)) | |
| 435 | - | for _, tab := range after { | |
| 436 | - | remaining[tab.ID] = struct{}{} | |
| 437 | - | } | |
| 438 | - | var removed string | |
| 439 | - | for _, tab := range before { | |
| 440 | - | if _, exists := remaining[tab.ID]; exists { | |
| 441 | - | continue | |
| 442 | - | } | |
| 443 | - | if removed != "" { | |
| 444 | - | return "", false | |
| 445 | - | } | |
| 446 | - | removed = tab.ID | |
| 447 | - | } | |
| 448 | - | return removed, removed != "" | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | func localEnvironment() []string { | |
| 452 | - | return []string{ | |
| 453 | - | "VEV_TEST_ENV=local", | |
| 454 | - | "XDG_RUNTIME_DIR=/run/local", | |
| 455 | - | "WAYLAND_DISPLAY=wayland-local", | |
| 456 | - | "TERM=xterm-256color", | |
| 457 | - | "COLORTERM=truecolor", | |
| 458 | - | "TERM_PROGRAM=local-harness", | |
| 459 | - | } | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | type harnessFrame struct { | |
| 463 | - | frame wire.Frame | |
| 464 | - | err error | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | type harnessTransport struct { | |
| 468 | - | wire.Transport | |
| 469 | - | probe *visualProbe | |
| 470 | - | frames chan harnessFrame | |
| 471 | - | queued []harnessFrame | |
| 472 | - | stop chan struct{} | |
| 473 | - | closeOnce sync.Once | |
| 474 | - | closeErr error | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | func newHarnessTransport(transport wire.Transport) *harnessTransport { | |
| 478 | - | h := &harnessTransport{ | |
| 479 | - | Transport: transport, | |
| 480 | - | probe: newVisualProbe(domain.Size{Cols: defaultProbeCols, Rows: defaultProbeRows}), | |
| 481 | - | frames: make(chan harnessFrame, 16), | |
| 482 | - | stop: make(chan struct{}), | |
| 483 | - | } | |
| 484 | - | go h.receiveLoop() | |
| 485 | - | return h | |
| 486 | - | } | |
| 487 | - | ||
| 488 | - | func (h *harnessTransport) receiveLoop() { | |
| 489 | - | for { | |
| 490 | - | frame, err := h.Transport.Recv() | |
| 491 | - | select { | |
| 492 | - | case h.frames <- harnessFrame{frame: frame, err: err}: | |
| 493 | - | case <-h.stop: | |
| 494 | - | return | |
| 495 | - | } | |
| 496 | - | if err != nil { | |
| 497 | - | return | |
| 498 | - | } | |
| 499 | - | } | |
| 500 | - | } | |
| 501 | - | ||
| 502 | - | func (h *harnessTransport) Close() error { | |
| 503 | - | h.closeOnce.Do(func() { | |
| 504 | - | close(h.stop) | |
| 505 | - | h.closeErr = h.Transport.Close() | |
| 506 | - | }) | |
| 507 | - | return h.closeErr | |
| 508 | - | } | |
| 509 | - | ||
| 510 | - | func openTransport(ctx context.Context, mode remoteadapter.TransportMode, target string) (*harnessTransport, error) { | |
| 511 | - | var transport wire.Transport | |
| 512 | - | var err error | |
| 513 | - | switch mode { | |
| 514 | - | case remoteadapter.TransportStdio: | |
| 515 | - | transport, err = sshstdio.DialContext(ctx, target, "", slog.New(slog.DiscardHandler)) | |
| 516 | - | case remoteadapter.TransportUDP: | |
| 517 | - | dialer := dgram.NewRemoteDialerWithLogger(target, "", slog.New(slog.DiscardHandler)) | |
| 518 | - | dialer.BootstrapTimeout = 8 * time.Second | |
| 519 | - | dialer.ProbeTimeout = 3 * time.Second | |
| 520 | - | transport, err = dialer.Dial(ctx) | |
| 521 | - | default: | |
| 522 | - | return nil, fmt.Errorf("unsupported transport %q", mode) | |
| 523 | - | } | |
| 524 | - | if err != nil { | |
| 525 | - | return nil, err | |
| 526 | - | } | |
| 527 | - | h := newHarnessTransport(transport) | |
| 528 | - | h.probe.configure(string(mode)) | |
| 529 | - | if artifact := harnessArtifactFromContext(ctx); artifact != nil { | |
| 530 | - | artifact.registerProbe(h.probe) | |
| 531 | - | } | |
| 532 | - | return h, nil | |
| 533 | - | } | |
| 534 | - | ||
| 535 | - | const commandStderrLimit = 4 << 10 | |
| 536 | - | ||
| 537 | - | type boundedStderr struct { | |
| 538 | - | bytes.Buffer | |
| 539 | - | truncated bool | |
| 540 | - | } | |
| 541 | - | ||
| 542 | - | func (b *boundedStderr) Write(p []byte) (int, error) { | |
| 543 | - | written := len(p) | |
| 544 | - | remaining := commandStderrLimit - b.Len() | |
| 545 | - | if remaining <= 0 { | |
| 546 | - | b.truncated = b.truncated || written != 0 | |
| 547 | - | return written, nil | |
| 548 | - | } | |
| 549 | - | if len(p) > remaining { | |
| 550 | - | p = p[:remaining] | |
| 551 | - | b.truncated = true | |
| 552 | - | } | |
| 553 | - | _, _ = b.Buffer.Write(p) | |
| 554 | - | return written, nil | |
| 555 | - | } | |
| 556 | - | ||
| 557 | - | func (b *boundedStderr) String() string { | |
| 558 | - | stderr := b.Buffer.String() | |
| 559 | - | if b.truncated { | |
| 560 | - | stderr += "\n[stderr truncated]" | |
| 561 | - | } | |
| 562 | - | return stderr | |
| 563 | - | } | |
| 564 | - | ||
| 565 | - | func commandErrorWithStderr(err error, stderr string) error { | |
| 566 | - | if stderr = strings.TrimSpace(stderr); stderr == "" { | |
| 567 | - | return err | |
| 568 | - | } | |
| 569 | - | return fmt.Errorf("%w: stderr: %s", err, stderr) | |
| 570 | - | } | |
| 571 | - | ||
| 572 | - | func startLocalDaemon(ctx context.Context) (*exec.Cmd, error) { | |
| 573 | - | cmd := exec.CommandContext(ctx, "vev", "--daemon") | |
| 574 | - | stderr := &boundedStderr{} | |
| 575 | - | cmd.Stdout = io.Discard | |
| 576 | - | cmd.Stderr = stderr | |
| 577 | - | if err := cmd.Start(); err != nil { | |
| 578 | - | return nil, err | |
| 579 | - | } | |
| 580 | - | ||
| 581 | - | deadline, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 582 | - | defer cancel() | |
| 583 | - | for { | |
| 584 | - | transport, err := ipc.DialContext(deadline, ipc.SocketDir()) | |
| 585 | - | if err == nil { | |
| 586 | - | _ = transport.Close() | |
| 587 | - | return cmd, nil | |
| 588 | - | } | |
| 589 | - | select { | |
| 590 | - | case <-deadline.Done(): | |
| 591 | - | stopLocalProcess(cmd) | |
| 592 | - | return nil, commandErrorWithStderr(deadline.Err(), stderr.String()) | |
| 593 | - | case <-time.After(25 * time.Millisecond): | |
| 594 | - | } | |
| 595 | - | } | |
| 596 | - | } | |
| 597 | - | ||
| 598 | - | func stopLocalDaemon(cmd *exec.Cmd) { | |
| 599 | - | cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 600 | - | defer cancel() | |
| 601 | - | _ = runLocalCommand(cleanup, "vev", "kill", "--daemon") | |
| 602 | - | stopLocalProcess(cmd) | |
| 603 | - | } | |
| 604 | - | ||
| 605 | - | func stopLocalProcess(cmd *exec.Cmd) { | |
| 606 | - | if cmd == nil || cmd.Process == nil { | |
| 607 | - | return | |
| 608 | - | } | |
| 609 | - | _ = cmd.Process.Kill() | |
| 610 | - | _ = cmd.Wait() | |
| 611 | - | } | |
| 612 | - | ||
| 613 | - | func runLocalCommand(ctx context.Context, name string, args ...string) error { | |
| 614 | - | return runCapturedCommand(ctx, name, args...) | |
| 615 | - | } | |
| 616 | - | ||
| 617 | - | func runCapturedCommand(ctx context.Context, name string, args ...string) error { | |
| 618 | - | cmd := exec.CommandContext(ctx, name, args...) | |
| 619 | - | stderr := &boundedStderr{} | |
| 620 | - | cmd.Stdout = io.Discard | |
| 621 | - | cmd.Stderr = stderr | |
| 622 | - | if err := cmd.Run(); err != nil { | |
| 623 | - | return commandErrorWithStderr(err, stderr.String()) | |
| 624 | - | } | |
| 625 | - | return nil | |
| 626 | - | } | |
| 627 | - | ||
| 628 | - | func openLocalTransport(ctx context.Context) (*harnessTransport, error) { | |
| 629 | - | transport, err := ipc.DialContext(ctx, ipc.SocketDir()) | |
| 630 | - | if err != nil { | |
| 631 | - | return nil, err | |
| 632 | - | } | |
| 633 | - | h := newHarnessTransport(transport) | |
| 634 | - | h.probe.configure("local") | |
| 635 | - | if artifact := harnessArtifactFromContext(ctx); artifact != nil { | |
| 636 | - | artifact.registerProbe(h.probe) | |
| 637 | - | } | |
| 638 | - | return h, nil | |
| 639 | - | } | |
| 640 | - | ||
| 641 | - | func attachAndCheck(ctx context.Context, tr *harnessTransport, hello protocol.Hello) error { | |
| 642 | - | hello.Version = protocol.Version | |
| 643 | - | hello.Size = domain.Size{Cols: 80, Rows: 24} | |
| 644 | - | if hello.EnvironmentPolicy == 0 { | |
| 645 | - | hello.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned | |
| 646 | - | } | |
| 647 | - | payload := wire.MarshalHello(hello) | |
| 648 | - | if err := tr.Send(wire.Frame{Type: wire.MsgHello, Payload: payload}); err != nil { | |
| 649 | - | return err | |
| 650 | - | } | |
| 651 | - | frame, err := receiveWithTimeout(ctx, tr) | |
| 652 | - | if err != nil { | |
| 653 | - | return err | |
| 654 | - | } | |
| 655 | - | if frame.Type == wire.MsgError { | |
| 656 | - | message, decodeErr := wire.UnmarshalErrorMsg(frame.Payload) | |
| 657 | - | if decodeErr != nil { | |
| 658 | - | return decodeErr | |
| 659 | - | } | |
| 660 | - | return fmt.Errorf("remote error code %d", message.Code) | |
| 661 | - | } | |
| 662 | - | if frame.Type != wire.MsgWelcome { | |
| 663 | - | return fmt.Errorf("welcome frame type %d", frame.Type) | |
| 664 | - | } | |
| 665 | - | tr.probe.recordControl(probeEventWelcome) | |
| 666 | - | if err := tr.Send(wire.Frame{Type: wire.MsgTheme, Payload: wire.MarshalTheme(protocol.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil { | |
| 667 | - | return fmt.Errorf("send initial theme: %w", err) | |
| 668 | - | } | |
| 669 | - | return nil | |
| 670 | - | } | |
| 671 | - | ||
| 672 | - | func sendRawInput(tr *harnessTransport, data string) error { | |
| 673 | - | return tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(data)})}) | |
| 674 | - | } | |
| 675 | - | ||
| 676 | - | func processOutputFrame(tr *harnessTransport, frame wire.Frame) error { | |
| 677 | - | output, err := wire.UnmarshalOutput(frame.Payload) | |
| 678 | - | if err != nil { | |
| 679 | - | return err | |
| 680 | - | } | |
| 681 | - | result := tr.probe.apply(output) | |
| 682 | - | if !result.Accepted { | |
| 683 | - | if !tr.probe.resetPending { | |
| 684 | - | if err := tr.Send(wire.Frame{Type: wire.MsgOutputResetRequest, Payload: wire.MarshalOutputResetRequest(protocol.OutputResetRequest{})}); err != nil { | |
| 685 | - | return err | |
| 686 | - | } | |
| 687 | - | tr.probe.resetPending = true | |
| 688 | - | tr.probe.recordControl(probeEventOutputReset) | |
| 689 | - | } | |
| 690 | - | return nil | |
| 691 | - | } | |
| 692 | - | if !result.StateBearing { | |
| 693 | - | return nil | |
| 694 | - | } | |
| 695 | - | payload, err := wire.MarshalAck(result.Ack) | |
| 696 | - | if err != nil { | |
| 697 | - | return err | |
| 698 | - | } | |
| 699 | - | if err := tr.Send(wire.Frame{Type: wire.MsgAck, Payload: payload}); err != nil { | |
| 700 | - | return err | |
| 701 | - | } | |
| 702 | - | tr.probe.resetPending = false | |
| 703 | - | tr.probe.markAcked(result) | |
| 704 | - | return nil | |
| 705 | - | } | |
| 706 | - | ||
| 707 | - | func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error { | |
| 708 | - | deadline, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 709 | - | defer cancel() | |
| 710 | - | visible := tr.probe.contains(want) | |
| 711 | - | if visible { | |
| 712 | - | return nil | |
| 713 | - | } | |
| 714 | - | for { | |
| 715 | - | frame, err := receiveWithTimeout(deadline, tr) | |
| 716 | - | if err != nil { | |
| 717 | - | return err | |
| 718 | - | } | |
| 719 | - | if frame.Type != wire.MsgOutput { | |
| 720 | - | continue | |
| 721 | - | } | |
| 722 | - | if err := processOutputFrame(tr, frame); err != nil { | |
| 723 | - | return err | |
| 724 | - | } | |
| 725 | - | nowVisible := tr.probe.contains(want) | |
| 726 | - | if !visible && nowVisible { | |
| 727 | - | return nil | |
| 728 | - | } | |
| 729 | - | visible = nowVisible | |
| 730 | - | } | |
| 731 | - | } | |
| 732 | - | ||
| 733 | - | func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error { | |
| 734 | - | deadline, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 735 | - | defer cancel() | |
| 736 | - | sawLoading := false | |
| 737 | - | markerVisible := tr.probe.contains(marker) | |
| 738 | - | for { | |
| 739 | - | frame, err := receiveWithTimeout(deadline, tr) | |
| 740 | - | if err != nil { | |
| 741 | - | return fmt.Errorf("no rendered remote preview after %d output frames (loading=%t): %w", tr.probe.outputFrames, sawLoading, err) | |
| 742 | - | } | |
| 743 | - | if frame.Type != wire.MsgOutput { | |
| 744 | - | continue | |
| 745 | - | } | |
| 746 | - | if err := processOutputFrame(tr, frame); err != nil { | |
| 747 | - | return err | |
| 748 | - | } | |
| 749 | - | text := tr.probe.text() | |
| 750 | - | sawLoading = sawLoading || strings.Contains(text, "loading remote preview") | |
| 751 | - | if strings.Contains(text, "remote preview unavailable") { | |
| 752 | - | return errors.New("remote preview unavailable") | |
| 753 | - | } | |
| 754 | - | nowMarkerVisible := strings.Contains(text, marker) | |
| 755 | - | if !markerVisible && nowMarkerVisible { | |
| 756 | - | return nil | |
| 757 | - | } | |
| 758 | - | markerVisible = nowMarkerVisible | |
| 759 | - | } | |
| 760 | - | } | |
| 761 | - | ||
| 762 | - | func remotePreviewContains(preview protocol.RemotePreview, want string) bool { | |
| 763 | - | var text strings.Builder | |
| 764 | - | for _, cell := range preview.Cells { | |
| 765 | - | if !cell.Continuation { | |
| 766 | - | text.WriteRune(cell.Rune) | |
| 767 | - | } | |
| 768 | - | } | |
| 769 | - | return strings.Contains(text.String(), want) | |
| 770 | - | } | |
| 771 | - | ||
| 772 | - | func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error { | |
| 773 | - | if err := tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(command + "\n")})}); err != nil { | |
| 774 | - | return err | |
| 775 | - | } | |
| 776 | - | deadline, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 777 | - | defer cancel() | |
| 778 | - | visible := tr.probe.contains(want) | |
| 779 | - | for { | |
| 780 | - | frame, err := receiveWithTimeout(deadline, tr) | |
| 781 | - | if err != nil { | |
| 782 | - | return fmt.Errorf("waiting for marker after %d output frames/%d bytes: %w", tr.probe.outputFrames, tr.probe.outputBytes, err) | |
| 783 | - | } | |
| 784 | - | if frame.Type != wire.MsgOutput { | |
| 785 | - | continue | |
| 786 | - | } | |
| 787 | - | if err := processOutputFrame(tr, frame); err != nil { | |
| 788 | - | return fmt.Errorf("process output: %w", err) | |
| 789 | - | } | |
| 790 | - | nowVisible := tr.probe.contains(want) | |
| 791 | - | if !visible && nowVisible { | |
| 792 | - | return nil | |
| 793 | - | } | |
| 794 | - | visible = nowVisible | |
| 795 | - | } | |
| 796 | - | } | |
| 797 | - | ||
| 798 | - | func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error { | |
| 799 | - | id := requestID.Add(1) | |
| 800 | - | request := protocol.CommandRequest{ | |
| 801 | - | Version: protocol.Version, | |
| 802 | - | RequestID: id, | |
| 803 | - | Attached: true, | |
| 804 | - | Slug: slug, | |
| 805 | - | } | |
| 806 | - | payload, err := wire.MarshalCommandRequest(request) | |
| 807 | - | if err != nil { | |
| 808 | - | return err | |
| 809 | - | } | |
| 810 | - | if err := tr.Send(wire.Frame{Type: wire.MsgCommand, Payload: payload}); err != nil { | |
| 811 | - | return err | |
| 812 | - | } | |
| 813 | - | deadline, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 814 | - | defer cancel() | |
| 815 | - | for { | |
| 816 | - | frame, err := receiveWithTimeout(deadline, tr) | |
| 817 | - | if err != nil { | |
| 818 | - | return err | |
| 819 | - | } | |
| 820 | - | if frame.Type == wire.MsgOutput { | |
| 821 | - | if err := processOutputFrame(tr, frame); err != nil { | |
| 822 | - | return err | |
| 823 | - | } | |
| 824 | - | continue | |
| 825 | - | } | |
| 826 | - | if frame.Type != wire.MsgCommandResult { | |
| 827 | - | continue | |
| 828 | - | } | |
| 829 | - | result, err := wire.UnmarshalCommandResult(frame.Payload) | |
| 830 | - | if err != nil { | |
| 831 | - | return err | |
| 832 | - | } | |
| 833 | - | if !result.OK { | |
| 834 | - | return fmt.Errorf("remote command failed (code %d): %s", result.Code, result.Text) | |
| 835 | - | } | |
| 836 | - | return nil | |
| 837 | - | } | |
| 838 | - | } | |
| 839 | - | ||
| 840 | - | func detach(ctx context.Context, tr *harnessTransport) error { | |
| 841 | - | if err := tr.Send(wire.Frame{Type: wire.MsgDetach, Payload: wire.MarshalDetach(protocol.Detach{})}); err != nil { | |
| 842 | - | return err | |
| 843 | - | } | |
| 844 | - | deadline, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 845 | - | defer cancel() | |
| 846 | - | for { | |
| 847 | - | frame, err := receiveWithTimeout(deadline, tr) | |
| 848 | - | if err != nil { | |
| 849 | - | return err | |
| 850 | - | } | |
| 851 | - | if frame.Type == wire.MsgDetached { | |
| 852 | - | return nil | |
| 853 | - | } | |
| 854 | - | } | |
| 855 | - | } | |
| 856 | - | ||
| 857 | - | func expectNoSuchTarget(ctx context.Context, tr *harnessTransport, target domain.RemoteSessionTarget) error { | |
| 858 | - | target.Endpoint = targetEndpoint(target.Endpoint) | |
| 859 | - | target.DisplayOrigin = "remote" | |
| 860 | - | target.SessionName = sessionName | |
| 861 | - | if err := target.Validate(); err != nil { | |
| 862 | - | return err | |
| 863 | - | } | |
| 864 | - | hello := protocol.Hello{ | |
| 865 | - | Version: protocol.Version, | |
| 866 | - | Intent: protocol.IntentResume, | |
| 867 | - | Name: sessionName, | |
| 868 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 869 | - | TermEnv: "xterm-256color", | |
| 870 | - | TrueColor: true, | |
| 871 | - | Env: localEnvironment(), | |
| 872 | - | Cwd: "/tmp/local-cwd", | |
| 873 | - | RemoteTarget: &target, | |
| 874 | - | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 875 | - | } | |
| 876 | - | payload := wire.MarshalHello(hello) | |
| 877 | - | if err := tr.Send(wire.Frame{Type: wire.MsgHello, Payload: payload}); err != nil { | |
| 878 | - | return err | |
| 879 | - | } | |
| 880 | - | frame, err := receiveWithTimeout(ctx, tr) | |
| 881 | - | if err != nil { | |
| 882 | - | return err | |
| 883 | - | } | |
| 884 | - | if frame.Type != wire.MsgError { | |
| 885 | - | return fmt.Errorf("stale target returned frame type %d", frame.Type) | |
| 886 | - | } | |
| 887 | - | message, err := wire.UnmarshalErrorMsg(frame.Payload) | |
| 888 | - | if err != nil { | |
| 889 | - | return err | |
| 890 | - | } | |
| 891 | - | if message.Code != protocol.ErrNoSuchTarget { | |
| 892 | - | return fmt.Errorf("stale target returned error code %d", message.Code) | |
| 893 | - | } | |
| 894 | - | return nil | |
| 895 | - | } | |
| 896 | - | ||
| 897 | - | func targetForSession(endpoint string, session catalogue.RemoteCatalogSession, stopped bool) (domain.RemoteSessionTarget, error) { | |
| 898 | - | tabs := catalogue.CatalogTabs(session) | |
| 899 | - | if len(tabs) == 0 { | |
| 900 | - | return domain.RemoteSessionTarget{}, errors.New("catalog session has no tabs") | |
| 901 | - | } | |
| 902 | - | tab := tabs[0] | |
| 903 | - | if !stopped && session.ActiveTabID != "" { | |
| 904 | - | for _, candidate := range tabs { | |
| 905 | - | if candidate.ID == session.ActiveTabID { | |
| 906 | - | tab = candidate | |
| 907 | - | break | |
| 908 | - | } | |
| 909 | - | } | |
| 910 | - | } | |
| 911 | - | target := domain.RemoteSessionTarget{ | |
| 912 | - | Endpoint: endpoint, | |
| 913 | - | DisplayOrigin: "remote", | |
| 914 | - | LifecycleID: session.LifecycleID, | |
| 915 | - | SessionName: session.Name, | |
| 916 | - | Stopped: stopped, | |
| 917 | - | } | |
| 918 | - | if stopped { | |
| 919 | - | target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(tab.ID)) | |
| 920 | - | } else { | |
| 921 | - | target.LiveTabID = domain.TabStableID(tab.ID) | |
| 922 | - | } | |
| 923 | - | if err := target.Validate(); err != nil { | |
| 924 | - | return domain.RemoteSessionTarget{}, err | |
| 925 | - | } | |
| 926 | - | return target, nil | |
| 927 | - | } | |
| 928 | - | ||
| 929 | - | func targetEndpoint(endpoint string) string { | |
| 930 | - | if endpoint == "" { | |
| 931 | - | return defaultTarget | |
| 932 | - | } | |
| 933 | - | return endpoint | |
| 934 | - | } | |
| 935 | - | ||
| 936 | - | func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state catalogue.RemoteCatalogSessionState) (catalogue.RemoteCatalogSession, error) { | |
| 937 | - | return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0) | |
| 938 | - | } | |
| 939 | - | ||
| 940 | - | func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state catalogue.RemoteCatalogSessionState, tabs int) (catalogue.RemoteCatalogSession, error) { | |
| 941 | - | deadline, cancel := context.WithTimeout(ctx, 10*time.Second) | |
| 942 | - | defer cancel() | |
| 943 | - | for { | |
| 944 | - | result, err := catalog.List(deadline, endpoint) | |
| 945 | - | if err == nil { | |
| 946 | - | for _, session := range result.Sessions { | |
| 947 | - | if session.Name == name && session.State == state && (tabs == 0 || len(catalogue.CatalogTabs(session)) == tabs) { | |
| 948 | - | return session, nil | |
| 949 | - | } | |
| 950 | - | } | |
| 951 | - | } | |
| 952 | - | select { | |
| 953 | - | case <-deadline.Done(): | |
| 954 | - | if err != nil { | |
| 955 | - | return catalogue.RemoteCatalogSession{}, err | |
| 956 | - | } | |
| 957 | - | return catalogue.RemoteCatalogSession{}, fmt.Errorf("session %q did not reach state %q", name, state) | |
| 958 | - | case <-time.After(100 * time.Millisecond): | |
| 959 | - | } | |
| 960 | - | } | |
| 961 | - | } | |
| 962 | - | ||
| 963 | - | func waitForMissingSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string) error { | |
| 964 | - | deadline, cancel := context.WithTimeout(ctx, 10*time.Second) | |
| 965 | - | defer cancel() | |
| 966 | - | for { | |
| 967 | - | result, err := catalog.List(deadline, endpoint) | |
| 968 | - | if err == nil { | |
| 969 | - | found := false | |
| 970 | - | for _, session := range result.Sessions { | |
| 971 | - | found = found || session.Name == name | |
| 972 | - | } | |
| 973 | - | if !found { | |
| 974 | - | return nil | |
| 975 | - | } | |
| 976 | - | } | |
| 977 | - | select { | |
| 978 | - | case <-deadline.Done(): | |
| 979 | - | return errors.New("destroyed session remained in catalog") | |
| 980 | - | case <-time.After(100 * time.Millisecond): | |
| 981 | - | } | |
| 982 | - | } | |
| 983 | - | } | |
| 984 | - | ||
| 985 | - | func touchRemoteFlag(ctx context.Context, target, path string) error { | |
| 986 | - | if strings.HasSuffix(path, "-off") { | |
| 987 | - | path = strings.TrimSuffix(path, "-off") | |
| 988 | - | return runRemoteCommand(ctx, target, "rm", "-f", path) | |
| 989 | - | } | |
| 990 | - | return runRemoteCommand(ctx, target, "touch", path) | |
| 991 | - | } | |
| 992 | - | ||
| 993 | - | func runRemoteCommand(ctx context.Context, target string, command ...string) error { | |
| 994 | - | spec := sshstdio.BuildCommandForRemoteCommand(target, command...) | |
| 995 | - | return runCapturedCommand(ctx, spec.Path, spec.Args...) | |
| 996 | - | } | |
| 997 | - | ||
| 998 | - | func assertNoDirectHandoff(tr *harnessTransport) error { | |
| 999 | - | if tr == nil || tr.probe == nil { | |
| 1000 | - | return errors.New("local picker transport has no visual probe") | |
| 1001 | - | } | |
| 1002 | - | // Inspect only the frames already queued at assertion start, retaining | |
| 1003 | - | // non-handoff frames for the next receiveWithTimeout call. | |
| 1004 | - | queued := len(tr.frames) | |
| 1005 | - | for range queued { | |
| 1006 | - | outcome := <-tr.frames | |
| 1007 | - | if outcome.frame.Type == wire.MsgAttachTarget { | |
| 1008 | - | tr.probe.recordIncoming(outcome.frame) | |
| 1009 | - | } else { | |
| 1010 | - | tr.queued = append(tr.queued, outcome) | |
| 1011 | - | } | |
| 1012 | - | } | |
| 1013 | - | if tr.probe.unexpectedHandoffs != 0 { | |
| 1014 | - | return fmt.Errorf("picker emitted %d direct MsgAttachTarget handoff frame(s)", tr.probe.unexpectedHandoffs) | |
| 1015 | - | } | |
| 1016 | - | return nil | |
| 1017 | - | } | |
| 1018 | - | ||
| 1019 | - | func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (wire.Frame, error) { | |
| 1020 | - | if len(tr.queued) != 0 { | |
| 1021 | - | outcome := tr.queued[0] | |
| 1022 | - | tr.queued = tr.queued[1:] | |
| 1023 | - | if outcome.err == nil { | |
| 1024 | - | tr.probe.recordIncoming(outcome.frame) | |
| 1025 | - | } | |
| 1026 | - | return outcome.frame, outcome.err | |
| 1027 | - | } | |
| 1028 | - | select { | |
| 1029 | - | case <-ctx.Done(): | |
| 1030 | - | return wire.Frame{}, ctx.Err() | |
| 1031 | - | case outcome := <-tr.frames: | |
| 1032 | - | if outcome.err == nil { | |
| 1033 | - | tr.probe.recordIncoming(outcome.frame) | |
| 1034 | - | } | |
| 1035 | - | return outcome.frame, outcome.err | |
| 1036 | - | } | |
| 1037 | - | } | |
D scripts/remote-picker-harness/main_test.go +0 -65
| @@ -1,65 +0,0 @@ | |||
| 1 | - | package main | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "strings" | |
| 6 | - | "testing" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | func TestBoundedStderrRetainsAtMostDiagnosticLimit(t *testing.T) { | |
| 12 | - | stderr := &boundedStderr{} | |
| 13 | - | input := strings.Repeat("x", commandStderrLimit+1) | |
| 14 | - | ||
| 15 | - | written, err := stderr.Write([]byte(input)) | |
| 16 | - | ||
| 17 | - | if err != nil { | |
| 18 | - | t.Fatal(err) | |
| 19 | - | } | |
| 20 | - | if written != len(input) { | |
| 21 | - | t.Fatalf("written = %d, want %d", written, len(input)) | |
| 22 | - | } | |
| 23 | - | if stderr.Len() != commandStderrLimit { | |
| 24 | - | t.Fatalf("stderr length = %d, want %d", stderr.Len(), commandStderrLimit) | |
| 25 | - | } | |
| 26 | - | if !strings.Contains(stderr.String(), "[stderr truncated]") { | |
| 27 | - | t.Fatalf("stderr = %q, want truncation marker", stderr.String()) | |
| 28 | - | } | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | func TestCommandErrorWithStderr(t *testing.T) { | |
| 32 | - | cause := errors.New("command failed") | |
| 33 | - | if got := commandErrorWithStderr(cause, " daemon could not start\n"); !errors.Is(got, cause) || !strings.Contains(got.Error(), "stderr: daemon could not start") { | |
| 34 | - | t.Fatalf("command error = %v", got) | |
| 35 | - | } | |
| 36 | - | if got := commandErrorWithStderr(cause, "\n"); got != cause { | |
| 37 | - | t.Fatalf("empty stderr error = %v, want original error", got) | |
| 38 | - | } | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | func TestRunCapturedCommandIncludesStderr(t *testing.T) { | |
| 42 | - | err := runCapturedCommand(t.Context(), "sh", "-c", "printf 'command diagnostics\\n' >&2; exit 1") | |
| 43 | - | if err == nil || !strings.Contains(err.Error(), "stderr: command diagnostics") { | |
| 44 | - | t.Fatalf("command error = %v", err) | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func TestAssertNoDirectHandoffProcessesInitialFramesAndRetainsOutput(t *testing.T) { | |
| 49 | - | tr := &harnessTransport{ | |
| 50 | - | probe: &visualProbe{}, | |
| 51 | - | frames: make(chan harnessFrame, 2), | |
| 52 | - | } | |
| 53 | - | tr.frames <- harnessFrame{frame: wire.Frame{Type: wire.MsgOutput}} | |
| 54 | - | tr.frames <- harnessFrame{frame: wire.Frame{Type: wire.MsgAttachTarget}} | |
| 55 | - | ||
| 56 | - | if err := assertNoDirectHandoff(tr); err == nil { | |
| 57 | - | t.Fatal("direct handoff was not rejected") | |
| 58 | - | } | |
| 59 | - | if len(tr.queued) != 1 || tr.queued[0].frame.Type != wire.MsgOutput { | |
| 60 | - | t.Fatalf("queued frames = %#v, want one retained output", tr.queued) | |
| 61 | - | } | |
| 62 | - | if tr.probe.unexpectedHandoffs != 1 { | |
| 63 | - | t.Fatalf("unexpected handoffs = %d, want 1", tr.probe.unexpectedHandoffs) | |
| 64 | - | } | |
| 65 | - | } | |
A scripts/remote-picker-harness/navigation_repro.py +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """UI-driver regression against two disposable demo-image containers. | |
| 3 | + | ||
| 4 | + | Pass client and remote container names. The client must have a pinned SSH | |
| 5 | + | host named remote reaching the remote container. No host session is touched. | |
| 6 | + | """ | |
| 7 | + | ||
| 8 | + | import json | |
| 9 | + | import subprocess | |
| 10 | + | import sys | |
| 11 | + | import uuid | |
| 12 | + | ||
| 13 | + | ||
| 14 | + | class Driver: | |
| 15 | + | def __init__(self, container, session): | |
| 16 | + | self.process = subprocess.Popen( | |
| 17 | + | ["docker", "exec", "-i", container, "vev", "--ui-driver", | |
| 18 | + | "--session", session, "--cols", "100", "--rows", "30"], | |
| 19 | + | stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, | |
| 20 | + | ) | |
| 21 | + | ready = json.loads(self.process.stdout.readline())["result"] | |
| 22 | + | self.attachment = ready["attachment"] | |
| 23 | + | self.generation = ready["generation"] | |
| 24 | + | self.request_id = 0 | |
| 25 | + | ||
| 26 | + | def call(self, operation, **fields): | |
| 27 | + | self.request_id += 1 | |
| 28 | + | request = dict(version=1, id=self.request_id, op=operation, | |
| 29 | + | attachment=self.attachment, **fields) | |
| 30 | + | if operation in ("keys", "text"): | |
| 31 | + | request["generation"] = self.generation | |
| 32 | + | self.process.stdin.write(json.dumps(request) + "\n") | |
| 33 | + | self.process.stdin.flush() | |
| 34 | + | response = json.loads(self.process.stdout.readline()) | |
| 35 | + | assert "error" not in response, response | |
| 36 | + | result = response["result"] | |
| 37 | + | self.generation = result.get("context", {}).get("generation", self.generation) | |
| 38 | + | return result | |
| 39 | + | ||
| 40 | + | def palette(self, query, label): | |
| 41 | + | self.call("keys", keys=["Alt+Space"]) | |
| 42 | + | self.call("text", text=query) | |
| 43 | + | self.call("wait", expect={"text_contains": label}, timeout_ms=10000) | |
| 44 | + | return self.call("capture")["text"] | |
| 45 | + | ||
| 46 | + | def close(self): | |
| 47 | + | self.process.stdin.close() | |
| 48 | + | self.process.wait(timeout=10) | |
| 49 | + | ||
| 50 | + | ||
| 51 | + | def run(client_container, remote_container): | |
| 52 | + | suffix = uuid.uuid4().hex[:8] | |
| 53 | + | first, second, remote = (name + suffix for name in ("sample", "second", "other")) | |
| 54 | + | fixture = Driver(remote_container, remote) | |
| 55 | + | fixture.close() | |
| 56 | + | driver = Driver(client_container, first) | |
| 57 | + | try: | |
| 58 | + | initial = driver.call("capture")["context"]["session"] | |
| 59 | + | fixture = Driver(client_container, second) | |
| 60 | + | fixture.close() | |
| 61 | + | driver.palette(second, "Switch to session " + second + "@local") | |
| 62 | + | driver.call("keys", keys=["Enter"]) | |
| 63 | + | driver.palette(remote, "Switch to session " + remote + "@remote") | |
| 64 | + | entered = driver.call("keys", keys=["Enter"]) | |
| 65 | + | assert entered["context"]["session"]["session_name"] == remote | |
| 66 | + | label = "Switch to session " + first + "@local" | |
| 67 | + | screen = driver.palette(first, label) | |
| 68 | + | assert screen.count(label) == 1, screen | |
| 69 | + | assert "Switch to session " + first + " " not in screen, screen | |
| 70 | + | selected = driver.call("keys", keys=["Enter"]) | |
| 71 | + | assert selected["context"]["session"] == initial, selected | |
| 72 | + | driver.call("wait", expect={"session": initial}, timeout_ms=10000) | |
| 73 | + | print("PASS: exact local return, one client-qualified row, committed UI action") | |
| 74 | + | finally: | |
| 75 | + | driver.close() | |
| 76 | + | ||
| 77 | + | ||
| 78 | + | if __name__ == "__main__": | |
| 79 | + | if len(sys.argv) != 3: | |
| 80 | + | raise SystemExit("usage: navigation_repro.py CLIENT_CONTAINER REMOTE_CONTAINER") | |
| 81 | + | run(*sys.argv[1:]) | |
M scripts/remote-picker-harness/run.sh +37 -108
| @@ -1,63 +1,29 @@ | |||
| 1 | 1 | #!/usr/bin/env bash | |
| 2 | + | # Disposable navigation acceptance with the real client, daemons and UI driver. | |
| 2 | 3 | set -euo pipefail | |
| 3 | - | ||
| 4 | - | usage() { | |
| 5 | - | cat >&2 <<'USAGE' | |
| 6 | - | Usage: scripts/remote-picker-harness/run.sh | |
| 7 | - | ||
| 8 | - | Builds the current vev binary into a disposable two-container environment and | |
| 9 | - | runs the unified same-connection local-picker proof plus SSH-stdio, UDP, | |
| 10 | - | catalog, preview, lifecycle-fence, and environment acceptance checks. Docker is | |
| 11 | - | invoked normally, | |
| 12 | - | so DOCKER_HOST and the active | |
| 13 | - | Docker context select the daemon; no socket path is assumed. | |
| 14 | - | ||
| 15 | - | Environment: | |
| 16 | - | DOCKER_HOST Docker endpoint, when the active context does not | |
| 17 | - | already select the rootless daemon. | |
| 18 | - | VEV_HARNESS_BASE_IMAGE Base image for the disposable containers | |
| 19 | - | (default: ubuntu:24.04). | |
| 20 | - | VEV_HARNESS_ARTIFACT_DIR Host directory for the bounded probe artifact. | |
| 21 | - | The JSON report is copied there before container | |
| 22 | - | cleanup; no report is written when unset. | |
| 23 | - | USAGE | |
| 24 | - | } | |
| 25 | - | ||
| 26 | 4 | case "${1:-}" in | |
| 27 | - | -h|--help) | |
| 28 | - | usage | |
| 29 | - | exit 0 | |
| 30 | - | ;; | |
| 31 | - | "") ;; | |
| 32 | - | *) | |
| 33 | - | usage | |
| 34 | - | exit 2 | |
| 35 | - | ;; | |
| 5 | + | -h|--help) printf 'Usage: scripts/remote-picker-harness/run.sh\nRequires Docker, Python 3 and ssh-keygen.\n'; exit 0 ;; | |
| 6 | + | '') ;; | |
| 7 | + | *) exit 2 ;; | |
| 36 | 8 | esac | |
| 37 | - | ||
| 38 | 9 | repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" | |
| 39 | - | base_image="${VEV_HARNESS_BASE_IMAGE:-ubuntu:24.04}" | |
| 40 | - | artifact_dir="${VEV_HARNESS_ARTIFACT_DIR:-}" | |
| 41 | - | if [ -n "$artifact_dir" ]; then | |
| 42 | - | umask 077 | |
| 43 | - | mkdir -p "$artifact_dir" | |
| 44 | - | fi | |
| 10 | + | for tool in docker python3 ssh-keygen; do command -v "$tool" >/dev/null; done | |
| 11 | + | docker info >/dev/null | |
| 45 | 12 | run_id="$(date +%s)-$$" | |
| 46 | - | image="vev-remote-picker-harness:${run_id}" | |
| 47 | - | network="vev-remote-picker-harness-${run_id}" | |
| 13 | + | image="vev-acceptance:${run_id}" | |
| 14 | + | network="vev-acceptance-${run_id}" | |
| 48 | 15 | local_container="${network}-local" | |
| 49 | 16 | remote_container="${network}-remote" | |
| 50 | - | context_dir="$(mktemp -d "${TMPDIR:-/tmp}/vev-remote-picker-harness.XXXXXX")" | |
| 51 | - | ||
| 17 | + | context_dir="$(mktemp -d)" | |
| 52 | 18 | cleanup() { | |
| 53 | 19 | status=$? | |
| 54 | 20 | trap - EXIT INT TERM | |
| 55 | 21 | set +e | |
| 56 | 22 | if [ "$status" -ne 0 ]; then | |
| 57 | - | printf 'remote picker harness: local container logs:\n' >&2 | |
| 58 | - | docker logs "$local_container" >&2 | |
| 59 | - | printf 'remote picker harness: remote container logs:\n' >&2 | |
| 60 | - | docker logs "$remote_container" >&2 | |
| 23 | + | for container in "$local_container" "$remote_container"; do | |
| 24 | + | docker logs "$container" >&2 | |
| 25 | + | docker exec "$container" sh -c 'tail -n 40 ~/.local/state/vev/vev-daemon.log' >&2 | |
| 26 | + | done | |
| 61 | 27 | fi | |
| 62 | 28 | docker rm -f "$local_container" "$remote_container" >/dev/null 2>&1 | |
| 63 | 29 | docker network rm "$network" >/dev/null 2>&1 | |
| @@ -65,68 +31,31 @@ cleanup() { | |||
| 65 | 31 | rm -rf "$context_dir" | |
| 66 | 32 | exit "$status" | |
| 67 | 33 | } | |
| 68 | - | trap cleanup EXIT INT TERM | |
| 69 | - | ||
| 70 | - | command -v docker >/dev/null || { echo 'remote picker harness: docker is required' >&2; exit 127; } | |
| 71 | - | command -v ssh-keygen >/dev/null || { echo 'remote picker harness: ssh-keygen is required' >&2; exit 127; } | |
| 34 | + | trap cleanup EXIT | |
| 35 | + | trap 'exit 130' INT | |
| 36 | + | trap 'exit 143' TERM | |
| 72 | 37 | ||
| 73 | - | if ! docker info >/dev/null 2>&1; then | |
| 74 | - | echo 'remote picker harness: Docker is unavailable through the active DOCKER_HOST/context' >&2 | |
| 75 | - | exit 1 | |
| 76 | - | fi | |
| 77 | - | ||
| 78 | - | printf 'remote picker harness: building %s\n' "$image" | |
| 79 | - | docker pull "$base_image" >/dev/null | |
| 80 | - | image_arch="$(docker image inspect "$base_image" --format '{{.Architecture}}')" | |
| 81 | - | GOOS=linux GOARCH="$image_arch" CGO_ENABLED=0 go build -o "$context_dir/vev" "$repo_root" | |
| 82 | - | GOOS=linux GOARCH="$image_arch" CGO_ENABLED=0 go build -o "$context_dir/harness" "$repo_root/scripts/remote-picker-harness" | |
| 83 | - | ssh-keygen -q -t ed25519 -N '' -f "$context_dir/id_ed25519" | |
| 84 | - | cp "$repo_root/scripts/remote-picker-harness/Dockerfile" "$context_dir/Dockerfile" | |
| 85 | - | cp "$repo_root/scripts/remote-picker-harness/vev-wrapper" "$context_dir/vev-wrapper" | |
| 86 | - | printf '%s\n' 'id_ed25519' > "$context_dir/.dockerignore" | |
| 87 | - | chmod 0600 "$context_dir/id_ed25519" | |
| 88 | - | ||
| 89 | - | docker build --build-arg "BASE_IMAGE=$base_image" -t "$image" "$context_dir" >/dev/null | |
| 38 | + | docker build -f "$repo_root/scripts/demo/Dockerfile" -t "$image" "$repo_root" >/dev/null | |
| 90 | 39 | docker network create "$network" >/dev/null | |
| 91 | - | ||
| 92 | - | docker run -d --name "$remote_container" \ | |
| 93 | - | --network "$network" --network-alias remote \ | |
| 94 | - | -e VEV_UDP_PORT_RANGE=61000 \ | |
| 95 | - | "$image" >/dev/null | |
| 96 | - | ||
| 97 | - | docker create --name "$local_container" \ | |
| 98 | - | --network "$network" --network-alias local \ | |
| 99 | - | "$image" sleep infinity >/dev/null | |
| 100 | - | docker cp "$context_dir/id_ed25519" "$local_container:/home/test/.ssh/id_ed25519" | |
| 101 | - | docker start "$local_container" >/dev/null | |
| 102 | - | docker exec "$local_container" chown test:test /home/test/.ssh/id_ed25519 | |
| 103 | - | docker exec "$local_container" chmod 0600 /home/test/.ssh/id_ed25519 | |
| 104 | - | ||
| 105 | - | printf 'remote picker harness: waiting for SSH service\n' | |
| 106 | - | for _ in $(seq 1 60); do | |
| 107 | - | if docker exec --user test -e HOME=/home/test "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=1 test@remote true >/dev/null 2>&1; then | |
| 108 | - | break | |
| 109 | - | fi | |
| 110 | - | sleep 0.2 | |
| 40 | + | for role in local remote; do | |
| 41 | + | docker run -d --name "${network}-${role}" --network "$network" \ | |
| 42 | + | --network-alias "$role" --entrypoint sleep "$image" infinity >/dev/null | |
| 111 | 43 | done | |
| 112 | - | if ! docker exec --user test -e HOME=/home/test "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=2 test@remote true >/dev/null 2>&1; then | |
| 113 | - | echo 'remote picker harness: SSH service did not become ready' >&2 | |
| 114 | - | exit 1 | |
| 115 | - | fi | |
| 116 | 44 | ||
| 117 | - | printf 'remote picker harness: running acceptance checks\n' | |
| 118 | - | harness_status=0 | |
| 119 | - | if [ -n "$artifact_dir" ]; then | |
| 120 | - | docker exec --user test -e HOME=/home/test -e VEV_HARNESS_ARTIFACT_DIR=/tmp/vev-harness-artifact "$local_container" /usr/local/bin/remote-picker-harness || harness_status=$? | |
| 121 | - | if ! docker cp "$local_container:/tmp/vev-harness-artifact/remote-picker-harness.json" "$artifact_dir/remote-picker-harness.json"; then | |
| 122 | - | echo "remote picker harness: failed to collect probe artifact (acceptance status $harness_status)" >&2 | |
| 123 | - | if [ "$harness_status" -ne 0 ]; then | |
| 124 | - | exit "$harness_status" | |
| 125 | - | fi | |
| 126 | - | exit 1 | |
| 127 | - | fi | |
| 128 | - | chmod 0600 "$artifact_dir/remote-picker-harness.json" | |
| 129 | - | else | |
| 130 | - | docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness || harness_status=$? | |
| 131 | - | fi | |
| 132 | - | exit "$harness_status" | |
| 45 | + | # Only generated fixture credentials enter these containers; no host HOME or | |
| 46 | + | # sockets are mounted. Pin the fresh remote key instead of trusting keyscan. | |
| 47 | + | ssh-keygen -q -t ed25519 -N '' -C acceptance-client -f "$context_dir/client" | |
| 48 | + | ssh-keygen -q -t ed25519 -N '' -C acceptance-host -f "$context_dir/host" | |
| 49 | + | docker exec --user root "$remote_container" sh -c 'rm -f /etc/ssh/ssh_host_* /home/demo/.ssh/id_ed25519*; mkdir -p /run/sshd' | |
| 50 | + | docker cp "$context_dir/host" "$remote_container:/etc/ssh/ssh_host_ed25519_key" | |
| 51 | + | docker cp "$context_dir/host.pub" "$remote_container:/etc/ssh/ssh_host_ed25519_key.pub" | |
| 52 | + | docker cp "$context_dir/client.pub" "$remote_container:/home/demo/.ssh/authorized_keys" | |
| 53 | + | docker exec --user root "$remote_container" sh -c 'chmod 600 /etc/ssh/ssh_host_ed25519_key /home/demo/.ssh/authorized_keys; chown demo:demo /home/demo/.ssh/authorized_keys; /usr/sbin/sshd' | |
| 54 | + | docker cp "$context_dir/client" "$local_container:/home/demo/.ssh/id_ed25519" | |
| 55 | + | docker cp "$context_dir/client.pub" "$local_container:/home/demo/.ssh/id_ed25519.pub" | |
| 56 | + | { printf 'remote '; cut -d' ' -f1-2 "$context_dir/host.pub"; } > "$context_dir/known_hosts" | |
| 57 | + | docker cp "$context_dir/known_hosts" "$local_container:/home/demo/.ssh/known_hosts" | |
| 58 | + | docker exec --user root "$local_container" sh -c 'chown demo:demo /home/demo/.ssh/id_ed25519* /home/demo/.ssh/known_hosts; chmod 600 /home/demo/.ssh/id_ed25519 /home/demo/.ssh/known_hosts' | |
| 59 | + | docker exec "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=5 remote true | |
| 60 | + | docker exec "$local_container" vev host add remote | |
| 61 | + | python3 "$repo_root/scripts/remote-picker-harness/navigation_repro.py" "$local_container" "$remote_container" | |
D scripts/remote-picker-harness/vev-wrapper +0 -27
| @@ -1,27 +0,0 @@ | |||
| 1 | - | #!/bin/sh | |
| 2 | - | set -eu | |
| 3 | - | ||
| 4 | - | export XDG_STATE_HOME=/tmp/vev-state | |
| 5 | - | export XDG_RUNTIME_DIR=/tmp/vev-runtime | |
| 6 | - | export VEV_TEST_ENV=remote | |
| 7 | - | export WAYLAND_DISPLAY=wayland-remote | |
| 8 | - | export TERM=xterm-256color | |
| 9 | - | export COLORTERM=truecolor | |
| 10 | - | ||
| 11 | - | ||
| 12 | - | case "$*" in | |
| 13 | - | *remote-catalog*) | |
| 14 | - | if [ -e /tmp/vev-bad-catalog ]; then | |
| 15 | - | # Keep zero invalid so this fixture cannot become a valid protocol version. | |
| 16 | - | printf '%s\n' '{"protocol_version":0,"schema_version":1,"sessions":[]}' | |
| 17 | - | exit 0 | |
| 18 | - | fi | |
| 19 | - | ;; | |
| 20 | - | *_remote-preview*) | |
| 21 | - | if [ -e /tmp/vev-slow-preview ]; then | |
| 22 | - | sleep 3 | |
| 23 | - | fi | |
| 24 | - | ;; | |
| 25 | - | esac | |
| 26 | - | ||
| 27 | - | exec /usr/local/bin/vev.real "$@" | |
D scripts/remote-picker-harness/visual_probe.go +0 -482
| @@ -1,482 +0,0 @@ | |||
| 1 | - | package main | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "os" | |
| 9 | - | "path/filepath" | |
| 10 | - | "strings" | |
| 11 | - | ||
| 12 | - | vt "github.com/bnema/vev-vt" | |
| 13 | - | "github.com/bnema/vev/internal/domain" | |
| 14 | - | "github.com/bnema/vev/internal/protocol" | |
| 15 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | const ( | |
| 19 | - | defaultProbeCols = 80 | |
| 20 | - | defaultProbeRows = 24 | |
| 21 | - | ||
| 22 | - | maxProbeEvents = 128 | |
| 23 | - | maxProbeCheckpoints = 16 | |
| 24 | - | maxArtifactBytes = 64 << 10 | |
| 25 | - | ||
| 26 | - | probeEventOutput = "output" | |
| 27 | - | probeEventWelcome = "welcome" | |
| 28 | - | probeEventOutputReset = "output_reset_request" | |
| 29 | - | probeEventUnexpectedHandoff = "unexpected_attach_target" | |
| 30 | - | ) | |
| 31 | - | ||
| 32 | - | // visualOutputState mirrors the client's output dependency chain. A probe may | |
| 33 | - | // inspect output only after the frame's epoch, base, and view revision continue | |
| 34 | - | // the last accepted state. | |
| 35 | - | type visualOutputState struct { | |
| 36 | - | epoch uint64 | |
| 37 | - | state uint64 | |
| 38 | - | viewRevision uint64 | |
| 39 | - | initialized bool | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func (s visualOutputState) next(output protocol.Output) (visualOutputState, bool) { | |
| 43 | - | if output.Epoch == 0 { | |
| 44 | - | return visualOutputState{}, false | |
| 45 | - | } | |
| 46 | - | if !s.initialized { | |
| 47 | - | if output.New == 0 { | |
| 48 | - | if output.Base != 0 || output.Full { | |
| 49 | - | return visualOutputState{}, false | |
| 50 | - | } | |
| 51 | - | return s, true | |
| 52 | - | } | |
| 53 | - | if output.Base != 0 || !output.Full { | |
| 54 | - | return visualOutputState{}, false | |
| 55 | - | } | |
| 56 | - | return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true | |
| 57 | - | } | |
| 58 | - | if output.Epoch < s.epoch { | |
| 59 | - | return visualOutputState{}, false | |
| 60 | - | } | |
| 61 | - | if output.Epoch == s.epoch { | |
| 62 | - | if output.ViewRevision != s.viewRevision { | |
| 63 | - | return visualOutputState{}, false | |
| 64 | - | } | |
| 65 | - | if output.New == 0 { | |
| 66 | - | if output.Base != 0 || output.Full { | |
| 67 | - | return visualOutputState{}, false | |
| 68 | - | } | |
| 69 | - | return s, true | |
| 70 | - | } | |
| 71 | - | if output.Full || output.Base != s.state || output.New != output.Base+1 { | |
| 72 | - | return visualOutputState{}, false | |
| 73 | - | } | |
| 74 | - | return visualOutputState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true | |
| 75 | - | } | |
| 76 | - | if !output.Full || output.Base != 0 || output.New == 0 { | |
| 77 | - | return visualOutputState{}, false | |
| 78 | - | } | |
| 79 | - | return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | type probeEvent struct { | |
| 83 | - | Sequence uint64 | |
| 84 | - | Transport string | |
| 85 | - | Kind string | |
| 86 | - | Accepted bool | |
| 87 | - | StateBearing bool | |
| 88 | - | Acked bool | |
| 89 | - | Epoch uint64 | |
| 90 | - | Base uint64 | |
| 91 | - | New uint64 | |
| 92 | - | ViewRevision uint64 | |
| 93 | - | Full bool | |
| 94 | - | DataBytes int | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | type visualCheckpoint struct { | |
| 98 | - | event *probeEvent | |
| 99 | - | state visualOutputState | |
| 100 | - | snapshot vt.ScreenSnapshot | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | type visualOutputResult struct { | |
| 104 | - | Accepted bool | |
| 105 | - | StateBearing bool | |
| 106 | - | Ack protocol.Ack | |
| 107 | - | event *probeEvent | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | // visualProbe is deliberately transport-local. A new physical transport gets | |
| 111 | - | // one screen and one output state; waiting for a particular marker never | |
| 112 | - | // creates a replacement screen and therefore cannot lose prior terminal state. | |
| 113 | - | type visualProbe struct { | |
| 114 | - | label string | |
| 115 | - | ||
| 116 | - | screen *vt.Screen | |
| 117 | - | state visualOutputState | |
| 118 | - | ||
| 119 | - | events []*probeEvent | |
| 120 | - | checkpoints []visualCheckpoint | |
| 121 | - | nextEvent uint64 | |
| 122 | - | ||
| 123 | - | outputFrames int | |
| 124 | - | outputBytes int | |
| 125 | - | resetPending bool | |
| 126 | - | ||
| 127 | - | unexpectedHandoffs int | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | type harnessArtifactContextKey struct{} | |
| 131 | - | ||
| 132 | - | func withHarnessArtifact(ctx context.Context, artifact *harnessArtifact) context.Context { | |
| 133 | - | return context.WithValue(ctx, harnessArtifactContextKey{}, artifact) | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | func harnessArtifactFromContext(ctx context.Context) *harnessArtifact { | |
| 137 | - | if ctx == nil { | |
| 138 | - | return nil | |
| 139 | - | } | |
| 140 | - | artifact, _ := ctx.Value(harnessArtifactContextKey{}).(*harnessArtifact) | |
| 141 | - | return artifact | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | func newVisualProbe(size domain.Size) *visualProbe { | |
| 145 | - | width, height := size.Cols, size.Rows | |
| 146 | - | if width <= 0 { | |
| 147 | - | width = defaultProbeCols | |
| 148 | - | } | |
| 149 | - | if height <= 0 { | |
| 150 | - | height = defaultProbeRows | |
| 151 | - | } | |
| 152 | - | return &visualProbe{screen: vt.NewScreen(width, height)} | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | func (p *visualProbe) configure(label string) { | |
| 156 | - | if p == nil { | |
| 157 | - | return | |
| 158 | - | } | |
| 159 | - | p.label = label | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | func (p *visualProbe) record(event probeEvent) *probeEvent { | |
| 163 | - | if p == nil { | |
| 164 | - | return nil | |
| 165 | - | } | |
| 166 | - | p.nextEvent++ | |
| 167 | - | event.Sequence = p.nextEvent | |
| 168 | - | event.Transport = p.label | |
| 169 | - | owned := &event | |
| 170 | - | if len(p.events) == maxProbeEvents { | |
| 171 | - | p.events = p.events[1:] | |
| 172 | - | } | |
| 173 | - | p.events = append(p.events, owned) | |
| 174 | - | return owned | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | func (p *visualProbe) recordControl(kind string) { | |
| 178 | - | if p == nil { | |
| 179 | - | return | |
| 180 | - | } | |
| 181 | - | p.record(probeEvent{Kind: kind, Accepted: true}) | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | func (p *visualProbe) apply(output protocol.Output) visualOutputResult { | |
| 185 | - | if p == nil { | |
| 186 | - | return visualOutputResult{} | |
| 187 | - | } | |
| 188 | - | p.outputFrames++ | |
| 189 | - | p.outputBytes += len(output.Data) | |
| 190 | - | accepted := protocol.ValidateOutput(output) == nil | |
| 191 | - | next := visualOutputState{} | |
| 192 | - | if accepted { | |
| 193 | - | next, accepted = p.state.next(output) | |
| 194 | - | } | |
| 195 | - | event := p.record(probeEvent{ | |
| 196 | - | Kind: probeEventOutput, | |
| 197 | - | Accepted: accepted, | |
| 198 | - | StateBearing: output.New != 0, | |
| 199 | - | Epoch: output.Epoch, | |
| 200 | - | Base: output.Base, | |
| 201 | - | New: output.New, | |
| 202 | - | ViewRevision: output.ViewRevision, | |
| 203 | - | Full: output.Full, | |
| 204 | - | DataBytes: len(output.Data), | |
| 205 | - | }) | |
| 206 | - | if !accepted { | |
| 207 | - | return visualOutputResult{event: event} | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | // The client writes side effects as well as state-bearing frames, but only | |
| 211 | - | // state-bearing frames advance the dependency chain and receive an ACK. | |
| 212 | - | if p.screen.Columns() != output.Size.Cols || p.screen.Rows() != output.Size.Rows { | |
| 213 | - | p.screen.Resize(output.Size.Cols, output.Size.Rows) | |
| 214 | - | } | |
| 215 | - | p.screen.Write(output.Data) | |
| 216 | - | p.state = next | |
| 217 | - | checkpoint := visualCheckpoint{event: event, state: p.state, snapshot: p.screen.Snapshot()} | |
| 218 | - | if len(p.checkpoints) == maxProbeCheckpoints { | |
| 219 | - | p.checkpoints = p.checkpoints[1:] | |
| 220 | - | } | |
| 221 | - | p.checkpoints = append(p.checkpoints, checkpoint) | |
| 222 | - | result := visualOutputResult{Accepted: true, StateBearing: output.New != 0, event: event} | |
| 223 | - | if result.StateBearing { | |
| 224 | - | result.Ack = protocol.Ack{Epoch: output.Epoch, State: output.New} | |
| 225 | - | } | |
| 226 | - | return result | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | func (p *visualProbe) text() string { | |
| 230 | - | if p == nil || p.screen == nil { | |
| 231 | - | return "" | |
| 232 | - | } | |
| 233 | - | return visualScreenText(p.screen) | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | func (p *visualProbe) contains(want string) bool { | |
| 237 | - | return strings.Contains(p.text(), want) | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | func (p *visualProbe) recordIncoming(frame wire.Frame) { | |
| 241 | - | if p == nil || frame.Type != wire.MsgAttachTarget { | |
| 242 | - | return | |
| 243 | - | } | |
| 244 | - | p.unexpectedHandoffs++ | |
| 245 | - | p.record(probeEvent{Kind: probeEventUnexpectedHandoff}) | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | func (p *visualProbe) markAcked(result visualOutputResult) { | |
| 249 | - | if result.event != nil && result.StateBearing && result.Accepted { | |
| 250 | - | result.event.Acked = true | |
| 251 | - | } | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | func visualScreenLines(screen *vt.Screen) []string { | |
| 255 | - | if screen == nil { | |
| 256 | - | return nil | |
| 257 | - | } | |
| 258 | - | lines := make([]string, screen.Rows()) | |
| 259 | - | for y := range screen.Rows() { | |
| 260 | - | var line strings.Builder | |
| 261 | - | for x := range screen.Columns() { | |
| 262 | - | r := screen.Cell(x, y).Rune | |
| 263 | - | if r == 0 { | |
| 264 | - | r = ' ' | |
| 265 | - | } | |
| 266 | - | line.WriteRune(r) | |
| 267 | - | } | |
| 268 | - | lines[y] = line.String() | |
| 269 | - | } | |
| 270 | - | return lines | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | func visualScreenText(screen *vt.Screen) string { | |
| 274 | - | lines := visualScreenLines(screen) | |
| 275 | - | if len(lines) == 0 { | |
| 276 | - | return "" | |
| 277 | - | } | |
| 278 | - | return strings.Join(lines, "\n") + "\n" | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | func capturePickerRows(probe *visualProbe) []string { | |
| 282 | - | if probe == nil { | |
| 283 | - | return nil | |
| 284 | - | } | |
| 285 | - | var rows []string | |
| 286 | - | for _, line := range visualScreenLines(probe.screen) { | |
| 287 | - | if line = strings.TrimSpace(line); line != "" { | |
| 288 | - | rows = append(rows, line) | |
| 289 | - | } | |
| 290 | - | } | |
| 291 | - | return rows | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | func assertUnifiedPickerRows(rows []string, localSession, remoteSession string) error { | |
| 295 | - | if len(rows) == 0 { | |
| 296 | - | return errors.New("picker screen had no visible rows") | |
| 297 | - | } | |
| 298 | - | contains := func(want string) bool { | |
| 299 | - | for _, row := range rows { | |
| 300 | - | if strings.Contains(row, want) { | |
| 301 | - | return true | |
| 302 | - | } | |
| 303 | - | } | |
| 304 | - | return false | |
| 305 | - | } | |
| 306 | - | if !contains(localSession) { | |
| 307 | - | return fmt.Errorf("unified picker rows omitted local session %q", localSession) | |
| 308 | - | } | |
| 309 | - | remoteRow := remoteSession + "@remote" | |
| 310 | - | if !contains(remoteRow) { | |
| 311 | - | return fmt.Errorf("unified picker rows omitted remote session %q", remoteRow) | |
| 312 | - | } | |
| 313 | - | return nil | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | func assertNoRemoteChrome(probe *visualProbe, remoteSession string) error { | |
| 317 | - | if probe == nil { | |
| 318 | - | return errors.New("remote view probe was not created") | |
| 319 | - | } | |
| 320 | - | lines := visualScreenLines(probe.screen) | |
| 321 | - | if len(lines) < 3 { | |
| 322 | - | return errors.New("remote view did not expose a content region") | |
| 323 | - | } | |
| 324 | - | // The first and last rows belong to the local attachment's bars. Remote | |
| 325 | - | // daemon chrome must not be replayed into the content rows. | |
| 326 | - | for _, line := range lines[1 : len(lines)-1] { | |
| 327 | - | if strings.Contains(line, remoteSession+"@remote") || strings.Contains(line, " Sessions · ") { | |
| 328 | - | return fmt.Errorf("remote chrome leaked into content row %q", strings.TrimSpace(line)) | |
| 329 | - | } | |
| 330 | - | } | |
| 331 | - | return nil | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | type harnessArtifact struct { | |
| 335 | - | dir string | |
| 336 | - | probes []*visualProbe | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | func newHarnessArtifact(dir string) *harnessArtifact { | |
| 340 | - | if dir == "" { | |
| 341 | - | return nil | |
| 342 | - | } | |
| 343 | - | return &harnessArtifact{dir: dir} | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | func (a *harnessArtifact) registerProbe(probe *visualProbe) { | |
| 347 | - | if a == nil || probe == nil { | |
| 348 | - | return | |
| 349 | - | } | |
| 350 | - | a.probes = append(a.probes, probe) | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | type artifactEvent struct { | |
| 354 | - | Sequence uint64 `json:"sequence"` | |
| 355 | - | Transport string `json:"transport"` | |
| 356 | - | Kind string `json:"kind"` | |
| 357 | - | Accepted bool `json:"accepted"` | |
| 358 | - | StateBearing bool `json:"state_bearing"` | |
| 359 | - | Acked bool `json:"acked"` | |
| 360 | - | Epoch uint64 `json:"epoch,omitempty"` | |
| 361 | - | Base uint64 `json:"base,omitempty"` | |
| 362 | - | New uint64 `json:"new,omitempty"` | |
| 363 | - | ViewRevision uint64 `json:"view_revision,omitempty"` | |
| 364 | - | Full bool `json:"full,omitempty"` | |
| 365 | - | DataBytes int `json:"data_bytes,omitempty"` | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | type artifactCheckpoint struct { | |
| 369 | - | Sequence uint64 `json:"sequence"` | |
| 370 | - | Epoch uint64 `json:"epoch,omitempty"` | |
| 371 | - | State uint64 `json:"state,omitempty"` | |
| 372 | - | ViewRevision uint64 `json:"view_revision,omitempty"` | |
| 373 | - | Columns int `json:"columns"` | |
| 374 | - | Rows int `json:"rows"` | |
| 375 | - | CursorRow int `json:"cursor_row"` | |
| 376 | - | CursorColumn int `json:"cursor_column"` | |
| 377 | - | AlternateScreen bool `json:"alternate_screen"` | |
| 378 | - | BracketedPaste bool `json:"bracketed_paste"` | |
| 379 | - | SynchronizedUpdate bool `json:"synchronized_update"` | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | type artifactProbe struct { | |
| 383 | - | Transport string `json:"transport"` | |
| 384 | - | Events []artifactEvent `json:"events"` | |
| 385 | - | Checkpoints []artifactCheckpoint `json:"checkpoints"` | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | type artifactDocument struct { | |
| 389 | - | Version int `json:"version"` | |
| 390 | - | Passed bool `json:"passed"` | |
| 391 | - | Probes []artifactProbe `json:"probes"` | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | func (a *harnessArtifact) document(passed bool) artifactDocument { | |
| 395 | - | document := artifactDocument{Version: 1, Passed: passed} | |
| 396 | - | for _, probe := range a.probes { | |
| 397 | - | if probe == nil { | |
| 398 | - | continue | |
| 399 | - | } | |
| 400 | - | item := artifactProbe{Transport: probe.label} | |
| 401 | - | for _, event := range probe.events { | |
| 402 | - | if event == nil { | |
| 403 | - | continue | |
| 404 | - | } | |
| 405 | - | item.Events = append(item.Events, artifactEventFrom(event)) | |
| 406 | - | } | |
| 407 | - | for _, checkpoint := range probe.checkpoints { | |
| 408 | - | snapshot := checkpoint.snapshot | |
| 409 | - | cursor := snapshot.Cursor() | |
| 410 | - | modes := snapshot.Modes() | |
| 411 | - | item.Checkpoints = append(item.Checkpoints, artifactCheckpoint{ | |
| 412 | - | Sequence: checkpoint.event.Sequence, | |
| 413 | - | Epoch: checkpoint.state.epoch, | |
| 414 | - | State: checkpoint.state.state, | |
| 415 | - | ViewRevision: checkpoint.state.viewRevision, | |
| 416 | - | Columns: snapshot.Columns(), | |
| 417 | - | Rows: snapshot.Rows(), | |
| 418 | - | CursorRow: cursor.Row, | |
| 419 | - | CursorColumn: cursor.Col, | |
| 420 | - | AlternateScreen: modes.AlternateScreen, | |
| 421 | - | BracketedPaste: modes.BracketedPaste, | |
| 422 | - | SynchronizedUpdate: modes.SynchronizedUpdate, | |
| 423 | - | }) | |
| 424 | - | } | |
| 425 | - | document.Probes = append(document.Probes, item) | |
| 426 | - | } | |
| 427 | - | return document | |
| 428 | - | } | |
| 429 | - | ||
| 430 | - | func artifactEventFrom(event *probeEvent) artifactEvent { | |
| 431 | - | return artifactEvent{ | |
| 432 | - | Sequence: event.Sequence, | |
| 433 | - | Transport: event.Transport, | |
| 434 | - | Kind: event.Kind, | |
| 435 | - | Accepted: event.Accepted, | |
| 436 | - | StateBearing: event.StateBearing, | |
| 437 | - | Acked: event.Acked, | |
| 438 | - | Epoch: event.Epoch, | |
| 439 | - | Base: event.Base, | |
| 440 | - | New: event.New, | |
| 441 | - | ViewRevision: event.ViewRevision, | |
| 442 | - | Full: event.Full, | |
| 443 | - | DataBytes: event.DataBytes, | |
| 444 | - | } | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | func (a *harnessArtifact) write(passed bool) error { | |
| 448 | - | if a == nil || a.dir == "" { | |
| 449 | - | return nil | |
| 450 | - | } | |
| 451 | - | data, err := json.MarshalIndent(a.document(passed), "", " ") | |
| 452 | - | if err != nil { | |
| 453 | - | return fmt.Errorf("marshal harness artifact: %w", err) | |
| 454 | - | } | |
| 455 | - | if len(data) > maxArtifactBytes { | |
| 456 | - | return fmt.Errorf("harness artifact exceeds %d bytes", maxArtifactBytes) | |
| 457 | - | } | |
| 458 | - | if err := os.MkdirAll(a.dir, 0o700); err != nil { | |
| 459 | - | return fmt.Errorf("create harness artifact directory: %w", err) | |
| 460 | - | } | |
| 461 | - | tmp, err := os.CreateTemp(a.dir, ".remote-picker-harness-*.tmp") | |
| 462 | - | if err != nil { | |
| 463 | - | return fmt.Errorf("create harness artifact: %w", err) | |
| 464 | - | } | |
| 465 | - | tmpName := tmp.Name() | |
| 466 | - | defer func() { _ = os.Remove(tmpName) }() | |
| 467 | - | if err := tmp.Chmod(0o600); err != nil { | |
| 468 | - | _ = tmp.Close() | |
| 469 | - | return fmt.Errorf("protect harness artifact: %w", err) | |
| 470 | - | } | |
| 471 | - | if _, err := tmp.Write(data); err != nil { | |
| 472 | - | _ = tmp.Close() | |
| 473 | - | return fmt.Errorf("write harness artifact: %w", err) | |
| 474 | - | } | |
| 475 | - | if err := tmp.Close(); err != nil { | |
| 476 | - | return fmt.Errorf("close harness artifact: %w", err) | |
| 477 | - | } | |
| 478 | - | if err := os.Rename(tmpName, filepath.Join(a.dir, "remote-picker-harness.json")); err != nil { | |
| 479 | - | return fmt.Errorf("publish harness artifact: %w", err) | |
| 480 | - | } | |
| 481 | - | return nil | |
| 482 | - | } | |
D scripts/remote-picker-harness/visual_probe_test.go +0 -357
| @@ -1,357 +0,0 @@ | |||
| 1 | - | package main | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/json" | |
| 5 | - | "errors" | |
| 6 | - | "os" | |
| 7 | - | "strings" | |
| 8 | - | "testing" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/vev/internal/domain" | |
| 11 | - | "github.com/bnema/vev/internal/protocol" | |
| 12 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 13 | - | "github.com/stretchr/testify/require" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | const ( | |
| 17 | - | probeTestCols = 32 | |
| 18 | - | probeTestRows = 2 | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | type probeTestTransport struct { | |
| 22 | - | sent []wire.Frame | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | func (t *probeTestTransport) Send(frame wire.Frame) error { | |
| 26 | - | t.sent = append(t.sent, frame) | |
| 27 | - | return nil | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | func (*probeTestTransport) Recv() (wire.Frame, error) { return wire.Frame{}, errors.New("not used") } | |
| 31 | - | func (*probeTestTransport) Close() error { return nil } | |
| 32 | - | ||
| 33 | - | func probeTestSemanticOutput(output protocol.Output) protocol.Output { | |
| 34 | - | if output.New != 0 && output.Context == nil { | |
| 35 | - | output.Context = &protocol.ViewContext{ | |
| 36 | - | Publication: output.New, | |
| 37 | - | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "probe-fixture"}}, | |
| 38 | - | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | return output | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | func probeTestOutput(t *testing.T, output protocol.Output) wire.Frame { | |
| 45 | - | t.Helper() | |
| 46 | - | payload, err := wire.MarshalOutput(probeTestSemanticOutput(output)) | |
| 47 | - | if err != nil { | |
| 48 | - | t.Fatalf("marshal output: %v", err) | |
| 49 | - | } | |
| 50 | - | return wire.Frame{Type: wire.MsgOutput, Payload: payload} | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) { | |
| 54 | - | size := domain.Size{Cols: probeTestCols, Rows: probeTestRows} | |
| 55 | - | probe := newVisualProbe(size) | |
| 56 | - | for _, tt := range []struct { | |
| 57 | - | name string | |
| 58 | - | output protocol.Output | |
| 59 | - | want string | |
| 60 | - | state uint64 | |
| 61 | - | }{ | |
| 62 | - | { | |
| 63 | - | name: "full", | |
| 64 | - | output: protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")}, | |
| 65 | - | want: "full", | |
| 66 | - | state: 1, | |
| 67 | - | }, | |
| 68 | - | { | |
| 69 | - | name: "incremental", | |
| 70 | - | output: protocol.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte(" incremental")}, | |
| 71 | - | want: "full incremental", | |
| 72 | - | state: 2, | |
| 73 | - | }, | |
| 74 | - | } { | |
| 75 | - | t.Run(tt.name, func(t *testing.T) { | |
| 76 | - | result := probe.apply(probeTestSemanticOutput(tt.output)) | |
| 77 | - | if !result.Accepted || !result.StateBearing { | |
| 78 | - | t.Fatalf("result = %+v, want accepted state-bearing output", result) | |
| 79 | - | } | |
| 80 | - | if result.Ack.Epoch != tt.output.Epoch || result.Ack.State != tt.state { | |
| 81 | - | t.Fatalf("ack = %+v, want epoch=%d state=%d", result.Ack, tt.output.Epoch, tt.state) | |
| 82 | - | } | |
| 83 | - | if !strings.Contains(probe.text(), tt.want) { | |
| 84 | - | t.Fatalf("screen = %q, want %q", probe.text(), tt.want) | |
| 85 | - | } | |
| 86 | - | if probe.state.state != tt.state { | |
| 87 | - | t.Fatalf("state = %d, want %d", probe.state.state, tt.state) | |
| 88 | - | } | |
| 89 | - | }) | |
| 90 | - | } | |
| 91 | - | if got := len(probe.checkpoints); got != 2 { | |
| 92 | - | t.Fatalf("checkpoints = %d, want 2", got) | |
| 93 | - | } | |
| 94 | - | if got := len(probe.events); got != 2 { | |
| 95 | - | t.Fatalf("events = %d, want 2", got) | |
| 96 | - | } | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | func TestVisualProbeResizesToAcceptedOutput(t *testing.T) { | |
| 100 | - | probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows}) | |
| 101 | - | outputSize := domain.Size{Cols: 48, Rows: 3} | |
| 102 | - | result := probe.apply(probeTestSemanticOutput(protocol.Output{ | |
| 103 | - | Epoch: 1, New: 1, Full: true, ViewRevision: 1, Size: outputSize, Data: []byte("resized"), | |
| 104 | - | })) | |
| 105 | - | ||
| 106 | - | require.True(t, result.Accepted) | |
| 107 | - | require.Equal(t, outputSize.Cols, probe.screen.Columns()) | |
| 108 | - | require.Equal(t, outputSize.Rows, probe.screen.Rows()) | |
| 109 | - | require.Len(t, probe.checkpoints, 1) | |
| 110 | - | require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns()) | |
| 111 | - | require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows()) | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | func TestVisualProbeRejectsOutputWithoutMutationOrAck(t *testing.T) { | |
| 115 | - | size := domain.Size{Cols: probeTestCols, Rows: probeTestRows} | |
| 116 | - | for _, tt := range []struct { | |
| 117 | - | name string | |
| 118 | - | output protocol.Output | |
| 119 | - | }{ | |
| 120 | - | { | |
| 121 | - | name: "base gap", | |
| 122 | - | output: protocol.Output{Epoch: 1, Base: 0, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("rejected")}, | |
| 123 | - | }, | |
| 124 | - | { | |
| 125 | - | name: "revision gap", | |
| 126 | - | output: protocol.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 5, Size: size, Data: []byte("rejected")}, | |
| 127 | - | }, | |
| 128 | - | { | |
| 129 | - | name: "stale epoch", | |
| 130 | - | output: protocol.Output{Epoch: 0, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte("rejected")}, | |
| 131 | - | }, | |
| 132 | - | } { | |
| 133 | - | t.Run(tt.name, func(t *testing.T) { | |
| 134 | - | probe := newVisualProbe(size) | |
| 135 | - | first := protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("keep")} | |
| 136 | - | if result := probe.apply(probeTestSemanticOutput(first)); !result.Accepted { | |
| 137 | - | t.Fatal("seed output was rejected") | |
| 138 | - | } | |
| 139 | - | beforeText, beforeState, beforeCheckpoints := probe.text(), probe.state, len(probe.checkpoints) | |
| 140 | - | result := probe.apply(probeTestSemanticOutput(tt.output)) | |
| 141 | - | if result.Accepted || result.StateBearing || result.Ack != (protocol.Ack{}) { | |
| 142 | - | t.Fatalf("result = %+v, want rejected output without ACK", result) | |
| 143 | - | } | |
| 144 | - | if got := probe.text(); got != beforeText { | |
| 145 | - | t.Fatalf("screen changed from %q to %q", beforeText, got) | |
| 146 | - | } | |
| 147 | - | if probe.state != beforeState { | |
| 148 | - | t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState) | |
| 149 | - | } | |
| 150 | - | if len(probe.checkpoints) != beforeCheckpoints { | |
| 151 | - | t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints) | |
| 152 | - | } | |
| 153 | - | }) | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | func TestVisualProbeRejectsInvalidUninitializedSideEffects(t *testing.T) { | |
| 158 | - | size := domain.Size{Cols: probeTestCols, Rows: probeTestRows} | |
| 159 | - | for _, output := range []protocol.Output{ | |
| 160 | - | {Epoch: 1, Base: 1, Size: size, Data: []byte("invalid base")}, | |
| 161 | - | {Epoch: 1, Full: true, Size: size, Data: []byte("invalid full")}, | |
| 162 | - | } { | |
| 163 | - | probe := newVisualProbe(size) | |
| 164 | - | result := probe.apply(output) | |
| 165 | - | if result.Accepted || result.StateBearing || result.Ack != (protocol.Ack{}) { | |
| 166 | - | t.Fatalf("result = %+v, want rejected uninitialized side effect", result) | |
| 167 | - | } | |
| 168 | - | require.Len(t, probe.events, 1) | |
| 169 | - | require.False(t, probe.events[0].Accepted) | |
| 170 | - | require.Empty(t, probe.checkpoints) | |
| 171 | - | } | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | func TestVisualProbeSideEffectsApplyWithoutAck(t *testing.T) { | |
| 175 | - | size := domain.Size{Cols: probeTestCols, Rows: probeTestRows} | |
| 176 | - | for _, tt := range []struct { | |
| 177 | - | name string | |
| 178 | - | seed *protocol.Output | |
| 179 | - | effect protocol.Output | |
| 180 | - | }{ | |
| 181 | - | { | |
| 182 | - | name: "before state-bearing output", | |
| 183 | - | effect: protocol.Output{Epoch: 1, Size: size, Data: []byte("side effect")}, | |
| 184 | - | }, | |
| 185 | - | { | |
| 186 | - | name: "after state-bearing output", | |
| 187 | - | seed: &protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")}, | |
| 188 | - | effect: protocol.Output{Epoch: 1, ViewRevision: 4, Size: size, Data: []byte(" side effect")}, | |
| 189 | - | }, | |
| 190 | - | } { | |
| 191 | - | t.Run(tt.name, func(t *testing.T) { | |
| 192 | - | probe := newVisualProbe(size) | |
| 193 | - | if tt.seed != nil && !probe.apply(probeTestSemanticOutput(*tt.seed)).Accepted { | |
| 194 | - | t.Fatal("seed output was rejected") | |
| 195 | - | } | |
| 196 | - | beforeState, beforeCheckpoints := probe.state, len(probe.checkpoints) | |
| 197 | - | result := probe.apply(tt.effect) | |
| 198 | - | if !result.Accepted || result.StateBearing || result.Ack != (protocol.Ack{}) { | |
| 199 | - | t.Fatalf("result = %+v, want accepted side effect without ACK", result) | |
| 200 | - | } | |
| 201 | - | if !strings.Contains(probe.text(), string(tt.effect.Data)) { | |
| 202 | - | t.Fatalf("screen = %q, want side effect %q", probe.text(), tt.effect.Data) | |
| 203 | - | } | |
| 204 | - | if probe.state != beforeState { | |
| 205 | - | t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState) | |
| 206 | - | } | |
| 207 | - | if len(probe.checkpoints) != beforeCheckpoints+1 { | |
| 208 | - | t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints+1) | |
| 209 | - | } | |
| 210 | - | }) | |
| 211 | - | } | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | func TestProcessOutputFrameACKsOnlyAcceptedStateBearingFrames(t *testing.T) { | |
| 215 | - | size := domain.Size{Cols: probeTestCols, Rows: probeTestRows} | |
| 216 | - | for _, tt := range []struct { | |
| 217 | - | name string | |
| 218 | - | seed *protocol.Output | |
| 219 | - | output protocol.Output | |
| 220 | - | wantACKs int | |
| 221 | - | wantResets int | |
| 222 | - | }{ | |
| 223 | - | { | |
| 224 | - | name: "accepted full", | |
| 225 | - | output: protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")}, | |
| 226 | - | wantACKs: 1, | |
| 227 | - | }, | |
| 228 | - | { | |
| 229 | - | name: "accepted side effect", | |
| 230 | - | output: protocol.Output{Epoch: 1, Size: size, Data: []byte("side effect")}, | |
| 231 | - | }, | |
| 232 | - | { | |
| 233 | - | name: "rejected state", | |
| 234 | - | seed: &protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")}, | |
| 235 | - | output: protocol.Output{Epoch: 1, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("gap")}, | |
| 236 | - | wantResets: 1, | |
| 237 | - | }, | |
| 238 | - | } { | |
| 239 | - | t.Run(tt.name, func(t *testing.T) { | |
| 240 | - | transport := &probeTestTransport{} | |
| 241 | - | tr := &harnessTransport{Transport: transport, probe: newVisualProbe(size)} | |
| 242 | - | if tt.seed != nil { | |
| 243 | - | if err := processOutputFrame(tr, probeTestOutput(t, *tt.seed)); err != nil { | |
| 244 | - | t.Fatal(err) | |
| 245 | - | } | |
| 246 | - | transport.sent = nil | |
| 247 | - | } | |
| 248 | - | if err := processOutputFrame(tr, probeTestOutput(t, tt.output)); err != nil { | |
| 249 | - | t.Fatal(err) | |
| 250 | - | } | |
| 251 | - | var ackCount, resetCount int | |
| 252 | - | for _, frame := range transport.sent { | |
| 253 | - | switch frame.Type { | |
| 254 | - | case wire.MsgAck: | |
| 255 | - | ackCount++ | |
| 256 | - | case wire.MsgOutputResetRequest: | |
| 257 | - | resetCount++ | |
| 258 | - | } | |
| 259 | - | } | |
| 260 | - | if ackCount != tt.wantACKs { | |
| 261 | - | t.Fatalf("ACK count = %d, want %d", ackCount, tt.wantACKs) | |
| 262 | - | } | |
| 263 | - | if resetCount != tt.wantResets { | |
| 264 | - | t.Fatalf("reset count = %d, want %d", resetCount, tt.wantResets) | |
| 265 | - | } | |
| 266 | - | }) | |
| 267 | - | } | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | func TestCaptureUnifiedPickerRowsRequiresLocalAndRemoteEntries(t *testing.T) { | |
| 271 | - | probe := newVisualProbe(domain.Size{Cols: 48, Rows: 4}) | |
| 272 | - | probe.screen.Write([]byte("local-picker\npicker@remote")) | |
| 273 | - | ||
| 274 | - | rows := capturePickerRows(probe) | |
| 275 | - | if err := assertUnifiedPickerRows(rows, "local-picker", "picker"); err != nil { | |
| 276 | - | t.Fatalf("assert unified picker rows: %v; rows = %#v", err, rows) | |
| 277 | - | } | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | func TestVisualProbeRecordsUnexpectedAttachTarget(t *testing.T) { | |
| 281 | - | probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows}) | |
| 282 | - | probe.recordIncoming(wire.Frame{Type: wire.MsgAttachTarget}) | |
| 283 | - | ||
| 284 | - | if probe.unexpectedHandoffs != 1 { | |
| 285 | - | t.Fatalf("unexpected handoffs = %d, want 1", probe.unexpectedHandoffs) | |
| 286 | - | } | |
| 287 | - | if len(probe.events) != 1 || probe.events[0].Kind != probeEventUnexpectedHandoff { | |
| 288 | - | t.Fatalf("events = %#v, want one unexpected handoff event", probe.events) | |
| 289 | - | } | |
| 290 | - | } | |
| 291 | - | ||
| 292 | - | func TestAssertNoRemoteChromeIgnoresLocalBarsButRejectsContentLeak(t *testing.T) { | |
| 293 | - | require.Error(t, assertNoRemoteChrome(nil, "picker")) | |
| 294 | - | for _, test := range []struct { | |
| 295 | - | name string | |
| 296 | - | content string | |
| 297 | - | wantErr bool | |
| 298 | - | }{ | |
| 299 | - | {name: "local bars", content: "picker@remote\r\nremote content\r\nlocal bottom", wantErr: false}, | |
| 300 | - | {name: "remote status in content", content: "local top\r\npicker@remote\r\nlocal bottom", wantErr: true}, | |
| 301 | - | } { | |
| 302 | - | t.Run(test.name, func(t *testing.T) { | |
| 303 | - | probe := newVisualProbe(domain.Size{Cols: 48, Rows: 3}) | |
| 304 | - | probe.screen.Write([]byte(test.content)) | |
| 305 | - | err := assertNoRemoteChrome(probe, "picker") | |
| 306 | - | if (err != nil) != test.wantErr { | |
| 307 | - | t.Fatalf("error = %v, want error = %t", err, test.wantErr) | |
| 308 | - | } | |
| 309 | - | }) | |
| 310 | - | } | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | func TestHarnessArtifactIsBoundedAndContainsOnlyMetadata(t *testing.T) { | |
| 314 | - | dir := t.TempDir() | |
| 315 | - | artifact := newHarnessArtifact(dir) | |
| 316 | - | size := domain.Size{Cols: probeTestCols, Rows: probeTestRows} | |
| 317 | - | probe := newVisualProbe(size) | |
| 318 | - | probe.configure("local-picker") | |
| 319 | - | artifact.registerProbe(probe) | |
| 320 | - | secret := "not-for-artifact" | |
| 321 | - | for state := uint64(1); state <= maxProbeEvents+1; state++ { | |
| 322 | - | output := protocol.Output{Epoch: 1, Base: state - 1, New: state, Size: size, Data: []byte(secret)} | |
| 323 | - | if state == 1 { | |
| 324 | - | output.Full = true | |
| 325 | - | } | |
| 326 | - | if result := probe.apply(probeTestSemanticOutput(output)); !result.Accepted { | |
| 327 | - | t.Fatalf("probe output %d was rejected", state) | |
| 328 | - | } | |
| 329 | - | } | |
| 330 | - | if err := artifact.write(true); err != nil { | |
| 331 | - | t.Fatal(err) | |
| 332 | - | } | |
| 333 | - | path := dir + "/remote-picker-harness.json" | |
| 334 | - | data, err := os.ReadFile(path) | |
| 335 | - | if err != nil { | |
| 336 | - | t.Fatal(err) | |
| 337 | - | } | |
| 338 | - | if len(data) > maxArtifactBytes { | |
| 339 | - | t.Fatalf("artifact bytes = %d, want <= %d", len(data), maxArtifactBytes) | |
| 340 | - | } | |
| 341 | - | if strings.Contains(string(data), secret) { | |
| 342 | - | t.Fatal("artifact contained raw output") | |
| 343 | - | } | |
| 344 | - | var document artifactDocument | |
| 345 | - | if err := json.Unmarshal(data, &document); err != nil { | |
| 346 | - | t.Fatalf("artifact JSON: %v", err) | |
| 347 | - | } | |
| 348 | - | if !document.Passed || len(document.Probes) != 1 { | |
| 349 | - | t.Fatalf("artifact document = %+v, want one passed probe", document) | |
| 350 | - | } | |
| 351 | - | if len(document.Probes[0].Events) != maxProbeEvents { | |
| 352 | - | t.Fatalf("artifact events = %d, want %d", len(document.Probes[0].Events), maxProbeEvents) | |
| 353 | - | } | |
| 354 | - | if len(document.Probes[0].Checkpoints) != maxProbeCheckpoints { | |
| 355 | - | t.Fatalf("artifact checkpoints = %d, want %d", len(document.Probes[0].Checkpoints), maxProbeCheckpoints) | |
| 356 | - | } | |
| 357 | - | } | |