feat(client): client-owned navigation picker and presentation lease (#276)
* feat(client): own the navigation picker presentation The navigation session picker becomes a typed client interaction that the client presents itself while the daemon keeps mutation authority. This consolidates the client-ownership work into one change on current main. - protocol v44: `PickerOpen`, `PickerSnapshot`, `PickerSelection`, `PickerClose` (both directions), and `PickerFailure` with strict codecs, semantic validators, encoded bounds, and byte-for-byte tests; the client advertises the client-picker capability in `Hello`. - daemon: capability-gated picker interactions with opaque row keys resolved to navigation targets under the session locks, lifecycle revalidation before commit, fail-closed snapshot sends, an authoritative full repaint when the client retires the interaction, and full-snapshot refresh on row-set and size changes. The overlay model stays untouched for every other attachment. - client: snapshot admission, local cursor and search, typed commit and cancel, and a presentation lease that owns the terminal from the snapshot acquisition barrier to the daemon's release paint; suppressed daemon paints still advance the applied shadow, the acked window, and the published context boundary, and picker bytes never reach the PTY. - client: async clipboard reads are fenced to the foreground owner. - tests: lease state rules, picker loop and cancel release end to end through the real attach attempt, palette frame admission, navigation transaction boundaries, and the UI-driver acceptance scenarios. - acceptance: `client-picker-navigate` for local IPC and direct remote (UDP and stdio), driven by the observed picker text instead of row order. - docs: keep the preferences/status contracts as proposals, describe the shipped picker contract in the architecture and session-navigation docs, and drop the executed pilot plan, approval record, stack coverage maps, and completed-migration checkpoints. * fix(client): give the navigation picker exclusive input ownership The client-owned picker was reachable only through the ui-driver automation path: physical keystrokes bypassed it and reached the session while the modal owned the screen. Close that hole and the paths it opened. - client: a shared pickerConsumer publishes who owns terminal input; the stdin pump decodes physical bytes and admitted automation batches into identified operations, and the attach loop, its only model writer, applies them. Unknown, oversized and paste-sized batches are consumed instead of falling back to the session pipeline, a multi-key batch never acts as a command, and an ambiguous escape prefix is bounded by a 20 ms clock timer. - client: the release window consumes and drops input until the daemon's authoritative repaint lands, replacing the input-gate hold that never engaged without a ui-driver. - client: terminal rejections and superseding interactions send PickerClose, retire the interaction permanently against late snapshots, and resolve the superseded lease instead of aborting the newly installed one. Release now requires a full paint accepted after the close boundary. - daemon: a client-owned interaction drops raw key and mouse input for its attachment before the mouse scanner and the key router, and a selection must carry exactly the displayed revision. - daemon: the client picker keeps the directory-refresh path alive even though it installs no overlay model. - protocol v45: PickerOpen is removed; it had no emitter and the interaction opens on palette execution. - remove dead surface: the test-only lease and interaction helpers, the empty s/x key branches, and the UI-side consumer accessors. - tests: physical-input decoding and ownership, no-replay release, daemon key and mouse drops, revision equality, retirement and supersession, plus the existing suite. - docs: architecture and session-navigation describe the corrected ownership. * fix(client): order picker release behind the daemon close The release boundary compared against the locally applied output state, which cannot tell a paint already in flight from the post-close restore, and a paste was still guessed from batch size. Both are replaced by explicit state. - client: a release is only accepted once the serving daemon's own close has been observed, at the boundary current when it arrived; a Full in flight before it no longer releases the terminal. - client: a terminal rejection is idempotent: a second failure for an interaction already draining neither cancels the release nor re-sends a close. - client: a superseding interaction retires the replaced one, and input stays owned until the replacement displays, so the replaced picker frame cannot leak keystrokes to the session. - client: paste is decoder state, not a heuristic. Bracketed-paste content is dropped across reads, incomplete UTF-8 prefixes are withheld instead of lost, and events keep their arrival order, so a read carrying several legitimate keystrokes is applied in order rather than discarded. - client: the escape deadline is armed once per withheld prefix and an expired deadline resolves before a later suffix can complete it. - client: the outcome queue is bounded and blocks the producer, and a batch that changes nothing no longer composes a frame. - daemon: the interaction is retired before the selection handoff, so the destination paint is the paint the client's release waits for; a failed handoff still publishes a restore. The clipboard image path is blocked while the interaction owns input. - remove dead surface: the unused consumer predicate, the pump's unread UI field, the unused lease size epoch, and the presentation predicate superseded by the ownership check. - tests: release ordering and the daemon-close barrier, interaction retirement and supersession, clipboard blocking, a real catalogue change for the refresh, ordered multi-keystroke batches, fragmented paste and UTF-8, and a no-ui-driver composition. * test(client): pin the picker paths the review left unproven Close the three gaps the review flagged and the environment-dependent suite failure behind them. - picker: bracketed paste is enabled with the first frame and reset exactly once when the interaction ends, so a paste arrives marker-wrapped and the decoder drops it as a unit instead of it looking like fast typing. - tests: the no-ui-driver composition now waits for the drawn picker and asserts the committed row, the mouse drop has a positive control proving the same report reaches the pane without the interaction and resumes after the close, and the decoded-operation scope pins that a retired generation or superseded interaction never applies stale input. - uidriver: the socket fixture creates its directory under the system temporary directory. Building it under the checkout made the test fail whenever the checkout path was long enough to overflow the unix socket limit, which is why make test reported a failure unrelated to the code under test. * refactor(client): drop the picker helpers left unused by the input rework The consumer rework removed the last callers of one lease predicate, and two test helpers became dead once the headless and paste tests were fixed: the harness fires deadlines through awaitAfterAmbiguityDeadlines instead. golangci-lint unused now reports no symbol on this branch that is not already unused on main. * wip(picker): unify the picker contract on v46 structured lines Foundation slice of the picker unification: the protocol carries one structured line model and one typed action set (navigate, move, kill) per source; the pure picker model renders lines instead of rebuilding fake session views from wire rows; and the serving daemon computes destination eligibility and publishes lines for every intent. Incomplete: the daemon overlay deletion, the client presentation of move intents, the home-picker removal, and the preview service still need to land before the branch compiles. * wip(picker): present every picker intent from the client The serving daemon now publishes structured lines for navigation, move-pane, and move-tab intents, offers the interaction with its acquisition barrier, and resolves typed navigate/move/kill selections; the client owns the model, cursor, search, sort, and per-row action choice for all three intents. Removes the daemon overlay picker, its input routing, its render branch, its preview subscriptions, and the startup-overlay entry, along with the picker search input handler and the parked-overlay generation plumbing. Production code compiles; the test suites that drove the overlay still need to be rewritten against the typed interaction. * test(wire): pin the v46 picker codecs and version Replaces the opaque-row codec tests with offer/begin/source-snapshot/ selection/result/closed byte-level coverage, including truncated-prefix and trailing-garbage checks, and moves the protocol version goldens to 46. * test(sessionwire): cover the v46 picker message directions Pins the begin/offer/close/closed/selection/result/failure frame types, their payload round trips, and the wrong-direction rejections for both adapter directions. * test(picker): pin the line model contract Replaces the session-view driven model tests with table-driven coverage of the line model: cursor restore by key, section-aware movement, local recency and grouped ordering, in-place refresh, search visibility, nearest-row snapping, layout boundaries, preview blitting, and status badges. * test(daemon): drive the picker suite through the typed interaction Rewrites the picker interaction tests against v46: offer and snapshot publication, per-source revisions, navigate/move intents, foreign-source and stale-revision rejections, close confirmation ordering, and input ownership. Drops the overlay-driven suites the deleted presentation owned and moves the tab-with-context helper into a shared test file. * test(daemon): adopt the typed kill in the effect-gate and policy suites The remote activation policy suite now drives the daemon-private view type, and the picker-delete gate tests send a typed kill selection through the same resolution path the client uses. Kill holds its admission until it finishes so a replacement initiator cannot leave the destructive action unattributed. * test(daemon): drop the picker cases from the raw-input precedence suite The daemon no longer routes raw input to a picker model, so the precedence suite keeps only the prompt, palette, and copy layers. Picker input ownership is covered by the typed interaction suite. * test(daemon): move the picker suites onto the typed interaction Rewrites the move-picker suite around typed move selections and typed opens, adapts the remote picker view/availability/handoff suites to the daemon-private view and line-set types, and drops the suites that drove the removed overlay preview subscriptions, parked picker generations, and daemon-side picker rendering. * test(daemon): compile the remaining picker suites against v46 Drops the picker modal from the composition, paint-invalidation, and damage fixtures, opens typed interactions where the suites used the overlay entry, and teaches the typed-connection test double the v46 offer/closed/result frames. The daemon test package now builds; several move-picker expectations and one picker cancel expectation still fail and one picker test hangs. * fix(picker): publish per-tab remote targets and keep previewed tabs renderable Each remote tab row now carries its own restore target (stable tab ID when known, ordinal fallback otherwise) instead of the session-level selector, and a fresh interaction starts on a session's active tab. A rejected move reports the same precise user notice the palette path does, the retired-target gate now applies to navigation only, and a headless tab stays renderable while a preview subscriber watches it so the preview service can compose from it. The daemon test package is green. * test(client): drive the picker suites through v46 offers and typed actions Replaces the overlay-era fixtures with offer plus per-source snapshot publication, typed navigate/move/kill commits, per-source revision admission, and close confirmations carrying the restore barrier. Fixes two client bugs the rewrite exposed: a background refresh of a superseded interaction replaced the open model instead of the retired one, and a search keystroke did not mark the frame dirty. * test(perf): keep the compact storage budgets measurable under -race The performance fixtures built a 10k-row history for every scenario, which the race detector turned into most of the suite's runtime while the allocation and byte budgets they feed are calibrated without it. Derive the fixture depth and its expectations from one constant, shrink it under -race like the existing copy-entry budget gate, and skip the byte-eviction budget there. Cold make test drops from 149s to a level dominated by real work. * test(remote): assert the bounded store lane by outcome, not by winner Two cache stores admitted back to back race for the lane's single extra slot: which one is accepted is admission timing, so the test now follows the reported lane-busy endpoint and checks that the payload the lane accepted is the payload it reported success for. This removes the intermittent failure seen under full suite load while keeping the bounded-lane contract pinned. * refactor(picker): delete the home-picker and parked-route presentation path The picker no longer parks a route: every attached client presents it, so the daemon never asks a client to open a home picker. Removes the client home-picker entry and its transient attachment, the parked-route lease state machine, the navigation directives, and the protocol surface they needed (startup overlay, home-picker and back capabilities, open-home action, parked-route request and response), with the Hello layout losing its overlay byte at v47. Attachment parking and resume stay: they carry the UDP and hybrid handoff. Inline picker capability advertisement goes too, since v46 admits the picker on every attachment. * fix(client): start the foreground pump before the receive loop Removing the parked-route helpers also dropped the unconditional startForeground call that preceded the receive loop, leaving the client without a reader until the first full output frame. Terminal palette replies were never read and keypresses never reached the session, so attachments stalled before the first paint. Restore the call where the helper block ended: the foreground owns terminal input from attach start, and the same-peer and picker paths suspend it through the input gate instead of starting it late. * fix(client): bind navigation capabilities to the intent that serves them The client validated an attach request against the capability bit set alone, so a capability the serving daemon rejects on Hello could still dial. Inventory is the one negotiable feature under v47, and only the intents whose attachment owns a relay can serve it. Share one intent-aware rule between the Hello validator and the client's pre-dial validation, and cover the ephemeral case that now fails closed. * test(client): drive the hybrid route switches through the client picker The parked-route suites that covered same-host reuse and cross-host fallback were removed with the mechanism they drove. Re-drive both journeys at the runner boundary: the client opens the picker from the serving daemon's offer, commits a row with a real keypress, and then either confirms the same-host target on the authenticated transport or dials the target endpoint and closes the source. Teach the external typed-connection bridge the picker messages so those journeys can cross it. * fix(daemon): republish an open client picker on the attention pulse A bell raised while a client presents the picker never reached it: the client owns the modal, so the daemon's repaint is suppressed and only a newer picker source updates the rows. Republish the serving source for every attachment whose picker interaction a client presents, on the same attention pulse that already refreshes the status bar while a bell rings. Cover the notification content the client renders and the pulse-driven refresh, including that a retired interaction is never reopened. * feat(protocol): carry a typed picker preview pair at v48 The client owns picker presentation, so the row preview must cross the wire instead of being painted by the daemon: the client names the row it displays and the serving daemon answers with a bounded styled-cell viewport. Add PickerPreviewRequest and PickerPreview with their strict codecs, share the cell layout and style block with the remote preview, and bump the session protocol to v48 for the new frame types. * feat(sessionwire): carry the picker preview pair across the wire Map the client's preview request and the daemon's preview answer in both directions, mark the new messages, and reject each in the wrong direction so a malformed peer cannot smuggle one through the other codec. * feat(daemon): serve picker row previews on request The client names the row it displays and the serving daemon captures it, reusing the navigation preview facts already in place: previewTarget and snapshotPickerPreview for local sessions, the remote preview cache for remote rows. A newer request supersedes the previous render subscription and the retirement of the interaction tears it down, so only the displayed row keeps publishing and a delayed capture cannot replace it. The capture is fitted to the requested bounds, blanking a double-width cell whose continuation column was cropped so the published cell run stays valid. * feat(client): request and render the displayed row preview The picker modal asks the serving daemon for the preview of the row the cursor rests on, debounced so scrolling does not ask for every row it passes, and renders the published viewport in its preview pane. One request is in flight per interaction: a row already requested is never asked twice, a late answer for a row the user left is discarded, and a status-only answer clears the viewport. * refactor(daemon): name the serving source through the protocol constant * feat(ports): add the client host registry seams The client must resolve a remote endpoint without importing an adapter: add the endpoint binding it reuses across handoffs, the factory the composition root implements from transport mode and launch policy, and the runner-scoped registry that also exposes the discovery projection. * feat(remotes): add the runner-scoped endpoint registry One runner resolves each remote endpoint once and reuses that binding for every later handoff, while projecting the discovery directory it drives. A failed resolution is never cached, so a refused endpoint retries through the same single authority instead of falling back to another resolver, and returned bindings are defensive copies that keep a nil environment distinct from an explicitly empty one. * feat(app): compose the client host registry and endpoint policy The launching client gets its own discovery composition: the same store, catalogue client, and monitor the daemon uses, over a runner-local catalogue cache that seeds from the durable snapshot and never writes it, so the two monitors cannot race the same file. Endpoint resolution applies the configured allowlist, transport mode, and endpoint environment in one place, and an explicitly empty environment stays distinct from an absent one. * feat(client): resolve handoffs through the runner host registry The runner no longer takes a per-target handoff callback: it resolves the endpoint through the host registry it also drives, so the endpoint binding (typed dialer and environment) is created once and reused for every later handoff, and one discovery loop serves the whole run instead of one per connection. The composition root keeps exactly the policy it owned: it validates the transport mode, the launch allowlist, and the per-endpoint environment behind the registry, and the runner derives each handoff request from the validated target plus that binding, including the picker environment rule a locally launched client follows. * test: pin the client registry beside daemon-owned remote picker data Cover the two owners together: a remote picker row is still captured through the daemon's own remote viewport client, and a client route that crosses serving peers resolves each endpoint through its registry and dials a fresh connection through the binding it already has. Document the current shape: the picker is client-presented on every attachment, previews and attention are daemon-published data, and remote discovery has a daemon owner and a runner-scoped client owner with separated catalogue writers. * refactor(client): drop the retired handoff callback type The registry seam replaced it, so the type only invited a second endpoint authority back in. * fix(daemon): key remote picker rows by their owning endpoint One machine can be registered under more than one target, a pinned alias and one learned by attach, and both endpoints legitimately expose the same session lifecycle and name. The picker keyed rows by incarnation and name alone, so those rows collided, the snapshot failed validation, and the session picker never opened at all: the palette reported "invalid navigation" and the user saw nothing. Qualify a remote row's key with its endpoint through a bounded digest, which keeps the key opaque and unique per endpoint, and pin the case. * fix(client): present the picker as a floating modal The client drew the picker into a full-terminal frame, so opening it wiped the session and the modal looked nothing like the daemon-owned overlay it replaced. Resolve the same 80% centred box the daemon used, render the rows into its inner rectangle, and write only the box's own cells: the session stays on screen around it until the daemon's authoritative repaint after the close. The box now lives in the picker package because both sides need it: the client draws it and the daemon sizes the preview it captures from it, so the client asks for exactly the pane it will blit into instead of the whole terminal. * fix(daemon): give remote picker rows their session incarnation A remote row left its incarnation zero, so every live session on one host shared one row identity. Two sessions on that host then produced the same row key, the snapshot failed validation, and the picker never opened: two sessions on a remote host were enough to break it. Use the session lifecycle, exactly like local rows, and cover two live sessions on two endpoints of the same host. * fix(protocol): say which row made a picker snapshot invalid A refused snapshot reported only "invalid navigation", which left a silently closed picker with nothing to debug. Wrap the reason with the offending row, and log the rejected snapshot with both colliding rows and the offer rejection with its cause. * feat(protocol): carry picker row focus eligibility The daemon computes selectability and focusability separately, but the published picker row only carried actions: the client rebuilt focus as "anything but a section header" and let the cursor rest on rows the source had deliberately skipped, where Enter silently does nothing. Publish the decision instead. PickerLine gains Focusable, with the invariants that an action row is always focusable, a section header never is, and a focusable row without actions is inspection-only. A malformed snapshot is rejected before it reaches the wire. Remote host status rows are published as PickerLineHost rather than a session row: they carry no session identity and exist for inspection. The row flag byte gains the focus bit and the protocol version moves to 49, since the line layout changed. Fixtures across protocol, adapters, client, and daemon set the flag so every published snapshot stays valid. * feat(picker): keep the cursor on rows the source authorises The cursor visited every row that was not a section header, including session headers the daemon had published without focus, so Enter on such a row did nothing and the footer still promised "Enter open". Place the cursor only on rows the source marked focusable, narrowed by the client's own query, and let every placement path agree: arrow movement, nearest-row selection, restoration by key after a refresh, sorting, and the search editor. A query with no matches clears the cursor instead of leaving a visible one that cannot commit, and rows the query hides are refreshed before the cursor is restored by key, so an index-keyed match map can never describe a rebuilt row list. The footer derives its hints from the effective selection, so a row kept for inspection reports "Enter unavailable" while still showing its status detail. * fix(daemon): revalidate remote picker targets in the handoff Selecting a remote session closed the picker, then reported a retired target and switched nowhere: the freshness gate resolved every committed row against the daemon's local session table, and a remote row names an endpoint-qualified session that table never holds. Remote targets carry their own route, so the handoff is their lifecycle gate: it revalidates the exact endpoint, session, lifecycle, and tab against the remote catalogue and reports its own precise rejection. The local gate keeps guarding local rows. * fix(remotes): publish the first registry read even when it is empty A host with no registered remote hosts never published its directory: the first successful registry read only marked the state dirty when the registration list changed, so an empty registry looked like an unfinished check. Every reader then showed a permanent "checking remotes" placeholder where there was simply nothing configured. Publish that first read: an empty registry is a known state. The picker on such a host no longer carries a phantom remote section. * feat(picker): move session navigation to client authority * fix(picker): revalidate home selections by target * fix(picker): avoid move selection self-deadlock * fix(picker): follow moved tab to destination * fix(picker): atomically follow moved content * fix(picker): harden authority refresh and completion * test(uidriver): resolve macOS temporary directory symlinks * fix(palette): relay only supported local inventory * fix(status): restore remote attention indicators * fix(navigation): complete cross-daemon picker presentation
- commit
- 7091acbe9b6afcba1933290ccf81b0899332e241
- author
- bnema · · 2026-09-12 14:08 UTC
- committer
- GitHub · · 2026-09-12 14:08 UTC
- parent
- 0dec724
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
170 files · +15319 -9534
M AGENTS.md +2 -2
| @@ -12,7 +12,7 @@ make test | |||
| 12 | 12 | go test ./internal/usecase/daemon -race -run TestName | |
| 13 | 13 | make lint | |
| 14 | 14 | make mocks | |
| 15 | - | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem | |
| 15 | + | go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem | |
| 16 | 16 | ``` | |
| 17 | 17 | ||
| 18 | 18 | - Format with **goimports**. | |
| @@ -68,7 +68,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o | |||
| 68 | 68 | Typed messages and negotiated version live in `internal/protocol`. Remote discovery JSON lives in `internal/protocol/catalogue`. Message IDs, frames, strict codecs, compression, encoded bounds, and raw carriage interfaces live in `internal/protocol/wire`. Connection framing lives in concrete carriage adapters such as `internal/adapters/ipc`. | |
| 69 | 69 | ||
| 70 | 70 | - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload. | |
| 71 | - | - Client message types occupy `1–13`, `15`, `32–33`, and `35` (`MsgParkedRouteRequest`); server types occupy `16–23`, `25–31`, `34`, and `36` (`MsgParkedRouteResponse`). Types `14` and `24` remain reserved. | |
| 71 | + | - Client message types occupy `1–13`, `15`, `32–33`, `35` (`MsgParkedRouteRequest`), and `49`/`51`/`53` (`MsgPickerOpen`/`MsgPickerCloseClient`/`MsgPickerSelection`); server types occupy `16–23`, `25–31`, `34`, `36` (`MsgParkedRouteResponse`), and `50`/`52`/`54` (`MsgPickerSnapshot`/`MsgPickerCloseServer`/`MsgPickerFailure`). Types `14` and `24` remain reserved. | |
| 72 | 72 | - Version negotiation requires strict equality. | |
| 73 | 73 | - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work. | |
| 74 | 74 | - Bump `ProtocolVersion` for any message layout change. | |
D docs/ansi-content-replay-phase0.md +0 -11
| @@ -1,11 +0,0 @@ | |||
| 1 | - | # Phase 0: ANSI content replay gate | |
| 2 | - | ||
| 3 | - | The former test-only prototype in `internal/usecase/daemon/ansi_content_replay_phase0_test.go` recorded the feasibility boundary for remote content. That prototype was removed when direct remote ownership became the production boundary; the constraints it established remain: | |
| 4 | - | ||
| 5 | - | - ANSI produced by `pkg/renderer` is replayed into a private `pkg/vt.Screen`. | |
| 6 | - | - The screen has nil response, bell, notification, progress, and clipboard callbacks, so replay cannot emit remote host-side effects. | |
| 7 | - | - Full and incremental output, styles, wide-cell continuation, cursor state, and split CSI/OSC writes are captured as cells and cursor metadata. | |
| 8 | - | - The captured grid can be handed to a local renderer for composition; the remote ANSI stream is not passed to that renderer or a transport. | |
| 9 | - | - A test-only ordered-frame model requires metadata before the first full content frame, contiguous state-bearing deltas, and base-zero full resets. Gaps and malformed resets are rejected. | |
| 10 | - | ||
| 11 | - | This remains feasibility evidence only. Direct remote attachments keep the remote daemon as the owner of the renderer, protocol, layout, and attach flow; no ANSI replay or local shadow-session compatibility path is implied. | |
M docs/architecture.md +23 -1
| @@ -42,6 +42,28 @@ Interactive observation is a separate opt-in composition. `term.Terminal` remain | |||
| 42 | 42 | ||
| 43 | 43 | `--web-daemon` launches a separate gateway with an inherited HTTP listener, loopback by default. App composition resolves `web.listen`/`web.origin` and CLI overrides before binding. The web adapter validates the configured browser-facing Host and Origin without trusting forwarded headers; a proxy owns TLS. The same-user local control socket returns the running gateway's settings and memory-only credential; readiness probes contact only its local listener. Each authenticated WebSocket owns a normal client runner and a `webterm.Terminal`. The adapter encodes browser events as terminal input, mirrors flushed client output through `vev-vt`, and sends transactional HTML updates. Disconnect cancels only that attachment. The multiplexer remains daemon-owned; browser input does not weaken the restricted UI-driver automation contract. | |
| 44 | 44 | ||
| 45 | + | ## Client-owned picker presentation | |
| 46 | + | ||
| 47 | + | Every attachment presents the navigation picker itself. The serving daemon publishes a typed interaction instead of installing an overlay: `PickerOffer` names the intent and the acquisition barrier, `PickerSnapshot` carries opaque row keys plus display text, `PickerSelection` commits a key at the displayed revision, `PickerClose` retires the interaction in either direction, and `PickerFailure` reports a bounded rejection. The daemon owns no presentation model: it keeps facts, eligibility, and mutations, and resolves committed keys to navigation, move, or kill targets. | |
| 48 | + | ||
| 49 | + | Source eligibility travels with every row. `PickerLine.Focusable` is the source's authorisation to rest the cursor on that row, while `PickerLine.Actions` is what the row may commit; an action row is always focusable, a section header never is, and a focusable row without actions is published for inspection only. The client's cursor visits focusable rows, narrowed by its own search query, so it never lands on a row the source skipped and the footer never promises a commit the cursor cannot make. | |
| 50 | + | ||
| 51 | + | The client owns who writes the terminal for the interaction lifetime, while the daemon keeps mutation authority and resolves keys. `pickerLease` in `internal/usecase/client` tracks the presentation states: a snapshot is admitted only once the client applied output through the barrier epoch/state it carries, daemon paints are then applied to the output shadow and acknowledged inside the bounded window without being written, and the interaction is released only by a full paint accepted after the close boundary. Locally composed frames and repaints go through the same single writer, UI transaction, and flush as daemon output, so a frame is never claimed before it is displayed. | |
| 52 | + | ||
| 53 | + | Input ownership is exclusive for the interaction lifetime. `pickerConsumer` in `internal/usecase/client` publishes the current owner to the stdin pump: while the picker owns input the pump decodes physical bytes and admitted automation batches into identified operations and the attach loop, its single model writer, applies them. Nothing falls back to the session pipeline: unknown, oversized, and paste-sized batches are consumed, a multi-key batch is never applied as a command, and an ambiguous escape prefix is bounded by a short deadline instead of being forwarded. The serving daemon mirrors the rule: while `pickerOpen` it drops raw key and mouse input for that attachment (`handleInput`/`handleInputForAttachment` return before the mouse scanner and the key router), so only typed `PickerSelection`, `PickerClose`, and `PickerPreviewRequest` can act. A terminal close — a resolved selection, a cancel, a stale-revision or retired-target rejection, or a superseding overlay — sends `PickerClose` when the client must retire the interaction itself, keeps consuming and dropping input until the daemon's authoritative repaint lands, and only then resumes normal routing. | |
| 54 | + | ||
| 55 | + | ### Row previews | |
| 56 | + | ||
| 57 | + | A preview is data, not a daemon paint: the client asks for the row its cursor rests on with a debounced `PickerPreviewRequest` (80 ms, one request in flight per interaction), and the serving daemon answers with a bounded styled-cell `PickerPreview`. The daemon captures local rows through the navigation preview facts it already owns (`previewTarget` plus `snapshotPickerPreview`), captures remote rows through its own remote viewport client and cache, and registers the requested row as that viewer's render subscription, so a row that keeps changing keeps publishing while a superseded request publishes nothing. Unchanged or retired rows answer with a status-only preview, and the client clears its viewport rather than showing another row's content. The client composes the viewport into its own modal; the daemon never writes it. | |
| 58 | + | ||
| 59 | + | ### Attention and notifications | |
| 60 | + | ||
| 61 | + | Attention is a daemon fact, not an overlay: a bell raised by a PTY, by a status-line notification, or by an agent run marks the owning tab, and the daemon publishes it in the frames its attachments receive. The status bar carries it as a pulsing glyph, the picker session header carries it inline, and `PickerLine.Attention` carries it on the tab row the client renders. Because a client-owned modal suppresses daemon paints, the attention pulse republishes the picker source for every attachment whose interaction the client presents, so a bell raised while the picker is open still reaches the displayed rows. | |
| 62 | + | ||
| 63 | + | ## Remote hosts | |
| 64 | + | ||
| 65 | + | Remote discovery has two owners with separated writers. The daemon composes its own store, catalogue client, cache, and monitor for the structured rows and exact-target validation its picker serves. The launching client composes a runner-scoped `remotes.HostRegistry` over its own store, catalogue client, and a runner-local catalogue cache that seeds from the durable snapshot and never writes it, so one monitor can never truncate the other's file. The registry projects that discovery through `ports.RemoteDirectory` and resolves each endpoint once into a `ports.RemoteEndpointBinding` — the typed dialer every session on that endpoint attaches through, plus the environment to advertise — which the runner reuses for its initial remote attach and for every later handoff. Transport mode, launch allowlist, and endpoint environment stay in `internal/app` behind `ports.RemoteEndpointFactory`; the client never inspects them, and a resolution failure is never cached, so a refused endpoint fails the handoff instead of falling back to another resolver. | |
| 66 | + | ||
| 45 | 67 | ## Session composition | |
| 46 | 68 | ||
| 47 | 69 | ```text | |
| @@ -61,4 +83,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||
| 61 | 83 | - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`. | |
| 62 | 84 | - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`. | |
| 63 | 85 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | |
| 64 | - | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `43`, including source-scoped route subscriptions and lifecycle retirement). | |
| 86 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `48`, including the client-picker interaction and its preview pair). | |
A docs/plans/client-preferences-contract.md +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | # Client preferences contract (P4.1, design-only) | |
| 2 | + | ||
| 3 | + | Status: proposed. No production edits accompany this document. Any scope | |
| 4 | + | change below requires explicit approval before implementation. | |
| 5 | + | ||
| 6 | + | ## Ground rules | |
| 7 | + | ||
| 8 | + | - Shared pane VT defaults stay daemon-arbitrated. `applyHostThemeLocked` | |
| 9 | + | (`internal/usecase/daemon/theme_resolve.go`) fans the resolved theme out to | |
| 10 | + | every pane VT in every tab of the session; per-attachment values never reach | |
| 11 | + | pane screens. | |
| 12 | + | - Picker sort scope is unchanged: `togglePickerSort` flips the daemon-wide | |
| 13 | + | `pickerSort` atomic (`internal/usecase/daemon/picker.go:40`); it is session | |
| 14 | + | chrome, not an attachment preference. | |
| 15 | + | - Bindings stay daemon-admitted. `keys.BuildBindingEntries` + | |
| 16 | + | `keys.Router.RouteWithHandler` (`internal/usecase/keys`) remain the single | |
| 17 | + | admission path; no duplicate client-side key router is proposed. | |
| 18 | + | - Config source of truth stays the daemon-loaded file parsed by | |
| 19 | + | `internal/adapters/config` into `domain.Config`, published atomically via | |
| 20 | + | `Daemon.ApplyConfig` snapshots (`internal/usecase/daemon/config.go`). | |
| 21 | + | ||
| 22 | + | ## Classes | |
| 23 | + | ||
| 24 | + | - **Client-local interaction**: affects only the local terminal device, never | |
| 25 | + | pane VT state, session state, or another attachment. The only class eligible | |
| 26 | + | for client-side ownership without a P5 lease. | |
| 27 | + | - **Attachment-rendering**: affects presentation of one attachment, executed by | |
| 28 | + | the serving daemon from per-attachment state. Shared session/pane state | |
| 29 | + | untouched. | |
| 30 | + | - **Shared application-visible**: affects panes, sessions, or all attachments. | |
| 31 | + | Daemon-owned; not candidates for client ownership in this plan. | |
| 32 | + | ||
| 33 | + | ## Field classification | |
| 34 | + | ||
| 35 | + | All keys are the existing `internal/adapters/config` keys. "Two-attachment | |
| 36 | + | result" describes two simultaneous attachments A and B on the same session | |
| 37 | + | with different local wishes. | |
| 38 | + | ||
| 39 | + | | Key | Class | Source / default / reload / validation | Two-attachment result | | |
| 40 | + | |---|---|---|---| | |
| 41 | + | | `theme`, `theme.palette`, `theme.accent` | Shared | Daemon config file; defaults `auto`/`on`/`auto` (`domain.Defaults`); atomic reload via `storeThemeConfig`, reapplied with `reapplyThemeAllSessions`; invalid values warn and keep prior (`config_test.go`) | Single effective theme per session (`effectiveThemeForConfig`, `resolveAppliedTheme`); per-attachment client theme is only an input to arbitration, never applied directly to panes. **Preserved as-is.** | | |
| 42 | + | | `scrollback.*` | Shared | Daemon config; defaults 50 MiB / 10k lines; `Scrollback.Valid()` falls back to defaults with a warning | Per-pane retention policy; identical for every viewer. **Preserved as-is.** | | |
| 43 | + | | `snapshot.*` | Shared | Daemon config; allowlist snapshot via `restoreProcessAllowlistFromConfig` | Session restore; identical for every viewer. **Preserved as-is.** | | |
| 44 | + | | `floating.command`, `floating.width`, `floating.height` | Shared | Daemon config; defaults 80x80; stored via `floatingConfig` | Geometry arbitration is daemon-owned (`calculateContentFloatingGeometry`). **Preserved as-is.** | | |
| 45 | + | | `bar.top-right`, `bar.bottom-right`, `bar.interval` | Shared | Daemon config; defaults empty/empty/5s, clamped to `MinBarInterval` (1s) | Daemon-host execution, per-session outputs. Governed by the status contract; **no client execution proposed here.** | | |
| 46 | + | | `code.*`, `binding.*` | Shared | Daemon config; `buildCodeOverrides` + `BuildBindingEntries`, warn-and-keep on invalid | Daemon-admitted palette commands and key bindings. **Single router preserved.** | | |
| 47 | + | | `copy.word-separators`, `copy.reduce-motion` | Shared (daemon-executed attached interaction) | Daemon config; defaults ` -_@` / false; `currentCopyConfig` snapshot | Copy mode stays daemon-owned per plan non-goals. Selection documents (`scopy.NewDocument`) and scroll animation execute in the daemon. **Not candidates; moving them requires the P5 lease.** | | |
| 48 | + | | `nav.overflow-tabs`, `nav.overflow-sessions` | Shared (attached-action behavior) | Daemon config; defaults false; `currentNavConfig` | Directional navigation is daemon-admitted (`pane_actions.go:465`). **Not candidates.** | | |
| 49 | + | | `tabs.terminal-title` | Attachment-rendering | Daemon config; default true; `currentTabsConfig` snapshot read at status composition (`status.go:303,334`, `picker.go:96`) | Label-only; per-attachment divergence is safe in principle, but no per-attachment source exists today. **Proposed future field (example): read from attachment claim, defaulting to daemon config. Requires approval; not implemented here.** | | |
| 50 | + | | `palette.anchor` | Attachment-rendering | Daemon config; default center; `currentPaletteConfig` read at render (`render.go:443`) | Placement-only; same future-field shape as above. **Requires approval; not implemented here.** | | |
| 51 | + | ||
| 52 | + | ## Proposed fields and examples | |
| 53 | + | ||
| 54 | + | No new configuration keys are introduced by this contract. The only proposed | |
| 55 | + | shape, pending approval, is: an attachment-rendering value resolves as | |
| 56 | + | *attachment-claimed value if present, else daemon config*, read at composition | |
| 57 | + | time from existing snapshots, never written to `domain.Config` or persisted. | |
| 58 | + | `tabs.terminal-title` and `palette.anchor` are the worked examples above. | |
| 59 | + | ||
| 60 | + | Explicitly out of scope: client-local clipboard policy, client-side theme | |
| 61 | + | editing, per-attachment scrollback, per-attachment bindings, and any new | |
| 62 | + | `domain.Config` field. Each would need its own approved contract. | |
| 63 | + | ||
| 64 | + | ## Tests mapped (to be added only after approval) | |
| 65 | + | ||
| 66 | + | - `TestAttachmentRenderingPreferenceFallsBackToDaemonConfig` (daemon): | |
| 67 | + | attachment without claim renders with daemon `palette.anchor` / | |
| 68 | + | `tabs.terminal-title`. | |
| 69 | + | - `TestAttachmentRenderingPreferenceClaimWins` (daemon): claimed value used, | |
| 70 | + | shared pane VT colors byte-identical to unclaimed control. | |
| 71 | + | - `TestTwoAttachmentsIndependentRenderingPrefs` (daemon): A and B on one | |
| 72 | + | session render different anchors/titles; pane screens identical. | |
| 73 | + | - `TestInvalidRenderingPreferenceWarnsAndKeepsPrior` (config + daemon): | |
| 74 | + | warn-and-continue path mirrors existing theme/scrollback tests. | |
| 75 | + | - Existing preserved-behavior pins: `TestDefaults*`, `TestParse*` in | |
| 76 | + | `internal/adapters/config`, `config_test.go` snapshot tests, | |
| 77 | + | `picker_test.go` sort tests, `theme_integration_test.go`. | |
| 78 | + | ||
| 79 | + | ## Stop condition | |
| 80 | + | ||
| 81 | + | P4.1 ends at this document. No preference scope change, no `domain.Config` | |
| 82 | + | field, no wire field, and no client ownership move is authorized by this | |
| 83 | + | plan alone. | |
A docs/plans/client-status-contract.md +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | # Client status contract (P4.2, design-only) | |
| 2 | + | ||
| 3 | + | Status: proposed. No production edits accompany this document. No provider | |
| 4 | + | implementation is authorized by this plan alone; a runnable task breakdown | |
| 5 | + | follows only after contract approval. | |
| 6 | + | ||
| 7 | + | ## Current daemon-host behavior (preserved) | |
| 8 | + | ||
| 9 | + | - Commands come only from the daemon-loaded config: `bar.top-right`, | |
| 10 | + | `bar.bottom-right`, `bar.interval` (defaults empty/empty/5s, | |
| 11 | + | `internal/adapters/config`, `domain.Defaults`). | |
| 12 | + | - Execution is daemon-host via `barScriptRunner.run` | |
| 13 | + | (`internal/usecase/daemon/bar_script.go`) with `ports.ShellCommandRunner`: | |
| 14 | + | timeout 1s (`barScriptTimeout`), stdout cap 1024, stderr cap 512, display | |
| 15 | + | cap 256, first-line + `stripANSIAndControls` sanitization, warn-once | |
| 16 | + | failure dedup (`lastFailure`). | |
| 17 | + | - Scheduling is per-session in `bar_refresh.go`: poller on the daemon clock | |
| 18 | + | with `MinBarInterval` (1s) floor, `running`/`pending` coalescing, context | |
| 19 | + | change (`lastContext`) forces refresh, `reload` channel wakes the poller on | |
| 20 | + | config change, `refreshBarScriptsAllSessions` applies new commands | |
| 21 | + | immediately. | |
| 22 | + | - Scripts observe daemon-side context only: `VEV_ANCHOR`, `VEV_SESSION`, | |
| 23 | + | `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, `VEV_COLS`. | |
| 24 | + | - Composition reads snapshots at paint time: `barScriptSnapshot(sess)` feeds | |
| 25 | + | `barState.topRight`/`bottomRight` alongside `status`, `statusFeedback`, | |
| 26 | + | `mru`/`rankedRecent`, `attentionFrame` (`status.go:161,308,334`). | |
| 27 | + | - The client has no status-provider code today: no reference to | |
| 28 | + | `topRight`/`bottomRight` exists under `internal/usecase/client`. The client | |
| 29 | + | renders daemon-published frames verbatim. This contract does not change | |
| 30 | + | that until its implementation is approved. | |
| 31 | + | ||
| 32 | + | ## Opt-in client-local provider contract | |
| 33 | + | ||
| 34 | + | ### Configuration authority | |
| 35 | + | ||
| 36 | + | - The command string for a client-local provider comes exclusively from the | |
| 37 | + | **local** configuration authority (the config file loaded by the local | |
| 38 | + | process composition). A remote daemon MUST NOT supply a shell command over | |
| 39 | + | the wire; the wire carries only rendered text values (see fields below). | |
| 40 | + | - Existing `bar.top-right` / `bar.bottom-right` scripts remain daemon-host | |
| 41 | + | execution with unchanged semantics. Client-local providers target the same | |
| 42 | + | two slots but never replace daemon execution implicitly: both run, and | |
| 43 | + | precedence (below) decides what paints. | |
| 44 | + | - Opt-in per slot: unset means absent. No implicit fallback to a remote path, | |
| 45 | + | and remote pane CWD is never interpreted as a local path. A provider that | |
| 46 | + | needs a working directory uses only locally configured values. | |
| 47 | + | ||
| 48 | + | ### Generations | |
| 49 | + | ||
| 50 | + | - **Provider generation**: a monotonically increasing epoch owned by the | |
| 51 | + | executing side, bumped on every configuration reload affecting the slot. | |
| 52 | + | Stale values (older provider generation than the latest admitted for that | |
| 53 | + | slot and attachment) are discarded, never painted. | |
| 54 | + | - **Attachment generation**: the existing attachment identity/generation used | |
| 55 | + | in output arbitration binds a value to the attachment it was produced for. | |
| 56 | + | A value admitted for attachment A is never painted on attachment B; on | |
| 57 | + | navigation/switch, pending provider values for the retired attachment are | |
| 58 | + | dropped with the foreground owner. | |
| 59 | + | ||
| 60 | + | ### Bounds, queues, cancellation | |
| 61 | + | ||
| 62 | + | - Keep the existing daemon bounds unless separately approved: 1s timeout, | |
| 63 | + | 1024-byte stdout cap, 512-byte stderr cap, 256-rune display cap. | |
| 64 | + | Client-local execution adopts the same caps. | |
| 65 | + | - Bounded work queue per attachment (proposed depth 1 with coalescing, | |
| 66 | + | mirroring `running`/`pending`): a slow provider run never stacks; a newer | |
| 67 | + | tick marks pending and supersedes. | |
| 68 | + | - Cancellation: provider runs cancel on attachment retirement, slot | |
| 69 | + | reconfiguration, and client shutdown via the existing foreground-lifetime | |
| 70 | + | mechanism. A late completion after cancel is discarded. | |
| 71 | + | ||
| 72 | + | ### Sanitization and error presentation | |
| 73 | + | ||
| 74 | + | - All provider output passes daemon-side-equivalent sanitization on the | |
| 75 | + | executing side before admission: first line, valid UTF-8, ANSI/control | |
| 76 | + | stripping (`sanitizeBarScriptOutput` semantics), display-cap truncation. | |
| 77 | + | - Stale/error presentation is deterministic: on failure the slot keeps the | |
| 78 | + | last good value; a slot with no good value renders empty. Failures warn | |
| 79 | + | once per (provider, slot, signature) until the signature changes. No error | |
| 80 | + | text from stderr is painted (stderr is diagnostics only, capped). | |
| 81 | + | ||
| 82 | + | ### Precedence (same slot, daemon + client values) | |
| 83 | + | ||
| 84 | + | 1. A fresh admitted daemon value and a fresh admitted client value for the | |
| 85 | + | same slot: the **client-local value wins only when the slot is explicitly | |
| 86 | + | configured for client-local authority**; otherwise the daemon value wins. | |
| 87 | + | Authority is per slot, from local configuration, never negotiated. | |
| 88 | + | 2. A stale value (older generation) never overrides a fresh one regardless | |
| 89 | + | of side. | |
| 90 | + | 3. Absent/unset on the winning side falls through to the other side's latest | |
| 91 | + | admitted value, then to empty. | |
| 92 | + | ||
| 93 | + | ## Proposed fields (exact, pending approval) | |
| 94 | + | ||
| 95 | + | Configuration (local file, new keys under the existing parser): | |
| 96 | + | ||
| 97 | + | - `status.top-right.provider = local | daemon` (default `daemon`) | |
| 98 | + | - `status.top-right.command = "<shell>"` (default empty = absent) | |
| 99 | + | - `status.bottom-right.provider`, `status.bottom-right.command`: same shape. | |
| 100 | + | - `status.interval`: duration, default 5s, floor 1s (`MinBarInterval`); | |
| 101 | + | invalid values warn and keep prior, mirroring `bar.interval`. | |
| 102 | + | ||
| 103 | + | Wire/schema (rendered text only, never commands): | |
| 104 | + | ||
| 105 | + | - `StatusSlotValue{Slot: top-right | bottom-right, Text: string (already | |
| 106 | + | sanitized, ≤256 runes), ProviderGeneration: uint64, | |
| 107 | + | AttachmentID: <existing attachment identity type>}` carried inside the | |
| 108 | + | existing publication path for the owning attachment only. | |
| 109 | + | - No new carriage primitive: values travel with the attachment's ordered | |
| 110 | + | publication; no persistence-format change. | |
| 111 | + | ||
| 112 | + | Validation: unknown `provider` value warns and keeps `daemon`; empty command | |
| 113 | + | disables the slot; interval below minimum clamps and warns. | |
| 114 | + | ||
| 115 | + | ## Tests mapped (to be added only after approval) | |
| 116 | + | ||
| 117 | + | - `TestClientProviderCommandNeverComesFromWire` (sessionwire/client): | |
| 118 | + | crafted inbound value with command content is rejected; only text fields | |
| 119 | + | admitted. | |
| 120 | + | - `TestStaleProviderGenerationDiscarded` (client/composition): older | |
| 121 | + | provider generation never paints over a newer admitted value. | |
| 122 | + | - `TestProviderValueBoundToAttachment` (client): value admitted for A never | |
| 123 | + | paints on B; retirement drops pending values. | |
| 124 | + | - `TestProviderPrecedenceDaemonWinsByDefault` / `TestProviderPrecedenceLocalWinsWhenConfigured` | |
| 125 | + | (composition): per-slot authority matrix. | |
| 126 | + | - `TestProviderFailureKeepsLastGood` / `TestProviderEmptyWhenNoGood` | |
| 127 | + | (provider harness): deterministic stale/error presentation; warn-once. | |
| 128 | + | - `TestProviderQueueCoalesces` (provider harness, fake clock): slow run + | |
| 129 | + | newer tick yields at most one pending execution; cancel on retirement. | |
| 130 | + | - `TestRemoteCwdNeverInterpretedLocally` (provider harness): provider env | |
| 131 | + | contains no remote-derived path. | |
| 132 | + | - Existing pins stay green: `bar_refresh_test.go`, `bar_script` tests, | |
| 133 | + | `status` composition tests, config parse tests. | |
| 134 | + | ||
| 135 | + | ## Stop condition | |
| 136 | + | ||
| 137 | + | P4.2 ends at this document. No provider implementation, no new config key, | |
| 138 | + | no wire field, and no new dependency is authorized by this plan alone. | |
M docs/session-navigation.md +8 -0
| @@ -45,6 +45,14 @@ startup. | |||
| 45 | 45 | cause while the originating interaction is still open; stale failures | |
| 46 | 46 | from closed interactions drop. Native commands stay usable throughout. | |
| 47 | 47 | ||
| 48 | + | ## Client-owned picker | |
| 49 | + | ||
| 50 | + | The navigation picker runs as a typed client interaction when the serving attachment advertises the client-picker capability: the palette command `SSP` (session picker) opens it, and a client with a home route answers that command with a home-picker navigation directive instead, so the picker stays daemon-rendered on the home route. | |
| 51 | + | ||
| 52 | + | The client renders the picker frame from the admitted snapshot rows, moves the cursor and filters with `/` locally, and sends only a typed selection (opaque row key plus displayed revision) or a close. The serving daemon re-resolves the key, revalidates the target lifecycle, and performs the unchanged navigation handoff; a selection must carry exactly the displayed revision, and a stale revision, unknown key, retired target, or failed navigation returns a typed failure instead of committing. Escape closes the interaction without selecting, `s` (sort) and `x` (kill) are unavailable in this mode, and the cursor/search state is local to the interaction. | |
| 53 | + | ||
| 54 | + | While the picker is open it owns terminal input on every path: physical keys and admitted automation batches reach the client picker and never the session, and the serving daemon drops raw key and mouse input for that attachment for the same window. After a close, the client keeps consuming and dropping input until the daemon's authoritative repaint is displayed, so nothing typed at the picker is replayed into the shell; routing resumes at that boundary. If the attachment generation changes before that repaint (a reconnect), the lease is aborted with the attachment and the new generation starts from normal routing. | |
| 55 | + | ||
| 48 | 56 | ## Remote vision | |
| 49 | 57 | ||
| 50 | 58 | Remote-origin rows display availability tags (`down`, `broken`, stopped) | |
D docs/vt-extraction-preparation.md +0 -39
| @@ -1,39 +0,0 @@ | |||
| 1 | - | # VT extraction preparation checkpoint | |
| 2 | - | ||
| 3 | - | The standalone module owns the frontend-neutral VT engine and ANSI renderer; | |
| 4 | - | vev consumes the released `github.com/bnema/vev-vt v0.2.1` module. VT behavior, | |
| 5 | - | renderer output, wire bytes, transport, ACK/rebase, and cursor-tail policy remain | |
| 6 | - | unchanged. | |
| 7 | - | ||
| 8 | - | ## Evidence | |
| 9 | - | ||
| 10 | - | - The feature worktree was created from a clean checkout. | |
| 11 | - | - `make test` passes (`go test ./... -race`). | |
| 12 | - | - `make lint` passes (`goimports` has no changes and `go vet ./...` passes). | |
| 13 | - | - External-package contract tests pass under `-race` for parsing, callbacks, | |
| 14 | - | borrowing, snapshots, history views, stable chunk identity, and single-owner | |
| 15 | - | usage. | |
| 16 | - | - VTH3 golden vectors round-trip byte-for-byte across empty, plain, | |
| 17 | - | continuation, styled/RGB, bounded, and line-bound cases. Truncation, | |
| 18 | - | corruption, invalid fields, and trailing bytes are rejected. | |
| 19 | - | - The VEVS v4 restore envelope golden decodes through the existing restore path, | |
| 20 | - | including sealed history, tail history, and recovery transcript blobs. | |
| 21 | - | Truncated, corrupted, trailing, and malformed nested VT payloads are rejected. | |
| 22 | - | - Representative VT/history/snapshot benchmarks pass; benchmark output is | |
| 23 | - | retained in the implementation run rather than committed as machine-specific | |
| 24 | - | data. | |
| 25 | - | - `git diff --check` passes. | |
| 26 | - | - The standalone module owns the frontend-neutral Cell, Style, RGB, Frame, | |
| 27 | - | Damage, RuneWidth, VT parser, history, snapshots, and ANSI output packages. | |
| 28 | - | - The cutover has no local VT, core, ANSI, or renderer implementation owner; | |
| 29 | - | vev resolves the released module through normal module download semantics. | |
| 30 | - | - A standalone core-only frame consumer test builds ANSI output without | |
| 31 | - | importing the VT parser. | |
| 32 | - | ||
| 33 | - | ## Scope guard | |
| 34 | - | ||
| 35 | - | The vev cutover changes only module ownership and import paths; no VT parser | |
| 36 | - | behavior, VTH3/VEVS bytes, or wire/protocol definition changed. There is no | |
| 37 | - | committed `replace` directive or compatibility owner in vev. Any unrelated | |
| 38 | - | behavior, wire, or dependency change discovered while maintaining the standalone | |
| 39 | - | module must be split into a separate plan. | |
D docs/vt-extraction-verification.md +0 -44
| @@ -1,44 +0,0 @@ | |||
| 1 | - | # VT extraction verification evidence | |
| 2 | - | ||
| 3 | - | ## Release | |
| 4 | - | ||
| 5 | - | - Module: `github.com/bnema/vev-vt v0.2.1` | |
| 6 | - | - Module checksum: `h1:sb5XWVVIgiYd82KSYb3gHvkHVt9r5oRoEK0hqO1B+Aw=` | |
| 7 | - | - Go module checksum: `h1:QdV0QI7vUnlAF+7+CiPhThHDpDSkxMEZmCYl/Eo+xCA=` | |
| 8 | - | - Standalone release commit: `7bd1a3c4` | |
| 9 | - | - vev cutover commit: `3fd4a02d` | |
| 10 | - | ||
| 11 | - | ## Acceptance evidence | |
| 12 | - | ||
| 13 | - | - vev: `make test`, `make lint`, `go mod verify`, and clean-checkout module | |
| 14 | - | resolution pass without a `replace` directive. | |
| 15 | - | - Standalone module: tests, race tests, `go vet`, `golangci-lint`, external | |
| 16 | - | consumer smoke, import-boundary checks, and signed release tag pass. | |
| 17 | - | - Fuzz smoke passes for VT input, ANSI rendering, VTH3 decoding, and VEVS v4 | |
| 18 | - | restore decoding. Seeds include escape sequences, Unicode, clipboard input, | |
| 19 | - | malformed history, and the VEVS restore golden. | |
| 20 | - | - VTH3 and VEVS fixtures remain byte-compatible. VEVS framing and session policy | |
| 21 | - | remain vev-owned. | |
| 22 | - | - History/codec benchmark allocations and bytes are unchanged in the selected | |
| 23 | - | pre/post comparison. Representative daemon frame benchmarks remained within | |
| 24 | - | normal run variance; no output-byte regression was observed. | |
| 25 | - | ||
| 26 | - | ## Diff classification | |
| 27 | - | ||
| 28 | - | - **Extraction:** standalone module release, vev module dependency cutover, and | |
| 29 | - | removal of the in-repository VT/core/ANSI owners. | |
| 30 | - | - **Tests and fixtures:** external ownership contracts, VTH3 golden vectors, | |
| 31 | - | VEVS restore fixtures, and fuzz smoke coverage. | |
| 32 | - | - **Documentation:** ownership, callback, borrowing, release, and verification | |
| 33 | - | records. | |
| 34 | - | - **CI:** standalone formatting, lint, race, fuzz, benchmark, and consumer jobs. | |
| 35 | - | ||
| 36 | - | ## Risks and follow-up | |
| 37 | - | ||
| 38 | - | - A broad baseline run including `BenchmarkScreenResizeReflowViewport` still | |
| 39 | - | exposes the pre-existing `invalid history row ID` benchmark panic. The | |
| 40 | - | selected extraction comparison excludes that benchmark rather than masking it. | |
| 41 | - | - There is no dedicated standalone `RuneWidth` benchmark; width behavior is | |
| 42 | - | covered by public contract tests and the screen/parser benchmark suite. | |
| 43 | - | - The outer VEVS codec intentionally remains in vev and must not move into the | |
| 44 | - | standalone module. | |
A internal/adapters/remote/memory_catalog_cache.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | appports "github.com/bnema/vev/internal/ports" | |
| 7 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // SeededCatalogCache is a runner-local remote catalogue cache: it starts from | |
| 11 | + | // the durable cache's entries and only ever writes in memory. A client monitor | |
| 12 | + | // uses it so its own observations can never race, truncate, or overwrite the | |
| 13 | + | // daemon's whole-directory snapshot file. | |
| 14 | + | type SeededCatalogCache struct { | |
| 15 | + | seed appports.RemoteCatalogCache | |
| 16 | + | ||
| 17 | + | mu sync.Mutex | |
| 18 | + | loaded bool | |
| 19 | + | entries []catalogue.RemoteCatalogCacheEntry | |
| 20 | + | loadErr error | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | var _ appports.RemoteCatalogCache = (*SeededCatalogCache)(nil) | |
| 24 | + | ||
| 25 | + | // NewSeededCatalogCache wraps the durable cache. Construction performs no I/O: | |
| 26 | + | // the seed is read on the first Load. | |
| 27 | + | func NewSeededCatalogCache(seed appports.RemoteCatalogCache) *SeededCatalogCache { | |
| 28 | + | return &SeededCatalogCache{seed: seed} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Load returns the in-memory entries, reading the durable seed once. | |
| 32 | + | func (c *SeededCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { | |
| 33 | + | if c == nil { | |
| 34 | + | return nil, nil | |
| 35 | + | } | |
| 36 | + | c.mu.Lock() | |
| 37 | + | defer c.mu.Unlock() | |
| 38 | + | if !c.loaded { | |
| 39 | + | c.loaded = true | |
| 40 | + | if c.seed != nil { | |
| 41 | + | entries, err := c.seed.Load() | |
| 42 | + | if err != nil { | |
| 43 | + | // A failed seed read is reported once and never retried: the | |
| 44 | + | // client monitor must not hammer a broken cache file. | |
| 45 | + | c.loadErr = err | |
| 46 | + | } else { | |
| 47 | + | c.entries = cloneCatalogEntries(entries) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | if c.loadErr != nil { | |
| 52 | + | return nil, c.loadErr | |
| 53 | + | } | |
| 54 | + | return cloneCatalogEntries(c.entries), nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Store replaces the in-memory entries. Nothing reaches the durable file. | |
| 58 | + | func (c *SeededCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error { | |
| 59 | + | if c == nil { | |
| 60 | + | return nil | |
| 61 | + | } | |
| 62 | + | c.mu.Lock() | |
| 63 | + | defer c.mu.Unlock() | |
| 64 | + | c.loaded = true | |
| 65 | + | c.entries = cloneCatalogEntries(entries) | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // cloneCatalogEntries returns a defensive copy of the entries, including the | |
| 70 | + | // nested session and tab slices the monitor publishes from. | |
| 71 | + | func cloneCatalogEntries(entries []catalogue.RemoteCatalogCacheEntry) []catalogue.RemoteCatalogCacheEntry { | |
| 72 | + | if entries == nil { | |
| 73 | + | return nil | |
| 74 | + | } | |
| 75 | + | out := make([]catalogue.RemoteCatalogCacheEntry, len(entries)) | |
| 76 | + | for i, entry := range entries { | |
| 77 | + | out[i] = entry | |
| 78 | + | out[i].Sessions = make([]catalogue.RemoteCatalogSession, len(entry.Sessions)) | |
| 79 | + | for j, session := range entry.Sessions { | |
| 80 | + | out[i].Sessions[j] = session | |
| 81 | + | out[i].Sessions[j].Tabs = append([]catalogue.RemoteCatalogTab(nil), session.Tabs...) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | return out | |
| 85 | + | } | |
M internal/adapters/remote/runtime_test.go +21 -14
| @@ -182,17 +182,24 @@ func TestRuntimeBlockedWriterDoesNotStallOtherLanes(t *testing.T) { | |||
| 182 | 182 | t.Fatalf("observes = %d, want 4", observes) | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | - | // A second store while the writer is occupied queues behind it; a third | |
| 186 | - | // concurrent store cannot enter the lane and retires with a lane-busy | |
| 187 | - | // failure instead of wedging dispatch. The service never issues either: | |
| 188 | - | // it keeps one active write plus one replaceable pending snapshot. | |
| 189 | - | second := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "second"}}} | |
| 190 | - | third := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "third"}}} | |
| 185 | + | // Two further stores arrive while the writer is occupied. The lane admits | |
| 186 | + | // exactly one of them and retires the other as lane-busy rather than | |
| 187 | + | // queueing without bound; which of the two wins is admission timing, so | |
| 188 | + | // the assertions follow the reported outcome instead of naming a winner. | |
| 189 | + | second := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Endpoint: "second", Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "second"}}} | |
| 190 | + | third := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Endpoint: "third", Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "third"}}} | |
| 191 | 191 | jobs <- second | |
| 192 | 192 | jobs <- third | |
| 193 | - | duplicate := receiveResult(t, results, "duplicate store") | |
| 194 | - | if duplicate.Kind != appports.RemoteJobCacheStore || !errors.Is(duplicate.Err, errRemoteLaneBusy) { | |
| 195 | - | t.Fatalf("duplicate store result = %+v, want lane-busy failure", duplicate) | |
| 193 | + | busy := receiveResult(t, results, "lane-busy store") | |
| 194 | + | if busy.Kind != appports.RemoteJobCacheStore || !errors.Is(busy.Err, errRemoteLaneBusy) { | |
| 195 | + | t.Fatalf("bounded-lane store result = %+v, want lane-busy failure", busy) | |
| 196 | + | } | |
| 197 | + | if busy.Endpoint != second.Endpoint && busy.Endpoint != third.Endpoint { | |
| 198 | + | t.Fatalf("lane-busy store endpoint = %q, want one of the two admitted stores", busy.Endpoint) | |
| 199 | + | } | |
| 200 | + | admitted := second.Endpoint | |
| 201 | + | if busy.Endpoint == second.Endpoint { | |
| 202 | + | admitted = third.Endpoint | |
| 196 | 203 | } | |
| 197 | 204 | ||
| 198 | 205 | close(cache.release) | |
| @@ -200,15 +207,15 @@ func TestRuntimeBlockedWriterDoesNotStallOtherLanes(t *testing.T) { | |||
| 200 | 207 | if firstStored.Kind != appports.RemoteJobCacheStore || firstStored.Err != nil { | |
| 201 | 208 | t.Fatalf("store result = %+v", firstStored) | |
| 202 | 209 | } | |
| 203 | - | secondStored := receiveResult(t, results, "queued cache store") | |
| 204 | - | if secondStored.Kind != appports.RemoteJobCacheStore || secondStored.Err != nil { | |
| 205 | - | t.Fatalf("queued store result = %+v", secondStored) | |
| 210 | + | admittedStored := receiveResult(t, results, "admitted cache store") | |
| 211 | + | if admittedStored.Kind != appports.RemoteJobCacheStore || admittedStored.Err != nil || admittedStored.Endpoint != admitted { | |
| 212 | + | t.Fatalf("admitted store result = %+v, want the store the lane accepted (%s)", admittedStored, admitted) | |
| 206 | 213 | } | |
| 207 | 214 | cache.mu.Lock() | |
| 208 | 215 | defer cache.mu.Unlock() | |
| 209 | 216 | if len(cache.stores) != 2 || len(cache.stores[0]) != 1 || cache.stores[0][0].Host != "user@arch" || | |
| 210 | - | len(cache.stores[1]) != 1 || cache.stores[1][0].Host != "second" { | |
| 211 | - | t.Fatalf("stored payloads = %+v, want serialized first then second", cache.stores) | |
| 217 | + | len(cache.stores[1]) != 1 || cache.stores[1][0].Host != admitted { | |
| 218 | + | t.Fatalf("stored payloads = %+v, want the blocked store first then %s", cache.stores, admitted) | |
| 212 | 219 | } | |
| 213 | 220 | } | |
| 214 | 221 | ||
M internal/adapters/sessionwire/client.go +67 -16
| @@ -91,8 +91,6 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 91 | 91 | return wire.UnmarshalSessions(frame.Payload) | |
| 92 | 92 | case wire.MsgCommandResult: | |
| 93 | 93 | return wire.UnmarshalCommandResult(frame.Payload) | |
| 94 | - | case wire.MsgNavigationAction: | |
| 95 | - | return wire.UnmarshalNavigationDirective(frame.Payload) | |
| 96 | 94 | case wire.MsgAttachTarget: | |
| 97 | 95 | return wire.UnmarshalAttachTarget(frame.Payload) | |
| 98 | 96 | case wire.MsgRemotePreviewResponse: | |
| @@ -111,14 +109,26 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 111 | 109 | return wire.UnmarshalRouteRetired(frame.Payload) | |
| 112 | 110 | case wire.MsgSamePeerSwitchFailure: | |
| 113 | 111 | return wire.UnmarshalSamePeerSwitchFailure(frame.Payload) | |
| 114 | - | case wire.MsgParkedRouteResponse: | |
| 115 | - | return wire.UnmarshalParkedRouteResponse(frame.Payload) | |
| 116 | 112 | case wire.MsgNavigationInventoryResponse: | |
| 117 | 113 | return wire.UnmarshalNavigationInventoryResponse(frame.Payload) | |
| 118 | 114 | case wire.MsgNavigationInventoryDemand: | |
| 119 | 115 | return wire.UnmarshalNavigationInventoryDemand(frame.Payload) | |
| 120 | 116 | case wire.MsgNavigationInventorySelection: | |
| 121 | 117 | return wire.UnmarshalNavigationInventorySelection(frame.Payload) | |
| 118 | + | case wire.MsgPickerOffer: | |
| 119 | + | return wire.UnmarshalPickerOffer(frame.Payload) | |
| 120 | + | case wire.MsgPickerSnapshot: | |
| 121 | + | return wire.UnmarshalPickerSnapshot(frame.Payload) | |
| 122 | + | case wire.MsgPickerClosedServer: | |
| 123 | + | return wire.UnmarshalPickerClosed(frame.Payload) | |
| 124 | + | case wire.MsgPickerResult: | |
| 125 | + | return wire.UnmarshalPickerResult(frame.Payload) | |
| 126 | + | case wire.MsgPickerFailure: | |
| 127 | + | return wire.UnmarshalPickerFailure(frame.Payload) | |
| 128 | + | case wire.MsgPickerPreview: | |
| 129 | + | return wire.UnmarshalPickerPreview(frame.Payload) | |
| 130 | + | case wire.MsgPickerControlResponse: | |
| 131 | + | return wire.UnmarshalPickerControlResponse(frame.Payload) | |
| 122 | 132 | case wire.MsgUIReceipt: | |
| 123 | 133 | return wire.UnmarshalUIReceipt(frame.Payload) | |
| 124 | 134 | case wire.MsgUIViewUpdate: | |
| @@ -127,9 +137,10 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 127 | 137 | wire.MsgList, wire.MsgKill, wire.MsgTheme, wire.MsgAck, wire.MsgImagePush, | |
| 128 | 138 | wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest, | |
| 129 | 139 | wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription, | |
| 130 | - | wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest, | |
| 140 | + | wire.MsgSamePeerSwitchRequest, | |
| 131 | 141 | wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence, | |
| 132 | - | wire.MsgNavigationInventoryRequest, wire.MsgNavigationInventoryPublication, wire.MsgNavigationInventoryFailure: | |
| 142 | + | wire.MsgNavigationInventoryRequest, wire.MsgNavigationInventoryPublication, wire.MsgNavigationInventoryFailure, | |
| 143 | + | wire.MsgPickerBegin, wire.MsgPickerCloseClient, wire.MsgPickerSelection, wire.MsgPickerPreviewRequest, wire.MsgPickerControlRequest: | |
| 133 | 144 | return nil, ErrWrongDirection | |
| 134 | 145 | default: | |
| 135 | 146 | return nil, ErrUnknownMessageType | |
| @@ -189,12 +200,6 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 189 | 200 | case protocol.SamePeerSwitchRequest: | |
| 190 | 201 | payload, err := wire.MarshalSamePeerSwitchRequest(m) | |
| 191 | 202 | return wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: payload}, err | |
| 192 | - | case protocol.ParkedRouteRequest: | |
| 193 | - | payload := wire.MarshalParkedRouteRequest(m) | |
| 194 | - | if payload == nil { | |
| 195 | - | return wire.Frame{}, ErrInvalidMessage | |
| 196 | - | } | |
| 197 | - | return wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: payload}, nil | |
| 198 | 203 | case protocol.NavigationInventoryRequest: | |
| 199 | 204 | payload := wire.MarshalNavigationInventoryRequest(m) | |
| 200 | 205 | if payload == nil { | |
| @@ -213,6 +218,36 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 213 | 218 | return wire.Frame{}, ErrInvalidMessage | |
| 214 | 219 | } | |
| 215 | 220 | return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: payload}, nil | |
| 221 | + | case protocol.PickerBegin: | |
| 222 | + | payload := wire.MarshalPickerBegin(m) | |
| 223 | + | if payload == nil { | |
| 224 | + | return wire.Frame{}, ErrInvalidMessage | |
| 225 | + | } | |
| 226 | + | return wire.Frame{Type: wire.MsgPickerBegin, Payload: payload}, nil | |
| 227 | + | case protocol.PickerClose: | |
| 228 | + | payload := wire.MarshalPickerClose(m) | |
| 229 | + | if payload == nil { | |
| 230 | + | return wire.Frame{}, ErrInvalidMessage | |
| 231 | + | } | |
| 232 | + | return wire.Frame{Type: wire.MsgPickerCloseClient, Payload: payload}, nil | |
| 233 | + | case protocol.PickerSelection: | |
| 234 | + | payload := wire.MarshalPickerSelection(m) | |
| 235 | + | if payload == nil { | |
| 236 | + | return wire.Frame{}, ErrInvalidMessage | |
| 237 | + | } | |
| 238 | + | return wire.Frame{Type: wire.MsgPickerSelection, Payload: payload}, nil | |
| 239 | + | case protocol.PickerControlRequest: | |
| 240 | + | payload := wire.MarshalPickerControlRequest(m) | |
| 241 | + | if payload == nil { | |
| 242 | + | return wire.Frame{}, ErrInvalidMessage | |
| 243 | + | } | |
| 244 | + | return wire.Frame{Type: wire.MsgPickerControlRequest, Payload: payload}, nil | |
| 245 | + | case protocol.PickerPreviewRequest: | |
| 246 | + | payload := wire.MarshalPickerPreviewRequest(m) | |
| 247 | + | if payload == nil { | |
| 248 | + | return wire.Frame{}, ErrInvalidMessage | |
| 249 | + | } | |
| 250 | + | return wire.Frame{Type: wire.MsgPickerPreviewRequest, Payload: payload}, nil | |
| 216 | 251 | case protocol.RecentRouteSnapshot: | |
| 217 | 252 | payload, err := wire.MarshalRecentRouteSnapshot(m) | |
| 218 | 253 | return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: payload}, err | |
| @@ -290,10 +325,6 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 290 | 325 | if m != nil { | |
| 291 | 326 | return encodeClient(*m) | |
| 292 | 327 | } | |
| 293 | - | case *protocol.ParkedRouteRequest: | |
| 294 | - | if m != nil { | |
| 295 | - | return encodeClient(*m) | |
| 296 | - | } | |
| 297 | 328 | case *protocol.NavigationInventoryRequest: | |
| 298 | 329 | if m != nil { | |
| 299 | 330 | return encodeClient(*m) | |
| @@ -306,6 +337,26 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 306 | 337 | if m != nil { | |
| 307 | 338 | return encodeClient(*m) | |
| 308 | 339 | } | |
| 340 | + | case *protocol.PickerBegin: | |
| 341 | + | if m != nil { | |
| 342 | + | return encodeClient(*m) | |
| 343 | + | } | |
| 344 | + | case *protocol.PickerClose: | |
| 345 | + | if m != nil { | |
| 346 | + | return encodeClient(*m) | |
| 347 | + | } | |
| 348 | + | case *protocol.PickerSelection: | |
| 349 | + | if m != nil { | |
| 350 | + | return encodeClient(*m) | |
| 351 | + | } | |
| 352 | + | case *protocol.PickerPreviewRequest: | |
| 353 | + | if m != nil { | |
| 354 | + | return encodeClient(*m) | |
| 355 | + | } | |
| 356 | + | case *protocol.PickerControlRequest: | |
| 357 | + | if m != nil { | |
| 358 | + | return encodeClient(*m) | |
| 359 | + | } | |
| 309 | 360 | case *protocol.RecentRouteSnapshot: | |
| 310 | 361 | if m != nil { | |
| 311 | 362 | return encodeClient(*m) | |
M internal/adapters/sessionwire/client_test.go +0 -5
| @@ -15,7 +15,6 @@ import ( | |||
| 15 | 15 | func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |
| 16 | 16 | target := testTarget() | |
| 17 | 17 | exact := protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName} | |
| 18 | - | lease := protocol.ParkedRouteLeaseID{1} | |
| 19 | 18 | tests := []struct { | |
| 20 | 19 | name string | |
| 21 | 20 | message protocol.ClientMessage | |
| @@ -39,7 +38,6 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||
| 39 | 38 | {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, typeID: wire.MsgRemotePreviewRequest}, | |
| 40 | 39 | {name: "attention", message: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}, typeID: wire.MsgRouteAttentionSubscription}, | |
| 41 | 40 | {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}, typeID: wire.MsgSamePeerSwitchRequest}, | |
| 42 | - | {name: "parked", message: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare}, typeID: wire.MsgParkedRouteRequest}, | |
| 43 | 41 | {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot}, | |
| 44 | 42 | {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure}, | |
| 45 | 43 | {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure}, | |
| @@ -66,7 +64,6 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||
| 66 | 64 | ||
| 67 | 65 | func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |
| 68 | 66 | exact := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"} | |
| 69 | - | lease := protocol.ParkedRouteLeaseID{1} | |
| 70 | 67 | tests := []struct { | |
| 71 | 68 | name string | |
| 72 | 69 | message protocol.ServerMessage | |
| @@ -78,7 +75,6 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |||
| 78 | 75 | {name: "pong", message: protocol.Pong{}}, | |
| 79 | 76 | {name: "sessions", message: protocol.Sessions{Sessions: []protocol.SessionInfo{}}}, | |
| 80 | 77 | {name: "command result", message: protocol.CommandResult{RequestID: 1, OK: true}}, | |
| 81 | - | {name: "navigation", message: protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: lease}}, | |
| 82 | 78 | {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}}, | |
| 83 | 79 | {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}}, | |
| 84 | 80 | {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}}, | |
| @@ -87,7 +83,6 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |||
| 87 | 83 | {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}}, | |
| 88 | 84 | {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}}, | |
| 89 | 85 | {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}}, | |
| 90 | - | {name: "parked response", message: protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady}}, | |
| 91 | 86 | } | |
| 92 | 87 | for _, tt := range tests { | |
| 93 | 88 | t.Run(tt.name, func(t *testing.T) { | |
A internal/adapters/sessionwire/picker_interaction_test.go +251 -0
| @@ -0,0 +1,251 @@ | |||
| 1 | + | package sessionwire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func pickerWireOffer() protocol.PickerOffer { | |
| 13 | + | return protocol.PickerOffer{ | |
| 14 | + | InteractionID: 7, RequestID: 3, Intent: protocol.PickerIntentMoveTab, | |
| 15 | + | MoveSourceKey: "ab12/work#tab-1", BarrierEpoch: 3, BarrierState: 9, SizeEpoch: 1, | |
| 16 | + | Title: " Sessions · grouped ", | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func pickerWireSnapshot() protocol.PickerSnapshot { | |
| 21 | + | return protocol.PickerSnapshot{ | |
| 22 | + | InteractionID: 7, SourceID: "serving", SourceRevision: 2, Status: protocol.PickerSourceOK, | |
| 23 | + | Lines: []protocol.PickerLine{ | |
| 24 | + | {Kind: protocol.PickerLineSection, Label: "LOCAL", Dim: true}, | |
| 25 | + | {Key: "ab12/work", Kind: protocol.PickerLineSession, Label: "work", Focusable: true, Actions: protocol.PickerCanNavigate}, | |
| 26 | + | {Key: "ab12/work#tab-1", Kind: protocol.PickerLineTab, Label: "editor", Detail: " (vim)", Attention: true, Focusable: true, Actions: protocol.PickerCanMove}, | |
| 27 | + | }, | |
| 28 | + | Cursor: protocol.PickerCursor{Key: "ab12/work", Index: 1}, | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func pickerWireSelection() protocol.PickerSelection { | |
| 33 | + | return protocol.PickerSelection{ | |
| 34 | + | CauseActionID: 9, RequestID: 3, InteractionID: 7, SourceID: "serving", | |
| 35 | + | SourceRevision: 2, Key: "ab12/work", Action: protocol.PickerActionMove, | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func pickerWirePreviewRequest() protocol.PickerPreviewRequest { | |
| 40 | + | return protocol.PickerPreviewRequest{ | |
| 41 | + | Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", | |
| 42 | + | Key: "ab12/work#tab-1", Width: 80, Height: 24, | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func pickerWirePreview() protocol.PickerPreview { | |
| 47 | + | return protocol.PickerPreview{ | |
| 48 | + | Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", | |
| 49 | + | Key: "ab12/work#tab-1", Status: protocol.PickerPreviewOK, Width: 2, Height: 1, | |
| 50 | + | Cells: []renderer.Cell{{Rune: 'o'}, {Rune: 'k'}}, | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func pickerWireControlRequest() protocol.PickerControlRequest { | |
| 55 | + | return protocol.PickerControlRequest{ | |
| 56 | + | Version: protocol.Version, RequestID: 4, Operation: protocol.PickerControlResolve, | |
| 57 | + | SourceRevision: 2, SourceID: "serving", Key: "ab12/work#tab-1", | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func pickerWireControlResponse() protocol.PickerControlResponse { | |
| 62 | + | snapshot := protocol.NormalizePickerSnapshot(pickerWireSnapshot()) | |
| 63 | + | return protocol.PickerControlResponse{ | |
| 64 | + | RequestID: 4, Operation: protocol.PickerControlSnapshot, Status: protocol.PickerSourceOK, Snapshot: &snapshot, | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestPickerClientMessagesEncodeWithTypes(t *testing.T) { | |
| 69 | + | begin := protocol.PickerBegin{RequestID: 3, Intent: protocol.PickerIntentNavigation} | |
| 70 | + | closeMessage := protocol.PickerClose{InteractionID: 7, RequestID: 3} | |
| 71 | + | selection := pickerWireSelection() | |
| 72 | + | previewRequest := pickerWirePreviewRequest() | |
| 73 | + | controlRequest := pickerWireControlRequest() | |
| 74 | + | tests := []struct { | |
| 75 | + | name string | |
| 76 | + | message protocol.ClientMessage | |
| 77 | + | typeID wire.MsgType | |
| 78 | + | }{ | |
| 79 | + | {name: "begin", message: begin, typeID: wire.MsgPickerBegin}, | |
| 80 | + | {name: "begin pointer", message: &begin, typeID: wire.MsgPickerBegin}, | |
| 81 | + | {name: "close", message: closeMessage, typeID: wire.MsgPickerCloseClient}, | |
| 82 | + | {name: "close pointer", message: &closeMessage, typeID: wire.MsgPickerCloseClient}, | |
| 83 | + | {name: "selection", message: selection, typeID: wire.MsgPickerSelection}, | |
| 84 | + | {name: "selection pointer", message: &selection, typeID: wire.MsgPickerSelection}, | |
| 85 | + | {name: "preview request", message: previewRequest, typeID: wire.MsgPickerPreviewRequest}, | |
| 86 | + | {name: "preview request pointer", message: &previewRequest, typeID: wire.MsgPickerPreviewRequest}, | |
| 87 | + | {name: "control request", message: controlRequest, typeID: wire.MsgPickerControlRequest}, | |
| 88 | + | {name: "control request pointer", message: &controlRequest, typeID: wire.MsgPickerControlRequest}, | |
| 89 | + | } | |
| 90 | + | for _, tt := range tests { | |
| 91 | + | t.Run(tt.name, func(t *testing.T) { | |
| 92 | + | raw := &scriptedTransport{} | |
| 93 | + | require.NoError(t, NewClientConnection(raw).SendClient(tt.message)) | |
| 94 | + | require.Equal(t, tt.typeID, raw.sent.Type) | |
| 95 | + | got, err := decodeClient(raw.sent) | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | switch message := tt.message.(type) { | |
| 98 | + | case *protocol.PickerBegin: | |
| 99 | + | require.Equal(t, *message, got) | |
| 100 | + | case *protocol.PickerClose: | |
| 101 | + | require.Equal(t, *message, got) | |
| 102 | + | case *protocol.PickerSelection: | |
| 103 | + | require.Equal(t, *message, got) | |
| 104 | + | case *protocol.PickerPreviewRequest: | |
| 105 | + | require.Equal(t, *message, got) | |
| 106 | + | case *protocol.PickerControlRequest: | |
| 107 | + | require.Equal(t, *message, got) | |
| 108 | + | default: | |
| 109 | + | require.Equal(t, tt.message, got) | |
| 110 | + | } | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestPickerServerMessagesEncodeWithTypes(t *testing.T) { | |
| 116 | + | offer := pickerWireOffer() | |
| 117 | + | snapshot := pickerWireSnapshot() | |
| 118 | + | closed := protocol.PickerClosed{InteractionID: 7, BarrierEpoch: 3, BarrierState: 9} | |
| 119 | + | result := protocol.PickerResult{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionKill} | |
| 120 | + | failure := protocol.PickerFailure{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionNavigate, Code: protocol.PickerRetiredTarget} | |
| 121 | + | preview := pickerWirePreview() | |
| 122 | + | controlResponse := pickerWireControlResponse() | |
| 123 | + | tests := []struct { | |
| 124 | + | name string | |
| 125 | + | message protocol.ServerMessage | |
| 126 | + | typeID wire.MsgType | |
| 127 | + | payload []byte | |
| 128 | + | }{ | |
| 129 | + | {name: "offer", message: offer, typeID: wire.MsgPickerOffer, payload: wire.MarshalPickerOffer(offer)}, | |
| 130 | + | {name: "offer pointer", message: &offer, typeID: wire.MsgPickerOffer, payload: wire.MarshalPickerOffer(offer)}, | |
| 131 | + | {name: "snapshot", message: snapshot, typeID: wire.MsgPickerSnapshot, payload: wire.MarshalPickerSnapshot(snapshot)}, | |
| 132 | + | {name: "snapshot pointer", message: &snapshot, typeID: wire.MsgPickerSnapshot, payload: wire.MarshalPickerSnapshot(snapshot)}, | |
| 133 | + | {name: "closed", message: closed, typeID: wire.MsgPickerClosedServer, payload: wire.MarshalPickerClosed(closed)}, | |
| 134 | + | {name: "closed pointer", message: &closed, typeID: wire.MsgPickerClosedServer, payload: wire.MarshalPickerClosed(closed)}, | |
| 135 | + | {name: "result", message: result, typeID: wire.MsgPickerResult, payload: wire.MarshalPickerResult(result)}, | |
| 136 | + | {name: "result pointer", message: &result, typeID: wire.MsgPickerResult, payload: wire.MarshalPickerResult(result)}, | |
| 137 | + | {name: "failure", message: failure, typeID: wire.MsgPickerFailure, payload: wire.MarshalPickerFailure(failure)}, | |
| 138 | + | {name: "failure pointer", message: &failure, typeID: wire.MsgPickerFailure, payload: wire.MarshalPickerFailure(failure)}, | |
| 139 | + | {name: "preview", message: preview, typeID: wire.MsgPickerPreview, payload: wire.MarshalPickerPreview(preview)}, | |
| 140 | + | {name: "preview pointer", message: &preview, typeID: wire.MsgPickerPreview, payload: wire.MarshalPickerPreview(preview)}, | |
| 141 | + | {name: "control response", message: controlResponse, typeID: wire.MsgPickerControlResponse, payload: wire.MarshalPickerControlResponse(controlResponse)}, | |
| 142 | + | {name: "control response pointer", message: &controlResponse, typeID: wire.MsgPickerControlResponse, payload: wire.MarshalPickerControlResponse(controlResponse)}, | |
| 143 | + | } | |
| 144 | + | for _, tt := range tests { | |
| 145 | + | t.Run(tt.name, func(t *testing.T) { | |
| 146 | + | require.NotNil(t, tt.payload) | |
| 147 | + | raw := &scriptedTransport{} | |
| 148 | + | require.NoError(t, NewServerConnection(raw).SendServer(tt.message)) | |
| 149 | + | require.Equal(t, tt.typeID, raw.sent.Type) | |
| 150 | + | require.Equal(t, tt.payload, raw.sent.Payload) | |
| 151 | + | }) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestPickerControlMessagesRoundTripThroughConnections(t *testing.T) { | |
| 156 | + | request := pickerWireControlRequest() | |
| 157 | + | response := pickerWireControlResponse() | |
| 158 | + | ||
| 159 | + | for _, tc := range []struct { | |
| 160 | + | name string | |
| 161 | + | message protocol.ClientMessage | |
| 162 | + | }{ | |
| 163 | + | {name: "request", message: request}, | |
| 164 | + | {name: "request pointer", message: &request}, | |
| 165 | + | } { | |
| 166 | + | t.Run(tc.name, func(t *testing.T) { | |
| 167 | + | raw := &scriptedTransport{} | |
| 168 | + | require.NoError(t, NewClientConnection(raw).SendClient(tc.message)) | |
| 169 | + | require.Equal(t, wire.MsgPickerControlRequest, raw.sent.Type) | |
| 170 | + | got, err := NewServerConnection(&scriptedTransport{recv: raw.sent}).ReceiveClient() | |
| 171 | + | require.NoError(t, err) | |
| 172 | + | require.Equal(t, request, got) | |
| 173 | + | }) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | for _, tc := range []struct { | |
| 177 | + | name string | |
| 178 | + | message protocol.ServerMessage | |
| 179 | + | }{ | |
| 180 | + | {name: "response", message: response}, | |
| 181 | + | {name: "response pointer", message: &response}, | |
| 182 | + | } { | |
| 183 | + | t.Run(tc.name, func(t *testing.T) { | |
| 184 | + | raw := &scriptedTransport{} | |
| 185 | + | require.NoError(t, NewServerConnection(raw).SendServer(tc.message)) | |
| 186 | + | require.Equal(t, wire.MsgPickerControlResponse, raw.sent.Type) | |
| 187 | + | got, err := NewClientConnection(&scriptedTransport{recv: raw.sent}).ReceiveServer() | |
| 188 | + | require.NoError(t, err) | |
| 189 | + | require.Equal(t, response, got) | |
| 190 | + | }) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func TestPickerControlRequestMalformedPreHandshakeClassification(t *testing.T) { | |
| 195 | + | tests := []struct { | |
| 196 | + | name string | |
| 197 | + | payload []byte | |
| 198 | + | version uint16 | |
| 199 | + | }{ | |
| 200 | + | {name: "truncated version", payload: []byte{0}}, | |
| 201 | + | {name: "mismatched version", payload: []byte{0, 60}, version: 60}, | |
| 202 | + | } | |
| 203 | + | for _, tt := range tests { | |
| 204 | + | t.Run(tt.name, func(t *testing.T) { | |
| 205 | + | _, err := NewServerConnection(&scriptedTransport{recv: wire.Frame{Type: wire.MsgPickerControlRequest, Payload: tt.payload}}).ReceiveClient() | |
| 206 | + | var failure *protocol.DecodeFailure | |
| 207 | + | require.ErrorAs(t, err, &failure) | |
| 208 | + | require.Equal(t, protocol.DecodeMalformed, failure.Category) | |
| 209 | + | require.Equal(t, uint8(wire.MsgPickerControlRequest), failure.Type) | |
| 210 | + | require.Equal(t, tt.version, failure.Version) | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestPickerMessagesRejectWrongDirection(t *testing.T) { | |
| 216 | + | offer := pickerWireOffer() | |
| 217 | + | snapshot := pickerWireSnapshot() | |
| 218 | + | selection := pickerWireSelection() | |
| 219 | + | clientFrames := []wire.Frame{ | |
| 220 | + | {Type: wire.MsgPickerBegin, Payload: wire.MarshalPickerBegin(protocol.PickerBegin{RequestID: 1, Intent: protocol.PickerIntentNavigation})}, | |
| 221 | + | {Type: wire.MsgPickerCloseClient, Payload: wire.MarshalPickerClose(protocol.PickerClose{InteractionID: 1})}, | |
| 222 | + | {Type: wire.MsgPickerSelection, Payload: wire.MarshalPickerSelection(selection)}, | |
| 223 | + | {Type: wire.MsgPickerPreviewRequest, Payload: wire.MarshalPickerPreviewRequest(pickerWirePreviewRequest())}, | |
| 224 | + | {Type: wire.MsgPickerControlRequest, Payload: wire.MarshalPickerControlRequest(pickerWireControlRequest())}, | |
| 225 | + | } | |
| 226 | + | serverFrames := []wire.Frame{ | |
| 227 | + | {Type: wire.MsgPickerOffer, Payload: wire.MarshalPickerOffer(offer)}, | |
| 228 | + | {Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(snapshot)}, | |
| 229 | + | {Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: 1})}, | |
| 230 | + | {Type: wire.MsgPickerResult, Payload: wire.MarshalPickerResult(protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "a/b", Action: protocol.PickerActionKill})}, | |
| 231 | + | {Type: wire.MsgPickerFailure, Payload: wire.MarshalPickerFailure(protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionKill, Code: protocol.PickerUnknownKey})}, | |
| 232 | + | {Type: wire.MsgPickerPreview, Payload: wire.MarshalPickerPreview(pickerWirePreview())}, | |
| 233 | + | {Type: wire.MsgPickerControlResponse, Payload: wire.MarshalPickerControlResponse(pickerWireControlResponse())}, | |
| 234 | + | } | |
| 235 | + | for _, frame := range clientFrames { | |
| 236 | + | require.NotNil(t, frame.Payload) | |
| 237 | + | got, err := decodeClient(frame) | |
| 238 | + | require.NoError(t, err) | |
| 239 | + | require.NotNil(t, got) | |
| 240 | + | _, err = decodeServer(frame) | |
| 241 | + | require.ErrorIs(t, err, ErrWrongDirection) | |
| 242 | + | } | |
| 243 | + | for _, frame := range serverFrames { | |
| 244 | + | require.NotNil(t, frame.Payload) | |
| 245 | + | got, err := decodeServer(frame) | |
| 246 | + | require.NoError(t, err) | |
| 247 | + | require.NotNil(t, got) | |
| 248 | + | _, err = decodeClient(frame) | |
| 249 | + | require.ErrorIs(t, err, ErrWrongDirection) | |
| 250 | + | } | |
| 251 | + | } | |
M internal/adapters/sessionwire/server.go +87 -26
| @@ -59,6 +59,8 @@ func (c *serverConnection) ReceiveClient() (protocol.ClientMessage, error) { | |||
| 59 | 59 | failure.Kind = protocol.DecodeMessageNavigationInventory | |
| 60 | 60 | failure.Version, _ = wire.PeekNavigationInventoryVersion(frame.Payload) | |
| 61 | 61 | failure.RequestID, failure.HasRequestID = wire.PeekNavigationInventoryRequestID(frame.Payload) | |
| 62 | + | case wire.MsgPickerControlRequest: | |
| 63 | + | failure.Version, _ = wire.PeekPickerControlVersion(frame.Payload) | |
| 62 | 64 | } | |
| 63 | 65 | return nil, failure | |
| 64 | 66 | } | |
| @@ -230,14 +232,22 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) { | |||
| 230 | 232 | return wire.UnmarshalRouteAttentionSubscription(frame.Payload) | |
| 231 | 233 | case wire.MsgSamePeerSwitchRequest: | |
| 232 | 234 | return wire.UnmarshalSamePeerSwitchRequest(frame.Payload) | |
| 233 | - | case wire.MsgParkedRouteRequest: | |
| 234 | - | return wire.UnmarshalParkedRouteRequest(frame.Payload) | |
| 235 | 235 | case wire.MsgNavigationInventoryRequest: | |
| 236 | 236 | return wire.UnmarshalNavigationInventoryRequest(frame.Payload) | |
| 237 | 237 | case wire.MsgNavigationInventoryPublication: | |
| 238 | 238 | return wire.UnmarshalNavigationInventoryPublication(frame.Payload) | |
| 239 | 239 | case wire.MsgNavigationInventoryFailure: | |
| 240 | 240 | return wire.UnmarshalNavigationInventoryFailure(frame.Payload) | |
| 241 | + | case wire.MsgPickerBegin: | |
| 242 | + | return wire.UnmarshalPickerBegin(frame.Payload) | |
| 243 | + | case wire.MsgPickerCloseClient: | |
| 244 | + | return wire.UnmarshalPickerClose(frame.Payload) | |
| 245 | + | case wire.MsgPickerSelection: | |
| 246 | + | return wire.UnmarshalPickerSelection(frame.Payload) | |
| 247 | + | case wire.MsgPickerPreviewRequest: | |
| 248 | + | return wire.UnmarshalPickerPreviewRequest(frame.Payload) | |
| 249 | + | case wire.MsgPickerControlRequest: | |
| 250 | + | return wire.UnmarshalPickerControlRequest(frame.Payload) | |
| 241 | 251 | case wire.MsgRecentRouteSnapshot: | |
| 242 | 252 | return wire.UnmarshalRecentRouteSnapshot(frame.Payload) | |
| 243 | 253 | case wire.MsgRouteNavigationFailure: | |
| @@ -247,12 +257,13 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) { | |||
| 247 | 257 | case wire.MsgUIFence: | |
| 248 | 258 | return wire.UnmarshalUIFence(frame.Payload) | |
| 249 | 259 | case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong, | |
| 250 | - | wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction, | |
| 251 | - | wire.MsgAttachTarget, wire.MsgRemotePreviewResponse, | |
| 260 | + | wire.MsgSessions, wire.MsgCommandResult, wire.MsgAttachTarget, wire.MsgRemotePreviewResponse, | |
| 252 | 261 | wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession, | |
| 253 | - | wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse, | |
| 262 | + | wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, | |
| 254 | 263 | wire.MsgUIReceipt, wire.MsgUIViewUpdate, wire.MsgNavigationInventoryResponse, | |
| 255 | - | wire.MsgNavigationInventoryDemand, wire.MsgNavigationInventorySelection, wire.MsgRouteRetired: | |
| 264 | + | wire.MsgNavigationInventoryDemand, wire.MsgNavigationInventorySelection, wire.MsgRouteRetired, | |
| 265 | + | wire.MsgPickerOffer, wire.MsgPickerSnapshot, wire.MsgPickerClosedServer, wire.MsgPickerResult, wire.MsgPickerFailure, | |
| 266 | + | wire.MsgPickerPreview, wire.MsgPickerControlResponse: | |
| 256 | 267 | return nil, ErrWrongDirection | |
| 257 | 268 | default: | |
| 258 | 269 | return nil, ErrUnknownMessageType | |
| @@ -289,12 +300,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 289 | 300 | return wire.Frame{Type: wire.MsgSessions, Payload: wire.MarshalSessions(m)}, nil | |
| 290 | 301 | case protocol.CommandResult: | |
| 291 | 302 | return wire.Frame{Type: wire.MsgCommandResult, Payload: wire.MarshalCommandResult(m)}, nil | |
| 292 | - | case protocol.NavigationDirective: | |
| 293 | - | payload := wire.MarshalNavigationDirective(m) | |
| 294 | - | if payload == nil { | |
| 295 | - | return wire.Frame{}, ErrInvalidMessage | |
| 296 | - | } | |
| 297 | - | return wire.Frame{Type: wire.MsgNavigationAction, Payload: payload}, nil | |
| 298 | 303 | case protocol.AttachTarget: | |
| 299 | 304 | payload := wire.MarshalAttachTarget(m) | |
| 300 | 305 | if payload == nil { | |
| @@ -328,12 +333,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 328 | 333 | case protocol.SamePeerSwitchFailure: | |
| 329 | 334 | payload, err := wire.MarshalSamePeerSwitchFailure(m) | |
| 330 | 335 | return wire.Frame{Type: wire.MsgSamePeerSwitchFailure, Payload: payload}, err | |
| 331 | - | case protocol.ParkedRouteResponse: | |
| 332 | - | payload := wire.MarshalParkedRouteResponse(m) | |
| 333 | - | if payload == nil { | |
| 334 | - | return wire.Frame{}, ErrInvalidMessage | |
| 335 | - | } | |
| 336 | - | return wire.Frame{Type: wire.MsgParkedRouteResponse, Payload: payload}, nil | |
| 337 | 336 | case protocol.NavigationInventoryResponse: | |
| 338 | 337 | payload := wire.MarshalNavigationInventoryResponse(m) | |
| 339 | 338 | if payload == nil { | |
| @@ -352,6 +351,48 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 352 | 351 | return wire.Frame{}, ErrInvalidMessage | |
| 353 | 352 | } | |
| 354 | 353 | return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: payload}, nil | |
| 354 | + | case protocol.PickerOffer: | |
| 355 | + | payload := wire.MarshalPickerOffer(m) | |
| 356 | + | if payload == nil { | |
| 357 | + | return wire.Frame{}, ErrInvalidMessage | |
| 358 | + | } | |
| 359 | + | return wire.Frame{Type: wire.MsgPickerOffer, Payload: payload}, nil | |
| 360 | + | case protocol.PickerSnapshot: | |
| 361 | + | payload := wire.MarshalPickerSnapshot(m) | |
| 362 | + | if payload == nil { | |
| 363 | + | return wire.Frame{}, ErrInvalidMessage | |
| 364 | + | } | |
| 365 | + | return wire.Frame{Type: wire.MsgPickerSnapshot, Payload: payload}, nil | |
| 366 | + | case protocol.PickerClosed: | |
| 367 | + | payload := wire.MarshalPickerClosed(m) | |
| 368 | + | if payload == nil { | |
| 369 | + | return wire.Frame{}, ErrInvalidMessage | |
| 370 | + | } | |
| 371 | + | return wire.Frame{Type: wire.MsgPickerClosedServer, Payload: payload}, nil | |
| 372 | + | case protocol.PickerResult: | |
| 373 | + | payload := wire.MarshalPickerResult(m) | |
| 374 | + | if payload == nil { | |
| 375 | + | return wire.Frame{}, ErrInvalidMessage | |
| 376 | + | } | |
| 377 | + | return wire.Frame{Type: wire.MsgPickerResult, Payload: payload}, nil | |
| 378 | + | case protocol.PickerFailure: | |
| 379 | + | payload := wire.MarshalPickerFailure(m) | |
| 380 | + | if payload == nil { | |
| 381 | + | return wire.Frame{}, ErrInvalidMessage | |
| 382 | + | } | |
| 383 | + | return wire.Frame{Type: wire.MsgPickerFailure, Payload: payload}, nil | |
| 384 | + | case protocol.PickerControlResponse: | |
| 385 | + | payload := wire.MarshalPickerControlResponse(m) | |
| 386 | + | if payload == nil { | |
| 387 | + | return wire.Frame{}, ErrInvalidMessage | |
| 388 | + | } | |
| 389 | + | return wire.Frame{Type: wire.MsgPickerControlResponse, Payload: payload}, nil | |
| 390 | + | case protocol.PickerPreview: | |
| 391 | + | payload := wire.MarshalPickerPreview(m) | |
| 392 | + | if payload == nil { | |
| 393 | + | return wire.Frame{}, ErrInvalidMessage | |
| 394 | + | } | |
| 395 | + | return wire.Frame{Type: wire.MsgPickerPreview, Payload: payload}, nil | |
| 355 | 396 | case *protocol.Welcome: | |
| 356 | 397 | if m != nil { | |
| 357 | 398 | return encodeServer(*m) | |
| @@ -388,10 +429,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 388 | 429 | if m != nil { | |
| 389 | 430 | return encodeServer(*m) | |
| 390 | 431 | } | |
| 391 | - | case *protocol.NavigationDirective: | |
| 392 | - | if m != nil { | |
| 393 | - | return encodeServer(*m) | |
| 394 | - | } | |
| 395 | 432 | case *protocol.AttachTarget: | |
| 396 | 433 | if m != nil { | |
| 397 | 434 | return encodeServer(*m) | |
| @@ -428,10 +465,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 428 | 465 | if m != nil { | |
| 429 | 466 | return encodeServer(*m) | |
| 430 | 467 | } | |
| 431 | - | case *protocol.ParkedRouteResponse: | |
| 432 | - | if m != nil { | |
| 433 | - | return encodeServer(*m) | |
| 434 | - | } | |
| 435 | 468 | case *protocol.NavigationInventoryResponse: | |
| 436 | 469 | if m != nil { | |
| 437 | 470 | return encodeServer(*m) | |
| @@ -444,6 +477,34 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 444 | 477 | if m != nil { | |
| 445 | 478 | return encodeServer(*m) | |
| 446 | 479 | } | |
| 480 | + | case *protocol.PickerOffer: | |
| 481 | + | if m != nil { | |
| 482 | + | return encodeServer(*m) | |
| 483 | + | } | |
| 484 | + | case *protocol.PickerSnapshot: | |
| 485 | + | if m != nil { | |
| 486 | + | return encodeServer(*m) | |
| 487 | + | } | |
| 488 | + | case *protocol.PickerClosed: | |
| 489 | + | if m != nil { | |
| 490 | + | return encodeServer(*m) | |
| 491 | + | } | |
| 492 | + | case *protocol.PickerResult: | |
| 493 | + | if m != nil { | |
| 494 | + | return encodeServer(*m) | |
| 495 | + | } | |
| 496 | + | case *protocol.PickerFailure: | |
| 497 | + | if m != nil { | |
| 498 | + | return encodeServer(*m) | |
| 499 | + | } | |
| 500 | + | case *protocol.PickerPreview: | |
| 501 | + | if m != nil { | |
| 502 | + | return encodeServer(*m) | |
| 503 | + | } | |
| 504 | + | case *protocol.PickerControlResponse: | |
| 505 | + | if m != nil { | |
| 506 | + | return encodeServer(*m) | |
| 507 | + | } | |
| 447 | 508 | default: | |
| 448 | 509 | return wire.Frame{}, ErrWrongDirection | |
| 449 | 510 | } | |
M internal/adapters/sessionwire/server_test.go +0 -7
| @@ -54,7 +54,6 @@ func testTarget() domain.RemoteSessionTarget { | |||
| 54 | 54 | func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |
| 55 | 55 | target := testTarget() | |
| 56 | 56 | exact := protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName} | |
| 57 | - | lease := protocol.ParkedRouteLeaseID{1} | |
| 58 | 57 | commandPayload, err := wire.MarshalCommandRequest(protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"}) | |
| 59 | 58 | require.NoError(t, err) | |
| 60 | 59 | resizePayload, err := wire.MarshalResize(protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}}) | |
| @@ -93,7 +92,6 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||
| 93 | 92 | {name: "preview", frame: wire.Frame{Type: wire.MsgRemotePreviewRequest, Payload: wire.MarshalRemotePreviewRequest(protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1})}, want: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}}, | |
| 94 | 93 | {name: "attention", frame: wire.Frame{Type: wire.MsgRouteAttentionSubscription, Payload: attentionPayload}, want: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}}, | |
| 95 | 94 | {name: "same peer", frame: wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: switchPayload}, want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}}, | |
| 96 | - | {name: "parked", frame: wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare})}, want: protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare}}, | |
| 97 | 95 | {name: "snapshot", frame: wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: snapshotPayload}, want: protocol.RecentRouteSnapshot{}}, | |
| 98 | 96 | {name: "route failure", frame: wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: failurePayload}, want: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}}, | |
| 99 | 97 | {name: "creation failure", frame: wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: creationFailurePayload}, want: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}}, | |
| @@ -110,7 +108,6 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||
| 110 | 108 | ||
| 111 | 109 | func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |
| 112 | 110 | exact := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"} | |
| 113 | - | lease := protocol.ParkedRouteLeaseID{1} | |
| 114 | 111 | welcome := protocol.Welcome{SessionID: "session"} | |
| 115 | 112 | errorMessage := protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"} | |
| 116 | 113 | output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()} | |
| @@ -120,7 +117,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 120 | 117 | pong := protocol.Pong{} | |
| 121 | 118 | sessions := protocol.Sessions{} | |
| 122 | 119 | commandResult := protocol.CommandResult{RequestID: 1, OK: true} | |
| 123 | - | navigation := protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: lease} | |
| 124 | 120 | attachTarget := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach} | |
| 125 | 121 | preview := protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable} | |
| 126 | 122 | identity := protocol.CommittedRouteIdentity{Target: exact} | |
| @@ -129,7 +125,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 129 | 125 | routeFailure := protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable} | |
| 130 | 126 | routePosition := protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"} | |
| 131 | 127 | switchFailure := protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable} | |
| 132 | - | parkedResponse := protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady} | |
| 133 | 128 | ||
| 134 | 129 | outputPayload, err := wire.MarshalOutput(output) | |
| 135 | 130 | require.NoError(t, err) | |
| @@ -167,7 +162,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 167 | 162 | {name: "pong", message: pong, typeID: wire.MsgPong, payload: wire.MarshalPong(pong)}, | |
| 168 | 163 | {name: "sessions", message: sessions, typeID: wire.MsgSessions, payload: wire.MarshalSessions(sessions)}, | |
| 169 | 164 | {name: "command result", message: commandResult, typeID: wire.MsgCommandResult, payload: wire.MarshalCommandResult(commandResult)}, | |
| 170 | - | {name: "navigation", message: navigation, typeID: wire.MsgNavigationAction, payload: wire.MarshalNavigationDirective(navigation)}, | |
| 171 | 165 | {name: "attach target", message: attachTarget, typeID: wire.MsgAttachTarget, payload: wire.MarshalAttachTarget(attachTarget)}, | |
| 172 | 166 | {name: "preview", message: preview, typeID: wire.MsgRemotePreviewResponse, payload: wire.MarshalRemotePreview(preview)}, | |
| 173 | 167 | {name: "identity", message: identity, typeID: wire.MsgCommittedRouteIdentity, payload: identityPayload}, | |
| @@ -177,7 +171,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 177 | 171 | {name: "route failure", message: routeFailure, typeID: wire.MsgRouteNavigationFailure, payload: routeFailurePayload}, | |
| 178 | 172 | {name: "route position", message: routePosition, typeID: wire.MsgRoutePosition, payload: routePositionPayload}, | |
| 179 | 173 | {name: "switch failure", message: switchFailure, typeID: wire.MsgSamePeerSwitchFailure, payload: switchFailurePayload}, | |
| 180 | - | {name: "parked response", message: parkedResponse, typeID: wire.MsgParkedRouteResponse, payload: wire.MarshalParkedRouteResponse(parkedResponse)}, | |
| 181 | 174 | } | |
| 182 | 175 | for _, tt := range tests { | |
| 183 | 176 | t.Run(tt.name, func(t *testing.T) { | |
M internal/adapters/sessionwire/ui_test.go +0 -1
| @@ -50,7 +50,6 @@ func TestUISynchronizationServerDispatch(t *testing.T) { | |||
| 50 | 50 | {"receipt", protocol.UIReceipt{ActionID: 7, Epoch: 2, State: 1, ViewPublication: 4, Outcome: protocol.UIReceiptProcessed}}, | |
| 51 | 51 | {"view update", protocol.UIViewUpdate{Epoch: 2, State: 1, Context: context}}, | |
| 52 | 52 | {"attach target", protocol.AttachTarget{CauseActionID: 7, Session: "work", Intent: protocol.IntentAttach}}, | |
| 53 | - | {"navigation", protocol.NavigationDirective{CauseActionID: 7, Action: protocol.NavigationBack}}, | |
| 54 | 53 | {"route action", protocol.RouteNavigationAction{CauseActionID: 7, SnapshotGeneration: 1, Key: 2, Generation: 3}}, | |
| 55 | 54 | {"route create", protocol.RouteCreateSessionAction{CauseActionID: 7, RequestID: 4, SnapshotGeneration: 1, Key: 2, Generation: 3, SessionName: "work"}}, | |
| 56 | 55 | } { | |
M internal/adapters/uidriver/socket_test.go +7 -1
| @@ -191,9 +191,15 @@ func TestBridgeUsesExistingSocketWithoutCreatingAttachment(t *testing.T) { | |||
| 191 | 191 | require.Zero(t, ready.ID) | |
| 192 | 192 | } | |
| 193 | 193 | ||
| 194 | + | // shortDir returns a private directory whose absolute path stays inside the | |
| 195 | + | // unix socket length limit. It is created under the system temporary | |
| 196 | + | // directory: the checkout's own path can already be long enough to overflow | |
| 197 | + | // the limit in a nested worktree. | |
| 194 | 198 | func shortDir(t *testing.T) string { | |
| 195 | 199 | t.Helper() | |
| 196 | - | directory, err := os.MkdirTemp(".", "v") | |
| 200 | + | base, err := filepath.EvalSymlinks(os.TempDir()) | |
| 201 | + | require.NoError(t, err) | |
| 202 | + | directory, err := os.MkdirTemp(base, "vev-ui") | |
| 197 | 203 | require.NoError(t, err) | |
| 198 | 204 | directory, err = filepath.Abs(directory) | |
| 199 | 205 | require.NoError(t, err) | |
A internal/app/client_hosts.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "log/slog" | |
| 8 | + | ||
| 9 | + | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 10 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/remotes" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // This file composes the endpoint bindings a launching client reuses across | |
| 17 | + | // handoffs. Remote discovery and catalogue publication remain daemon-owned. | |
| 18 | + | ||
| 19 | + | // clientEndpointFactory resolves one endpoint into the carriage the client | |
| 20 | + | // reuses for every session on that endpoint. Transport mode, launch | |
| 21 | + | // configuration, and endpoint environment are frozen here by the composition | |
| 22 | + | // root; the client only receives the resulting typed dialer. | |
| 23 | + | type clientEndpointFactory struct { | |
| 24 | + | factory remoteDialerForTarget | |
| 25 | + | mode remoteadapter.TransportMode | |
| 26 | + | // modeErr is the transport-mode validation failure, reported for every | |
| 27 | + | // endpoint so an invalid configured mode fails a handoff instead of | |
| 28 | + | // silently selecting another carriage. | |
| 29 | + | modeErr error | |
| 30 | + | // allowlistErr is the launch-allowlist validation failure, reported for | |
| 31 | + | // every endpoint so a malformed configured allowlist fails remote | |
| 32 | + | // resolution instead of admitting an unvetted endpoint. It is deliberately | |
| 33 | + | // not fatal to construction: a local attach never resolves an endpoint and | |
| 34 | + | // must keep working with a malformed allowlist in the environment. | |
| 35 | + | allowlistErr error | |
| 36 | + | environment func(string) []string | |
| 37 | + | allowed map[string]struct{} | |
| 38 | + | // restricted reports whether an allowlist was configured at all. A | |
| 39 | + | // configured allowlist is authoritative even when it lists nothing. | |
| 40 | + | restricted bool | |
| 41 | + | log *slog.Logger | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | var _ ports.RemoteEndpointFactory = clientEndpointFactory{} | |
| 45 | + | ||
| 46 | + | func (f clientEndpointFactory) ResolveEndpoint(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 47 | + | if err := ctx.Err(); err != nil { | |
| 48 | + | return ports.RemoteEndpointBinding{}, err | |
| 49 | + | } | |
| 50 | + | if err := domain.ValidateRemoteHostTarget(endpoint); err != nil { | |
| 51 | + | return ports.RemoteEndpointBinding{}, fmt.Errorf("vev: invalid remote endpoint: %w", err) | |
| 52 | + | } | |
| 53 | + | if f.modeErr != nil { | |
| 54 | + | return ports.RemoteEndpointBinding{}, f.modeErr | |
| 55 | + | } | |
| 56 | + | if f.allowlistErr != nil { | |
| 57 | + | return ports.RemoteEndpointBinding{}, f.allowlistErr | |
| 58 | + | } | |
| 59 | + | if f.restricted && !allowlistedRemoteEndpoint(f.allowed, endpoint) { | |
| 60 | + | return ports.RemoteEndpointBinding{}, fmt.Errorf("vev: remote endpoint %q is not allowed", endpoint) | |
| 61 | + | } | |
| 62 | + | if f.factory == nil { | |
| 63 | + | return ports.RemoteEndpointBinding{}, errors.New("vev: remote dialer factory unavailable") | |
| 64 | + | } | |
| 65 | + | // The carriage is endpoint-scoped: the session name only labels logs, so a | |
| 66 | + | // binding resolved for one session serves every later session on it. | |
| 67 | + | dialer, err := f.factory(endpoint, "", f.mode, f.log) | |
| 68 | + | if err != nil { | |
| 69 | + | return ports.RemoteEndpointBinding{}, err | |
| 70 | + | } | |
| 71 | + | if dialer == nil { | |
| 72 | + | return ports.RemoteEndpointBinding{}, errors.New("vev: remote dialer factory returned no dialer") | |
| 73 | + | } | |
| 74 | + | return ports.RemoteEndpointBinding{ | |
| 75 | + | Dialer: sessionwire.NewClientDialer(dialer), | |
| 76 | + | Environment: clientEnvironment(f.environment, endpoint), | |
| 77 | + | }, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // newClientHostRegistry composes the runner-scoped endpoint cache. A malformed | |
| 81 | + | // launch allowlist is stored on the factory and deferred to ResolveEndpoint, so | |
| 82 | + | // it can only fail a remote target's resolution, never construction or a local | |
| 83 | + | // attach. Without a configured allowlist every endpoint is admitted. | |
| 84 | + | func newClientHostRegistry(deps runAttachDeps, mode remoteadapter.TransportMode, modeErr error, log *slog.Logger) ports.ClientHostRegistry { | |
| 85 | + | allowed, restricted, allowlistErr := remoteLaunchAllowlistFromEnv() | |
| 86 | + | factory := clientEndpointFactory{ | |
| 87 | + | factory: deps.remoteDialerFactory, mode: mode, modeErr: modeErr, allowlistErr: allowlistErr, | |
| 88 | + | environment: deps.remoteEnvironment, allowed: allowed, restricted: restricted, log: log, | |
| 89 | + | } | |
| 90 | + | return remotes.NewHostRegistry(factory) | |
| 91 | + | } | |
A internal/app/client_hosts_test.go +262 -0
| @@ -0,0 +1,262 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "log/slog" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/protocol" | |
| 15 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 16 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | type clientHostsTestDialer struct{ name string } | |
| 21 | + | ||
| 22 | + | func (d clientHostsTestDialer) Dial(context.Context) (wire.Transport, error) { | |
| 23 | + | return nil, errors.New("client hosts test dialer is never dialed") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | var _ wire.Dialer = clientHostsTestDialer{} | |
| 27 | + | ||
| 28 | + | func clientHostsEndpointFactory(factory remoteDialerForTarget, allowed map[string]struct{}, restricted bool, environment func(string) []string) clientEndpointFactory { | |
| 29 | + | return clientEndpointFactory{ | |
| 30 | + | factory: factory, mode: remoteadapter.TransportUDP, environment: environment, | |
| 31 | + | allowed: allowed, restricted: restricted, log: slog.New(slog.DiscardHandler), | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestClientEndpointFactoryResolvesOneCarriagePerEndpoint(t *testing.T) { | |
| 36 | + | factory := newRemoteDialerFactoryMock(t) | |
| 37 | + | factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportUDP, nil).Return(clientHostsTestDialer{name: "arch"}, nil).Once() | |
| 38 | + | factory.EXPECT().DialerForRemote("cache", "", remoteadapter.TransportUDP, nil).Return(clientHostsTestDialer{name: "cache"}, nil).Once() | |
| 39 | + | ||
| 40 | + | resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, func(endpoint string) []string { | |
| 41 | + | return []string{"ENDPOINT=" + endpoint} | |
| 42 | + | }) | |
| 43 | + | first, err := resolver.ResolveEndpoint(context.Background(), "arch") | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | require.NotNil(t, first.Dialer) | |
| 46 | + | require.Equal(t, []string{"ENDPOINT=arch"}, first.Environment) | |
| 47 | + | ||
| 48 | + | second, err := resolver.ResolveEndpoint(context.Background(), "cache") | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | require.Equal(t, []string{"ENDPOINT=cache"}, second.Environment) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestClientEndpointFactoryRefusesEndpointsOutsideTheAllowlist(t *testing.T) { | |
| 54 | + | factory := newRemoteDialerFactoryMock(t) | |
| 55 | + | resolver := clientHostsEndpointFactory(factory.DialerForRemote, map[string]struct{}{"arch": {}}, true, nil) | |
| 56 | + | ||
| 57 | + | _, err := resolver.ResolveEndpoint(context.Background(), "other") | |
| 58 | + | require.ErrorContains(t, err, "not allowed") | |
| 59 | + | ||
| 60 | + | // A configured allowlist that lists nothing denies every endpoint, and the | |
| 61 | + | // refusal never reaches the dialer factory. | |
| 62 | + | resolver = clientHostsEndpointFactory(factory.DialerForRemote, map[string]struct{}{}, true, nil) | |
| 63 | + | _, err = resolver.ResolveEndpoint(context.Background(), "arch") | |
| 64 | + | require.ErrorContains(t, err, "not allowed") | |
| 65 | + | ||
| 66 | + | // Without a configured allowlist every valid endpoint resolves. | |
| 67 | + | factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportUDP, nil).Return(clientHostsTestDialer{}, nil).Once() | |
| 68 | + | resolver = clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil) | |
| 69 | + | _, err = resolver.ResolveEndpoint(context.Background(), "arch") | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestClientEndpointFactoryRejectsInvalidEndpoints(t *testing.T) { | |
| 74 | + | factory := newRemoteDialerFactoryMock(t) | |
| 75 | + | resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil) | |
| 76 | + | for _, endpoint := range []string{"", "bad\x1bhost", "spaces are not a host"} { | |
| 77 | + | _, err := resolver.ResolveEndpoint(context.Background(), endpoint) | |
| 78 | + | require.Error(t, err, "endpoint %q must be refused before a carriage is built", endpoint) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestClientEndpointFactoryKeepsFactoryErrorsAuthoritative(t *testing.T) { | |
| 83 | + | factory := newRemoteDialerFactoryMock(t) | |
| 84 | + | want := errors.New("transport mode refused this endpoint") | |
| 85 | + | factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportUDP, nil).Return(nil, want).Once() | |
| 86 | + | resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil) | |
| 87 | + | ||
| 88 | + | _, err := resolver.ResolveEndpoint(context.Background(), "arch") | |
| 89 | + | require.ErrorIs(t, err, want) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestClientEndpointFactoryHonoursContextCancellation(t *testing.T) { | |
| 93 | + | factory := newRemoteDialerFactoryMock(t) | |
| 94 | + | resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil) | |
| 95 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 96 | + | cancel() | |
| 97 | + | _, err := resolver.ResolveEndpoint(ctx, "arch") | |
| 98 | + | require.ErrorIs(t, err, context.Canceled) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // TestClientHostRegistryDefersMalformedLaunchAllowlist pins that a malformed | |
| 102 | + | // configured allowlist is stored on the endpoint factory instead of failing | |
| 103 | + | // registry construction: a local attach never resolves a remote endpoint, so | |
| 104 | + | // construction and local attach keep working, while remote resolution stays | |
| 105 | + | // fail-closed. | |
| 106 | + | func TestClientHostRegistryDefersMalformedLaunchAllowlist(t *testing.T) { | |
| 107 | + | t.Setenv(launchAllowedRemoteEndpointsEnv, "user@bad host") | |
| 108 | + | factory := newRemoteDialerFactoryMock(t) | |
| 109 | + | ||
| 110 | + | registry := newClientHostRegistry(runAttachDeps{remoteDialerFactory: factory.DialerForRemote}, remoteadapter.TransportUDP, nil, slog.New(slog.DiscardHandler)) | |
| 111 | + | require.NotNil(t, registry, "a malformed allowlist must not fail registry construction") | |
| 112 | + | ||
| 113 | + | _, err := registry.ResolveEndpoint(context.Background(), "arch") | |
| 114 | + | require.ErrorContains(t, err, "invalid configured remote endpoint allowlist") | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // TestRunAttachWithDepsLocalAttachSurvivesMalformedLaunchAllowlist pins the | |
| 118 | + | // local attach path end to end: a malformed remote allowlist in the | |
| 119 | + | // environment is deferred, so a local session still attaches. | |
| 120 | + | func TestRunAttachWithDepsLocalAttachSurvivesMalformedLaunchAllowlist(t *testing.T) { | |
| 121 | + | t.Setenv(launchAllowedRemoteEndpointsEnv, "user@bad host") | |
| 122 | + | factory := newRemoteDialerFactoryMock(t) | |
| 123 | + | attached := false | |
| 124 | + | err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{ | |
| 125 | + | localDialer: func() wire.Dialer { return clientHostsTestDialer{name: "local"} }, | |
| 126 | + | remoteDialerFactory: factory.DialerForRemote, | |
| 127 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 128 | + | require.False(t, request.Remote) | |
| 129 | + | attached = true | |
| 130 | + | return nil | |
| 131 | + | }, | |
| 132 | + | }) | |
| 133 | + | require.NoError(t, err, "a malformed remote allowlist must not break local attach") | |
| 134 | + | require.True(t, attached) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestClientEndpointFactoryKeepsNilEnvironmentDistinct(t *testing.T) { | |
| 138 | + | factory := newRemoteDialerFactoryMock(t) | |
| 139 | + | factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportUDP, nil).Return(clientHostsTestDialer{}, nil).Once() | |
| 140 | + | factory.EXPECT().DialerForRemote("cache", "", remoteadapter.TransportUDP, nil).Return(clientHostsTestDialer{}, nil).Once() | |
| 141 | + | resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, func(endpoint string) []string { | |
| 142 | + | if endpoint == "cache" { | |
| 143 | + | return []string{} | |
| 144 | + | } | |
| 145 | + | return nil | |
| 146 | + | }) | |
| 147 | + | ||
| 148 | + | absent, err := resolver.ResolveEndpoint(context.Background(), "arch") | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | require.Nil(t, absent.Environment) | |
| 151 | + | ||
| 152 | + | empty, err := resolver.ResolveEndpoint(context.Background(), "cache") | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | require.NotNil(t, empty.Environment) | |
| 155 | + | require.Empty(t, empty.Environment) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // memorySeedCache is a durable-cache stand-in that records writes, so a test | |
| 159 | + | // can prove the client monitor never writes the daemon's snapshot file. | |
| 160 | + | type memorySeedCache struct { | |
| 161 | + | mu sync.Mutex | |
| 162 | + | entries []catalogue.RemoteCatalogCacheEntry | |
| 163 | + | loads int | |
| 164 | + | stores int | |
| 165 | + | loadErr error | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (c *memorySeedCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { | |
| 169 | + | c.mu.Lock() | |
| 170 | + | defer c.mu.Unlock() | |
| 171 | + | c.loads++ | |
| 172 | + | if c.loadErr != nil { | |
| 173 | + | return nil, c.loadErr | |
| 174 | + | } | |
| 175 | + | return append([]catalogue.RemoteCatalogCacheEntry(nil), c.entries...), nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (c *memorySeedCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error { | |
| 179 | + | c.mu.Lock() | |
| 180 | + | defer c.mu.Unlock() | |
| 181 | + | c.stores++ | |
| 182 | + | c.entries = append([]catalogue.RemoteCatalogCacheEntry(nil), entries...) | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (c *memorySeedCache) writeCount() int { | |
| 187 | + | c.mu.Lock() | |
| 188 | + | defer c.mu.Unlock() | |
| 189 | + | return c.stores | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (c *memorySeedCache) loadCount() int { | |
| 193 | + | c.mu.Lock() | |
| 194 | + | defer c.mu.Unlock() | |
| 195 | + | return c.loads | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | var _ ports.RemoteCatalogCache = (*memorySeedCache)(nil) | |
| 199 | + | ||
| 200 | + | // TestSeededCatalogCacheNeverWritesTheDurableSnapshot pins the client cache | |
| 201 | + | // contract: it seeds from the durable entries once and keeps every later write | |
| 202 | + | // in its own memory, so a client monitor cannot race the daemon's writer. | |
| 203 | + | func TestSeededCatalogCacheNeverWritesTheDurableSnapshot(t *testing.T) { | |
| 204 | + | seed := &memorySeedCache{entries: []catalogue.RemoteCatalogCacheEntry{{Host: "arch"}}} | |
| 205 | + | client := remoteadapter.NewSeededCatalogCache(seed) | |
| 206 | + | ||
| 207 | + | seeded, err := client.Load() | |
| 208 | + | require.NoError(t, err) | |
| 209 | + | require.Len(t, seeded, 1) | |
| 210 | + | require.Equal(t, "arch", seeded[0].Host) | |
| 211 | + | require.Equal(t, 1, seed.loadCount(), "the durable cache is read once") | |
| 212 | + | ||
| 213 | + | require.NoError(t, client.Store([]catalogue.RemoteCatalogCacheEntry{{Host: "cache"}})) | |
| 214 | + | require.Zero(t, seed.writeCount(), "the client cache must never write the durable snapshot") | |
| 215 | + | require.Equal(t, 1, seed.loadCount(), "a write suppresses the seed read") | |
| 216 | + | ||
| 217 | + | stored, err := client.Load() | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | require.Equal(t, "cache", stored[0].Host) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestSeededCatalogCacheCopiesEntriesDefensively(t *testing.T) { | |
| 223 | + | seed := &memorySeedCache{entries: []catalogue.RemoteCatalogCacheEntry{{ | |
| 224 | + | Host: "arch", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}}}, | |
| 225 | + | }}} | |
| 226 | + | client := remoteadapter.NewSeededCatalogCache(seed) | |
| 227 | + | ||
| 228 | + | first, err := client.Load() | |
| 229 | + | require.NoError(t, err) | |
| 230 | + | first[0].Sessions[0].Tabs[0].ID = "mutated" | |
| 231 | + | first[0].Host = "mutated" | |
| 232 | + | ||
| 233 | + | second, err := client.Load() | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | require.Equal(t, "arch", second[0].Host) | |
| 236 | + | require.Equal(t, "tab-1", second[0].Sessions[0].Tabs[0].ID) | |
| 237 | + | ||
| 238 | + | seed.mu.Lock() | |
| 239 | + | seed.entries[0].Host = "mutated" | |
| 240 | + | seed.mu.Unlock() | |
| 241 | + | third, err := client.Load() | |
| 242 | + | require.NoError(t, err) | |
| 243 | + | require.Equal(t, "arch", third[0].Host, "the client cache is not a live view of the durable file") | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func TestSeededCatalogCacheReportsASeedFailureOnce(t *testing.T) { | |
| 247 | + | want := errors.New("durable cache is unreadable") | |
| 248 | + | seed := &memorySeedCache{loadErr: want} | |
| 249 | + | client := remoteadapter.NewSeededCatalogCache(seed) | |
| 250 | + | ||
| 251 | + | _, err := client.Load() | |
| 252 | + | require.ErrorIs(t, err, want) | |
| 253 | + | _, err = client.Load() | |
| 254 | + | require.ErrorIs(t, err, want) | |
| 255 | + | require.Equal(t, 1, seed.loadCount(), "a broken seed is reported, never retried") | |
| 256 | + | ||
| 257 | + | var nilCache *remoteadapter.SeededCatalogCache | |
| 258 | + | entries, err := nilCache.Load() | |
| 259 | + | require.NoError(t, err) | |
| 260 | + | require.Empty(t, entries) | |
| 261 | + | require.NoError(t, nilCache.Store(nil)) | |
| 262 | + | } | |
M internal/app/dial_only_test.go +21 -0
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // TestDialOnlyLocalDialerNeverStartsDaemon pins the D2 source contract: the | |
| @@ -28,3 +30,22 @@ func TestDialOnlyLocalDialerNeverStartsDaemon(t *testing.T) { | |||
| 28 | 30 | t.Fatal("dial-only control dial without observer must also fail fast") | |
| 29 | 31 | } | |
| 30 | 32 | } | |
| 33 | + | ||
| 34 | + | // TestHybridMissingLocalSourceNeverStartsDaemon pins the hybrid degradation | |
| 35 | + | // contract: when the dial-only local inventory source is unreachable, the | |
| 36 | + | // composition must surface the dial failure rather than starting a daemon or | |
| 37 | + | // creating a local attachment. The missing source degrades that source only. | |
| 38 | + | func TestHybridMissingLocalSourceNeverStartsDaemon(t *testing.T) { | |
| 39 | + | dir := t.TempDir() | |
| 40 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 41 | + | defer cancel() | |
| 42 | + | dialer := dialOnlyLocalDialer{dir: dir} | |
| 43 | + | require.Error(t, func() error { | |
| 44 | + | conn, err := dialer.Dial(ctx) | |
| 45 | + | if err != nil { | |
| 46 | + | return err | |
| 47 | + | } | |
| 48 | + | _ = conn.Close() | |
| 49 | + | return nil | |
| 50 | + | }(), "missing local control source must fail instead of starting a daemon") | |
| 51 | + | } | |
M internal/app/remote_final_acceptance_test.go +0 -3
| @@ -123,9 +123,6 @@ func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) { | |||
| 123 | 123 | outputs := make(chan string, 4) | |
| 124 | 124 | factory := &acceptanceRemoteFactory{outputs: outputs} | |
| 125 | 125 | hostStore := portsmocks.NewMockRemoteHostStore(t) | |
| 126 | - | for _, host := range []string{"direct.example", "picker.example", "picked.example"} { | |
| 127 | - | hostStore.EXPECT().Remember(host).Return(nil).Once() | |
| 128 | - | } | |
| 129 | 126 | localCalls := 0 | |
| 130 | 127 | terminals := make([]*acceptanceRemoteTerminal, 0, 3) | |
| 131 | 128 | deps := runAttachDeps{ | |
M internal/app/remote_hosts.go +0 -24
| @@ -258,27 +258,3 @@ func mergeRemoteHosts(pinned, learned []domain.RemoteRegistration) []domain.Remo | |||
| 258 | 258 | } | |
| 259 | 259 | return out | |
| 260 | 260 | } | |
| 261 | - | ||
| 262 | - | type remoteHostLearner struct { | |
| 263 | - | store ports.RemoteHostStore | |
| 264 | - | target string | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | func (l remoteHostLearner) RememberRemoteHost() error { | |
| 268 | - | return l.store.Remember(l.target) | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | func attachRememberLearner(deps runAttachDeps, remoteTarget string, _ *slog.Logger) ports.RemoteHostLearner { | |
| 272 | - | if remoteTarget == "" { | |
| 273 | - | return nil | |
| 274 | - | } | |
| 275 | - | store := deps.hostStore | |
| 276 | - | if store == nil { | |
| 277 | - | stateDir := deps.stateDir | |
| 278 | - | if stateDir == nil { | |
| 279 | - | stateDir = platform.StateDir | |
| 280 | - | } | |
| 281 | - | store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(stateDir())) | |
| 282 | - | } | |
| 283 | - | return remoteHostLearner{store: store, target: remoteTarget} | |
| 284 | - | } | |
M internal/app/remote_test.go +3 -27
| @@ -304,41 +304,17 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) { | |||
| 304 | 304 | } | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | - | func TestRunAttachWithDepsRemoteLearning(t *testing.T) { | |
| 307 | + | func TestRunAttachWithDepsDoesNotLearnDirectRemoteHost(t *testing.T) { | |
| 308 | 308 | store := portsmocks.NewMockRemoteHostStore(t) | |
| 309 | - | store.EXPECT().Remember("build@mule").Return(nil).Once() | |
| 310 | 309 | factory := newRemoteDialerFactoryMock(t) | |
| 311 | - | factory.EXPECT().DialerForRemote("build@mule", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 310 | + | factory.EXPECT().DialerForRemote("build@mule", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 312 | 311 | ||
| 313 | - | var learner ports.RemoteHostLearner | |
| 314 | 312 | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{ | |
| 315 | 313 | remoteDialerFactory: factory.DialerForRemote, | |
| 316 | 314 | hostStore: store, | |
| 317 | - | runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error { | |
| 318 | - | learner = deps.RemoteHostLearner | |
| 315 | + | runClient: func(context.Context, client.Dependencies, client.AttachRequest) error { | |
| 319 | 316 | return nil | |
| 320 | 317 | }, | |
| 321 | 318 | }) | |
| 322 | 319 | require.NoError(t, err) | |
| 323 | - | require.NotNil(t, learner) | |
| 324 | - | require.NoError(t, learner.RememberRemoteHost()) | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | func TestRunAttachWithDepsAlwaysLearnsRemoteHost(t *testing.T) { | |
| 328 | - | store := portsmocks.NewMockRemoteHostStore(t) | |
| 329 | - | store.EXPECT().Remember("arch").Return(nil).Once() | |
| 330 | - | factory := newRemoteDialerFactoryMock(t) | |
| 331 | - | factory.EXPECT().DialerForRemote("arch", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 332 | - | var learner ports.RemoteHostLearner | |
| 333 | - | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "arch", "", nil, runAttachDeps{ | |
| 334 | - | remoteDialerFactory: factory.DialerForRemote, | |
| 335 | - | hostStore: store, | |
| 336 | - | runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error { | |
| 337 | - | learner = deps.RemoteHostLearner | |
| 338 | - | return nil | |
| 339 | - | }, | |
| 340 | - | }) | |
| 341 | - | require.NoError(t, err) | |
| 342 | - | require.NotNil(t, learner) | |
| 343 | - | require.NoError(t, learner.RememberRemoteHost()) | |
| 344 | 320 | } | |
M internal/app/run.go +16 -51
| @@ -1002,7 +1002,9 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1002 | 1002 | localDialer = defaultLocalDialer | |
| 1003 | 1003 | } | |
| 1004 | 1004 | mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport) | |
| 1005 | - | factory := deps.remoteDialerFactory | |
| 1005 | + | if deps.remoteDialerFactory == nil { | |
| 1006 | + | deps.remoteDialerFactory = defaultRemoteDialerFactory() | |
| 1007 | + | } | |
| 1006 | 1008 | var remoteSelection *domain.RemoteSessionTarget | |
| 1007 | 1009 | var remoteEnvironmentPolicy protocol.EnvironmentPolicy | |
| 1008 | 1010 | remoteDisplayOrigin := domain.RemoteDisplayOrigin(remoteTarget) | |
| @@ -1014,9 +1016,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1014 | 1016 | if modeErr != nil { | |
| 1015 | 1017 | return modeErr | |
| 1016 | 1018 | } | |
| 1017 | - | if factory == nil { | |
| 1018 | - | factory = defaultRemoteDialerFactory() | |
| 1019 | - | } | |
| 1020 | 1019 | } | |
| 1021 | 1020 | pickerHandoff := remoteTarget == "" | |
| 1022 | 1021 | pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, intent uint8, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy { | |
| @@ -1025,42 +1024,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1025 | 1024 | } | |
| 1026 | 1025 | return policy | |
| 1027 | 1026 | } | |
| 1028 | - | handoff := func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |
| 1029 | - | if err := validateRemoteAttachHandoff(target); err != nil { | |
| 1030 | - | return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err) | |
| 1031 | - | } | |
| 1032 | - | if modeErr != nil { | |
| 1033 | - | return nil, client.AttachRequest{}, modeErr | |
| 1034 | - | } | |
| 1035 | - | if factory == nil { | |
| 1036 | - | factory = defaultRemoteDialerFactory() | |
| 1037 | - | } | |
| 1038 | - | var selection *domain.RemoteSessionTarget | |
| 1039 | - | if target.RemoteTarget != nil { | |
| 1040 | - | copyTarget := *target.RemoteTarget | |
| 1041 | - | selection = ©Target | |
| 1042 | - | } | |
| 1043 | - | dialer, err := factory(target.Endpoint, target.Session, mode, log) | |
| 1044 | - | if err != nil { | |
| 1045 | - | return nil, client.AttachRequest{}, err | |
| 1046 | - | } | |
| 1047 | - | policy := pickerEnvironmentPolicy(selection, target.Intent, target.EnvironmentPolicy) | |
| 1048 | - | displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint) | |
| 1049 | - | if selection != nil { | |
| 1050 | - | displayOrigin = selection.DisplayOrigin | |
| 1051 | - | } | |
| 1052 | - | return sessionwire.NewClientDialer(dialer), client.AttachRequest{ | |
| 1053 | - | Intent: target.Intent, | |
| 1054 | - | SessionName: target.Session, | |
| 1055 | - | Remote: true, | |
| 1056 | - | Environment: clientEnvironment(deps.remoteEnvironment, target.Endpoint), | |
| 1057 | - | Origin: protocol.RouteOriginDiscovery, | |
| 1058 | - | OriginKey: target.Endpoint, | |
| 1059 | - | RemoteTarget: selection, | |
| 1060 | - | HostLabel: displayOrigin, | |
| 1061 | - | EnvironmentPolicy: policy, | |
| 1062 | - | }, nil | |
| 1063 | - | } | |
| 1027 | + | // The launching client owns one host registry for the whole run: endpoint | |
| 1028 | + | // bindings are resolved once and reused by every later handoff, and the | |
| 1029 | + | // discovery loop it drives lives exactly as long as the runner. | |
| 1030 | + | registry := newClientHostRegistry(deps, mode, modeErr, log) | |
| 1064 | 1031 | ||
| 1065 | 1032 | handoffAttempts := 0 | |
| 1066 | 1033 | for { | |
| @@ -1069,12 +1036,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1069 | 1036 | if log != nil { | |
| 1070 | 1037 | log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode)) | |
| 1071 | 1038 | } | |
| 1072 | - | dialer, dialErr := factory(remoteTarget, name, mode, log) | |
| 1073 | - | if dialErr != nil { | |
| 1074 | - | return dialErr | |
| 1039 | + | // The initial remote attach resolves through the same registry as | |
| 1040 | + | // every later handoff, so one endpoint has one carriage authority. | |
| 1041 | + | binding, resolveErr := registry.ResolveEndpoint(ctx, remoteTarget) | |
| 1042 | + | if resolveErr != nil { | |
| 1043 | + | return resolveErr | |
| 1075 | 1044 | } | |
| 1076 | 1045 | err = runClient(ctx, client.Dependencies{ | |
| 1077 | - | Dialer: sessionwire.NewClientDialer(dialer), | |
| 1046 | + | Dialer: binding.Dialer, | |
| 1078 | 1047 | LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}), | |
| 1079 | 1048 | Terminal: clientTerminal(deps), | |
| 1080 | 1049 | Clock: clientClock(deps), | |
| @@ -1083,8 +1052,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1083 | 1052 | Clipboard: deps.clipboard, | |
| 1084 | 1053 | Logger: log, | |
| 1085 | 1054 | RuntimeObserver: deps.runtimeObserver, | |
| 1086 | - | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | |
| 1087 | - | AttachHandoff: handoff, | |
| 1055 | + | HostRegistry: registry, | |
| 1088 | 1056 | Remote: true, | |
| 1089 | 1057 | Origin: protocol.RouteOriginRemote, | |
| 1090 | 1058 | OriginKey: remoteTarget, | |
| @@ -1092,7 +1060,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1092 | 1060 | Intent: intent, | |
| 1093 | 1061 | SessionName: name, | |
| 1094 | 1062 | Remote: true, | |
| 1095 | - | Environment: clientEnvironment(deps.remoteEnvironment, remoteTarget), | |
| 1063 | + | Environment: binding.Environment, | |
| 1096 | 1064 | Origin: routeOrigin, | |
| 1097 | 1065 | OriginKey: routeOriginKey, | |
| 1098 | 1066 | RemoteTarget: remoteSelection, | |
| @@ -1113,7 +1081,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1113 | 1081 | Clipboard: deps.clipboard, | |
| 1114 | 1082 | Logger: log, | |
| 1115 | 1083 | RuntimeObserver: deps.runtimeObserver, | |
| 1116 | - | AttachHandoff: handoff, | |
| 1084 | + | HostRegistry: registry, | |
| 1117 | 1085 | Remote: false, | |
| 1118 | 1086 | Origin: protocol.RouteOriginLocal, | |
| 1119 | 1087 | OriginKey: "local", | |
| @@ -1150,9 +1118,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1150 | 1118 | if modeErr != nil { | |
| 1151 | 1119 | return modeErr | |
| 1152 | 1120 | } | |
| 1153 | - | if factory == nil { | |
| 1154 | - | factory = defaultRemoteDialerFactory() | |
| 1155 | - | } | |
| 1156 | 1121 | } | |
| 1157 | 1122 | } | |
| 1158 | 1123 | ||
M internal/app/run_test.go +40 -29
| @@ -918,7 +918,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |||
| 918 | 918 | var gotClipboard ports.ClipboardReader | |
| 919 | 919 | clip := portsmocks.NewMockClipboardReader(t) | |
| 920 | 920 | factory := newRemoteDialerFactoryMock(t) | |
| 921 | - | factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil) | |
| 921 | + | factory.EXPECT().DialerForRemote("remote.example", "", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil) | |
| 922 | 922 | ||
| 923 | 923 | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 924 | 924 | remoteDialerFactory: factory.DialerForRemote, | |
| @@ -954,33 +954,36 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |||
| 954 | 954 | } | |
| 955 | 955 | } | |
| 956 | 956 | ||
| 957 | - | func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testing.T) { | |
| 957 | + | // TestRunAttachWithDepsDirectAttachOwnsOneEndpointAuthority pins that the | |
| 958 | + | // launching client resolves its endpoint through the registry the composition | |
| 959 | + | // root injects: the runner receives that registry, and the endpoint is resolved | |
| 960 | + | // exactly once for the route it starts on. | |
| 961 | + | func TestRunAttachWithDepsDirectAttachOwnsOneEndpointAuthority(t *testing.T) { | |
| 958 | 962 | factory := newRemoteDialerFactoryMock(t) | |
| 959 | - | factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once() | |
| 960 | - | factory.EXPECT().DialerForRemote("selected.example", "picked", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once() | |
| 963 | + | factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once() | |
| 961 | 964 | ||
| 965 | + | var registryDials int | |
| 962 | 966 | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 963 | 967 | remoteDialerFactory: factory.DialerForRemote, | |
| 964 | 968 | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | |
| 965 | 969 | require.True(t, request.Remote) | |
| 966 | 970 | require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy) | |
| 967 | - | nextDialer, nextRequest, err := deps.AttachHandoff(protocol.AttachTarget{ | |
| 968 | - | Endpoint: "selected.example", Session: "picked", Intent: protocol.IntentAttach, | |
| 969 | - | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 970 | - | }) | |
| 971 | - | require.NoError(t, err) | |
| 972 | - | require.NotNil(t, nextDialer) | |
| 973 | - | require.Equal(t, protocol.EnvironmentPolicyClientOwned, nextRequest.EnvironmentPolicy) | |
| 971 | + | require.NotNil(t, deps.HostRegistry, "a launched client must own a host registry") | |
| 972 | + | binding, resolveErr := deps.HostRegistry.ResolveEndpoint(context.Background(), "remote.example") | |
| 973 | + | require.NoError(t, resolveErr) | |
| 974 | + | require.NotNil(t, binding.Dialer) | |
| 975 | + | registryDials++ | |
| 974 | 976 | return nil | |
| 975 | 977 | }, | |
| 976 | 978 | }) | |
| 977 | 979 | require.NoError(t, err) | |
| 980 | + | require.Equal(t, 1, registryDials) | |
| 978 | 981 | } | |
| 979 | 982 | ||
| 980 | 983 | func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) { | |
| 981 | 984 | factory := newRemoteDialerFactoryMock(t) | |
| 982 | - | factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once() | |
| 983 | - | factory.EXPECT().DialerForRemote("selected.example", "picked", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once() | |
| 985 | + | factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once() | |
| 986 | + | factory.EXPECT().DialerForRemote("selected.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once() | |
| 984 | 987 | var calls int | |
| 985 | 988 | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 986 | 989 | remoteDialerFactory: factory.DialerForRemote, | |
| @@ -999,29 +1002,37 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing. | |||
| 999 | 1002 | require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection") | |
| 1000 | 1003 | } | |
| 1001 | 1004 | ||
| 1002 | - | func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) { | |
| 1005 | + | // TestRunAttachWithDepsReusesOneEndpointBinding pins that the registry the | |
| 1006 | + | // runner receives resolves each endpoint once: revisiting a host, even for a | |
| 1007 | + | // different session, hands back the same typed dialer and never rebuilds the | |
| 1008 | + | // carriage. | |
| 1009 | + | func TestRunAttachWithDepsReusesOneEndpointBinding(t *testing.T) { | |
| 1003 | 1010 | factory := newRemoteDialerFactoryMock(t) | |
| 1004 | - | factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 1005 | - | factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once() | |
| 1011 | + | factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 1006 | 1012 | ||
| 1013 | + | var first, second ports.ClientDialer | |
| 1007 | 1014 | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 1008 | 1015 | remoteDialerFactory: factory.DialerForRemote, | |
| 1009 | 1016 | runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error { | |
| 1010 | - | dialer, request, err := deps.AttachHandoff(protocol.AttachTarget{ | |
| 1011 | - | Endpoint: "remote.example", Session: "work", Intent: protocol.IntentAttach, | |
| 1012 | - | }) | |
| 1013 | - | require.NoError(t, err) | |
| 1014 | - | require.NotNil(t, dialer) | |
| 1015 | - | require.Equal(t, "work", request.SessionName) | |
| 1017 | + | resolve := func() ports.ClientDialer { | |
| 1018 | + | binding, resolveErr := deps.HostRegistry.ResolveEndpoint(context.Background(), "remote.example") | |
| 1019 | + | require.NoError(t, resolveErr) | |
| 1020 | + | return binding.Dialer | |
| 1021 | + | } | |
| 1022 | + | first, second = resolve(), resolve() | |
| 1016 | 1023 | return nil | |
| 1017 | 1024 | }, | |
| 1018 | 1025 | }) | |
| 1019 | 1026 | require.NoError(t, err) | |
| 1027 | + | require.NotNil(t, first) | |
| 1028 | + | require.Equal(t, first, second, "revisiting an endpoint must reuse its binding") | |
| 1020 | 1029 | } | |
| 1021 | 1030 | ||
| 1022 | 1031 | func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) { | |
| 1023 | 1032 | factory := newRemoteDialerFactoryMock(t) | |
| 1024 | - | factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1) | |
| 1033 | + | // The registry resolves the endpoint once: every repeated handoff reuses | |
| 1034 | + | // that binding, and the attempt bound is what the loop still enforces. | |
| 1035 | + | factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 1025 | 1036 | ||
| 1026 | 1037 | calls := 0 | |
| 1027 | 1038 | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| @@ -1047,7 +1058,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) | |||
| 1047 | 1058 | } { | |
| 1048 | 1059 | t.Run(tt.name, func(t *testing.T) { | |
| 1049 | 1060 | factory := newRemoteDialerFactoryMock(t) | |
| 1050 | - | factory.EXPECT().DialerForRemote("selected.example", "picked", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(2) | |
| 1061 | + | factory.EXPECT().DialerForRemote("selected.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 1051 | 1062 | target := protocol.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: tt.intent, RequestID: tt.requestID, EnvironmentPolicy: tt.policy} | |
| 1052 | 1063 | want := client.AttachRequest{Intent: tt.intent, SessionName: "picked", Remote: true, Origin: protocol.RouteOriginDiscovery, OriginKey: "selected.example", HostLabel: "selected.example", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned} | |
| 1053 | 1064 | localCalls, remoteCalls := 0, 0 | |
| @@ -1062,9 +1073,9 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) | |||
| 1062 | 1073 | } | |
| 1063 | 1074 | localCalls++ | |
| 1064 | 1075 | require.Equal(t, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local"}, request) | |
| 1065 | - | _, next, err := deps.AttachHandoff(target) | |
| 1066 | - | require.NoError(t, err) | |
| 1067 | - | require.Equal(t, want, next, "in-run handoff must preserve environment ownership") | |
| 1076 | + | binding, resolveErr := deps.HostRegistry.ResolveEndpoint(context.Background(), target.Endpoint) | |
| 1077 | + | require.NoError(t, resolveErr) | |
| 1078 | + | require.NotNil(t, binding.Dialer) | |
| 1068 | 1079 | return &client.AttachTargetError{Target: target} | |
| 1069 | 1080 | }, | |
| 1070 | 1081 | }) | |
| @@ -1098,7 +1109,7 @@ func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) { | |||
| 1098 | 1109 | for _, tt := range tests { | |
| 1099 | 1110 | t.Run(tt.name, func(t *testing.T) { | |
| 1100 | 1111 | factory := newRemoteDialerFactoryMock(t) | |
| 1101 | - | factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 1112 | + | factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 1102 | 1113 | calls := 0 | |
| 1103 | 1114 | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 1104 | 1115 | remoteDialerFactory: factory.DialerForRemote, | |
| @@ -1136,7 +1147,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing. | |||
| 1136 | 1147 | func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) { | |
| 1137 | 1148 | factoryErr := errors.New("factory failed") | |
| 1138 | 1149 | factory := newRemoteDialerFactoryMock(t) | |
| 1139 | - | factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(nil, factoryErr) | |
| 1150 | + | factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportUDP, mock.Anything).Return(nil, factoryErr) | |
| 1140 | 1151 | runClientCalled := false | |
| 1141 | 1152 | ||
| 1142 | 1153 | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
M internal/app/transport_observability_test.go +1 -1
| @@ -225,7 +225,7 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) { | |||
| 225 | 225 | t.Cleanup(func() { newPerformanceTrace = originalTrace }) | |
| 226 | 226 | ||
| 227 | 227 | factory := newRemoteDialerFactoryMock(t) | |
| 228 | - | factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil) | |
| 228 | + | factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil) | |
| 229 | 229 | originalFactory := newRemoteDialerFactoryWithRuntimeObserver | |
| 230 | 230 | factoryCalls := 0 | |
| 231 | 231 | newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) remoteDialerForTarget { | |
M internal/app/ui_driver.go +8 -1
| @@ -573,11 +573,18 @@ func clientClock(deps runAttachDeps) ports.Clock { | |||
| 573 | 573 | return clock.New() | |
| 574 | 574 | } | |
| 575 | 575 | ||
| 576 | + | // clientEnvironment returns the endpoint's environment as a defensive copy. An | |
| 577 | + | // endpoint configured with an explicitly empty environment stays empty: only an | |
| 578 | + | // endpoint with no configured environment reports nil. | |
| 576 | 579 | func clientEnvironment(factory func(string) []string, target string) []string { | |
| 577 | 580 | if factory == nil { | |
| 578 | 581 | return nil | |
| 579 | 582 | } | |
| 580 | - | return append([]string(nil), factory(target)...) | |
| 583 | + | environment := factory(target) | |
| 584 | + | if environment == nil { | |
| 585 | + | return nil | |
| 586 | + | } | |
| 587 | + | return append([]string{}, environment...) | |
| 581 | 588 | } | |
| 582 | 589 | ||
| 583 | 590 | func runUIDriver(ctx context.Context, options uiDriverOptions) error { | |
M internal/ports/mocks/remote_mock.go +187 -71
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | 12 | "github.com/bnema/vev/internal/protocol" | |
| 12 | 13 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 13 | 14 | mock "github.com/stretchr/testify/mock" | |
| @@ -367,77 +368,6 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st | |||
| 367 | 368 | return _c | |
| 368 | 369 | } | |
| 369 | 370 | ||
| 370 | - | // NewMockRemoteHostLearner creates a new instance of MockRemoteHostLearner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 371 | - | // The first argument is typically a *testing.T value. | |
| 372 | - | func NewMockRemoteHostLearner(t interface { | |
| 373 | - | mock.TestingT | |
| 374 | - | Cleanup(func()) | |
| 375 | - | }) *MockRemoteHostLearner { | |
| 376 | - | mock := &MockRemoteHostLearner{} | |
| 377 | - | mock.Mock.Test(t) | |
| 378 | - | ||
| 379 | - | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 380 | - | ||
| 381 | - | return mock | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | // MockRemoteHostLearner is an autogenerated mock type for the RemoteHostLearner type | |
| 385 | - | type MockRemoteHostLearner struct { | |
| 386 | - | mock.Mock | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | type MockRemoteHostLearner_Expecter struct { | |
| 390 | - | mock *mock.Mock | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | func (_m *MockRemoteHostLearner) EXPECT() *MockRemoteHostLearner_Expecter { | |
| 394 | - | return &MockRemoteHostLearner_Expecter{mock: &_m.Mock} | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | // RememberRemoteHost provides a mock function for the type MockRemoteHostLearner | |
| 398 | - | func (_mock *MockRemoteHostLearner) RememberRemoteHost() error { | |
| 399 | - | ret := _mock.Called() | |
| 400 | - | ||
| 401 | - | if len(ret) == 0 { | |
| 402 | - | panic("no return value specified for RememberRemoteHost") | |
| 403 | - | } | |
| 404 | - | ||
| 405 | - | var r0 error | |
| 406 | - | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 407 | - | r0 = returnFunc() | |
| 408 | - | } else { | |
| 409 | - | r0 = ret.Error(0) | |
| 410 | - | } | |
| 411 | - | return r0 | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | // MockRemoteHostLearner_RememberRemoteHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RememberRemoteHost' | |
| 415 | - | type MockRemoteHostLearner_RememberRemoteHost_Call struct { | |
| 416 | - | *mock.Call | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | // RememberRemoteHost is a helper method to define mock.On call | |
| 420 | - | func (_e *MockRemoteHostLearner_Expecter) RememberRemoteHost() *MockRemoteHostLearner_RememberRemoteHost_Call { | |
| 421 | - | return &MockRemoteHostLearner_RememberRemoteHost_Call{Call: _e.mock.On("RememberRemoteHost")} | |
| 422 | - | } | |
| 423 | - | ||
| 424 | - | func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) Run(run func()) *MockRemoteHostLearner_RememberRemoteHost_Call { | |
| 425 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 426 | - | run() | |
| 427 | - | }) | |
| 428 | - | return _c | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) Return(err error) *MockRemoteHostLearner_RememberRemoteHost_Call { | |
| 432 | - | _c.Call.Return(err) | |
| 433 | - | return _c | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) RunAndReturn(run func() error) *MockRemoteHostLearner_RememberRemoteHost_Call { | |
| 437 | - | _c.Call.Return(run) | |
| 438 | - | return _c | |
| 439 | - | } | |
| 440 | - | ||
| 441 | 371 | // NewMockRemoteCatalogClient creates a new instance of MockRemoteCatalogClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 442 | 372 | // The first argument is typically a *testing.T value. | |
| 443 | 373 | func NewMockRemoteCatalogClient(t interface { | |
| @@ -768,3 +698,189 @@ func (_c *MockRemoteCatalogCache_Store_Call) RunAndReturn(run func(remoteCatalog | |||
| 768 | 698 | _c.Call.Return(run) | |
| 769 | 699 | return _c | |
| 770 | 700 | } | |
| 701 | + | ||
| 702 | + | // NewMockRemoteEndpointFactory creates a new instance of MockRemoteEndpointFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 703 | + | // The first argument is typically a *testing.T value. | |
| 704 | + | func NewMockRemoteEndpointFactory(t interface { | |
| 705 | + | mock.TestingT | |
| 706 | + | Cleanup(func()) | |
| 707 | + | }) *MockRemoteEndpointFactory { | |
| 708 | + | mock := &MockRemoteEndpointFactory{} | |
| 709 | + | mock.Mock.Test(t) | |
| 710 | + | ||
| 711 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 712 | + | ||
| 713 | + | return mock | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | // MockRemoteEndpointFactory is an autogenerated mock type for the RemoteEndpointFactory type | |
| 717 | + | type MockRemoteEndpointFactory struct { | |
| 718 | + | mock.Mock | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | type MockRemoteEndpointFactory_Expecter struct { | |
| 722 | + | mock *mock.Mock | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | func (_m *MockRemoteEndpointFactory) EXPECT() *MockRemoteEndpointFactory_Expecter { | |
| 726 | + | return &MockRemoteEndpointFactory_Expecter{mock: &_m.Mock} | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | // ResolveEndpoint provides a mock function for the type MockRemoteEndpointFactory | |
| 730 | + | func (_mock *MockRemoteEndpointFactory) ResolveEndpoint(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 731 | + | ret := _mock.Called(ctx, endpoint) | |
| 732 | + | ||
| 733 | + | if len(ret) == 0 { | |
| 734 | + | panic("no return value specified for ResolveEndpoint") | |
| 735 | + | } | |
| 736 | + | ||
| 737 | + | var r0 ports.RemoteEndpointBinding | |
| 738 | + | var r1 error | |
| 739 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.RemoteEndpointBinding, error)); ok { | |
| 740 | + | return returnFunc(ctx, endpoint) | |
| 741 | + | } | |
| 742 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.RemoteEndpointBinding); ok { | |
| 743 | + | r0 = returnFunc(ctx, endpoint) | |
| 744 | + | } else { | |
| 745 | + | r0 = ret.Get(0).(ports.RemoteEndpointBinding) | |
| 746 | + | } | |
| 747 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 748 | + | r1 = returnFunc(ctx, endpoint) | |
| 749 | + | } else { | |
| 750 | + | r1 = ret.Error(1) | |
| 751 | + | } | |
| 752 | + | return r0, r1 | |
| 753 | + | } | |
| 754 | + | ||
| 755 | + | // MockRemoteEndpointFactory_ResolveEndpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveEndpoint' | |
| 756 | + | type MockRemoteEndpointFactory_ResolveEndpoint_Call struct { | |
| 757 | + | *mock.Call | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | // ResolveEndpoint is a helper method to define mock.On call | |
| 761 | + | // - ctx context.Context | |
| 762 | + | // - endpoint string | |
| 763 | + | func (_e *MockRemoteEndpointFactory_Expecter) ResolveEndpoint(ctx any, endpoint any) *MockRemoteEndpointFactory_ResolveEndpoint_Call { | |
| 764 | + | return &MockRemoteEndpointFactory_ResolveEndpoint_Call{Call: _e.mock.On("ResolveEndpoint", ctx, endpoint)} | |
| 765 | + | } | |
| 766 | + | ||
| 767 | + | func (_c *MockRemoteEndpointFactory_ResolveEndpoint_Call) Run(run func(ctx context.Context, endpoint string)) *MockRemoteEndpointFactory_ResolveEndpoint_Call { | |
| 768 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 769 | + | var arg0 context.Context | |
| 770 | + | if args[0] != nil { | |
| 771 | + | arg0 = args[0].(context.Context) | |
| 772 | + | } | |
| 773 | + | var arg1 string | |
| 774 | + | if args[1] != nil { | |
| 775 | + | arg1 = args[1].(string) | |
| 776 | + | } | |
| 777 | + | run( | |
| 778 | + | arg0, | |
| 779 | + | arg1, | |
| 780 | + | ) | |
| 781 | + | }) | |
| 782 | + | return _c | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | func (_c *MockRemoteEndpointFactory_ResolveEndpoint_Call) Return(remoteEndpointBinding ports.RemoteEndpointBinding, err error) *MockRemoteEndpointFactory_ResolveEndpoint_Call { | |
| 786 | + | _c.Call.Return(remoteEndpointBinding, err) | |
| 787 | + | return _c | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | func (_c *MockRemoteEndpointFactory_ResolveEndpoint_Call) RunAndReturn(run func(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error)) *MockRemoteEndpointFactory_ResolveEndpoint_Call { | |
| 791 | + | _c.Call.Return(run) | |
| 792 | + | return _c | |
| 793 | + | } | |
| 794 | + | ||
| 795 | + | // NewMockClientHostRegistry creates a new instance of MockClientHostRegistry. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 796 | + | // The first argument is typically a *testing.T value. | |
| 797 | + | func NewMockClientHostRegistry(t interface { | |
| 798 | + | mock.TestingT | |
| 799 | + | Cleanup(func()) | |
| 800 | + | }) *MockClientHostRegistry { | |
| 801 | + | mock := &MockClientHostRegistry{} | |
| 802 | + | mock.Mock.Test(t) | |
| 803 | + | ||
| 804 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 805 | + | ||
| 806 | + | return mock | |
| 807 | + | } | |
| 808 | + | ||
| 809 | + | // MockClientHostRegistry is an autogenerated mock type for the ClientHostRegistry type | |
| 810 | + | type MockClientHostRegistry struct { | |
| 811 | + | mock.Mock | |
| 812 | + | } | |
| 813 | + | ||
| 814 | + | type MockClientHostRegistry_Expecter struct { | |
| 815 | + | mock *mock.Mock | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | func (_m *MockClientHostRegistry) EXPECT() *MockClientHostRegistry_Expecter { | |
| 819 | + | return &MockClientHostRegistry_Expecter{mock: &_m.Mock} | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | // ResolveEndpoint provides a mock function for the type MockClientHostRegistry | |
| 823 | + | func (_mock *MockClientHostRegistry) ResolveEndpoint(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 824 | + | ret := _mock.Called(ctx, endpoint) | |
| 825 | + | ||
| 826 | + | if len(ret) == 0 { | |
| 827 | + | panic("no return value specified for ResolveEndpoint") | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | var r0 ports.RemoteEndpointBinding | |
| 831 | + | var r1 error | |
| 832 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.RemoteEndpointBinding, error)); ok { | |
| 833 | + | return returnFunc(ctx, endpoint) | |
| 834 | + | } | |
| 835 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.RemoteEndpointBinding); ok { | |
| 836 | + | r0 = returnFunc(ctx, endpoint) | |
| 837 | + | } else { | |
| 838 | + | r0 = ret.Get(0).(ports.RemoteEndpointBinding) | |
| 839 | + | } | |
| 840 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 841 | + | r1 = returnFunc(ctx, endpoint) | |
| 842 | + | } else { | |
| 843 | + | r1 = ret.Error(1) | |
| 844 | + | } | |
| 845 | + | return r0, r1 | |
| 846 | + | } | |
| 847 | + | ||
| 848 | + | // MockClientHostRegistry_ResolveEndpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveEndpoint' | |
| 849 | + | type MockClientHostRegistry_ResolveEndpoint_Call struct { | |
| 850 | + | *mock.Call | |
| 851 | + | } | |
| 852 | + | ||
| 853 | + | // ResolveEndpoint is a helper method to define mock.On call | |
| 854 | + | // - ctx context.Context | |
| 855 | + | // - endpoint string | |
| 856 | + | func (_e *MockClientHostRegistry_Expecter) ResolveEndpoint(ctx any, endpoint any) *MockClientHostRegistry_ResolveEndpoint_Call { | |
| 857 | + | return &MockClientHostRegistry_ResolveEndpoint_Call{Call: _e.mock.On("ResolveEndpoint", ctx, endpoint)} | |
| 858 | + | } | |
| 859 | + | ||
| 860 | + | func (_c *MockClientHostRegistry_ResolveEndpoint_Call) Run(run func(ctx context.Context, endpoint string)) *MockClientHostRegistry_ResolveEndpoint_Call { | |
| 861 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 862 | + | var arg0 context.Context | |
| 863 | + | if args[0] != nil { | |
| 864 | + | arg0 = args[0].(context.Context) | |
| 865 | + | } | |
| 866 | + | var arg1 string | |
| 867 | + | if args[1] != nil { | |
| 868 | + | arg1 = args[1].(string) | |
| 869 | + | } | |
| 870 | + | run( | |
| 871 | + | arg0, | |
| 872 | + | arg1, | |
| 873 | + | ) | |
| 874 | + | }) | |
| 875 | + | return _c | |
| 876 | + | } | |
| 877 | + | ||
| 878 | + | func (_c *MockClientHostRegistry_ResolveEndpoint_Call) Return(remoteEndpointBinding ports.RemoteEndpointBinding, err error) *MockClientHostRegistry_ResolveEndpoint_Call { | |
| 879 | + | _c.Call.Return(remoteEndpointBinding, err) | |
| 880 | + | return _c | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | func (_c *MockClientHostRegistry_ResolveEndpoint_Call) RunAndReturn(run func(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error)) *MockClientHostRegistry_ResolveEndpoint_Call { | |
| 884 | + | _c.Call.Return(run) | |
| 885 | + | return _c | |
| 886 | + | } | |
M internal/ports/mocks/ui_mock.go +1 -6
| @@ -365,13 +365,8 @@ func (_c *MockUIObservationSink_ObserveTerminalResize_Call) RunAndReturn(run fun | |||
| 365 | 365 | } | |
| 366 | 366 | ||
| 367 | 367 | // ObserveTerminalWrite provides a mock function for the type MockUIObservationSink | |
| 368 | - | // | |
| 369 | - | // NOTE(hand-edit): the defensive copy below is intentional. Callers pass | |
| 370 | - | // sub-slices of reused buffers, so recording the slice header without | |
| 371 | - | // copying makes assertions flaky. `make mocks` regenerates this file via | |
| 372 | - | // mockery and drops the copy; re-apply it after regeneration. | |
| 373 | 368 | func (_mock *MockUIObservationSink) ObserveTerminalWrite(data []byte) { | |
| 374 | - | _mock.Called(append([]byte(nil), data...)) | |
| 369 | + | _mock.Called(data) | |
| 375 | 370 | return | |
| 376 | 371 | } | |
| 377 | 372 | ||
M internal/ports/remote.go +22 -5
| @@ -21,11 +21,6 @@ type RemoteHostStore interface { | |||
| 21 | 21 | Remove(target string) (deleted bool, err error) | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | - | // RemoteHostLearner records the validated remote target after an attach. | |
| 25 | - | type RemoteHostLearner interface { | |
| 26 | - | RememberRemoteHost() error | |
| 27 | - | } | |
| 28 | - | ||
| 29 | 24 | // RemoteCatalogClient fetches a versioned session catalogue from a remote host. | |
| 30 | 25 | type RemoteCatalogClient interface { | |
| 31 | 26 | List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) | |
| @@ -42,3 +37,25 @@ type RemoteCatalogCache interface { | |||
| 42 | 37 | Load() ([]catalogue.RemoteCatalogCacheEntry, error) | |
| 43 | 38 | Store([]catalogue.RemoteCatalogCacheEntry) error | |
| 44 | 39 | } | |
| 40 | + | ||
| 41 | + | // RemoteEndpointBinding is one endpoint's resolved client carriage: the typed | |
| 42 | + | // dialer every session on that endpoint attaches through, and the environment | |
| 43 | + | // to advertise when the remote environment is daemon-owned. It never carries | |
| 44 | + | // session identity, a resume token, or discovered inventory. | |
| 45 | + | type RemoteEndpointBinding struct { | |
| 46 | + | Dialer ClientDialer | |
| 47 | + | Environment []string | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // RemoteEndpointFactory resolves one endpoint into its binding. The composition | |
| 51 | + | // root implements it from the configured transport mode, the launch allowlist, | |
| 52 | + | // and the endpoint environment; consumers never inspect those policies. | |
| 53 | + | type RemoteEndpointFactory interface { | |
| 54 | + | ResolveEndpoint(ctx context.Context, endpoint string) (RemoteEndpointBinding, error) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // ClientHostRegistry resolves and caches the endpoint bindings one runner | |
| 58 | + | // reuses across handoffs. Remote discovery remains daemon-owned. | |
| 59 | + | type ClientHostRegistry interface { | |
| 60 | + | ResolveEndpoint(ctx context.Context, endpoint string) (RemoteEndpointBinding, error) | |
| 61 | + | } | |
M internal/protocol/messages.go +12 -3
| @@ -22,7 +22,6 @@ func (OutputResetRequest) clientMessage() {} | |||
| 22 | 22 | func (RemotePreviewRequest) clientMessage() {} | |
| 23 | 23 | func (RouteAttentionSubscription) clientMessage() {} | |
| 24 | 24 | func (SamePeerSwitchRequest) clientMessage() {} | |
| 25 | - | func (ParkedRouteRequest) clientMessage() {} | |
| 26 | 25 | func (RecentRouteSnapshot) clientMessage() {} | |
| 27 | 26 | func (RouteNavigationFailure) clientMessage() {} | |
| 28 | 27 | func (SessionCreationFailure) clientMessage() {} | |
| @@ -30,6 +29,11 @@ func (UIFence) clientMessage() {} | |||
| 30 | 29 | func (NavigationInventoryRequest) clientMessage() {} | |
| 31 | 30 | func (NavigationInventoryPublication) clientMessage() {} | |
| 32 | 31 | func (NavigationInventoryFailure) clientMessage() {} | |
| 32 | + | func (PickerBegin) clientMessage() {} | |
| 33 | + | func (PickerClose) clientMessage() {} | |
| 34 | + | func (PickerSelection) clientMessage() {} | |
| 35 | + | func (PickerPreviewRequest) clientMessage() {} | |
| 36 | + | func (PickerControlRequest) clientMessage() {} | |
| 33 | 37 | ||
| 34 | 38 | func (Welcome) serverMessage() {} | |
| 35 | 39 | func (ErrorMsg) serverMessage() {} | |
| @@ -38,7 +42,6 @@ func (Detached) serverMessage() {} | |||
| 38 | 42 | func (Pong) serverMessage() {} | |
| 39 | 43 | func (Sessions) serverMessage() {} | |
| 40 | 44 | func (CommandResult) serverMessage() {} | |
| 41 | - | func (NavigationDirective) serverMessage() {} | |
| 42 | 45 | func (AttachTarget) serverMessage() {} | |
| 43 | 46 | func (RemotePreview) serverMessage() {} | |
| 44 | 47 | func (CommittedRouteIdentity) serverMessage() {} | |
| @@ -48,9 +51,15 @@ func (RouteNavigationFailure) serverMessage() {} | |||
| 48 | 51 | func (RoutePosition) serverMessage() {} | |
| 49 | 52 | func (RouteRetired) serverMessage() {} | |
| 50 | 53 | func (SamePeerSwitchFailure) serverMessage() {} | |
| 51 | - | func (ParkedRouteResponse) serverMessage() {} | |
| 52 | 54 | func (UIReceipt) serverMessage() {} | |
| 53 | 55 | func (UIViewUpdate) serverMessage() {} | |
| 54 | 56 | func (NavigationInventoryResponse) serverMessage() {} | |
| 55 | 57 | func (NavigationInventoryDemand) serverMessage() {} | |
| 56 | 58 | func (NavigationInventorySelection) serverMessage() {} | |
| 59 | + | func (PickerOffer) serverMessage() {} | |
| 60 | + | func (PickerSnapshot) serverMessage() {} | |
| 61 | + | func (PickerClosed) serverMessage() {} | |
| 62 | + | func (PickerResult) serverMessage() {} | |
| 63 | + | func (PickerFailure) serverMessage() {} | |
| 64 | + | func (PickerPreview) serverMessage() {} | |
| 65 | + | func (PickerControlResponse) serverMessage() {} | |
M internal/protocol/navigation.go +17 -104
| @@ -1,121 +1,34 @@ | |||
| 1 | 1 | package protocol | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/internal/domain" | |
| 4 | - | ||
| 3 | + | // NavigationCapabilities advertises optional client-driven navigation | |
| 4 | + | // features. Presenting a picker is not one of them: every attached client | |
| 5 | + | // renders the picker itself, so the serving daemon only negotiates the | |
| 6 | + | // features it must act on. | |
| 5 | 7 | type NavigationCapabilities uint8 | |
| 6 | 8 | ||
| 7 | 9 | const ( | |
| 8 | - | NavigationCapabilityHomePicker NavigationCapabilities = 1 << iota | |
| 9 | - | NavigationCapabilityBack | |
| 10 | - | NavigationCapabilityInventory | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | type StartupOverlay uint8 | |
| 14 | - | ||
| 15 | - | const ( | |
| 16 | - | StartupOverlayNone StartupOverlay = iota | |
| 17 | - | StartupOverlaySessionPicker | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | type NavigationAction uint8 | |
| 21 | - | ||
| 22 | - | const ( | |
| 23 | - | NavigationOpenHomePicker NavigationAction = 1 | |
| 24 | - | NavigationBack NavigationAction = 2 | |
| 25 | - | ) | |
| 26 | - | ||
| 27 | - | type ParkedRouteLeaseID [16]byte | |
| 28 | - | ||
| 29 | - | func (id ParkedRouteLeaseID) IsZero() bool { return id == ParkedRouteLeaseID{} } | |
| 30 | - | ||
| 31 | - | type NavigationDirective struct { | |
| 32 | - | CauseActionID uint64 | |
| 33 | - | Action NavigationAction | |
| 34 | - | LeaseID ParkedRouteLeaseID | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | type ParkedRouteAction uint8 | |
| 38 | - | ||
| 39 | - | const ( | |
| 40 | - | ParkedRoutePrepare ParkedRouteAction = iota + 1 | |
| 41 | - | ParkedRouteResume | |
| 42 | - | ParkedRouteSwitch | |
| 43 | - | ) | |
| 44 | - | ||
| 45 | - | type ParkedRouteRequest struct { | |
| 46 | - | RequestID uint64 | |
| 47 | - | LeaseID ParkedRouteLeaseID | |
| 48 | - | Action ParkedRouteAction | |
| 49 | - | Target *domain.RemoteSessionTarget | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | type ParkedRouteStatus uint8 | |
| 53 | - | ||
| 54 | - | const ( | |
| 55 | - | ParkedRouteReady ParkedRouteStatus = iota + 1 | |
| 56 | - | ParkedRouteResumed | |
| 57 | - | ParkedRouteSwitched | |
| 58 | - | ParkedRouteRejected | |
| 59 | - | ParkedRouteExpired | |
| 60 | - | ParkedRouteStaleTarget | |
| 10 | + | NavigationCapabilityInventory NavigationCapabilities = 1 << iota | |
| 61 | 11 | ) | |
| 62 | 12 | ||
| 63 | - | type ParkedRouteResponse struct { | |
| 64 | - | RequestID uint64 | |
| 65 | - | Status ParkedRouteStatus | |
| 66 | - | } | |
| 67 | - | ||
| 68 | 13 | func validNavigationCapabilities(capabilities NavigationCapabilities) bool { | |
| 69 | - | return capabilities&^(NavigationCapabilityHomePicker|NavigationCapabilityBack|NavigationCapabilityInventory) == 0 | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | func validStartupOverlay(overlay StartupOverlay) bool { | |
| 73 | - | return overlay == StartupOverlayNone || overlay == StartupOverlaySessionPicker | |
| 14 | + | return capabilities&^NavigationCapabilityInventory == 0 | |
| 74 | 15 | } | |
| 75 | 16 | ||
| 76 | - | func ValidateNavigation(capabilities NavigationCapabilities, overlay StartupOverlay, homePickerRoute bool) error { | |
| 77 | - | if !validNavigationCapabilities(capabilities) || !validStartupOverlay(overlay) { | |
| 78 | - | return ErrInvalidNavigation | |
| 79 | - | } | |
| 80 | - | home := capabilities&NavigationCapabilityHomePicker != 0 | |
| 81 | - | back := capabilities&NavigationCapabilityBack != 0 | |
| 82 | - | if (home && !homePickerRoute) || back != (overlay == StartupOverlaySessionPicker) { | |
| 83 | - | return ErrInvalidNavigation | |
| 84 | - | } | |
| 85 | - | if homePickerRoute && back { | |
| 17 | + | // ValidateNavigation rejects capabilities this protocol does not define and | |
| 18 | + | // accepts the inventory capability only on the intents whose attachment can | |
| 19 | + | // serve it. Both the client before dialing and the serving daemon on Hello use | |
| 20 | + | // this rule, so a request the client admits is one the daemon accepts. | |
| 21 | + | func ValidateNavigation(intent uint8, capabilities NavigationCapabilities) error { | |
| 22 | + | if !validNavigationCapabilities(capabilities) { | |
| 86 | 23 | return ErrInvalidNavigation | |
| 87 | 24 | } | |
| 88 | - | return nil | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | func validateHelloNavigation(h Hello) error { | |
| 92 | - | if h.Intent == IntentNew { | |
| 93 | - | return ValidateNavigation(h.NavigationCapabilities, h.StartupOverlay, true) | |
| 94 | - | } | |
| 95 | - | if h.Intent != IntentAttach && h.Intent != IntentResume { | |
| 96 | - | if h.NavigationCapabilities != 0 || h.StartupOverlay != StartupOverlayNone { | |
| 97 | - | return ErrInvalidNavigation | |
| 98 | - | } | |
| 25 | + | switch intent { | |
| 26 | + | case IntentNew, IntentAttach, IntentResume: | |
| 99 | 27 | return nil | |
| 100 | - | } | |
| 101 | - | return ValidateNavigation(h.NavigationCapabilities, h.StartupOverlay, h.RemoteTarget != nil || h.EnvironmentPolicy == EnvironmentPolicyDaemonOwned) | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | func ValidateParkedRouteRequest(request ParkedRouteRequest) error { | |
| 105 | - | if request.RequestID == 0 || request.LeaseID.IsZero() { | |
| 106 | - | return ErrInvalidNavigation | |
| 107 | - | } | |
| 108 | - | switch request.Action { | |
| 109 | - | case ParkedRoutePrepare, ParkedRouteResume: | |
| 110 | - | if request.Target != nil { | |
| 111 | - | return ErrInvalidNavigation | |
| 112 | - | } | |
| 113 | - | case ParkedRouteSwitch: | |
| 114 | - | if request.Target == nil || validateRemoteTarget(*request.Target) != nil { | |
| 28 | + | default: | |
| 29 | + | if capabilities != 0 { | |
| 115 | 30 | return ErrInvalidNavigation | |
| 116 | 31 | } | |
| 117 | - | default: | |
| 118 | - | return ErrInvalidNavigation | |
| 32 | + | return nil | |
| 119 | 33 | } | |
| 120 | - | return nil | |
| 121 | 34 | } | |
M internal/protocol/navigation_inventory_test.go +28 -1
| @@ -64,7 +64,34 @@ func TestValidateNavigationInventoryResponseUnions(t *testing.T) { | |||
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | func TestNavigationCapabilityInventoryValid(t *testing.T) { | |
| 67 | - | if err := ValidateNavigation(NavigationCapabilityInventory, StartupOverlayNone, false); err != nil { | |
| 67 | + | if err := ValidateNavigation(IntentAttach, NavigationCapabilityInventory); err != nil { | |
| 68 | 68 | t.Fatalf("ValidateNavigation(inventory) = %v", err) | |
| 69 | 69 | } | |
| 70 | 70 | } | |
| 71 | + | ||
| 72 | + | // TestValidateNavigationBindsInventoryToTheIntentsThatServeIt pins the shared | |
| 73 | + | // client/daemon rule: inventory is negotiable on the intents whose attachment | |
| 74 | + | // owns a relay, and no capability is admissible on the others. | |
| 75 | + | func TestValidateNavigationBindsInventoryToTheIntentsThatServeIt(t *testing.T) { | |
| 76 | + | tests := []struct { | |
| 77 | + | name string | |
| 78 | + | intent uint8 | |
| 79 | + | capabilities NavigationCapabilities | |
| 80 | + | wantErr bool | |
| 81 | + | }{ | |
| 82 | + | {name: "attach inventory", intent: IntentAttach, capabilities: NavigationCapabilityInventory}, | |
| 83 | + | {name: "resume inventory", intent: IntentResume, capabilities: NavigationCapabilityInventory}, | |
| 84 | + | {name: "new inventory", intent: IntentNew, capabilities: NavigationCapabilityInventory}, | |
| 85 | + | {name: "ephemeral inventory", intent: IntentEphemeral, capabilities: NavigationCapabilityInventory, wantErr: true}, | |
| 86 | + | {name: "ephemeral clean", intent: IntentEphemeral}, | |
| 87 | + | {name: "unknown bit", intent: IntentAttach, capabilities: 1 << 7, wantErr: true}, | |
| 88 | + | } | |
| 89 | + | for _, tt := range tests { | |
| 90 | + | t.Run(tt.name, func(t *testing.T) { | |
| 91 | + | err := ValidateNavigation(tt.intent, tt.capabilities) | |
| 92 | + | if (err != nil) != tt.wantErr { | |
| 93 | + | t.Fatalf("ValidateNavigation(%d, %d) = %v, wantErr %t", tt.intent, tt.capabilities, err, tt.wantErr) | |
| 94 | + | } | |
| 95 | + | }) | |
| 96 | + | } | |
| 97 | + | } | |
A internal/protocol/picker_interaction.go +665 -0
| @@ -0,0 +1,665 @@ | |||
| 1 | + | package protocol | |
| 2 | + | ||
| 3 | + | import "fmt" | |
| 4 | + | ||
| 5 | + | // PickerServingSourceID names the source the serving daemon owns: every | |
| 6 | + | // attachment publishes its own rows under this one source identity. | |
| 7 | + | const ( | |
| 8 | + | PickerServingSourceID = "serving" | |
| 9 | + | PickerHomeSourceID = "home" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Client-picker interaction bounds. A source publishes at most | |
| 13 | + | // PickerInteractionMaxLines structured lines; the complete encoded snapshot | |
| 14 | + | // stays below PickerInteractionMaxEncodedBytes, the same 4 MiB export budget | |
| 15 | + | // shared with navigation inventory. | |
| 16 | + | const ( | |
| 17 | + | PickerInteractionMaxLines = 4096 | |
| 18 | + | PickerInteractionMaxDisplayBytes = 256 | |
| 19 | + | PickerInteractionMaxEncodedBytes = 4 << 20 | |
| 20 | + | PickerInteractionMaxSourceBytes = 128 | |
| 21 | + | PickerInteractionMaxTitleBytes = 256 | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // PickerIntent names which destinations a picker offers and which mutation a | |
| 25 | + | // selection performs. The daemon owns the intent: it captures move sources and | |
| 26 | + | // decides which rows are eligible. | |
| 27 | + | type PickerIntent uint8 | |
| 28 | + | ||
| 29 | + | const ( | |
| 30 | + | PickerIntentNavigation PickerIntent = iota + 1 | |
| 31 | + | PickerIntentMovePane | |
| 32 | + | PickerIntentMoveTab | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | // PickerAction names the mutation one selection requests. | |
| 36 | + | type PickerAction uint8 | |
| 37 | + | ||
| 38 | + | const ( | |
| 39 | + | PickerActionNavigate PickerAction = iota + 1 | |
| 40 | + | PickerActionMove | |
| 41 | + | PickerActionKill | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | // PickerLineKind names the row shape. Section rows are group headers and | |
| 45 | + | // carry no key; presentation-only rows (host status) stay focusable but not | |
| 46 | + | // selectable. | |
| 47 | + | type PickerLineKind uint8 | |
| 48 | + | ||
| 49 | + | const ( | |
| 50 | + | PickerLineSection PickerLineKind = iota + 1 | |
| 51 | + | PickerLineSession | |
| 52 | + | PickerLineTab | |
| 53 | + | PickerLineHost | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | // PickerLineStatus is the bounded state badge drawn on a row. | |
| 57 | + | type PickerLineStatus uint8 | |
| 58 | + | ||
| 59 | + | const ( | |
| 60 | + | PickerLineStatusNone PickerLineStatus = iota | |
| 61 | + | PickerLineStatusUp | |
| 62 | + | PickerLineStatusStopped | |
| 63 | + | PickerLineStatusDown | |
| 64 | + | PickerLineStatusStale | |
| 65 | + | PickerLineStatusVersion | |
| 66 | + | PickerLineStatusError | |
| 67 | + | ) | |
| 68 | + | ||
| 69 | + | // PickerLineActions is the set of actions a row admits. The presenting client | |
| 70 | + | // offers only what the owning source authorised; the source revalidates the | |
| 71 | + | // action and the row lifecycle at commit. | |
| 72 | + | type PickerLineActions uint8 | |
| 73 | + | ||
| 74 | + | const ( | |
| 75 | + | PickerCanNavigate PickerLineActions = 1 << iota | |
| 76 | + | PickerCanMove | |
| 77 | + | PickerCanKill | |
| 78 | + | ) | |
| 79 | + | ||
| 80 | + | // PickerSourceStatus is the per-source outcome. A failed source never | |
| 81 | + | // invalidates another source's lines. | |
| 82 | + | type PickerSourceStatus uint8 | |
| 83 | + | ||
| 84 | + | const ( | |
| 85 | + | PickerSourceOK PickerSourceStatus = iota + 1 | |
| 86 | + | PickerSourceUnavailable | |
| 87 | + | PickerSourceTooLarge | |
| 88 | + | ) | |
| 89 | + | ||
| 90 | + | // PickerLine is one structured row. Key is opaque and source-assigned; it | |
| 91 | + | // resolves to a target source-side only. Display strings carry presentation | |
| 92 | + | // facts, never endpoints, credentials, environment, CWD, or pane content. | |
| 93 | + | // Lines are published in the source's own order (recency for local sessions) | |
| 94 | + | // and Ephemeral marks sessions created for one attach, which is what the | |
| 95 | + | // presenting client needs to offer its local grouped order. | |
| 96 | + | type PickerLine struct { | |
| 97 | + | Key string | |
| 98 | + | Kind PickerLineKind | |
| 99 | + | Label string | |
| 100 | + | Detail string | |
| 101 | + | Status PickerLineStatus | |
| 102 | + | StatusDetail string | |
| 103 | + | Attention bool | |
| 104 | + | Stopped bool | |
| 105 | + | Dim bool | |
| 106 | + | // Focusable reports that the source authorises this row as a cursor | |
| 107 | + | // destination for this interaction, independently of whether it admits an | |
| 108 | + | // action. A Focusable row with no Actions is deliberately inspectable and | |
| 109 | + | // never committable; a row that is neither Focusable nor actionable is | |
| 110 | + | // rendered but skipped by cursor navigation. | |
| 111 | + | Focusable bool | |
| 112 | + | Actions PickerLineActions | |
| 113 | + | Ephemeral bool | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Selectable reports whether the row admits at least one action. | |
| 117 | + | func (line PickerLine) Selectable() bool { return line.Actions != 0 } | |
| 118 | + | ||
| 119 | + | // PickerCursor restores the selected row by opaque key with its list index. | |
| 120 | + | type PickerCursor struct { | |
| 121 | + | Key string | |
| 122 | + | Index int | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // PickerOffer opens one interaction. The daemon sends it for intents it | |
| 126 | + | // initiates (a session-picker command or a move request); it carries the | |
| 127 | + | // acquisition barrier the client must display before owning the terminal and | |
| 128 | + | // the opaque identity of the captured move source. RequestID echoes the | |
| 129 | + | // PickerBegin that asked for the interaction, and is zero for daemon-driven | |
| 130 | + | // opens. | |
| 131 | + | type PickerOffer struct { | |
| 132 | + | InteractionID uint64 | |
| 133 | + | RequestID uint64 | |
| 134 | + | Intent PickerIntent | |
| 135 | + | MoveSourceKey string | |
| 136 | + | BarrierEpoch uint64 | |
| 137 | + | BarrierState uint64 | |
| 138 | + | SizeEpoch uint64 | |
| 139 | + | Title string | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // PickerBegin asks the serving daemon to open one interaction. Startup | |
| 143 | + | // pickers and client-driven navigation requests use it; a daemon that already | |
| 144 | + | // executed a picker command sends PickerOffer directly. | |
| 145 | + | type PickerBegin struct { | |
| 146 | + | RequestID uint64 | |
| 147 | + | Intent PickerIntent | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // PickerControlOperation selects a stateless request to the client's stable | |
| 151 | + | // local picker authority. It never creates an attachment. | |
| 152 | + | type PickerControlOperation uint8 | |
| 153 | + | ||
| 154 | + | const ( | |
| 155 | + | PickerControlSnapshot PickerControlOperation = iota + 1 | |
| 156 | + | PickerControlResolve | |
| 157 | + | PickerControlObserve | |
| 158 | + | ) | |
| 159 | + | ||
| 160 | + | // PickerControlMaxTargets bounds one observation request. A periodic Runner | |
| 161 | + | // polls a small fixed working set of exact targets per query; a full directory | |
| 162 | + | // belongs to navigation inventory, not to this stateless probe. | |
| 163 | + | const PickerControlMaxTargets = 64 | |
| 164 | + | ||
| 165 | + | // PickerRoutePresence reports whether an exact target still resolves to one | |
| 166 | + | // live route. Unknown is a first-class answer: an unanswerable probe must | |
| 167 | + | // never be reported as Absent, because the caller would otherwise act on a | |
| 168 | + | // false negative. | |
| 169 | + | type PickerRoutePresence uint8 | |
| 170 | + | ||
| 171 | + | const ( | |
| 172 | + | PickerRoutePresent PickerRoutePresence = iota + 1 | |
| 173 | + | PickerRouteAbsent | |
| 174 | + | PickerRouteUnknown | |
| 175 | + | ) | |
| 176 | + | ||
| 177 | + | // PickerRouteObservation is one exact-target answer. Attention is meaningful | |
| 178 | + | // only for Present targets: Absent and Unknown observations always carry | |
| 179 | + | // false, so a stale flag can never be mistaken for live attention. | |
| 180 | + | type PickerRouteObservation struct { | |
| 181 | + | Target ExactSessionTarget | |
| 182 | + | Presence PickerRoutePresence | |
| 183 | + | Attention bool | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // PickerControlRequest queries, resolves, or observes the navigation picker | |
| 187 | + | // owned by the client's local daemon. Version stays first for strict | |
| 188 | + | // pre-handshake peeking. The operation payloads are exclusive: snapshot and | |
| 189 | + | // resolve carry source/key identity, observe carries one bounded exact-target | |
| 190 | + | // list, and every field an operation does not own stays zero. | |
| 191 | + | type PickerControlRequest struct { | |
| 192 | + | Version uint16 | |
| 193 | + | RequestID uint64 | |
| 194 | + | Operation PickerControlOperation | |
| 195 | + | SourceRevision uint64 | |
| 196 | + | SourceID string | |
| 197 | + | Key string | |
| 198 | + | Targets []ExactSessionTarget | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // PickerControlResponse returns one complete authoritative snapshot, one | |
| 202 | + | // revalidated attach target, or the observation list for an observe request. | |
| 203 | + | // The operation payloads are exclusive. | |
| 204 | + | type PickerControlResponse struct { | |
| 205 | + | RequestID uint64 | |
| 206 | + | Operation PickerControlOperation | |
| 207 | + | Status PickerSourceStatus | |
| 208 | + | Snapshot *PickerSnapshot | |
| 209 | + | Resolved *AttachTarget | |
| 210 | + | Observations []PickerRouteObservation | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // PickerProjection is one complete daemon-authorised presentation. Recent and | |
| 214 | + | // Grouped contain the same destination keys and actions but may differ in | |
| 215 | + | // order, sections, and display labels. | |
| 216 | + | type PickerProjection struct { | |
| 217 | + | Lines []PickerLine | |
| 218 | + | Cursor PickerCursor | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // PickerSnapshot atomically publishes both presentation projections of one | |
| 222 | + | // source at one revision. The client chooses a projection but never derives | |
| 223 | + | // grouping or ordering policy from row metadata. | |
| 224 | + | type PickerSnapshot struct { | |
| 225 | + | InteractionID uint64 | |
| 226 | + | SourceID string | |
| 227 | + | SourceRevision uint64 | |
| 228 | + | Status PickerSourceStatus | |
| 229 | + | StatusDetail string | |
| 230 | + | // Lines/Cursor are retained as construction aliases inside this v0 branch; | |
| 231 | + | // normalization copies them into both projections before encoding. | |
| 232 | + | Lines []PickerLine | |
| 233 | + | Cursor PickerCursor | |
| 234 | + | Recent PickerProjection | |
| 235 | + | Grouped PickerProjection | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // PickerSelection commits the opaque row key displayed at SourceRevision. | |
| 239 | + | // CauseActionID correlates ui-driver input like NavigationInventorySelection. | |
| 240 | + | // RequestID echoes the PickerBegin that opened a client-initiated | |
| 241 | + | // interaction, and is zero otherwise. | |
| 242 | + | type PickerSelection struct { | |
| 243 | + | CauseActionID uint64 | |
| 244 | + | RequestID uint64 | |
| 245 | + | InteractionID uint64 | |
| 246 | + | SourceID string | |
| 247 | + | SourceRevision uint64 | |
| 248 | + | Key string | |
| 249 | + | Action PickerAction | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // PickerResult reports a completed mutation that changed source state, so the | |
| 253 | + | // client can refresh without guessing. Rejections use PickerFailure. | |
| 254 | + | type PickerResult struct { | |
| 255 | + | CauseActionID uint64 | |
| 256 | + | RequestID uint64 | |
| 257 | + | InteractionID uint64 | |
| 258 | + | SourceID string | |
| 259 | + | Key string | |
| 260 | + | Action PickerAction | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // PickerFailureCode classifies client-picker selection failures. | |
| 264 | + | type PickerFailureCode uint8 | |
| 265 | + | ||
| 266 | + | const ( | |
| 267 | + | PickerStaleRevision PickerFailureCode = iota + 1 | |
| 268 | + | PickerUnknownKey | |
| 269 | + | PickerRetiredTarget | |
| 270 | + | PickerNavigationFailed | |
| 271 | + | PickerUnknownSource | |
| 272 | + | PickerActionFailed | |
| 273 | + | ) | |
| 274 | + | ||
| 275 | + | // PickerFailure reports a rejected selection with a bounded code. | |
| 276 | + | type PickerFailure struct { | |
| 277 | + | CauseActionID uint64 | |
| 278 | + | RequestID uint64 | |
| 279 | + | InteractionID uint64 | |
| 280 | + | SourceID string | |
| 281 | + | Key string | |
| 282 | + | Action PickerAction | |
| 283 | + | Code PickerFailureCode | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // PickerClose asks the serving daemon to retire one interaction. It is | |
| 287 | + | // idempotent: a close for an unknown or already retired interaction is a | |
| 288 | + | // no-op, never an error. | |
| 289 | + | type PickerClose struct { | |
| 290 | + | InteractionID uint64 | |
| 291 | + | RequestID uint64 | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // PickerClosed confirms that the serving daemon retired one interaction. The | |
| 295 | + | // barrier names the daemon output boundary the client must display before it | |
| 296 | + | // releases the terminal. | |
| 297 | + | type PickerClosed struct { | |
| 298 | + | InteractionID uint64 | |
| 299 | + | BarrierEpoch uint64 | |
| 300 | + | BarrierState uint64 | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func validPickerControlOperation(operation PickerControlOperation) bool { | |
| 304 | + | return operation == PickerControlSnapshot || operation == PickerControlResolve || operation == PickerControlObserve | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func validPickerRoutePresence(presence PickerRoutePresence) bool { | |
| 308 | + | switch presence { | |
| 309 | + | case PickerRoutePresent, PickerRouteAbsent, PickerRouteUnknown: | |
| 310 | + | return true | |
| 311 | + | default: | |
| 312 | + | return false | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // ValidatePickerControlRequest enforces explicit, exclusive operation payloads. | |
| 317 | + | // Snapshot and resolve must carry no targets; observe must carry 1.. | |
| 318 | + | // PickerControlMaxTargets distinct valid exact targets and no source/key | |
| 319 | + | // identity, so a query can never smuggle a second payload shape. | |
| 320 | + | func ValidatePickerControlRequest(request PickerControlRequest) error { | |
| 321 | + | if request.Version == 0 || request.RequestID == 0 || !validPickerControlOperation(request.Operation) { | |
| 322 | + | return ErrInvalidNavigation | |
| 323 | + | } | |
| 324 | + | switch request.Operation { | |
| 325 | + | case PickerControlSnapshot: | |
| 326 | + | if request.SourceRevision != 0 || request.SourceID != "" || request.Key != "" || len(request.Targets) != 0 { | |
| 327 | + | return ErrInvalidNavigation | |
| 328 | + | } | |
| 329 | + | return nil | |
| 330 | + | case PickerControlResolve: | |
| 331 | + | if len(request.Targets) != 0 { | |
| 332 | + | return ErrInvalidNavigation | |
| 333 | + | } | |
| 334 | + | if request.SourceRevision == 0 || !validPickerSourceID(request.SourceID) || !validPickerKey(request.Key) { | |
| 335 | + | return ErrInvalidNavigation | |
| 336 | + | } | |
| 337 | + | return nil | |
| 338 | + | default: | |
| 339 | + | if request.SourceRevision != 0 || request.SourceID != "" || request.Key != "" { | |
| 340 | + | return ErrInvalidNavigation | |
| 341 | + | } | |
| 342 | + | if len(request.Targets) == 0 || len(request.Targets) > PickerControlMaxTargets { | |
| 343 | + | return ErrInvalidNavigation | |
| 344 | + | } | |
| 345 | + | seen := make(map[ExactSessionTarget]struct{}, len(request.Targets)) | |
| 346 | + | for _, target := range request.Targets { | |
| 347 | + | if err := target.Validate(); err != nil { | |
| 348 | + | return ErrInvalidNavigation | |
| 349 | + | } | |
| 350 | + | if _, dup := seen[target]; dup { | |
| 351 | + | return ErrInvalidNavigation | |
| 352 | + | } | |
| 353 | + | seen[target] = struct{}{} | |
| 354 | + | } | |
| 355 | + | return nil | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // validatePickerRouteObservations requires one bounded, distinct observation | |
| 360 | + | // per requested target and refuses an attention flag on anything but a present | |
| 361 | + | // target. | |
| 362 | + | func validatePickerRouteObservations(observations []PickerRouteObservation) error { | |
| 363 | + | if len(observations) == 0 || len(observations) > PickerControlMaxTargets { | |
| 364 | + | return ErrInvalidNavigation | |
| 365 | + | } | |
| 366 | + | seen := make(map[ExactSessionTarget]struct{}, len(observations)) | |
| 367 | + | for _, observation := range observations { | |
| 368 | + | if !validPickerRoutePresence(observation.Presence) { | |
| 369 | + | return ErrInvalidNavigation | |
| 370 | + | } | |
| 371 | + | if err := observation.Target.Validate(); err != nil { | |
| 372 | + | return ErrInvalidNavigation | |
| 373 | + | } | |
| 374 | + | if observation.Presence != PickerRoutePresent && observation.Attention { | |
| 375 | + | return ErrInvalidNavigation | |
| 376 | + | } | |
| 377 | + | if _, dup := seen[observation.Target]; dup { | |
| 378 | + | return ErrInvalidNavigation | |
| 379 | + | } | |
| 380 | + | seen[observation.Target] = struct{}{} | |
| 381 | + | } | |
| 382 | + | return nil | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // ValidatePickerControlResponse enforces the exclusive response union: exactly | |
| 386 | + | // the payload owned by the operation and status is present, and a failed | |
| 387 | + | // status never carries payload bytes at all. | |
| 388 | + | func ValidatePickerControlResponse(response PickerControlResponse) error { | |
| 389 | + | if response.RequestID == 0 || !validPickerControlOperation(response.Operation) || !validPickerSourceStatus(response.Status) { | |
| 390 | + | return ErrInvalidNavigation | |
| 391 | + | } | |
| 392 | + | if response.Status != PickerSourceOK { | |
| 393 | + | if response.Snapshot != nil || response.Resolved != nil || len(response.Observations) != 0 { | |
| 394 | + | return ErrInvalidNavigation | |
| 395 | + | } | |
| 396 | + | return nil | |
| 397 | + | } | |
| 398 | + | switch response.Operation { | |
| 399 | + | case PickerControlSnapshot: | |
| 400 | + | if response.Snapshot == nil || response.Resolved != nil || len(response.Observations) != 0 { | |
| 401 | + | return ErrInvalidNavigation | |
| 402 | + | } | |
| 403 | + | return ValidatePickerSnapshot(*response.Snapshot) | |
| 404 | + | case PickerControlResolve: | |
| 405 | + | if response.Resolved == nil || response.Snapshot != nil || len(response.Observations) != 0 { | |
| 406 | + | return ErrInvalidNavigation | |
| 407 | + | } | |
| 408 | + | return ValidateAttachTarget(*response.Resolved) | |
| 409 | + | default: | |
| 410 | + | if response.Snapshot != nil || response.Resolved != nil { | |
| 411 | + | return ErrInvalidNavigation | |
| 412 | + | } | |
| 413 | + | return validatePickerRouteObservations(response.Observations) | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func validPickerIntent(intent PickerIntent) bool { | |
| 418 | + | switch intent { | |
| 419 | + | case PickerIntentNavigation, PickerIntentMovePane, PickerIntentMoveTab: | |
| 420 | + | return true | |
| 421 | + | default: | |
| 422 | + | return false | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func validPickerAction(action PickerAction) bool { | |
| 427 | + | switch action { | |
| 428 | + | case PickerActionNavigate, PickerActionMove, PickerActionKill: | |
| 429 | + | return true | |
| 430 | + | default: | |
| 431 | + | return false | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | func validPickerLineKind(kind PickerLineKind) bool { | |
| 436 | + | switch kind { | |
| 437 | + | case PickerLineSection, PickerLineSession, PickerLineTab, PickerLineHost: | |
| 438 | + | return true | |
| 439 | + | default: | |
| 440 | + | return false | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | func validPickerLineStatus(status PickerLineStatus) bool { | |
| 445 | + | return status <= PickerLineStatusError | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func validPickerLineActions(actions PickerLineActions) bool { | |
| 449 | + | return actions&^(PickerCanNavigate|PickerCanMove|PickerCanKill) == 0 | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func validPickerSourceStatus(status PickerSourceStatus) bool { | |
| 453 | + | switch status { | |
| 454 | + | case PickerSourceOK, PickerSourceUnavailable, PickerSourceTooLarge: | |
| 455 | + | return true | |
| 456 | + | default: | |
| 457 | + | return false | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | func validPickerFailureCode(code PickerFailureCode) bool { | |
| 462 | + | switch code { | |
| 463 | + | case PickerStaleRevision, PickerUnknownKey, PickerRetiredTarget, PickerNavigationFailed, PickerUnknownSource, PickerActionFailed: | |
| 464 | + | return true | |
| 465 | + | default: | |
| 466 | + | return false | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func validPickerKey(value string) bool { | |
| 471 | + | return validInventoryKey(value) | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func validPickerDisplay(value string) bool { | |
| 475 | + | if len(value) > PickerInteractionMaxDisplayBytes { | |
| 476 | + | return false | |
| 477 | + | } | |
| 478 | + | return validInventoryDisplay(value) | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | func validPickerSourceID(value string) bool { | |
| 482 | + | if value == "" || len(value) > PickerInteractionMaxSourceBytes { | |
| 483 | + | return false | |
| 484 | + | } | |
| 485 | + | return validInventoryDisplay(value) | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | // ValidatePickerOffer enforces a nonzero interaction, a known intent, bounded | |
| 489 | + | // display strings, and a move source only on move intents. | |
| 490 | + | func ValidatePickerOffer(offer PickerOffer) error { | |
| 491 | + | if offer.InteractionID == 0 || !validPickerIntent(offer.Intent) { | |
| 492 | + | return ErrInvalidNavigation | |
| 493 | + | } | |
| 494 | + | if !validPickerDisplay(offer.Title) { | |
| 495 | + | return ErrInvalidNavigation | |
| 496 | + | } | |
| 497 | + | if offer.MoveSourceKey != "" { | |
| 498 | + | if offer.Intent == PickerIntentNavigation || !validPickerKey(offer.MoveSourceKey) { | |
| 499 | + | return ErrInvalidNavigation | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | return nil | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | // ValidatePickerBegin enforces a nonzero request ID and a known intent. | |
| 506 | + | func ValidatePickerBegin(begin PickerBegin) error { | |
| 507 | + | if begin.RequestID == 0 || !validPickerIntent(begin.Intent) { | |
| 508 | + | return ErrInvalidNavigation | |
| 509 | + | } | |
| 510 | + | return nil | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | // ValidatePickerSnapshot enforces a nonzero interaction/source revision, a | |
| 514 | + | // bounded source identity, and unique opaque keys with display safety. Order | |
| 515 | + | // is preserved verbatim: lines are never re-sorted here. | |
| 516 | + | // ValidatePickerSnapshot enforces the envelope, the per-line shape, unique row | |
| 517 | + | // keys, and a cursor that names a published row. Every rejection wraps | |
| 518 | + | // ErrInvalidNavigation with the offending row, because a refused snapshot | |
| 519 | + | // otherwise leaves an operator with nothing but a silent, closed picker. | |
| 520 | + | func ValidatePickerSnapshot(snapshot PickerSnapshot) error { | |
| 521 | + | snapshot = NormalizePickerSnapshot(snapshot) | |
| 522 | + | if snapshot.InteractionID == 0 || snapshot.SourceRevision == 0 { | |
| 523 | + | return fmt.Errorf("%w: snapshot envelope", ErrInvalidNavigation) | |
| 524 | + | } | |
| 525 | + | if !validPickerSourceID(snapshot.SourceID) || !validPickerSourceStatus(snapshot.Status) { | |
| 526 | + | return fmt.Errorf("%w: snapshot source %q", ErrInvalidNavigation, snapshot.SourceID) | |
| 527 | + | } | |
| 528 | + | if !validPickerDisplay(snapshot.StatusDetail) { | |
| 529 | + | return fmt.Errorf("%w: snapshot status detail", ErrInvalidNavigation) | |
| 530 | + | } | |
| 531 | + | recentKeys, err := validatePickerProjection("recent", snapshot.Recent) | |
| 532 | + | if err != nil { | |
| 533 | + | return err | |
| 534 | + | } | |
| 535 | + | groupedKeys, err := validatePickerProjection("grouped", snapshot.Grouped) | |
| 536 | + | if err != nil { | |
| 537 | + | return err | |
| 538 | + | } | |
| 539 | + | if len(recentKeys) != len(groupedKeys) { | |
| 540 | + | return fmt.Errorf("%w: projection destination count", ErrInvalidNavigation) | |
| 541 | + | } | |
| 542 | + | for key, recent := range recentKeys { | |
| 543 | + | grouped, ok := groupedKeys[key] | |
| 544 | + | if !ok || recent != grouped { | |
| 545 | + | return fmt.Errorf("%w: projection destination %q", ErrInvalidNavigation, key) | |
| 546 | + | } | |
| 547 | + | } | |
| 548 | + | return nil | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | func NormalizePickerSnapshot(snapshot PickerSnapshot) PickerSnapshot { | |
| 552 | + | if len(snapshot.Recent.Lines) == 0 && len(snapshot.Lines) != 0 { | |
| 553 | + | snapshot.Recent = PickerProjection{Lines: snapshot.Lines, Cursor: snapshot.Cursor} | |
| 554 | + | } | |
| 555 | + | if len(snapshot.Grouped.Lines) == 0 && len(snapshot.Lines) != 0 { | |
| 556 | + | snapshot.Grouped = PickerProjection{Lines: snapshot.Lines, Cursor: snapshot.Cursor} | |
| 557 | + | } | |
| 558 | + | return snapshot | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | func validatePickerProjection(name string, projection PickerProjection) (map[string]PickerLineActions, error) { | |
| 562 | + | if len(projection.Lines) > PickerInteractionMaxLines { | |
| 563 | + | return nil, fmt.Errorf("%w: %s has %d lines", ErrInvalidNavigation, name, len(projection.Lines)) | |
| 564 | + | } | |
| 565 | + | seen := make(map[string]PickerLineActions, len(projection.Lines)) | |
| 566 | + | for i, line := range projection.Lines { | |
| 567 | + | if !validPickerLineKind(line.Kind) || !validPickerLineStatus(line.Status) || !validPickerLineActions(line.Actions) { | |
| 568 | + | return nil, fmt.Errorf("%w: %s line %d kind/status/actions", ErrInvalidNavigation, name, i) | |
| 569 | + | } | |
| 570 | + | if !validPickerDisplay(line.Label) || !validPickerDisplay(line.Detail) || !validPickerDisplay(line.StatusDetail) { | |
| 571 | + | return nil, fmt.Errorf("%w: %s line %d display", ErrInvalidNavigation, name, i) | |
| 572 | + | } | |
| 573 | + | // Focus eligibility is published explicitly: a row that admits an | |
| 574 | + | // action is always a cursor destination, and a section header is never | |
| 575 | + | // one. A row that is neither is rendered and skipped. | |
| 576 | + | if line.Actions != 0 && !line.Focusable { | |
| 577 | + | return nil, fmt.Errorf("%w: %s line %d action without focus", ErrInvalidNavigation, name, i) | |
| 578 | + | } | |
| 579 | + | if line.Kind == PickerLineSection { | |
| 580 | + | if line.Key != "" || line.Focusable || line.Actions != 0 { | |
| 581 | + | return nil, fmt.Errorf("%w: %s line %d section key or action", ErrInvalidNavigation, name, i) | |
| 582 | + | } | |
| 583 | + | continue | |
| 584 | + | } | |
| 585 | + | if !validPickerKey(line.Key) { | |
| 586 | + | return nil, fmt.Errorf("%w: %s line %d key %q", ErrInvalidNavigation, name, i, line.Key) | |
| 587 | + | } | |
| 588 | + | if _, dup := seen[line.Key]; dup { | |
| 589 | + | return nil, fmt.Errorf("%w: %s line %d repeats key %q", ErrInvalidNavigation, name, i, line.Key) | |
| 590 | + | } | |
| 591 | + | seen[line.Key] = line.Actions | |
| 592 | + | } | |
| 593 | + | if projection.Cursor.Key != "" { | |
| 594 | + | if !validPickerKey(projection.Cursor.Key) { | |
| 595 | + | return nil, fmt.Errorf("%w: %s cursor key %q", ErrInvalidNavigation, name, projection.Cursor.Key) | |
| 596 | + | } | |
| 597 | + | if _, ok := seen[projection.Cursor.Key]; !ok { | |
| 598 | + | return nil, fmt.Errorf("%w: %s cursor key %q is not a published row", ErrInvalidNavigation, name, projection.Cursor.Key) | |
| 599 | + | } | |
| 600 | + | } | |
| 601 | + | if projection.Cursor.Index < 0 || projection.Cursor.Index > len(projection.Lines) { | |
| 602 | + | return nil, fmt.Errorf("%w: %s cursor index %d of %d lines", ErrInvalidNavigation, name, projection.Cursor.Index, len(projection.Lines)) | |
| 603 | + | } | |
| 604 | + | return seen, nil | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | // ValidatePickerClose requires a nonzero interaction. | |
| 608 | + | func ValidatePickerClose(close PickerClose) error { | |
| 609 | + | if close.InteractionID == 0 { | |
| 610 | + | return ErrInvalidNavigation | |
| 611 | + | } | |
| 612 | + | return nil | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // ValidatePickerClosed requires a nonzero interaction. | |
| 616 | + | func ValidatePickerClosed(closed PickerClosed) error { | |
| 617 | + | if closed.InteractionID == 0 { | |
| 618 | + | return ErrInvalidNavigation | |
| 619 | + | } | |
| 620 | + | return nil | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | // ValidatePickerSelection enforces nonzero interaction/source revision, a | |
| 624 | + | // bounded source identity, a known action, and a known key shape. Unknown | |
| 625 | + | // keys, foreign sources, and stale revisions reject at resolve time, not here. | |
| 626 | + | func ValidatePickerSelection(selection PickerSelection) error { | |
| 627 | + | if selection.InteractionID == 0 || selection.SourceRevision == 0 { | |
| 628 | + | return ErrInvalidNavigation | |
| 629 | + | } | |
| 630 | + | if !validPickerSourceID(selection.SourceID) || !validPickerAction(selection.Action) { | |
| 631 | + | return ErrInvalidNavigation | |
| 632 | + | } | |
| 633 | + | if !validPickerKey(selection.Key) { | |
| 634 | + | return ErrInvalidNavigation | |
| 635 | + | } | |
| 636 | + | return nil | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | // ValidatePickerResult enforces matching identity for a completed mutation. | |
| 640 | + | func ValidatePickerResult(result PickerResult) error { | |
| 641 | + | if result.InteractionID == 0 || !validPickerSourceID(result.SourceID) || !validPickerAction(result.Action) { | |
| 642 | + | return ErrInvalidNavigation | |
| 643 | + | } | |
| 644 | + | if !validPickerKey(result.Key) { | |
| 645 | + | return ErrInvalidNavigation | |
| 646 | + | } | |
| 647 | + | return nil | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | // ValidatePickerFailure enforces matching IDs and bounded codes. | |
| 651 | + | func ValidatePickerFailure(failure PickerFailure) error { | |
| 652 | + | if failure.InteractionID == 0 || !validPickerAction(failure.Action) { | |
| 653 | + | return ErrInvalidNavigation | |
| 654 | + | } | |
| 655 | + | if failure.SourceID != "" && !validPickerSourceID(failure.SourceID) { | |
| 656 | + | return ErrInvalidNavigation | |
| 657 | + | } | |
| 658 | + | if failure.Key != "" && !validPickerKey(failure.Key) { | |
| 659 | + | return ErrInvalidNavigation | |
| 660 | + | } | |
| 661 | + | if !validPickerFailureCode(failure.Code) { | |
| 662 | + | return ErrInvalidNavigation | |
| 663 | + | } | |
| 664 | + | return nil | |
| 665 | + | } | |
A internal/protocol/picker_interaction_test.go +180 -0
| @@ -0,0 +1,180 @@ | |||
| 1 | + | package protocol | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func pickerObserveTargets(seed, count int) []ExactSessionTarget { | |
| 11 | + | targets := make([]ExactSessionTarget, 0, count) | |
| 12 | + | for i := range count { | |
| 13 | + | targets = append(targets, ExactSessionTarget{ | |
| 14 | + | LifecycleID: domain.SessionLifecycleID{byte(seed + i)}, | |
| 15 | + | SessionName: "work", | |
| 16 | + | }) | |
| 17 | + | } | |
| 18 | + | return targets | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestValidatePickerControlObserveRequest(t *testing.T) { | |
| 22 | + | targets := pickerObserveTargets(1, 2) | |
| 23 | + | target := targets[0] | |
| 24 | + | ||
| 25 | + | valid := PickerControlRequest{Version: 1, RequestID: 9, Operation: PickerControlObserve, Targets: targets} | |
| 26 | + | require.NoError(t, ValidatePickerControlRequest(valid)) | |
| 27 | + | ||
| 28 | + | // The pre-existing exclusive payloads keep working. | |
| 29 | + | require.NoError(t, ValidatePickerControlRequest(PickerControlRequest{Version: 1, RequestID: 9, Operation: PickerControlSnapshot})) | |
| 30 | + | require.NoError(t, ValidatePickerControlRequest(PickerControlRequest{ | |
| 31 | + | Version: 1, RequestID: 9, Operation: PickerControlResolve, | |
| 32 | + | SourceRevision: 1, SourceID: PickerHomeSourceID, Key: "a/b", | |
| 33 | + | })) | |
| 34 | + | ||
| 35 | + | atLimit := PickerControlRequest{ | |
| 36 | + | Version: 1, RequestID: 9, Operation: PickerControlObserve, | |
| 37 | + | Targets: pickerObserveTargets(1, PickerControlMaxTargets), | |
| 38 | + | } | |
| 39 | + | require.NoError(t, ValidatePickerControlRequest(atLimit)) | |
| 40 | + | ||
| 41 | + | cases := map[string]PickerControlRequest{ | |
| 42 | + | "no targets": {Version: 1, RequestID: 9, Operation: PickerControlObserve}, | |
| 43 | + | "duplicate target": { | |
| 44 | + | Version: 1, RequestID: 9, Operation: PickerControlObserve, | |
| 45 | + | Targets: []ExactSessionTarget{target, target}, | |
| 46 | + | }, | |
| 47 | + | "unvalidated target": { | |
| 48 | + | Version: 1, RequestID: 9, Operation: PickerControlObserve, | |
| 49 | + | Targets: []ExactSessionTarget{{SessionName: "work"}}, | |
| 50 | + | }, | |
| 51 | + | "over the target bound": { | |
| 52 | + | Version: 1, RequestID: 9, Operation: PickerControlObserve, | |
| 53 | + | Targets: pickerObserveTargets(1, PickerControlMaxTargets+1), | |
| 54 | + | }, | |
| 55 | + | "observe with source identity": { | |
| 56 | + | Version: 1, RequestID: 9, Operation: PickerControlObserve, | |
| 57 | + | SourceID: PickerHomeSourceID, Targets: targets, | |
| 58 | + | }, | |
| 59 | + | "observe with key": { | |
| 60 | + | Version: 1, RequestID: 9, Operation: PickerControlObserve, | |
| 61 | + | Key: "a/b", Targets: targets, | |
| 62 | + | }, | |
| 63 | + | "observe with revision": { | |
| 64 | + | Version: 1, RequestID: 9, Operation: PickerControlObserve, | |
| 65 | + | SourceRevision: 2, Targets: targets, | |
| 66 | + | }, | |
| 67 | + | "snapshot with targets": {Version: 1, RequestID: 9, Operation: PickerControlSnapshot, Targets: targets}, | |
| 68 | + | "resolve with targets": { | |
| 69 | + | Version: 1, RequestID: 9, Operation: PickerControlResolve, | |
| 70 | + | SourceRevision: 1, SourceID: PickerHomeSourceID, Key: "a/b", Targets: targets, | |
| 71 | + | }, | |
| 72 | + | "unknown operation": {Version: 1, RequestID: 9, Operation: 9, Targets: targets}, | |
| 73 | + | "zero version": {RequestID: 9, Operation: PickerControlObserve, Targets: targets}, | |
| 74 | + | "zero request id": {Version: 1, Operation: PickerControlObserve, Targets: targets}, | |
| 75 | + | } | |
| 76 | + | for name, request := range cases { | |
| 77 | + | t.Run(name, func(t *testing.T) { | |
| 78 | + | require.ErrorIs(t, ValidatePickerControlRequest(request), ErrInvalidNavigation) | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestValidatePickerControlObserveResponse(t *testing.T) { | |
| 84 | + | targets := pickerObserveTargets(1, 2) | |
| 85 | + | ||
| 86 | + | valid := PickerControlResponse{ | |
| 87 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 88 | + | Observations: []PickerRouteObservation{ | |
| 89 | + | {Target: targets[0], Presence: PickerRoutePresent, Attention: true}, | |
| 90 | + | {Target: targets[1], Presence: PickerRouteAbsent}, | |
| 91 | + | }, | |
| 92 | + | } | |
| 93 | + | require.NoError(t, ValidatePickerControlResponse(valid)) | |
| 94 | + | ||
| 95 | + | // A failed observe carries no payload at all. | |
| 96 | + | require.NoError(t, ValidatePickerControlResponse(PickerControlResponse{ | |
| 97 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceUnavailable, | |
| 98 | + | })) | |
| 99 | + | ||
| 100 | + | atLimit := PickerControlResponse{ | |
| 101 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 102 | + | Observations: pickerObservationsForTest(1, PickerControlMaxTargets, PickerRoutePresent), | |
| 103 | + | } | |
| 104 | + | require.NoError(t, ValidatePickerControlResponse(atLimit)) | |
| 105 | + | ||
| 106 | + | cases := map[string]PickerControlResponse{ | |
| 107 | + | "no observations": { | |
| 108 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 109 | + | }, | |
| 110 | + | "over the observation bound": { | |
| 111 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 112 | + | Observations: pickerObservationsForTest(1, PickerControlMaxTargets+1, PickerRoutePresent), | |
| 113 | + | }, | |
| 114 | + | "duplicate target": { | |
| 115 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 116 | + | Observations: []PickerRouteObservation{ | |
| 117 | + | {Target: targets[0], Presence: PickerRoutePresent}, | |
| 118 | + | {Target: targets[0], Presence: PickerRouteAbsent}, | |
| 119 | + | }, | |
| 120 | + | }, | |
| 121 | + | "zero presence": { | |
| 122 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 123 | + | Observations: []PickerRouteObservation{{Target: targets[0]}}, | |
| 124 | + | }, | |
| 125 | + | "unknown presence value": { | |
| 126 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 127 | + | Observations: []PickerRouteObservation{{Target: targets[0], Presence: 9}}, | |
| 128 | + | }, | |
| 129 | + | "unvalidated target": { | |
| 130 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 131 | + | Observations: []PickerRouteObservation{{Target: ExactSessionTarget{SessionName: "work"}, Presence: PickerRoutePresent}}, | |
| 132 | + | }, | |
| 133 | + | "attention on absent": { | |
| 134 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 135 | + | Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRouteAbsent, Attention: true}}, | |
| 136 | + | }, | |
| 137 | + | "attention on unknown": { | |
| 138 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 139 | + | Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRouteUnknown, Attention: true}}, | |
| 140 | + | }, | |
| 141 | + | "observe carries snapshot": { | |
| 142 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 143 | + | Snapshot: &PickerSnapshot{}, | |
| 144 | + | Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRoutePresent}}, | |
| 145 | + | }, | |
| 146 | + | "observe carries resolved": { | |
| 147 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK, | |
| 148 | + | Resolved: &AttachTarget{}, | |
| 149 | + | Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRoutePresent}}, | |
| 150 | + | }, | |
| 151 | + | "failed observe carries observations": { | |
| 152 | + | RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceUnavailable, | |
| 153 | + | Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRoutePresent}}, | |
| 154 | + | }, | |
| 155 | + | "snapshot carries observations": { | |
| 156 | + | RequestID: 9, Operation: PickerControlSnapshot, Status: PickerSourceOK, | |
| 157 | + | Snapshot: &PickerSnapshot{}, | |
| 158 | + | Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRoutePresent}}, | |
| 159 | + | }, | |
| 160 | + | "resolve carries observations": { | |
| 161 | + | RequestID: 9, Operation: PickerControlResolve, Status: PickerSourceOK, | |
| 162 | + | Resolved: &AttachTarget{}, | |
| 163 | + | Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRoutePresent}}, | |
| 164 | + | }, | |
| 165 | + | } | |
| 166 | + | for name, response := range cases { | |
| 167 | + | t.Run(name, func(t *testing.T) { | |
| 168 | + | require.ErrorIs(t, ValidatePickerControlResponse(response), ErrInvalidNavigation) | |
| 169 | + | }) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func pickerObservationsForTest(seed, count int, presence PickerRoutePresence) []PickerRouteObservation { | |
| 174 | + | targets := pickerObserveTargets(seed, count) | |
| 175 | + | observations := make([]PickerRouteObservation, 0, count) | |
| 176 | + | for _, target := range targets { | |
| 177 | + | observations = append(observations, PickerRouteObservation{Target: target, Presence: presence}) | |
| 178 | + | } | |
| 179 | + | return observations | |
| 180 | + | } | |
A internal/protocol/picker_preview.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package protocol | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "unicode" | |
| 6 | + | "unicode/utf8" | |
| 7 | + | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // PickerPreviewSchemaVersion versions the picker preview payload independently | |
| 12 | + | // from the attachment protocol version. | |
| 13 | + | const PickerPreviewSchemaVersion uint16 = 1 | |
| 14 | + | ||
| 15 | + | // PickerPreviewStatus reports whether a preview carries a viewport. | |
| 16 | + | type PickerPreviewStatus uint8 | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | PickerPreviewOK PickerPreviewStatus = iota | |
| 20 | + | // PickerPreviewNoSuchTarget means the key is no longer part of the | |
| 21 | + | // interaction: the row was retired, replaced, or the revision is stale. | |
| 22 | + | PickerPreviewNoSuchTarget | |
| 23 | + | // PickerPreviewUnavailable means the row exists but its content cannot be | |
| 24 | + | // captured: a stopped session, no renderable tab, or a failed remote fetch. | |
| 25 | + | PickerPreviewUnavailable | |
| 26 | + | PickerPreviewTooLarge | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // PickerPreviewRequest asks the serving daemon for the preview of one displayed | |
| 30 | + | // row. The client debounces its own cursor movement before sending it; the | |
| 31 | + | // daemon registers that row as the viewer's preview source and pushes a new | |
| 32 | + | // PickerPreview whenever the row's content changes. | |
| 33 | + | type PickerPreviewRequest struct { | |
| 34 | + | Version uint16 | |
| 35 | + | InteractionID uint64 | |
| 36 | + | SourceID string | |
| 37 | + | Key string | |
| 38 | + | Width uint16 | |
| 39 | + | Height uint16 | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // PickerPreview is the bounded row-major styled-cell viewport of one picker | |
| 43 | + | // row. It is never persisted, logged, or traced. | |
| 44 | + | type PickerPreview struct { | |
| 45 | + | Version uint16 | |
| 46 | + | InteractionID uint64 | |
| 47 | + | SourceID string | |
| 48 | + | Key string | |
| 49 | + | Status PickerPreviewStatus | |
| 50 | + | Width uint16 | |
| 51 | + | Height uint16 | |
| 52 | + | Cells []renderer.Cell | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Preview bounds are shared with the remote preview: both publish the same | |
| 56 | + | // bounded viewport shape, only one is fetched from a remote daemon. | |
| 57 | + | const ( | |
| 58 | + | PickerPreviewMaxWidth = RemotePreviewMaxWidth | |
| 59 | + | PickerPreviewMaxHeight = RemotePreviewMaxHeight | |
| 60 | + | PickerPreviewMaxCells = RemotePreviewMaxCells | |
| 61 | + | PickerPreviewMaxBytes = RemotePreviewMaxBytes | |
| 62 | + | ) | |
| 63 | + | ||
| 64 | + | var ( | |
| 65 | + | ErrInvalidPickerPreviewRequest = errors.New("vev: invalid picker preview request") | |
| 66 | + | ErrInvalidPickerPreview = errors.New("vev: invalid picker preview") | |
| 67 | + | ) | |
| 68 | + | ||
| 69 | + | func ValidatePickerPreviewRequest(request PickerPreviewRequest) error { | |
| 70 | + | if request.Version != PickerPreviewSchemaVersion || request.InteractionID == 0 { | |
| 71 | + | return ErrInvalidPickerPreviewRequest | |
| 72 | + | } | |
| 73 | + | if !validPickerSourceID(request.SourceID) || !validPickerKey(request.Key) { | |
| 74 | + | return ErrInvalidPickerPreviewRequest | |
| 75 | + | } | |
| 76 | + | if request.Width == 0 || request.Height == 0 || | |
| 77 | + | int(request.Width) > PickerPreviewMaxWidth || int(request.Height) > PickerPreviewMaxHeight { | |
| 78 | + | return ErrInvalidPickerPreviewRequest | |
| 79 | + | } | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func ValidatePickerPreview(preview PickerPreview) error { | |
| 84 | + | if preview.Version != PickerPreviewSchemaVersion || preview.Status > PickerPreviewTooLarge { | |
| 85 | + | return ErrInvalidPickerPreview | |
| 86 | + | } | |
| 87 | + | if preview.InteractionID == 0 || !validPickerSourceID(preview.SourceID) || !validPickerKey(preview.Key) { | |
| 88 | + | return ErrInvalidPickerPreview | |
| 89 | + | } | |
| 90 | + | if preview.Status != PickerPreviewOK { | |
| 91 | + | if preview.Width != 0 || preview.Height != 0 || len(preview.Cells) != 0 { | |
| 92 | + | return ErrInvalidPickerPreview | |
| 93 | + | } | |
| 94 | + | return nil | |
| 95 | + | } | |
| 96 | + | return ValidatePickerPreviewViewport(preview.Width, preview.Height, preview.Cells) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // ValidatePickerPreviewViewport checks only the viewport shape, so a capture can | |
| 100 | + | // be validated before its interaction identity is stamped onto it. | |
| 101 | + | func ValidatePickerPreviewViewport(width, height uint16, cells []renderer.Cell) error { | |
| 102 | + | if width == 0 || height == 0 || | |
| 103 | + | int(width) > PickerPreviewMaxWidth || int(height) > PickerPreviewMaxHeight { | |
| 104 | + | return ErrInvalidPickerPreview | |
| 105 | + | } | |
| 106 | + | want := int(width) * int(height) | |
| 107 | + | if want > PickerPreviewMaxCells || len(cells) != want { | |
| 108 | + | return ErrInvalidPickerPreview | |
| 109 | + | } | |
| 110 | + | if !previewCellRunValid(int(width), cells) { | |
| 111 | + | return ErrInvalidPickerPreview | |
| 112 | + | } | |
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (p PickerPreview) FrameRows() [][]renderer.Cell { | |
| 117 | + | if p.Width == 0 || p.Height == 0 || len(p.Cells) != int(p.Width)*int(p.Height) { | |
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | rows := make([][]renderer.Cell, p.Height) | |
| 121 | + | width := int(p.Width) | |
| 122 | + | for y := range rows { | |
| 123 | + | rows[y] = append([]renderer.Cell(nil), p.Cells[y*width:(y+1)*width]...) | |
| 124 | + | } | |
| 125 | + | return rows | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // previewCellRunValid reports whether cells form the row-major run of a | |
| 129 | + | // width-wide viewport: every rune and style is displayable, and each | |
| 130 | + | // continuation column follows a double-width cell on the same row. | |
| 131 | + | func previewCellRunValid(width int, cells []renderer.Cell) bool { | |
| 132 | + | if width <= 0 { | |
| 133 | + | return false | |
| 134 | + | } | |
| 135 | + | for i, cell := range cells { | |
| 136 | + | if !utf8.ValidRune(cell.Rune) || (cell.Rune != 0 && unicode.IsControl(cell.Rune)) || !validPreviewCellStyle(cell.Style) { | |
| 137 | + | return false | |
| 138 | + | } | |
| 139 | + | rowStart := (i / width) * width | |
| 140 | + | rowEnd := rowStart + width | |
| 141 | + | if cell.Continuation { | |
| 142 | + | if cell.Rune != 0 || i == rowStart || cells[i-1].Continuation || renderer.RuneWidth(cells[i-1].Rune) != 2 { | |
| 143 | + | return false | |
| 144 | + | } | |
| 145 | + | } else if renderer.RuneWidth(cell.Rune) == 2 && (i+1 >= rowEnd || !cells[i+1].Continuation) { | |
| 146 | + | return false | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | return true | |
| 150 | + | } | |
A internal/protocol/picker_preview_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package protocol | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func pickerPreviewCells(width uint16) []renderer.Cell { | |
| 10 | + | cells := make([]renderer.Cell, width) | |
| 11 | + | for i := range cells { | |
| 12 | + | cells[i] = renderer.Cell{Rune: 'x'} | |
| 13 | + | } | |
| 14 | + | return cells | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestValidatePickerPreviewRequest(t *testing.T) { | |
| 18 | + | valid := PickerPreviewRequest{ | |
| 19 | + | Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first", | |
| 20 | + | Width: 80, Height: 24, | |
| 21 | + | } | |
| 22 | + | tests := []struct { | |
| 23 | + | name string | |
| 24 | + | mutate func(*PickerPreviewRequest) | |
| 25 | + | wantErr bool | |
| 26 | + | }{ | |
| 27 | + | {name: "valid"}, | |
| 28 | + | {name: "schema version", mutate: func(r *PickerPreviewRequest) { r.Version = 2 }, wantErr: true}, | |
| 29 | + | {name: "zero interaction", mutate: func(r *PickerPreviewRequest) { r.InteractionID = 0 }, wantErr: true}, | |
| 30 | + | {name: "empty source", mutate: func(r *PickerPreviewRequest) { r.SourceID = "" }, wantErr: true}, | |
| 31 | + | {name: "control source", mutate: func(r *PickerPreviewRequest) { r.SourceID = "serv\x1bing" }, wantErr: true}, | |
| 32 | + | {name: "empty key", mutate: func(r *PickerPreviewRequest) { r.Key = "" }, wantErr: true}, | |
| 33 | + | {name: "control key", mutate: func(r *PickerPreviewRequest) { r.Key = "aa\x1bfirst" }, wantErr: true}, | |
| 34 | + | {name: "zero width", mutate: func(r *PickerPreviewRequest) { r.Width = 0 }, wantErr: true}, | |
| 35 | + | {name: "zero height", mutate: func(r *PickerPreviewRequest) { r.Height = 0 }, wantErr: true}, | |
| 36 | + | {name: "width over bound", mutate: func(r *PickerPreviewRequest) { r.Width = PickerPreviewMaxWidth + 1 }, wantErr: true}, | |
| 37 | + | {name: "height over bound", mutate: func(r *PickerPreviewRequest) { r.Height = PickerPreviewMaxHeight + 1 }, wantErr: true}, | |
| 38 | + | } | |
| 39 | + | for _, tt := range tests { | |
| 40 | + | t.Run(tt.name, func(t *testing.T) { | |
| 41 | + | request := valid | |
| 42 | + | if tt.mutate != nil { | |
| 43 | + | tt.mutate(&request) | |
| 44 | + | } | |
| 45 | + | err := ValidatePickerPreviewRequest(request) | |
| 46 | + | if (err != nil) != tt.wantErr { | |
| 47 | + | t.Fatalf("ValidatePickerPreviewRequest() = %v, wantErr %t", err, tt.wantErr) | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestValidatePickerPreview(t *testing.T) { | |
| 54 | + | valid := PickerPreview{ | |
| 55 | + | Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first", | |
| 56 | + | Status: PickerPreviewOK, Width: 2, Height: 1, Cells: pickerPreviewCells(2), | |
| 57 | + | } | |
| 58 | + | wide := PickerPreview{ | |
| 59 | + | Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first", | |
| 60 | + | Status: PickerPreviewOK, | |
| 61 | + | Width: 2, Height: 1, | |
| 62 | + | Cells: []renderer.Cell{{Rune: '界'}, {Continuation: true}}, | |
| 63 | + | } | |
| 64 | + | tests := []struct { | |
| 65 | + | name string | |
| 66 | + | preview PickerPreview | |
| 67 | + | wantErr bool | |
| 68 | + | }{ | |
| 69 | + | {name: "valid", preview: valid}, | |
| 70 | + | {name: "double width continuation", preview: wide}, | |
| 71 | + | {name: "schema version", preview: func() PickerPreview { p := valid; p.Version = 2; return p }(), wantErr: true}, | |
| 72 | + | {name: "zero interaction", preview: func() PickerPreview { p := valid; p.InteractionID = 0; return p }(), wantErr: true}, | |
| 73 | + | {name: "empty key", preview: func() PickerPreview { p := valid; p.Key = ""; return p }(), wantErr: true}, | |
| 74 | + | {name: "unknown status", preview: func() PickerPreview { p := valid; p.Status = 99; return p }(), wantErr: true}, | |
| 75 | + | {name: "unavailable carries a viewport", preview: func() PickerPreview { | |
| 76 | + | p := valid | |
| 77 | + | p.Status = PickerPreviewUnavailable | |
| 78 | + | return p | |
| 79 | + | }(), wantErr: true}, | |
| 80 | + | {name: "unavailable empty", preview: PickerPreview{ | |
| 81 | + | Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first", | |
| 82 | + | Status: PickerPreviewNoSuchTarget, | |
| 83 | + | }}, | |
| 84 | + | {name: "zero width", preview: func() PickerPreview { | |
| 85 | + | p := valid | |
| 86 | + | p.Width = 0 | |
| 87 | + | return p | |
| 88 | + | }(), wantErr: true}, | |
| 89 | + | {name: "cell count mismatch", preview: func() PickerPreview { | |
| 90 | + | p := valid | |
| 91 | + | p.Cells = pickerPreviewCells(1) | |
| 92 | + | return p | |
| 93 | + | }(), wantErr: true}, | |
| 94 | + | {name: "control rune", preview: func() PickerPreview { | |
| 95 | + | p := valid | |
| 96 | + | p.Cells = []renderer.Cell{{Rune: '\x1b'}, {Rune: 'x'}} | |
| 97 | + | return p | |
| 98 | + | }(), wantErr: true}, | |
| 99 | + | {name: "orphan continuation", preview: func() PickerPreview { | |
| 100 | + | p := valid | |
| 101 | + | p.Cells = []renderer.Cell{{Continuation: true}, {Rune: 'x'}} | |
| 102 | + | return p | |
| 103 | + | }(), wantErr: true}, | |
| 104 | + | {name: "unpaired double width", preview: func() PickerPreview { | |
| 105 | + | p := valid | |
| 106 | + | p.Cells = []renderer.Cell{{Rune: '界'}, {Rune: 'x'}} | |
| 107 | + | return p | |
| 108 | + | }(), wantErr: true}, | |
| 109 | + | {name: "unsupported style", preview: func() PickerPreview { | |
| 110 | + | p := valid | |
| 111 | + | p.Cells = []renderer.Cell{{Rune: 'x', Style: renderer.Style{Attrs: 1 << 7, UnderlineStyle: renderer.UnderlineDashed + 1}}, {Rune: 'x'}} | |
| 112 | + | return p | |
| 113 | + | }(), wantErr: true}, | |
| 114 | + | } | |
| 115 | + | for _, tt := range tests { | |
| 116 | + | t.Run(tt.name, func(t *testing.T) { | |
| 117 | + | err := ValidatePickerPreview(tt.preview) | |
| 118 | + | if (err != nil) != tt.wantErr { | |
| 119 | + | t.Fatalf("ValidatePickerPreview() = %v, wantErr %t", err, tt.wantErr) | |
| 120 | + | } | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestPickerPreviewFrameRows(t *testing.T) { | |
| 126 | + | preview := PickerPreview{ | |
| 127 | + | Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first", | |
| 128 | + | Status: PickerPreviewOK, Width: 2, Height: 2, Cells: pickerPreviewCells(4), | |
| 129 | + | } | |
| 130 | + | rows := preview.FrameRows() | |
| 131 | + | if len(rows) != 2 || len(rows[0]) != 2 || rows[0][0].Rune != 'x' { | |
| 132 | + | t.Fatalf("FrameRows() = %+v, want two 2-cell rows", rows) | |
| 133 | + | } | |
| 134 | + | rows[0][0].Rune = 'y' | |
| 135 | + | if preview.Cells[0].Rune != 'x' { | |
| 136 | + | t.Fatal("FrameRows must copy the cells, not alias them") | |
| 137 | + | } | |
| 138 | + | empty := PickerPreview{Version: PickerPreviewSchemaVersion, Status: PickerPreviewUnavailable} | |
| 139 | + | if rows := empty.FrameRows(); rows != nil { | |
| 140 | + | t.Fatalf("FrameRows() without a viewport = %+v, want nil", rows) | |
| 141 | + | } | |
| 142 | + | } | |
M internal/protocol/preview.go +3 -17
| @@ -3,8 +3,6 @@ package protocol | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "math" | |
| 6 | - | "unicode" | |
| 7 | - | "unicode/utf8" | |
| 8 | 6 | ||
| 9 | 7 | renderer "github.com/bnema/vev-vt" | |
| 10 | 8 | "github.com/bnema/vev/internal/domain" | |
| @@ -97,25 +95,13 @@ func ValidateRemotePreview(preview RemotePreview) error { | |||
| 97 | 95 | if want > RemotePreviewMaxCells || len(preview.Cells) != want { | |
| 98 | 96 | return ErrRemotePreviewTooLarge | |
| 99 | 97 | } | |
| 100 | - | width := int(preview.Width) | |
| 101 | - | for i, cell := range preview.Cells { | |
| 102 | - | if !utf8.ValidRune(cell.Rune) || (cell.Rune != 0 && unicode.IsControl(cell.Rune)) || !validRemotePreviewStyle(cell.Style) { | |
| 103 | - | return ErrInvalidRemotePreview | |
| 104 | - | } | |
| 105 | - | rowStart := (i / width) * width | |
| 106 | - | rowEnd := rowStart + width | |
| 107 | - | if cell.Continuation { | |
| 108 | - | if cell.Rune != 0 || i == rowStart || preview.Cells[i-1].Continuation || renderer.RuneWidth(preview.Cells[i-1].Rune) != 2 { | |
| 109 | - | return ErrInvalidRemotePreview | |
| 110 | - | } | |
| 111 | - | } else if renderer.RuneWidth(cell.Rune) == 2 && (i+1 >= rowEnd || !preview.Cells[i+1].Continuation) { | |
| 112 | - | return ErrInvalidRemotePreview | |
| 113 | - | } | |
| 98 | + | if !previewCellRunValid(int(preview.Width), preview.Cells) { | |
| 99 | + | return ErrInvalidRemotePreview | |
| 114 | 100 | } | |
| 115 | 101 | return nil | |
| 116 | 102 | } | |
| 117 | 103 | ||
| 118 | - | func validRemotePreviewStyle(style renderer.Style) bool { | |
| 104 | + | func validPreviewCellStyle(style renderer.Style) bool { | |
| 119 | 105 | return style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) == 0 && | |
| 120 | 106 | style.UnderlineStyle <= renderer.UnderlineDashed && | |
| 121 | 107 | style.Foreground >= math.MinInt16 && style.Foreground <= math.MaxInt16 && | |
M internal/protocol/session.go +2 -3
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // Version is the negotiated vev session protocol version. | |
| 16 | - | const Version uint16 = 43 | |
| 16 | + | const Version uint16 = 51 | |
| 17 | 17 | ||
| 18 | 18 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 19 | 19 | // first committed publication. | |
| @@ -113,7 +113,6 @@ type Hello struct { | |||
| 113 | 113 | PreferredTabID domain.TabStableID | |
| 114 | 114 | EnvironmentPolicy EnvironmentPolicy | |
| 115 | 115 | NavigationCapabilities NavigationCapabilities | |
| 116 | - | StartupOverlay StartupOverlay | |
| 117 | 116 | Remote bool | |
| 118 | 117 | } | |
| 119 | 118 | ||
| @@ -368,7 +367,7 @@ func ValidateHello(h Hello) error { | |||
| 368 | 367 | if !validEnvironmentPolicy(h.EnvironmentPolicy) { | |
| 369 | 368 | return ErrInvalidHello | |
| 370 | 369 | } | |
| 371 | - | if err := validateHelloNavigation(h); err != nil { | |
| 370 | + | if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil { | |
| 372 | 371 | return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err) | |
| 373 | 372 | } | |
| 374 | 373 | if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || uint64(len(h.Env)) > math.MaxUint32 { | |
M internal/protocol/session_test.go +2 -3
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(43), Version) | |
| 13 | + | require.Equal(t, uint16(51), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| @@ -24,7 +24,7 @@ func TestHelloSemanticValidation(t *testing.T) { | |||
| 24 | 24 | {name: "valid", mutate: func(*Hello) {}}, | |
| 25 | 25 | {name: "unknown intent", mutate: func(h *Hello) { h.Intent = 99 }, want: ErrInvalidHello}, | |
| 26 | 26 | {name: "invalid geometry", mutate: func(h *Hello) { h.Size.Rows = 0 }, want: ErrInvalidHello}, | |
| 27 | - | {name: "unsafe navigation", mutate: func(h *Hello) { h.NavigationCapabilities = NavigationCapabilityBack }, want: ErrInvalidHello}, | |
| 27 | + | {name: "unsafe navigation", mutate: func(h *Hello) { h.NavigationCapabilities = 8 }, want: ErrInvalidHello}, | |
| 28 | 28 | {name: "exact target name mismatch", mutate: func(h *Hello) { | |
| 29 | 29 | h.ExactTarget = &ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "other"} | |
| 30 | 30 | }, want: ErrInvalidHello}, | |
| @@ -85,5 +85,4 @@ func TestRouteAndPreviewSemanticValidation(t *testing.T) { | |||
| 85 | 85 | func TestSemanticErrorsPreserveClassification(t *testing.T) { | |
| 86 | 86 | require.True(t, errors.Is(ValidateAck(Ack{}), ErrInvalidAck)) | |
| 87 | 87 | require.True(t, errors.Is(ValidateAttachTarget(AttachTarget{}), ErrInvalidAttachTarget)) | |
| 88 | - | require.True(t, errors.Is(ValidateParkedRouteRequest(ParkedRouteRequest{}), ErrInvalidNavigation)) | |
| 89 | 88 | } | |
M internal/protocol/wire/frame.go +17 -5
| @@ -9,8 +9,11 @@ type MsgType uint8 | |||
| 9 | 9 | ||
| 10 | 10 | // Frame message types use non-contiguous allocations: the legacy client range | |
| 11 | 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, 46, and 48. Values 14 and 24 remain reserved for future extensions. | |
| 12 | + | // 47, 49, 51, 53, 55, and 57 (49+ carry the client-picker interaction: begin, | |
| 13 | + | // close, selection, preview request). Server-originated messages occupy 16–23, | |
| 14 | + | // 25–31, 34, 36, 38, 40–41, 43–44, 46, 48, 50, 52, 54, 56, 58, and 59 (50+ | |
| 15 | + | // carry the client-picker interaction: offer, snapshot, closed, failure, | |
| 16 | + | // result, preview). Values 14 and 24 remain reserved for future extensions. | |
| 14 | 17 | const ( | |
| 15 | 18 | MsgHello MsgType = 1 | |
| 16 | 19 | MsgInput MsgType = 2 | |
| @@ -28,12 +31,16 @@ const ( | |||
| 28 | 31 | MsgRemotePreviewRequest MsgType = 15 | |
| 29 | 32 | MsgRouteAttentionSubscription MsgType = 32 | |
| 30 | 33 | MsgSamePeerSwitchRequest MsgType = 33 | |
| 31 | - | MsgParkedRouteRequest MsgType = 35 | |
| 32 | 34 | MsgSessionCreationFailure MsgType = 37 | |
| 33 | 35 | MsgUIFence MsgType = 39 | |
| 34 | 36 | MsgNavigationInventoryRequest MsgType = 42 | |
| 35 | 37 | MsgNavigationInventoryPublication MsgType = 45 | |
| 36 | 38 | MsgNavigationInventoryFailure MsgType = 47 | |
| 39 | + | MsgPickerCloseClient MsgType = 51 | |
| 40 | + | MsgPickerSelection MsgType = 53 | |
| 41 | + | MsgPickerBegin MsgType = 55 | |
| 42 | + | MsgPickerPreviewRequest MsgType = 57 | |
| 43 | + | MsgPickerControlRequest MsgType = 60 | |
| 37 | 44 | ||
| 38 | 45 | MsgWelcome MsgType = 16 | |
| 39 | 46 | MsgError MsgType = 17 | |
| @@ -42,7 +49,6 @@ const ( | |||
| 42 | 49 | MsgPong MsgType = 20 | |
| 43 | 50 | MsgSessions MsgType = 21 | |
| 44 | 51 | MsgCommandResult MsgType = 22 | |
| 45 | - | MsgNavigationAction MsgType = 23 | |
| 46 | 52 | MsgAttachTarget MsgType = 25 | |
| 47 | 53 | MsgRemotePreviewResponse MsgType = 26 | |
| 48 | 54 | MsgCommittedRouteIdentity MsgType = 27 | |
| @@ -51,7 +57,6 @@ const ( | |||
| 51 | 57 | MsgRouteNavigationFailure MsgType = 30 | |
| 52 | 58 | MsgRoutePosition MsgType = 31 | |
| 53 | 59 | MsgSamePeerSwitchFailure MsgType = 34 | |
| 54 | - | MsgParkedRouteResponse MsgType = 36 | |
| 55 | 60 | MsgRouteCreateSession MsgType = 38 | |
| 56 | 61 | MsgUIReceipt MsgType = 40 | |
| 57 | 62 | MsgUIViewUpdate MsgType = 41 | |
| @@ -59,6 +64,13 @@ const ( | |||
| 59 | 64 | MsgNavigationInventoryDemand MsgType = 44 | |
| 60 | 65 | MsgNavigationInventorySelection MsgType = 46 | |
| 61 | 66 | MsgRouteRetired MsgType = 48 | |
| 67 | + | MsgPickerSnapshot MsgType = 50 | |
| 68 | + | MsgPickerClosedServer MsgType = 52 | |
| 69 | + | MsgPickerFailure MsgType = 54 | |
| 70 | + | MsgPickerOffer MsgType = 56 | |
| 71 | + | MsgPickerResult MsgType = 58 | |
| 72 | + | MsgPickerPreview MsgType = 59 | |
| 73 | + | MsgPickerControlResponse MsgType = 61 | |
| 62 | 74 | ) | |
| 63 | 75 | ||
| 64 | 76 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
M internal/protocol/wire/frame_test.go +10 -8
| @@ -7,8 +7,8 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | func TestProtocolVersion(t *testing.T) { | |
| 10 | - | if protocol.Version != 43 { | |
| 11 | - | t.Fatalf("ProtocolVersion = %d, want 43", protocol.Version) | |
| 10 | + | if protocol.Version != 51 { | |
| 11 | + | t.Fatalf("ProtocolVersion = %d, want 51", protocol.Version) | |
| 12 | 12 | } | |
| 13 | 13 | } | |
| 14 | 14 | ||
| @@ -25,9 +25,6 @@ func TestControlMsgTypes(t *testing.T) { | |||
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | func TestMsgTypeUnique(t *testing.T) { | |
| 28 | - | if MsgNavigationAction != 23 { | |
| 29 | - | t.Fatalf("MsgNavigationAction = %d, want 23", MsgNavigationAction) | |
| 30 | - | } | |
| 31 | 28 | tests := []struct { | |
| 32 | 29 | name string | |
| 33 | 30 | typ MsgType | |
| @@ -37,14 +34,19 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 37 | 34 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | |
| 38 | 35 | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, | |
| 39 | 36 | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, | |
| 40 | - | {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest}, {"MsgSessionCreationFailure", MsgSessionCreationFailure}, | |
| 37 | + | {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgSessionCreationFailure", MsgSessionCreationFailure}, | |
| 41 | 38 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | |
| 42 | 39 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | |
| 43 | 40 | {"MsgCommandResult", MsgCommandResult}, | |
| 44 | - | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | |
| 41 | + | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, | |
| 45 | 42 | {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot}, | |
| 46 | 43 | {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure}, | |
| 47 | - | {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse}, {"MsgRouteCreateSession", MsgRouteCreateSession}, | |
| 44 | + | {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgRouteCreateSession", MsgRouteCreateSession}, | |
| 45 | + | {"MsgPickerOffer", MsgPickerOffer}, {"MsgPickerSnapshot", MsgPickerSnapshot}, {"MsgPickerClosedServer", MsgPickerClosedServer}, | |
| 46 | + | {"MsgPickerFailure", MsgPickerFailure}, {"MsgPickerResult", MsgPickerResult}, {"MsgPickerPreview", MsgPickerPreview}, | |
| 47 | + | {"MsgPickerBegin", MsgPickerBegin}, {"MsgPickerCloseClient", MsgPickerCloseClient}, {"MsgPickerSelection", MsgPickerSelection}, | |
| 48 | + | {"MsgPickerPreviewRequest", MsgPickerPreviewRequest}, | |
| 49 | + | {"MsgPickerControlRequest", MsgPickerControlRequest}, {"MsgPickerControlResponse", MsgPickerControlResponse}, | |
| 48 | 50 | } | |
| 49 | 51 | ||
| 50 | 52 | seen := make(map[MsgType]string, len(tests)) | |
M internal/protocol/wire/navigation_test.go +8 -148
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package wire | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/hex" | |
| 5 | 4 | "testing" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/vev/internal/domain" | |
| @@ -9,129 +8,6 @@ import ( | |||
| 9 | 8 | "github.com/stretchr/testify/require" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | - | func TestNavigationDirectiveWireTable(t *testing.T) { | |
| 13 | - | lease := protocol.ParkedRouteLeaseID{1, 2, 3} | |
| 14 | - | tests := []struct { | |
| 15 | - | name string | |
| 16 | - | directive protocol.NavigationDirective | |
| 17 | - | valid bool | |
| 18 | - | }{ | |
| 19 | - | {name: "open home", directive: protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: lease}, valid: true}, | |
| 20 | - | {name: "back", directive: protocol.NavigationDirective{Action: protocol.NavigationBack}, valid: true}, | |
| 21 | - | {name: "open home without lease", directive: protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker}}, | |
| 22 | - | {name: "back with lease", directive: protocol.NavigationDirective{Action: protocol.NavigationBack, LeaseID: lease}}, | |
| 23 | - | {name: "zero", directive: protocol.NavigationDirective{}}, | |
| 24 | - | {name: "unknown", directive: protocol.NavigationDirective{Action: 3}}, | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | for _, tt := range tests { | |
| 28 | - | t.Run(tt.name, func(t *testing.T) { | |
| 29 | - | got := MarshalNavigationDirective(tt.directive) | |
| 30 | - | if !tt.valid { | |
| 31 | - | require.Nil(t, got) | |
| 32 | - | return | |
| 33 | - | } | |
| 34 | - | decoded, err := UnmarshalNavigationDirective(got) | |
| 35 | - | require.NoError(t, err) | |
| 36 | - | require.Equal(t, tt.directive, decoded) | |
| 37 | - | assertAllPrefixesFail(t, got, UnmarshalNavigationDirective) | |
| 38 | - | _, err = UnmarshalNavigationDirective(append(append([]byte(nil), got...), 0)) | |
| 39 | - | require.ErrorIs(t, err, protocol.ErrInvalidNavigation) | |
| 40 | - | }) | |
| 41 | - | } | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | func TestParkedRouteWireRoundTripsStrictly(t *testing.T) { | |
| 45 | - | lease := protocol.ParkedRouteLeaseID{1, 2, 3} | |
| 46 | - | target := &domain.RemoteSessionTarget{ | |
| 47 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{4}, | |
| 48 | - | SessionName: "work", LiveTabID: "tab-1", | |
| 49 | - | } | |
| 50 | - | requests := []struct { | |
| 51 | - | name string | |
| 52 | - | value protocol.ParkedRouteRequest | |
| 53 | - | golden string | |
| 54 | - | }{ | |
| 55 | - | {name: "prepare", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare}, golden: "0000000000000001" + "01020300000000000000000000000000" + "010001"}, | |
| 56 | - | {name: "resume", value: protocol.ParkedRouteRequest{RequestID: 2, LeaseID: lease, Action: protocol.ParkedRouteResume}}, | |
| 57 | - | {name: "switch", value: protocol.ParkedRouteRequest{RequestID: 3, LeaseID: lease, Action: protocol.ParkedRouteSwitch, Target: target}}, | |
| 58 | - | } | |
| 59 | - | for _, tt := range requests { | |
| 60 | - | t.Run("request "+tt.name, func(t *testing.T) { | |
| 61 | - | payload := MarshalParkedRouteRequest(tt.value) | |
| 62 | - | require.NotNil(t, payload) | |
| 63 | - | if tt.golden != "" { | |
| 64 | - | golden, err := hex.DecodeString(tt.golden) | |
| 65 | - | require.NoError(t, err) | |
| 66 | - | require.Equal(t, golden, payload) | |
| 67 | - | } | |
| 68 | - | decoded, err := UnmarshalParkedRouteRequest(payload) | |
| 69 | - | require.NoError(t, err) | |
| 70 | - | require.Equal(t, tt.value, decoded) | |
| 71 | - | assertAllPrefixesFail(t, payload, UnmarshalParkedRouteRequest) | |
| 72 | - | assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteRequest) | |
| 73 | - | }) | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | responses := []struct { | |
| 77 | - | name string | |
| 78 | - | value protocol.ParkedRouteResponse | |
| 79 | - | golden string | |
| 80 | - | }{ | |
| 81 | - | {name: "ready", value: protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady}, golden: "000000000000000101"}, | |
| 82 | - | {name: "resumed", value: protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteResumed}}, | |
| 83 | - | {name: "switched", value: protocol.ParkedRouteResponse{RequestID: 3, Status: protocol.ParkedRouteSwitched}}, | |
| 84 | - | {name: "rejected", value: protocol.ParkedRouteResponse{RequestID: 4, Status: protocol.ParkedRouteRejected}}, | |
| 85 | - | {name: "expired", value: protocol.ParkedRouteResponse{RequestID: 5, Status: protocol.ParkedRouteExpired}}, | |
| 86 | - | {name: "stale target", value: protocol.ParkedRouteResponse{RequestID: 6, Status: protocol.ParkedRouteStaleTarget}}, | |
| 87 | - | } | |
| 88 | - | for _, tt := range responses { | |
| 89 | - | t.Run("response "+tt.name, func(t *testing.T) { | |
| 90 | - | payload := MarshalParkedRouteResponse(tt.value) | |
| 91 | - | if tt.golden != "" { | |
| 92 | - | golden, err := hex.DecodeString(tt.golden) | |
| 93 | - | require.NoError(t, err) | |
| 94 | - | require.Equal(t, golden, payload) | |
| 95 | - | } | |
| 96 | - | decoded, err := UnmarshalParkedRouteResponse(payload) | |
| 97 | - | require.NoError(t, err) | |
| 98 | - | require.Equal(t, tt.value, decoded) | |
| 99 | - | assertAllPrefixesFail(t, payload, UnmarshalParkedRouteResponse) | |
| 100 | - | assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteResponse) | |
| 101 | - | }) | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | invalidRequests := []struct { | |
| 105 | - | name string | |
| 106 | - | value protocol.ParkedRouteRequest | |
| 107 | - | }{ | |
| 108 | - | {name: "zero request ID", value: protocol.ParkedRouteRequest{LeaseID: lease, Action: protocol.ParkedRoutePrepare}}, | |
| 109 | - | {name: "zero lease", value: protocol.ParkedRouteRequest{RequestID: 1, Action: protocol.ParkedRoutePrepare}}, | |
| 110 | - | {name: "unknown action", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: 99}}, | |
| 111 | - | {name: "prepare target", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare, Target: target}}, | |
| 112 | - | {name: "resume target", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRouteResume, Target: target}}, | |
| 113 | - | {name: "missing switch target", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRouteSwitch}}, | |
| 114 | - | {name: "invalid switch target", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}}}, | |
| 115 | - | } | |
| 116 | - | for _, tt := range invalidRequests { | |
| 117 | - | t.Run("invalid request "+tt.name, func(t *testing.T) { | |
| 118 | - | require.Nil(t, MarshalParkedRouteRequest(tt.value)) | |
| 119 | - | }) | |
| 120 | - | } | |
| 121 | - | for _, tt := range []struct { | |
| 122 | - | name string | |
| 123 | - | value protocol.ParkedRouteResponse | |
| 124 | - | }{ | |
| 125 | - | {name: "zero request ID", value: protocol.ParkedRouteResponse{Status: protocol.ParkedRouteReady}}, | |
| 126 | - | {name: "zero status", value: protocol.ParkedRouteResponse{RequestID: 1}}, | |
| 127 | - | {name: "unknown status", value: protocol.ParkedRouteResponse{RequestID: 1, Status: 99}}, | |
| 128 | - | } { | |
| 129 | - | t.Run("invalid response "+tt.name, func(t *testing.T) { | |
| 130 | - | require.Nil(t, MarshalParkedRouteResponse(tt.value)) | |
| 131 | - | }) | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | ||
| 135 | 11 | func TestHelloNavigationValidationTable(t *testing.T) { | |
| 136 | 12 | base := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 137 | 13 | remoteTarget := &domain.RemoteSessionTarget{ | |
| @@ -139,26 +15,18 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 139 | 15 | SessionName: "work", LiveTabID: "tab-1", | |
| 140 | 16 | } | |
| 141 | 17 | tests := []struct { | |
| 142 | - | name string | |
| 143 | - | hello protocol.Hello | |
| 144 | - | valid bool | |
| 145 | - | capability protocol.NavigationCapabilities | |
| 146 | - | overlay protocol.StartupOverlay | |
| 147 | - | wantPayload string | |
| 18 | + | name string | |
| 19 | + | hello protocol.Hello | |
| 20 | + | valid bool | |
| 21 | + | capability protocol.NavigationCapabilities | |
| 148 | 22 | }{ | |
| 149 | 23 | {name: "ordinary", hello: base, valid: true}, | |
| 150 | 24 | {name: "resume route", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentResume, Size: domain.Size{Cols: 80, Rows: 24}}, valid: true}, | |
| 151 | - | {name: "daemon-owned home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: true, capability: protocol.NavigationCapabilityHomePicker}, | |
| 152 | - | {name: "remote-target home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityHomePicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: true, capability: protocol.NavigationCapabilityHomePicker}, | |
| 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 | - | {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 | - | {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: "002b02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 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 | - | {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}, | |
| 25 | + | {name: "inventory capability attached", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityInventory}, valid: true, capability: protocol.NavigationCapabilityInventory}, | |
| 26 | + | {name: "inventory capability on a remote target", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityInventory, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: true, capability: protocol.NavigationCapabilityInventory}, | |
| 27 | + | {name: "new accepts inventory", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityInventory}, valid: true, capability: protocol.NavigationCapabilityInventory}, | |
| 28 | + | {name: "ephemeral rejects inventory", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentEphemeral, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityInventory}, valid: false}, | |
| 159 | 29 | {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 8}, valid: false}, | |
| 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 | - | {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 | 30 | } | |
| 163 | 31 | ||
| 164 | 32 | for _, tt := range tests { | |
| @@ -168,17 +36,9 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 168 | 36 | require.NoError(t, err) | |
| 169 | 37 | payload := MarshalHello(tt.hello) | |
| 170 | 38 | require.NotNil(t, payload) | |
| 171 | - | if tt.wantPayload != "" { | |
| 172 | - | expected, decodeErr := hex.DecodeString(tt.wantPayload) | |
| 173 | - | require.NoError(t, decodeErr) | |
| 174 | - | require.Equal(t, expected, payload) | |
| 175 | - | assertAllPrefixesFail(t, expected, UnmarshalHello) | |
| 176 | - | assertTrailingGarbageFails(t, expected, UnmarshalHello) | |
| 177 | - | } | |
| 178 | 39 | decoded, decodeErr := UnmarshalHello(payload) | |
| 179 | 40 | require.NoError(t, decodeErr) | |
| 180 | 41 | require.Equal(t, tt.capability, decoded.NavigationCapabilities) | |
| 181 | - | require.Equal(t, tt.overlay, decoded.StartupOverlay) | |
| 182 | 42 | return | |
| 183 | 43 | } | |
| 184 | 44 | require.Error(t, err) | |
A internal/protocol/wire/picker_control.go +249 -0
| @@ -0,0 +1,249 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/protocol" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func PeekPickerControlVersion(data []byte) (uint16, bool) { | |
| 10 | + | if len(data) < 2 { | |
| 11 | + | return 0, false | |
| 12 | + | } | |
| 13 | + | return binary.BigEndian.Uint16(data), true | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // marshalPickerControlTargets encodes a bounded exact-target list. The count | |
| 17 | + | // guard mirrors semantic validation so a malformed caller can never write an | |
| 18 | + | // unbounded frame. | |
| 19 | + | func marshalPickerControlTargets(w *payloadWriter, targets []protocol.ExactSessionTarget) bool { | |
| 20 | + | if len(targets) > protocol.PickerControlMaxTargets { | |
| 21 | + | return false | |
| 22 | + | } | |
| 23 | + | w.putUint16(uint16(len(targets))) | |
| 24 | + | for _, target := range targets { | |
| 25 | + | marshalExactSessionTarget(w, target) | |
| 26 | + | } | |
| 27 | + | return true | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func unmarshalPickerControlTargets(r *payloadReader) ([]protocol.ExactSessionTarget, error) { | |
| 31 | + | count, err := r.getUint16() | |
| 32 | + | if err != nil { | |
| 33 | + | return nil, err | |
| 34 | + | } | |
| 35 | + | if int(count) > protocol.PickerControlMaxTargets { | |
| 36 | + | return nil, protocol.ErrInvalidNavigation | |
| 37 | + | } | |
| 38 | + | if count == 0 { | |
| 39 | + | return nil, nil | |
| 40 | + | } | |
| 41 | + | targets := make([]protocol.ExactSessionTarget, 0, int(count)) | |
| 42 | + | for range int(count) { | |
| 43 | + | target, err := unmarshalExactSessionTarget(r) | |
| 44 | + | if err != nil { | |
| 45 | + | return nil, err | |
| 46 | + | } | |
| 47 | + | targets = append(targets, target) | |
| 48 | + | } | |
| 49 | + | return targets, nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // marshalPickerRouteObservations encodes the observation payload carried inside | |
| 53 | + | // the response union. The exact-target helper keeps the target layout shared | |
| 54 | + | // with the rest of the wire. | |
| 55 | + | func marshalPickerRouteObservations(w *payloadWriter, observations []protocol.PickerRouteObservation) bool { | |
| 56 | + | if len(observations) > protocol.PickerControlMaxTargets { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | w.putUint16(uint16(len(observations))) | |
| 60 | + | for _, observation := range observations { | |
| 61 | + | marshalExactSessionTarget(w, observation.Target) | |
| 62 | + | w.putUint8(uint8(observation.Presence)) | |
| 63 | + | w.putBool(observation.Attention) | |
| 64 | + | } | |
| 65 | + | return true | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // unmarshalPickerRouteObservations decodes the observation payload carried | |
| 69 | + | // inside the response union and rejects an overlong count or trailing bytes. | |
| 70 | + | // Semantic checks stay with ValidatePickerControlResponse, which runs after the | |
| 71 | + | // complete response is decoded. | |
| 72 | + | func unmarshalPickerRouteObservations(data []byte) ([]protocol.PickerRouteObservation, error) { | |
| 73 | + | r := payloadReader{b: data} | |
| 74 | + | count, err := r.getUint16() | |
| 75 | + | if err != nil { | |
| 76 | + | return nil, err | |
| 77 | + | } | |
| 78 | + | if int(count) > protocol.PickerControlMaxTargets { | |
| 79 | + | return nil, protocol.ErrInvalidNavigation | |
| 80 | + | } | |
| 81 | + | if count == 0 { | |
| 82 | + | return nil, nil | |
| 83 | + | } | |
| 84 | + | observations := make([]protocol.PickerRouteObservation, 0, int(count)) | |
| 85 | + | for range int(count) { | |
| 86 | + | var observation protocol.PickerRouteObservation | |
| 87 | + | target, err := unmarshalExactSessionTarget(&r) | |
| 88 | + | if err != nil { | |
| 89 | + | return nil, err | |
| 90 | + | } | |
| 91 | + | observation.Target = target | |
| 92 | + | presence, err := r.getUint8() | |
| 93 | + | if err != nil { | |
| 94 | + | return nil, err | |
| 95 | + | } | |
| 96 | + | observation.Presence = protocol.PickerRoutePresence(presence) | |
| 97 | + | if observation.Attention, err = r.getBool(); err != nil { | |
| 98 | + | return nil, err | |
| 99 | + | } | |
| 100 | + | observations = append(observations, observation) | |
| 101 | + | } | |
| 102 | + | if err := r.done(); err != nil { | |
| 103 | + | return nil, err | |
| 104 | + | } | |
| 105 | + | return observations, nil | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func MarshalPickerControlRequest(request protocol.PickerControlRequest) []byte { | |
| 109 | + | if protocol.ValidatePickerControlRequest(request) != nil { | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | w := payloadWriter{} | |
| 113 | + | w.putUint16(request.Version) | |
| 114 | + | w.putUint64(request.RequestID) | |
| 115 | + | w.putUint8(uint8(request.Operation)) | |
| 116 | + | w.putUint64(request.SourceRevision) | |
| 117 | + | w.putString(request.SourceID) | |
| 118 | + | w.putString(request.Key) | |
| 119 | + | if !marshalPickerControlTargets(&w, request.Targets) { | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | return w.b | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func UnmarshalPickerControlRequest(data []byte) (protocol.PickerControlRequest, error) { | |
| 126 | + | r := payloadReader{b: data} | |
| 127 | + | var request protocol.PickerControlRequest | |
| 128 | + | var err error | |
| 129 | + | if request.Version, err = r.getUint16(); err != nil { | |
| 130 | + | return request, err | |
| 131 | + | } | |
| 132 | + | if request.RequestID, err = r.getUint64(); err != nil { | |
| 133 | + | return request, err | |
| 134 | + | } | |
| 135 | + | op, err := r.getUint8() | |
| 136 | + | if err != nil { | |
| 137 | + | return request, err | |
| 138 | + | } | |
| 139 | + | request.Operation = protocol.PickerControlOperation(op) | |
| 140 | + | if request.SourceRevision, err = r.getUint64(); err != nil { | |
| 141 | + | return request, err | |
| 142 | + | } | |
| 143 | + | if request.SourceID, err = r.getString(); err != nil { | |
| 144 | + | return request, err | |
| 145 | + | } | |
| 146 | + | if request.Key, err = r.getString(); err != nil { | |
| 147 | + | return request, err | |
| 148 | + | } | |
| 149 | + | if request.Targets, err = unmarshalPickerControlTargets(&r); err != nil { | |
| 150 | + | return request, err | |
| 151 | + | } | |
| 152 | + | if err := r.done(); err != nil { | |
| 153 | + | return request, err | |
| 154 | + | } | |
| 155 | + | if err := protocol.ValidatePickerControlRequest(request); err != nil { | |
| 156 | + | return request, err | |
| 157 | + | } | |
| 158 | + | return request, nil | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func MarshalPickerControlResponse(response protocol.PickerControlResponse) []byte { | |
| 162 | + | if protocol.ValidatePickerControlResponse(response) != nil { | |
| 163 | + | return nil | |
| 164 | + | } | |
| 165 | + | w := payloadWriter{} | |
| 166 | + | w.putUint64(response.RequestID) | |
| 167 | + | w.putUint8(uint8(response.Operation)) | |
| 168 | + | w.putUint8(uint8(response.Status)) | |
| 169 | + | if response.Snapshot != nil { | |
| 170 | + | w.putLongBytes(MarshalPickerSnapshot(*response.Snapshot)) | |
| 171 | + | } else { | |
| 172 | + | w.putLongBytes(nil) | |
| 173 | + | } | |
| 174 | + | if response.Resolved != nil { | |
| 175 | + | payload := MarshalAttachTarget(*response.Resolved) | |
| 176 | + | w.putLongBytes(payload) | |
| 177 | + | } else { | |
| 178 | + | w.putLongBytes(nil) | |
| 179 | + | } | |
| 180 | + | if len(response.Observations) != 0 { | |
| 181 | + | inner := payloadWriter{} | |
| 182 | + | if !marshalPickerRouteObservations(&inner, response.Observations) { | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | w.putLongBytes(inner.b) | |
| 186 | + | } else { | |
| 187 | + | w.putLongBytes(nil) | |
| 188 | + | } | |
| 189 | + | return w.b | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func UnmarshalPickerControlResponse(data []byte) (protocol.PickerControlResponse, error) { | |
| 193 | + | r := payloadReader{b: data} | |
| 194 | + | var response protocol.PickerControlResponse | |
| 195 | + | var err error | |
| 196 | + | if response.RequestID, err = r.getUint64(); err != nil { | |
| 197 | + | return response, err | |
| 198 | + | } | |
| 199 | + | op, err := r.getUint8() | |
| 200 | + | if err != nil { | |
| 201 | + | return response, err | |
| 202 | + | } | |
| 203 | + | response.Operation = protocol.PickerControlOperation(op) | |
| 204 | + | status, err := r.getUint8() | |
| 205 | + | if err != nil { | |
| 206 | + | return response, err | |
| 207 | + | } | |
| 208 | + | response.Status = protocol.PickerSourceStatus(status) | |
| 209 | + | snapshotData, err := r.getLongBytes() | |
| 210 | + | if err != nil { | |
| 211 | + | return response, err | |
| 212 | + | } | |
| 213 | + | if len(snapshotData) != 0 { | |
| 214 | + | snapshot, err := UnmarshalPickerSnapshot(snapshotData) | |
| 215 | + | if err != nil { | |
| 216 | + | return response, err | |
| 217 | + | } | |
| 218 | + | response.Snapshot = &snapshot | |
| 219 | + | } | |
| 220 | + | targetData, err := r.getLongBytes() | |
| 221 | + | if err != nil { | |
| 222 | + | return response, err | |
| 223 | + | } | |
| 224 | + | if len(targetData) != 0 { | |
| 225 | + | target, err := UnmarshalAttachTarget(targetData) | |
| 226 | + | if err != nil { | |
| 227 | + | return response, err | |
| 228 | + | } | |
| 229 | + | response.Resolved = &target | |
| 230 | + | } | |
| 231 | + | observationsData, err := r.getLongBytes() | |
| 232 | + | if err != nil { | |
| 233 | + | return response, err | |
| 234 | + | } | |
| 235 | + | if len(observationsData) != 0 { | |
| 236 | + | observations, err := unmarshalPickerRouteObservations(observationsData) | |
| 237 | + | if err != nil { | |
| 238 | + | return response, err | |
| 239 | + | } | |
| 240 | + | response.Observations = observations | |
| 241 | + | } | |
| 242 | + | if err := r.done(); err != nil { | |
| 243 | + | return response, err | |
| 244 | + | } | |
| 245 | + | if err := protocol.ValidatePickerControlResponse(response); err != nil { | |
| 246 | + | return response, err | |
| 247 | + | } | |
| 248 | + | return response, nil | |
| 249 | + | } | |
A internal/protocol/wire/picker_control_test.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestPickerControlRequestRoundTripStrict(t *testing.T) { | |
| 12 | + | request := protocol.PickerControlRequest{Version: protocol.Version, RequestID: 7, Operation: protocol.PickerControlResolve, SourceRevision: 3, SourceID: protocol.PickerHomeSourceID, Key: "a/b"} | |
| 13 | + | payload := MarshalPickerControlRequest(request) | |
| 14 | + | require.NotNil(t, payload) | |
| 15 | + | decoded, err := UnmarshalPickerControlRequest(payload) | |
| 16 | + | require.NoError(t, err) | |
| 17 | + | require.Equal(t, request, decoded) | |
| 18 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerControlRequest) | |
| 19 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerControlRequest) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestPickerControlResponseRoundTripStrict(t *testing.T) { | |
| 23 | + | snapshot := pickerSnapshotForTest() | |
| 24 | + | snapshot.SourceID = protocol.PickerHomeSourceID | |
| 25 | + | snapshot = protocol.NormalizePickerSnapshot(snapshot) | |
| 26 | + | response := protocol.PickerControlResponse{RequestID: 8, Operation: protocol.PickerControlSnapshot, Status: protocol.PickerSourceOK, Snapshot: &snapshot} | |
| 27 | + | payload := MarshalPickerControlResponse(response) | |
| 28 | + | require.NotNil(t, payload) | |
| 29 | + | decoded, err := UnmarshalPickerControlResponse(payload) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | require.Equal(t, response, decoded) | |
| 32 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerControlResponse) | |
| 33 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerControlResponse) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func pickerObserveTargetsForTest(n int) []protocol.ExactSessionTarget { | |
| 37 | + | targets := make([]protocol.ExactSessionTarget, 0, n) | |
| 38 | + | for i := range n { | |
| 39 | + | targets = append(targets, protocol.ExactSessionTarget{ | |
| 40 | + | LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, | |
| 41 | + | SessionName: "work", | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | return targets | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestPickerControlObserveRequestRoundTripStrict(t *testing.T) { | |
| 48 | + | request := protocol.PickerControlRequest{ | |
| 49 | + | Version: protocol.Version, | |
| 50 | + | RequestID: 11, | |
| 51 | + | Operation: protocol.PickerControlObserve, | |
| 52 | + | Targets: pickerObserveTargetsForTest(2), | |
| 53 | + | } | |
| 54 | + | payload := MarshalPickerControlRequest(request) | |
| 55 | + | require.NotNil(t, payload) | |
| 56 | + | decoded, err := UnmarshalPickerControlRequest(payload) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | require.Equal(t, request, decoded) | |
| 59 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerControlRequest) | |
| 60 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerControlRequest) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestPickerControlObserveResponseRoundTripStrict(t *testing.T) { | |
| 64 | + | targets := pickerObserveTargetsForTest(3) | |
| 65 | + | response := protocol.PickerControlResponse{ | |
| 66 | + | RequestID: 11, | |
| 67 | + | Operation: protocol.PickerControlObserve, | |
| 68 | + | Status: protocol.PickerSourceOK, | |
| 69 | + | Observations: []protocol.PickerRouteObservation{ | |
| 70 | + | {Target: targets[0], Presence: protocol.PickerRoutePresent, Attention: true}, | |
| 71 | + | {Target: targets[1], Presence: protocol.PickerRouteAbsent}, | |
| 72 | + | {Target: targets[2], Presence: protocol.PickerRouteUnknown}, | |
| 73 | + | }, | |
| 74 | + | } | |
| 75 | + | payload := MarshalPickerControlResponse(response) | |
| 76 | + | require.NotNil(t, payload) | |
| 77 | + | decoded, err := UnmarshalPickerControlResponse(payload) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | require.Equal(t, response, decoded) | |
| 80 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerControlResponse) | |
| 81 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerControlResponse) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestPickerControlRejectsExclusivePayloadViolations(t *testing.T) { | |
| 85 | + | targets := pickerObserveTargetsForTest(1) | |
| 86 | + | // Inspect and resolve never carry targets; observe never carries source | |
| 87 | + | // identity. | |
| 88 | + | require.Nil(t, MarshalPickerControlRequest(protocol.PickerControlRequest{ | |
| 89 | + | Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlSnapshot, Targets: targets, | |
| 90 | + | })) | |
| 91 | + | require.Nil(t, MarshalPickerControlRequest(protocol.PickerControlRequest{ | |
| 92 | + | Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlResolve, SourceRevision: 1, | |
| 93 | + | SourceID: protocol.PickerHomeSourceID, Key: "a/b", Targets: targets, | |
| 94 | + | })) | |
| 95 | + | require.Nil(t, MarshalPickerControlRequest(protocol.PickerControlRequest{ | |
| 96 | + | Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlObserve, | |
| 97 | + | SourceID: protocol.PickerHomeSourceID, Targets: targets, | |
| 98 | + | })) | |
| 99 | + | snapshot := pickerSnapshotForTest() | |
| 100 | + | snapshot.SourceID = protocol.PickerHomeSourceID | |
| 101 | + | // A snapshot response never carries observations. | |
| 102 | + | require.Nil(t, MarshalPickerControlResponse(protocol.PickerControlResponse{ | |
| 103 | + | RequestID: 1, Operation: protocol.PickerControlSnapshot, Status: protocol.PickerSourceOK, Snapshot: &snapshot, | |
| 104 | + | Observations: []protocol.PickerRouteObservation{{Target: targets[0], Presence: protocol.PickerRoutePresent}}, | |
| 105 | + | })) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func pickerObserveResponseEnvelope(observations []byte) []byte { | |
| 109 | + | w := payloadWriter{} | |
| 110 | + | w.putUint64(1) | |
| 111 | + | w.putUint8(uint8(protocol.PickerControlObserve)) | |
| 112 | + | w.putUint8(uint8(protocol.PickerSourceOK)) | |
| 113 | + | w.putLongBytes(nil) | |
| 114 | + | w.putLongBytes(nil) | |
| 115 | + | w.putLongBytes(observations) | |
| 116 | + | return w.b | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestPickerControlObserveWireRejectsUnboundedOrEmptyPayloads(t *testing.T) { | |
| 120 | + | // An observe request claiming more targets than the bound fails during | |
| 121 | + | // decoding, before semantic validation ever runs. | |
| 122 | + | request := payloadWriter{} | |
| 123 | + | request.putUint16(protocol.Version) | |
| 124 | + | request.putUint64(1) | |
| 125 | + | request.putUint8(uint8(protocol.PickerControlObserve)) | |
| 126 | + | request.putUint64(0) | |
| 127 | + | request.putString("") | |
| 128 | + | request.putString("") | |
| 129 | + | request.putUint16(protocol.PickerControlMaxTargets + 1) | |
| 130 | + | _, err := UnmarshalPickerControlRequest(request.b) | |
| 131 | + | require.Error(t, err) | |
| 132 | + | ||
| 133 | + | // A missing observation payload cannot answer an observe request. | |
| 134 | + | _, err = UnmarshalPickerControlResponse(pickerObserveResponseEnvelope(nil)) | |
| 135 | + | require.Error(t, err) | |
| 136 | + | ||
| 137 | + | // A present but empty observation payload is rejected: the exclusive union | |
| 138 | + | // member is set and must carry at least one observation. | |
| 139 | + | empty := payloadWriter{} | |
| 140 | + | empty.putUint16(0) | |
| 141 | + | _, err = UnmarshalPickerControlResponse(pickerObserveResponseEnvelope(empty.b)) | |
| 142 | + | require.Error(t, err) | |
| 143 | + | ||
| 144 | + | // An observation payload claiming more entries than the bound is rejected | |
| 145 | + | // at the nested decode boundary. | |
| 146 | + | overlong := payloadWriter{} | |
| 147 | + | overlong.putUint16(protocol.PickerControlMaxTargets + 1) | |
| 148 | + | _, err = UnmarshalPickerControlResponse(pickerObserveResponseEnvelope(overlong.b)) | |
| 149 | + | require.Error(t, err) | |
| 150 | + | } | |
A internal/protocol/wire/picker_interaction_wire.go +510 -0
| @@ -0,0 +1,510 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/protocol" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | // pickerLineFlags packs the independent line booleans into one byte so the | |
| 8 | + | // wire layout stays compact and explicit. | |
| 9 | + | const ( | |
| 10 | + | pickerLineAttention uint8 = 1 << iota | |
| 11 | + | pickerLineStopped | |
| 12 | + | pickerLineDim | |
| 13 | + | pickerLineFocusable | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // marshalPickerLines encodes structured lines, enforcing the semantic | |
| 17 | + | // validator first so an invalid list never reaches the wire. | |
| 18 | + | func marshalPickerLines(w *payloadWriter, lines []protocol.PickerLine) bool { | |
| 19 | + | if len(lines) > protocol.PickerInteractionMaxLines { | |
| 20 | + | return false | |
| 21 | + | } | |
| 22 | + | w.putUint16(uint16(len(lines))) | |
| 23 | + | for _, line := range lines { | |
| 24 | + | w.putString(line.Key) | |
| 25 | + | w.putUint8(uint8(line.Kind)) | |
| 26 | + | w.putString(line.Label) | |
| 27 | + | w.putString(line.Detail) | |
| 28 | + | w.putUint8(uint8(line.Status)) | |
| 29 | + | w.putString(line.StatusDetail) | |
| 30 | + | var flags uint8 | |
| 31 | + | if line.Attention { | |
| 32 | + | flags |= pickerLineAttention | |
| 33 | + | } | |
| 34 | + | if line.Stopped { | |
| 35 | + | flags |= pickerLineStopped | |
| 36 | + | } | |
| 37 | + | if line.Dim { | |
| 38 | + | flags |= pickerLineDim | |
| 39 | + | } | |
| 40 | + | if line.Focusable { | |
| 41 | + | flags |= pickerLineFocusable | |
| 42 | + | } | |
| 43 | + | w.putUint8(flags) | |
| 44 | + | w.putUint8(uint8(line.Actions)) | |
| 45 | + | w.putBool(line.Ephemeral) | |
| 46 | + | } | |
| 47 | + | return true | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func unmarshalPickerLines(r *payloadReader) ([]protocol.PickerLine, error) { | |
| 51 | + | count, err := r.getUint16() | |
| 52 | + | if err != nil { | |
| 53 | + | return nil, err | |
| 54 | + | } | |
| 55 | + | if int(count) > protocol.PickerInteractionMaxLines { | |
| 56 | + | return nil, protocol.ErrInvalidNavigation | |
| 57 | + | } | |
| 58 | + | lines := make([]protocol.PickerLine, 0, int(count)) | |
| 59 | + | for range int(count) { | |
| 60 | + | var line protocol.PickerLine | |
| 61 | + | if line.Key, err = r.getString(); err != nil { | |
| 62 | + | return nil, err | |
| 63 | + | } | |
| 64 | + | kind, err := r.getUint8() | |
| 65 | + | if err != nil { | |
| 66 | + | return nil, err | |
| 67 | + | } | |
| 68 | + | line.Kind = protocol.PickerLineKind(kind) | |
| 69 | + | if line.Label, err = r.getString(); err != nil { | |
| 70 | + | return nil, err | |
| 71 | + | } | |
| 72 | + | if line.Detail, err = r.getString(); err != nil { | |
| 73 | + | return nil, err | |
| 74 | + | } | |
| 75 | + | status, err := r.getUint8() | |
| 76 | + | if err != nil { | |
| 77 | + | return nil, err | |
| 78 | + | } | |
| 79 | + | line.Status = protocol.PickerLineStatus(status) | |
| 80 | + | if line.StatusDetail, err = r.getString(); err != nil { | |
| 81 | + | return nil, err | |
| 82 | + | } | |
| 83 | + | flags, err := r.getUint8() | |
| 84 | + | if err != nil { | |
| 85 | + | return nil, err | |
| 86 | + | } | |
| 87 | + | line.Attention = flags&pickerLineAttention != 0 | |
| 88 | + | line.Stopped = flags&pickerLineStopped != 0 | |
| 89 | + | line.Dim = flags&pickerLineDim != 0 | |
| 90 | + | line.Focusable = flags&pickerLineFocusable != 0 | |
| 91 | + | actions, err := r.getUint8() | |
| 92 | + | if err != nil { | |
| 93 | + | return nil, err | |
| 94 | + | } | |
| 95 | + | line.Actions = protocol.PickerLineActions(actions) | |
| 96 | + | if line.Ephemeral, err = r.getBool(); err != nil { | |
| 97 | + | return nil, err | |
| 98 | + | } | |
| 99 | + | lines = append(lines, line) | |
| 100 | + | } | |
| 101 | + | return lines, nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func marshalPickerCursor(w *payloadWriter, cursor protocol.PickerCursor) { | |
| 105 | + | w.putString(cursor.Key) | |
| 106 | + | w.putUint64(uint64(cursor.Index)) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func unmarshalPickerCursor(r *payloadReader) (protocol.PickerCursor, error) { | |
| 110 | + | var cursor protocol.PickerCursor | |
| 111 | + | var err error | |
| 112 | + | if cursor.Key, err = r.getString(); err != nil { | |
| 113 | + | return protocol.PickerCursor{}, err | |
| 114 | + | } | |
| 115 | + | index, err := r.getUint64() | |
| 116 | + | if err != nil { | |
| 117 | + | return protocol.PickerCursor{}, err | |
| 118 | + | } | |
| 119 | + | cursor.Index = int(index) | |
| 120 | + | return cursor, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // MarshalPickerOffer encodes one interaction offer. | |
| 124 | + | func MarshalPickerOffer(offer protocol.PickerOffer) []byte { | |
| 125 | + | if protocol.ValidatePickerOffer(offer) != nil { | |
| 126 | + | return nil | |
| 127 | + | } | |
| 128 | + | w := payloadWriter{} | |
| 129 | + | w.putUint64(offer.InteractionID) | |
| 130 | + | w.putUint64(offer.RequestID) | |
| 131 | + | w.putUint8(uint8(offer.Intent)) | |
| 132 | + | w.putString(offer.MoveSourceKey) | |
| 133 | + | w.putUint64(offer.BarrierEpoch) | |
| 134 | + | w.putUint64(offer.BarrierState) | |
| 135 | + | w.putUint64(offer.SizeEpoch) | |
| 136 | + | w.putString(offer.Title) | |
| 137 | + | return w.b | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // UnmarshalPickerOffer decodes a strict interaction offer. | |
| 141 | + | func UnmarshalPickerOffer(data []byte) (protocol.PickerOffer, error) { | |
| 142 | + | r := payloadReader{b: data} | |
| 143 | + | var offer protocol.PickerOffer | |
| 144 | + | var err error | |
| 145 | + | if offer.InteractionID, err = r.getUint64(); err != nil { | |
| 146 | + | return protocol.PickerOffer{}, err | |
| 147 | + | } | |
| 148 | + | if offer.RequestID, err = r.getUint64(); err != nil { | |
| 149 | + | return protocol.PickerOffer{}, err | |
| 150 | + | } | |
| 151 | + | intent, err := r.getUint8() | |
| 152 | + | if err != nil { | |
| 153 | + | return protocol.PickerOffer{}, err | |
| 154 | + | } | |
| 155 | + | offer.Intent = protocol.PickerIntent(intent) | |
| 156 | + | if offer.MoveSourceKey, err = r.getString(); err != nil { | |
| 157 | + | return protocol.PickerOffer{}, err | |
| 158 | + | } | |
| 159 | + | if offer.BarrierEpoch, err = r.getUint64(); err != nil { | |
| 160 | + | return protocol.PickerOffer{}, err | |
| 161 | + | } | |
| 162 | + | if offer.BarrierState, err = r.getUint64(); err != nil { | |
| 163 | + | return protocol.PickerOffer{}, err | |
| 164 | + | } | |
| 165 | + | if offer.SizeEpoch, err = r.getUint64(); err != nil { | |
| 166 | + | return protocol.PickerOffer{}, err | |
| 167 | + | } | |
| 168 | + | if offer.Title, err = r.getString(); err != nil { | |
| 169 | + | return protocol.PickerOffer{}, err | |
| 170 | + | } | |
| 171 | + | if err := r.done(); err != nil { | |
| 172 | + | return protocol.PickerOffer{}, err | |
| 173 | + | } | |
| 174 | + | if err := protocol.ValidatePickerOffer(offer); err != nil { | |
| 175 | + | return protocol.PickerOffer{}, err | |
| 176 | + | } | |
| 177 | + | return offer, nil | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // MarshalPickerBegin encodes one interaction request. | |
| 181 | + | func MarshalPickerBegin(begin protocol.PickerBegin) []byte { | |
| 182 | + | if protocol.ValidatePickerBegin(begin) != nil { | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | w := payloadWriter{} | |
| 186 | + | w.putUint64(begin.RequestID) | |
| 187 | + | w.putUint8(uint8(begin.Intent)) | |
| 188 | + | return w.b | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // UnmarshalPickerBegin decodes a strict interaction request. | |
| 192 | + | func UnmarshalPickerBegin(data []byte) (protocol.PickerBegin, error) { | |
| 193 | + | r := payloadReader{b: data} | |
| 194 | + | var begin protocol.PickerBegin | |
| 195 | + | var err error | |
| 196 | + | if begin.RequestID, err = r.getUint64(); err != nil { | |
| 197 | + | return protocol.PickerBegin{}, err | |
| 198 | + | } | |
| 199 | + | intent, err := r.getUint8() | |
| 200 | + | if err != nil { | |
| 201 | + | return protocol.PickerBegin{}, err | |
| 202 | + | } | |
| 203 | + | begin.Intent = protocol.PickerIntent(intent) | |
| 204 | + | if err := r.done(); err != nil { | |
| 205 | + | return protocol.PickerBegin{}, err | |
| 206 | + | } | |
| 207 | + | if err := protocol.ValidatePickerBegin(begin); err != nil { | |
| 208 | + | return protocol.PickerBegin{}, err | |
| 209 | + | } | |
| 210 | + | return begin, nil | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // MarshalPickerSnapshot encodes one source publication. Line order is | |
| 214 | + | // preserved verbatim. | |
| 215 | + | func MarshalPickerSnapshot(snapshot protocol.PickerSnapshot) []byte { | |
| 216 | + | snapshot = protocol.NormalizePickerSnapshot(snapshot) | |
| 217 | + | if protocol.ValidatePickerSnapshot(snapshot) != nil { | |
| 218 | + | return nil | |
| 219 | + | } | |
| 220 | + | w := payloadWriter{} | |
| 221 | + | w.putUint64(snapshot.InteractionID) | |
| 222 | + | w.putString(snapshot.SourceID) | |
| 223 | + | w.putUint64(snapshot.SourceRevision) | |
| 224 | + | w.putUint8(uint8(snapshot.Status)) | |
| 225 | + | w.putString(snapshot.StatusDetail) | |
| 226 | + | if !marshalPickerLines(&w, snapshot.Recent.Lines) { | |
| 227 | + | return nil | |
| 228 | + | } | |
| 229 | + | marshalPickerCursor(&w, snapshot.Recent.Cursor) | |
| 230 | + | if !marshalPickerLines(&w, snapshot.Grouped.Lines) { | |
| 231 | + | return nil | |
| 232 | + | } | |
| 233 | + | marshalPickerCursor(&w, snapshot.Grouped.Cursor) | |
| 234 | + | if len(w.b) > protocol.PickerInteractionMaxEncodedBytes { | |
| 235 | + | return nil | |
| 236 | + | } | |
| 237 | + | return w.b | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // UnmarshalPickerSnapshot decodes a strict source publication. | |
| 241 | + | func UnmarshalPickerSnapshot(data []byte) (protocol.PickerSnapshot, error) { | |
| 242 | + | if len(data) > protocol.PickerInteractionMaxEncodedBytes { | |
| 243 | + | return protocol.PickerSnapshot{}, protocol.ErrInvalidNavigation | |
| 244 | + | } | |
| 245 | + | r := payloadReader{b: data} | |
| 246 | + | var snapshot protocol.PickerSnapshot | |
| 247 | + | var err error | |
| 248 | + | if snapshot.InteractionID, err = r.getUint64(); err != nil { | |
| 249 | + | return protocol.PickerSnapshot{}, err | |
| 250 | + | } | |
| 251 | + | if snapshot.SourceID, err = r.getString(); err != nil { | |
| 252 | + | return protocol.PickerSnapshot{}, err | |
| 253 | + | } | |
| 254 | + | if snapshot.SourceRevision, err = r.getUint64(); err != nil { | |
| 255 | + | return protocol.PickerSnapshot{}, err | |
| 256 | + | } | |
| 257 | + | status, err := r.getUint8() | |
| 258 | + | if err != nil { | |
| 259 | + | return protocol.PickerSnapshot{}, err | |
| 260 | + | } | |
| 261 | + | snapshot.Status = protocol.PickerSourceStatus(status) | |
| 262 | + | if snapshot.StatusDetail, err = r.getString(); err != nil { | |
| 263 | + | return protocol.PickerSnapshot{}, err | |
| 264 | + | } | |
| 265 | + | if snapshot.Recent.Lines, err = unmarshalPickerLines(&r); err != nil { | |
| 266 | + | return protocol.PickerSnapshot{}, err | |
| 267 | + | } | |
| 268 | + | if snapshot.Recent.Cursor, err = unmarshalPickerCursor(&r); err != nil { | |
| 269 | + | return protocol.PickerSnapshot{}, err | |
| 270 | + | } | |
| 271 | + | if snapshot.Grouped.Lines, err = unmarshalPickerLines(&r); err != nil { | |
| 272 | + | return protocol.PickerSnapshot{}, err | |
| 273 | + | } | |
| 274 | + | if snapshot.Grouped.Cursor, err = unmarshalPickerCursor(&r); err != nil { | |
| 275 | + | return protocol.PickerSnapshot{}, err | |
| 276 | + | } | |
| 277 | + | if err := r.done(); err != nil { | |
| 278 | + | return protocol.PickerSnapshot{}, err | |
| 279 | + | } | |
| 280 | + | if err := protocol.ValidatePickerSnapshot(snapshot); err != nil { | |
| 281 | + | return protocol.PickerSnapshot{}, err | |
| 282 | + | } | |
| 283 | + | // Keep construction aliases populated for internal callers while both | |
| 284 | + | // authoritative projections remain available to client-owned sorting. | |
| 285 | + | snapshot.Lines, snapshot.Cursor = snapshot.Recent.Lines, snapshot.Recent.Cursor | |
| 286 | + | return snapshot, nil | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // MarshalPickerClose encodes a client interaction close. | |
| 290 | + | func MarshalPickerClose(close protocol.PickerClose) []byte { | |
| 291 | + | if protocol.ValidatePickerClose(close) != nil { | |
| 292 | + | return nil | |
| 293 | + | } | |
| 294 | + | w := payloadWriter{} | |
| 295 | + | w.putUint64(close.InteractionID) | |
| 296 | + | w.putUint64(close.RequestID) | |
| 297 | + | return w.b | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // UnmarshalPickerClose decodes a strict client interaction close. | |
| 301 | + | func UnmarshalPickerClose(data []byte) (protocol.PickerClose, error) { | |
| 302 | + | r := payloadReader{b: data} | |
| 303 | + | var close protocol.PickerClose | |
| 304 | + | var err error | |
| 305 | + | if close.InteractionID, err = r.getUint64(); err != nil { | |
| 306 | + | return protocol.PickerClose{}, err | |
| 307 | + | } | |
| 308 | + | if close.RequestID, err = r.getUint64(); err != nil { | |
| 309 | + | return protocol.PickerClose{}, err | |
| 310 | + | } | |
| 311 | + | if err := r.done(); err != nil { | |
| 312 | + | return protocol.PickerClose{}, err | |
| 313 | + | } | |
| 314 | + | if err := protocol.ValidatePickerClose(close); err != nil { | |
| 315 | + | return protocol.PickerClose{}, err | |
| 316 | + | } | |
| 317 | + | return close, nil | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // MarshalPickerClosed encodes the serving daemon's close confirmation. | |
| 321 | + | func MarshalPickerClosed(closed protocol.PickerClosed) []byte { | |
| 322 | + | if protocol.ValidatePickerClosed(closed) != nil { | |
| 323 | + | return nil | |
| 324 | + | } | |
| 325 | + | w := payloadWriter{} | |
| 326 | + | w.putUint64(closed.InteractionID) | |
| 327 | + | w.putUint64(closed.BarrierEpoch) | |
| 328 | + | w.putUint64(closed.BarrierState) | |
| 329 | + | return w.b | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // UnmarshalPickerClosed decodes a strict close confirmation. | |
| 333 | + | func UnmarshalPickerClosed(data []byte) (protocol.PickerClosed, error) { | |
| 334 | + | r := payloadReader{b: data} | |
| 335 | + | var closed protocol.PickerClosed | |
| 336 | + | var err error | |
| 337 | + | if closed.InteractionID, err = r.getUint64(); err != nil { | |
| 338 | + | return protocol.PickerClosed{}, err | |
| 339 | + | } | |
| 340 | + | if closed.BarrierEpoch, err = r.getUint64(); err != nil { | |
| 341 | + | return protocol.PickerClosed{}, err | |
| 342 | + | } | |
| 343 | + | if closed.BarrierState, err = r.getUint64(); err != nil { | |
| 344 | + | return protocol.PickerClosed{}, err | |
| 345 | + | } | |
| 346 | + | if err := r.done(); err != nil { | |
| 347 | + | return protocol.PickerClosed{}, err | |
| 348 | + | } | |
| 349 | + | if err := protocol.ValidatePickerClosed(closed); err != nil { | |
| 350 | + | return protocol.PickerClosed{}, err | |
| 351 | + | } | |
| 352 | + | return closed, nil | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // MarshalPickerSelection encodes an opaque row-key commit request. | |
| 356 | + | func MarshalPickerSelection(selection protocol.PickerSelection) []byte { | |
| 357 | + | if protocol.ValidatePickerSelection(selection) != nil { | |
| 358 | + | return nil | |
| 359 | + | } | |
| 360 | + | w := payloadWriter{} | |
| 361 | + | w.putUint64(selection.CauseActionID) | |
| 362 | + | w.putUint64(selection.RequestID) | |
| 363 | + | w.putUint64(selection.InteractionID) | |
| 364 | + | w.putString(selection.SourceID) | |
| 365 | + | w.putUint64(selection.SourceRevision) | |
| 366 | + | w.putString(selection.Key) | |
| 367 | + | w.putUint8(uint8(selection.Action)) | |
| 368 | + | return w.b | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // UnmarshalPickerSelection decodes a strict commit request. | |
| 372 | + | func UnmarshalPickerSelection(data []byte) (protocol.PickerSelection, error) { | |
| 373 | + | r := payloadReader{b: data} | |
| 374 | + | var selection protocol.PickerSelection | |
| 375 | + | var err error | |
| 376 | + | if selection.CauseActionID, err = r.getUint64(); err != nil { | |
| 377 | + | return protocol.PickerSelection{}, err | |
| 378 | + | } | |
| 379 | + | if selection.RequestID, err = r.getUint64(); err != nil { | |
| 380 | + | return protocol.PickerSelection{}, err | |
| 381 | + | } | |
| 382 | + | if selection.InteractionID, err = r.getUint64(); err != nil { | |
| 383 | + | return protocol.PickerSelection{}, err | |
| 384 | + | } | |
| 385 | + | if selection.SourceID, err = r.getString(); err != nil { | |
| 386 | + | return protocol.PickerSelection{}, err | |
| 387 | + | } | |
| 388 | + | if selection.SourceRevision, err = r.getUint64(); err != nil { | |
| 389 | + | return protocol.PickerSelection{}, err | |
| 390 | + | } | |
| 391 | + | if selection.Key, err = r.getString(); err != nil { | |
| 392 | + | return protocol.PickerSelection{}, err | |
| 393 | + | } | |
| 394 | + | action, err := r.getUint8() | |
| 395 | + | if err != nil { | |
| 396 | + | return protocol.PickerSelection{}, err | |
| 397 | + | } | |
| 398 | + | selection.Action = protocol.PickerAction(action) | |
| 399 | + | if err := r.done(); err != nil { | |
| 400 | + | return protocol.PickerSelection{}, err | |
| 401 | + | } | |
| 402 | + | if err := protocol.ValidatePickerSelection(selection); err != nil { | |
| 403 | + | return protocol.PickerSelection{}, err | |
| 404 | + | } | |
| 405 | + | return selection, nil | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // MarshalPickerResult encodes a completed mutation report. | |
| 409 | + | func MarshalPickerResult(result protocol.PickerResult) []byte { | |
| 410 | + | if protocol.ValidatePickerResult(result) != nil { | |
| 411 | + | return nil | |
| 412 | + | } | |
| 413 | + | w := payloadWriter{} | |
| 414 | + | w.putUint64(result.CauseActionID) | |
| 415 | + | w.putUint64(result.RequestID) | |
| 416 | + | w.putUint64(result.InteractionID) | |
| 417 | + | w.putString(result.SourceID) | |
| 418 | + | w.putString(result.Key) | |
| 419 | + | w.putUint8(uint8(result.Action)) | |
| 420 | + | return w.b | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | // UnmarshalPickerResult decodes a strict mutation report. | |
| 424 | + | func UnmarshalPickerResult(data []byte) (protocol.PickerResult, error) { | |
| 425 | + | r := payloadReader{b: data} | |
| 426 | + | var result protocol.PickerResult | |
| 427 | + | var err error | |
| 428 | + | if result.CauseActionID, err = r.getUint64(); err != nil { | |
| 429 | + | return protocol.PickerResult{}, err | |
| 430 | + | } | |
| 431 | + | if result.RequestID, err = r.getUint64(); err != nil { | |
| 432 | + | return protocol.PickerResult{}, err | |
| 433 | + | } | |
| 434 | + | if result.InteractionID, err = r.getUint64(); err != nil { | |
| 435 | + | return protocol.PickerResult{}, err | |
| 436 | + | } | |
| 437 | + | if result.SourceID, err = r.getString(); err != nil { | |
| 438 | + | return protocol.PickerResult{}, err | |
| 439 | + | } | |
| 440 | + | if result.Key, err = r.getString(); err != nil { | |
| 441 | + | return protocol.PickerResult{}, err | |
| 442 | + | } | |
| 443 | + | action, err := r.getUint8() | |
| 444 | + | if err != nil { | |
| 445 | + | return protocol.PickerResult{}, err | |
| 446 | + | } | |
| 447 | + | result.Action = protocol.PickerAction(action) | |
| 448 | + | if err := r.done(); err != nil { | |
| 449 | + | return protocol.PickerResult{}, err | |
| 450 | + | } | |
| 451 | + | if err := protocol.ValidatePickerResult(result); err != nil { | |
| 452 | + | return protocol.PickerResult{}, err | |
| 453 | + | } | |
| 454 | + | return result, nil | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | // MarshalPickerFailure encodes a bounded selection failure. | |
| 458 | + | func MarshalPickerFailure(failure protocol.PickerFailure) []byte { | |
| 459 | + | if protocol.ValidatePickerFailure(failure) != nil { | |
| 460 | + | return nil | |
| 461 | + | } | |
| 462 | + | w := payloadWriter{} | |
| 463 | + | w.putUint64(failure.CauseActionID) | |
| 464 | + | w.putUint64(failure.RequestID) | |
| 465 | + | w.putUint64(failure.InteractionID) | |
| 466 | + | w.putString(failure.SourceID) | |
| 467 | + | w.putString(failure.Key) | |
| 468 | + | w.putUint8(uint8(failure.Action)) | |
| 469 | + | w.putUint8(uint8(failure.Code)) | |
| 470 | + | return w.b | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // UnmarshalPickerFailure decodes a strict selection failure. | |
| 474 | + | func UnmarshalPickerFailure(data []byte) (protocol.PickerFailure, error) { | |
| 475 | + | r := payloadReader{b: data} | |
| 476 | + | var failure protocol.PickerFailure | |
| 477 | + | var err error | |
| 478 | + | if failure.CauseActionID, err = r.getUint64(); err != nil { | |
| 479 | + | return protocol.PickerFailure{}, err | |
| 480 | + | } | |
| 481 | + | if failure.RequestID, err = r.getUint64(); err != nil { | |
| 482 | + | return protocol.PickerFailure{}, err | |
| 483 | + | } | |
| 484 | + | if failure.InteractionID, err = r.getUint64(); err != nil { | |
| 485 | + | return protocol.PickerFailure{}, err | |
| 486 | + | } | |
| 487 | + | if failure.SourceID, err = r.getString(); err != nil { | |
| 488 | + | return protocol.PickerFailure{}, err | |
| 489 | + | } | |
| 490 | + | if failure.Key, err = r.getString(); err != nil { | |
| 491 | + | return protocol.PickerFailure{}, err | |
| 492 | + | } | |
| 493 | + | action, err := r.getUint8() | |
| 494 | + | if err != nil { | |
| 495 | + | return protocol.PickerFailure{}, err | |
| 496 | + | } | |
| 497 | + | failure.Action = protocol.PickerAction(action) | |
| 498 | + | code, err := r.getUint8() | |
| 499 | + | if err != nil { | |
| 500 | + | return protocol.PickerFailure{}, err | |
| 501 | + | } | |
| 502 | + | failure.Code = protocol.PickerFailureCode(code) | |
| 503 | + | if err := r.done(); err != nil { | |
| 504 | + | return protocol.PickerFailure{}, err | |
| 505 | + | } | |
| 506 | + | if err := protocol.ValidatePickerFailure(failure); err != nil { | |
| 507 | + | return protocol.PickerFailure{}, err | |
| 508 | + | } | |
| 509 | + | return failure, nil | |
| 510 | + | } | |
A internal/protocol/wire/picker_interaction_wire_test.go +288 -0
| @@ -0,0 +1,288 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func pickerOfferForTest() protocol.PickerOffer { | |
| 11 | + | return protocol.PickerOffer{ | |
| 12 | + | InteractionID: 7, RequestID: 3, Intent: protocol.PickerIntentMovePane, | |
| 13 | + | MoveSourceKey: "ab12/work#tab-1", BarrierEpoch: 3, BarrierState: 9, SizeEpoch: 1, | |
| 14 | + | Title: " Sessions · recent ", | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func pickerSnapshotForTest() protocol.PickerSnapshot { | |
| 19 | + | return protocol.PickerSnapshot{ | |
| 20 | + | InteractionID: 7, SourceID: "serving", SourceRevision: 2, | |
| 21 | + | Status: protocol.PickerSourceOK, | |
| 22 | + | Lines: []protocol.PickerLine{ | |
| 23 | + | {Kind: protocol.PickerLineSection, Label: "LOCAL", Dim: true}, | |
| 24 | + | { | |
| 25 | + | Key: "ab12/work", Kind: protocol.PickerLineSession, Label: "work", Detail: "stopped", | |
| 26 | + | Status: protocol.PickerLineStatusStopped, Stopped: true, Focusable: true, | |
| 27 | + | Actions: protocol.PickerCanNavigate | protocol.PickerCanKill, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | Key: "ab12/work#tab-1", Kind: protocol.PickerLineTab, Label: "editor", | |
| 31 | + | Detail: " (vim)", Attention: true, StatusDetail: "stale catalog", Focusable: true, | |
| 32 | + | Actions: protocol.PickerCanNavigate, Ephemeral: true, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | Key: "cd34/host", Kind: protocol.PickerLineHost, Label: "example.test", | |
| 36 | + | Dim: true, Focusable: true, Status: protocol.PickerLineStatusDown, StatusDetail: "unreachable", | |
| 37 | + | }, | |
| 38 | + | }, | |
| 39 | + | Cursor: protocol.PickerCursor{Key: "ab12/work#tab-1", Index: 2}, | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestPickerOfferRoundTrip(t *testing.T) { | |
| 44 | + | offer := pickerOfferForTest() | |
| 45 | + | payload := MarshalPickerOffer(offer) | |
| 46 | + | if payload == nil { | |
| 47 | + | t.Fatal("MarshalPickerOffer returned nil") | |
| 48 | + | } | |
| 49 | + | decoded, err := UnmarshalPickerOffer(payload) | |
| 50 | + | if err != nil { | |
| 51 | + | t.Fatalf("UnmarshalPickerOffer() error = %v", err) | |
| 52 | + | } | |
| 53 | + | if decoded != offer { | |
| 54 | + | t.Fatalf("UnmarshalPickerOffer() = %#v, want %#v", decoded, offer) | |
| 55 | + | } | |
| 56 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerOffer) | |
| 57 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerOffer) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestPickerBeginRoundTrip(t *testing.T) { | |
| 61 | + | begin := protocol.PickerBegin{RequestID: 11, Intent: protocol.PickerIntentNavigation} | |
| 62 | + | payload := MarshalPickerBegin(begin) | |
| 63 | + | if payload == nil { | |
| 64 | + | t.Fatal("MarshalPickerBegin returned nil") | |
| 65 | + | } | |
| 66 | + | decoded, err := UnmarshalPickerBegin(payload) | |
| 67 | + | if err != nil || decoded != begin { | |
| 68 | + | t.Fatalf("UnmarshalPickerBegin() = %#v, %v; want %#v", decoded, err, begin) | |
| 69 | + | } | |
| 70 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerBegin) | |
| 71 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerBegin) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestPickerSnapshotRoundTrip(t *testing.T) { | |
| 75 | + | snapshot := pickerSnapshotForTest() | |
| 76 | + | payload := MarshalPickerSnapshot(snapshot) | |
| 77 | + | if payload == nil { | |
| 78 | + | t.Fatal("MarshalPickerSnapshot returned nil") | |
| 79 | + | } | |
| 80 | + | decoded, err := UnmarshalPickerSnapshot(payload) | |
| 81 | + | if err != nil { | |
| 82 | + | t.Fatalf("UnmarshalPickerSnapshot() error = %v", err) | |
| 83 | + | } | |
| 84 | + | if decoded.InteractionID != snapshot.InteractionID || decoded.SourceID != snapshot.SourceID || | |
| 85 | + | decoded.SourceRevision != snapshot.SourceRevision || decoded.Status != snapshot.Status || | |
| 86 | + | decoded.Cursor != snapshot.Cursor || len(decoded.Lines) != len(snapshot.Lines) { | |
| 87 | + | t.Fatalf("UnmarshalPickerSnapshot() = %#v, want %#v", decoded, snapshot) | |
| 88 | + | } | |
| 89 | + | for i, line := range decoded.Lines { | |
| 90 | + | if line != snapshot.Lines[i] { | |
| 91 | + | t.Fatalf("UnmarshalPickerSnapshot() line %d = %#v, want %#v", i, line, snapshot.Lines[i]) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerSnapshot) | |
| 95 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerSnapshot) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestPickerCloseSelectionResultFailureRoundTrip(t *testing.T) { | |
| 99 | + | close := protocol.PickerClose{InteractionID: 7, RequestID: 3} | |
| 100 | + | if payload := MarshalPickerClose(close); payload == nil { | |
| 101 | + | t.Fatal("MarshalPickerClose returned nil") | |
| 102 | + | } else { | |
| 103 | + | decoded, err := UnmarshalPickerClose(payload) | |
| 104 | + | if err != nil || decoded != close { | |
| 105 | + | t.Fatalf("UnmarshalPickerClose() = %#v, %v; want %#v", decoded, err, close) | |
| 106 | + | } | |
| 107 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerClose) | |
| 108 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerClose) | |
| 109 | + | } | |
| 110 | + | closed := protocol.PickerClosed{InteractionID: 7, BarrierEpoch: 3, BarrierState: 9} | |
| 111 | + | if payload := MarshalPickerClosed(closed); payload == nil { | |
| 112 | + | t.Fatal("MarshalPickerClosed returned nil") | |
| 113 | + | } else { | |
| 114 | + | decoded, err := UnmarshalPickerClosed(payload) | |
| 115 | + | if err != nil || decoded != closed { | |
| 116 | + | t.Fatalf("UnmarshalPickerClosed() = %#v, %v; want %#v", decoded, err, closed) | |
| 117 | + | } | |
| 118 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerClosed) | |
| 119 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerClosed) | |
| 120 | + | } | |
| 121 | + | selection := protocol.PickerSelection{ | |
| 122 | + | CauseActionID: 9, RequestID: 3, InteractionID: 7, SourceID: "serving", | |
| 123 | + | SourceRevision: 2, Key: "ab12/work", Action: protocol.PickerActionMove, | |
| 124 | + | } | |
| 125 | + | if payload := MarshalPickerSelection(selection); payload == nil { | |
| 126 | + | t.Fatal("MarshalPickerSelection returned nil") | |
| 127 | + | } else { | |
| 128 | + | decoded, err := UnmarshalPickerSelection(payload) | |
| 129 | + | if err != nil || decoded != selection { | |
| 130 | + | t.Fatalf("UnmarshalPickerSelection() = %#v, %v; want %#v", decoded, err, selection) | |
| 131 | + | } | |
| 132 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerSelection) | |
| 133 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerSelection) | |
| 134 | + | } | |
| 135 | + | result := protocol.PickerResult{ | |
| 136 | + | CauseActionID: 9, RequestID: 3, InteractionID: 7, SourceID: "serving", | |
| 137 | + | Key: "ab12/work", Action: protocol.PickerActionKill, | |
| 138 | + | } | |
| 139 | + | if payload := MarshalPickerResult(result); payload == nil { | |
| 140 | + | t.Fatal("MarshalPickerResult returned nil") | |
| 141 | + | } else { | |
| 142 | + | decoded, err := UnmarshalPickerResult(payload) | |
| 143 | + | if err != nil || decoded != result { | |
| 144 | + | t.Fatalf("UnmarshalPickerResult() = %#v, %v; want %#v", decoded, err, result) | |
| 145 | + | } | |
| 146 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerResult) | |
| 147 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerResult) | |
| 148 | + | } | |
| 149 | + | failure := protocol.PickerFailure{ | |
| 150 | + | CauseActionID: 9, RequestID: 3, InteractionID: 7, SourceID: "serving", | |
| 151 | + | Key: "ab12/work", Action: protocol.PickerActionNavigate, Code: protocol.PickerRetiredTarget, | |
| 152 | + | } | |
| 153 | + | if payload := MarshalPickerFailure(failure); payload == nil { | |
| 154 | + | t.Fatal("MarshalPickerFailure returned nil") | |
| 155 | + | } else { | |
| 156 | + | decoded, err := UnmarshalPickerFailure(payload) | |
| 157 | + | if err != nil || decoded != failure { | |
| 158 | + | t.Fatalf("UnmarshalPickerFailure() = %#v, %v; want %#v", decoded, err, failure) | |
| 159 | + | } | |
| 160 | + | assertAllPrefixesFail(t, payload, UnmarshalPickerFailure) | |
| 161 | + | assertTrailingGarbageFails(t, payload, UnmarshalPickerFailure) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestPickerLineFocusFlagLayout(t *testing.T) { | |
| 166 | + | line := protocol.PickerLine{ | |
| 167 | + | Key: "aa/host", Kind: protocol.PickerLineHost, Label: "host", | |
| 168 | + | Status: protocol.PickerLineStatusDown, Focusable: true, Attention: true, | |
| 169 | + | } | |
| 170 | + | w := &payloadWriter{} | |
| 171 | + | if !marshalPickerLines(w, []protocol.PickerLine{line}) { | |
| 172 | + | t.Fatal("marshalPickerLines rejected a focusable inspection row") | |
| 173 | + | } | |
| 174 | + | // The flags byte is the third from the end of a single-line payload: | |
| 175 | + | // flags, actions, ephemeral. Attention is bit 0 and focus bit 3. | |
| 176 | + | flags := w.b[len(w.b)-3] | |
| 177 | + | if flags != pickerLineAttention|pickerLineFocusable { | |
| 178 | + | t.Fatalf("line flags = %#x, want attention|focusable", flags) | |
| 179 | + | } | |
| 180 | + | r := payloadReader{b: w.b} | |
| 181 | + | lines, err := unmarshalPickerLines(&r) | |
| 182 | + | if err != nil { | |
| 183 | + | t.Fatalf("unmarshalPickerLines() error = %v", err) | |
| 184 | + | } | |
| 185 | + | if len(lines) != 1 || lines[0] != line { | |
| 186 | + | t.Fatalf("unmarshalPickerLines() = %#v, want %#v", lines, line) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestPickerSnapshotBounds(t *testing.T) { | |
| 191 | + | snapshot := pickerSnapshotForTest() | |
| 192 | + | oversize := snapshot | |
| 193 | + | oversize.SourceID = strings.Repeat("x", protocol.PickerInteractionMaxSourceBytes+1) | |
| 194 | + | if MarshalPickerSnapshot(oversize) != nil { | |
| 195 | + | t.Fatal("MarshalPickerSnapshot accepted oversize source id") | |
| 196 | + | } | |
| 197 | + | oversize = snapshot | |
| 198 | + | oversize.Lines = append([]protocol.PickerLine(nil), snapshot.Lines...) | |
| 199 | + | oversize.Lines[1].Label = strings.Repeat("y", protocol.PickerInteractionMaxDisplayBytes+1) | |
| 200 | + | if MarshalPickerSnapshot(oversize) != nil { | |
| 201 | + | t.Fatal("MarshalPickerSnapshot accepted oversize line label") | |
| 202 | + | } | |
| 203 | + | duplicate := snapshot | |
| 204 | + | duplicate.Lines = []protocol.PickerLine{snapshot.Lines[1], snapshot.Lines[1]} | |
| 205 | + | if MarshalPickerSnapshot(duplicate) != nil { | |
| 206 | + | t.Fatal("MarshalPickerSnapshot accepted duplicate keys") | |
| 207 | + | } | |
| 208 | + | keyedSection := snapshot | |
| 209 | + | keyedSection.Lines = []protocol.PickerLine{{Kind: protocol.PickerLineSection, Label: "LOCAL", Key: "nope"}} | |
| 210 | + | if MarshalPickerSnapshot(keyedSection) != nil { | |
| 211 | + | t.Fatal("MarshalPickerSnapshot accepted a keyed section line") | |
| 212 | + | } | |
| 213 | + | focusableSection := snapshot | |
| 214 | + | focusableSection.Lines = []protocol.PickerLine{{Kind: protocol.PickerLineSection, Label: "LOCAL", Focusable: true}} | |
| 215 | + | if MarshalPickerSnapshot(focusableSection) != nil { | |
| 216 | + | t.Fatal("MarshalPickerSnapshot accepted a focusable section line") | |
| 217 | + | } | |
| 218 | + | actionWithoutFocus := snapshot | |
| 219 | + | actionWithoutFocus.Lines = []protocol.PickerLine{{Key: "aa/one", Kind: protocol.PickerLineSession, Label: "one", Actions: protocol.PickerCanNavigate}} | |
| 220 | + | if MarshalPickerSnapshot(actionWithoutFocus) != nil { | |
| 221 | + | t.Fatal("MarshalPickerSnapshot accepted an action row the source never made focusable") | |
| 222 | + | } | |
| 223 | + | unknownCursor := snapshot | |
| 224 | + | unknownCursor.Cursor = protocol.PickerCursor{Key: "ff00/ghost", Index: 0} | |
| 225 | + | if MarshalPickerSnapshot(unknownCursor) != nil { | |
| 226 | + | t.Fatal("MarshalPickerSnapshot accepted unknown cursor key") | |
| 227 | + | } | |
| 228 | + | many := snapshot | |
| 229 | + | many.Lines = make([]protocol.PickerLine, protocol.PickerInteractionMaxLines+1) | |
| 230 | + | for i := range many.Lines { | |
| 231 | + | many.Lines[i] = protocol.PickerLine{Key: "k" + strings.Repeat("0", 120), Kind: protocol.PickerLineSession, Label: "d"} | |
| 232 | + | } | |
| 233 | + | if MarshalPickerSnapshot(many) != nil { | |
| 234 | + | t.Fatal("MarshalPickerSnapshot accepted too many lines") | |
| 235 | + | } | |
| 236 | + | if MarshalPickerSelection(protocol.PickerSelection{InteractionID: 7, SourceRevision: 2, SourceID: "serving", Action: protocol.PickerActionNavigate}) != nil { | |
| 237 | + | t.Fatal("MarshalPickerSelection accepted empty key") | |
| 238 | + | } | |
| 239 | + | if MarshalPickerFailure(protocol.PickerFailure{InteractionID: 7, Action: protocol.PickerActionNavigate, Code: 99}) != nil { | |
| 240 | + | t.Fatal("MarshalPickerFailure accepted unknown code") | |
| 241 | + | } | |
| 242 | + | if MarshalPickerClose(protocol.PickerClose{}) != nil { | |
| 243 | + | t.Fatal("MarshalPickerClose accepted zero interaction") | |
| 244 | + | } | |
| 245 | + | if MarshalPickerOffer(protocol.PickerOffer{InteractionID: 1, Intent: protocol.PickerIntentNavigation, MoveSourceKey: "a/b"}) != nil { | |
| 246 | + | t.Fatal("MarshalPickerOffer accepted a move source on a navigation intent") | |
| 247 | + | } | |
| 248 | + | if MarshalPickerBegin(protocol.PickerBegin{Intent: protocol.PickerIntentNavigation}) != nil { | |
| 249 | + | t.Fatal("MarshalPickerBegin accepted a zero request id") | |
| 250 | + | } | |
| 251 | + | if MarshalPickerResult(protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "a/b"}) != nil { | |
| 252 | + | t.Fatal("MarshalPickerResult accepted an unknown action") | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func TestPickerMessageTypes(t *testing.T) { | |
| 257 | + | // Direction is enforced by the sessionwire adapters (see | |
| 258 | + | // internal/adapters/sessionwire/picker_interaction_test.go); here we pin | |
| 259 | + | // the frame type each payload marshals to and that a nil payload never | |
| 260 | + | // reaches a transport. | |
| 261 | + | snapshot := pickerSnapshotForTest() | |
| 262 | + | cases := []struct { | |
| 263 | + | name string | |
| 264 | + | want MsgType | |
| 265 | + | payload []byte | |
| 266 | + | }{ | |
| 267 | + | {name: "offer", want: MsgPickerOffer, payload: MarshalPickerOffer(pickerOfferForTest())}, | |
| 268 | + | {name: "snapshot", want: MsgPickerSnapshot, payload: MarshalPickerSnapshot(snapshot)}, | |
| 269 | + | {name: "closed", want: MsgPickerClosedServer, payload: MarshalPickerClosed(protocol.PickerClosed{InteractionID: 1})}, | |
| 270 | + | {name: "result", want: MsgPickerResult, payload: MarshalPickerResult(protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "a/b", Action: protocol.PickerActionKill})}, | |
| 271 | + | {name: "failure", want: MsgPickerFailure, payload: MarshalPickerFailure(protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionKill, Code: protocol.PickerUnknownKey})}, | |
| 272 | + | {name: "begin", want: MsgPickerBegin, payload: MarshalPickerBegin(protocol.PickerBegin{RequestID: 1, Intent: protocol.PickerIntentNavigation})}, | |
| 273 | + | {name: "close-client", want: MsgPickerCloseClient, payload: MarshalPickerClose(protocol.PickerClose{InteractionID: 1})}, | |
| 274 | + | {name: "selection", want: MsgPickerSelection, payload: MarshalPickerSelection(protocol.PickerSelection{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Key: "a/b", Action: protocol.PickerActionNavigate})}, | |
| 275 | + | } | |
| 276 | + | for _, tt := range cases { | |
| 277 | + | t.Run(tt.name, func(t *testing.T) { | |
| 278 | + | if tt.payload == nil { | |
| 279 | + | t.Fatal("marshal returned nil") | |
| 280 | + | } | |
| 281 | + | if tt.want != MsgPickerOffer && tt.want != MsgPickerSnapshot && tt.want != MsgPickerClosedServer && | |
| 282 | + | tt.want != MsgPickerResult && tt.want != MsgPickerFailure && | |
| 283 | + | tt.want != MsgPickerBegin && tt.want != MsgPickerCloseClient && tt.want != MsgPickerSelection { | |
| 284 | + | t.Fatalf("unexpected frame type %d", tt.want) | |
| 285 | + | } | |
| 286 | + | }) | |
| 287 | + | } | |
| 288 | + | } | |
A internal/protocol/wire/picker_preview_wire.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // This file carries the picker preview pair: the client's request for the | |
| 11 | + | // preview of one displayed row, and the serving daemon's bounded viewport of | |
| 12 | + | // that row. It reuses the remote preview's cell layout and style codec so both | |
| 13 | + | // preview kinds encode identically. | |
| 14 | + | ||
| 15 | + | func MarshalPickerPreviewRequest(request protocol.PickerPreviewRequest) []byte { | |
| 16 | + | if protocol.ValidatePickerPreviewRequest(request) != nil { | |
| 17 | + | return nil | |
| 18 | + | } | |
| 19 | + | w := payloadWriter{} | |
| 20 | + | w.putUint16(request.Version) | |
| 21 | + | w.putUint64(request.InteractionID) | |
| 22 | + | w.putString(request.SourceID) | |
| 23 | + | w.putString(request.Key) | |
| 24 | + | w.putUint16(request.Width) | |
| 25 | + | w.putUint16(request.Height) | |
| 26 | + | return w.b | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func UnmarshalPickerPreviewRequest(data []byte) (protocol.PickerPreviewRequest, error) { | |
| 30 | + | var request protocol.PickerPreviewRequest | |
| 31 | + | if len(data) > protocol.PickerPreviewMaxBytes { | |
| 32 | + | return request, protocol.ErrInvalidPickerPreviewRequest | |
| 33 | + | } | |
| 34 | + | r := payloadReader{b: data} | |
| 35 | + | var err error | |
| 36 | + | if request.Version, err = r.getUint16(); err != nil { | |
| 37 | + | return request, err | |
| 38 | + | } | |
| 39 | + | if request.InteractionID, err = r.getUint64(); err != nil { | |
| 40 | + | return request, err | |
| 41 | + | } | |
| 42 | + | if request.SourceID, err = r.getString(); err != nil { | |
| 43 | + | return request, err | |
| 44 | + | } | |
| 45 | + | if request.Key, err = r.getString(); err != nil { | |
| 46 | + | return request, err | |
| 47 | + | } | |
| 48 | + | if request.Width, err = r.getUint16(); err != nil { | |
| 49 | + | return request, err | |
| 50 | + | } | |
| 51 | + | if request.Height, err = r.getUint16(); err != nil { | |
| 52 | + | return request, err | |
| 53 | + | } | |
| 54 | + | if err := r.done(); err != nil { | |
| 55 | + | return request, err | |
| 56 | + | } | |
| 57 | + | if err := protocol.ValidatePickerPreviewRequest(request); err != nil { | |
| 58 | + | return request, err | |
| 59 | + | } | |
| 60 | + | return request, nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func MarshalPickerPreview(preview protocol.PickerPreview) []byte { | |
| 64 | + | if protocol.ValidatePickerPreview(preview) != nil { | |
| 65 | + | return nil | |
| 66 | + | } | |
| 67 | + | w := payloadWriter{} | |
| 68 | + | w.putUint16(preview.Version) | |
| 69 | + | w.putUint64(preview.InteractionID) | |
| 70 | + | w.putString(preview.SourceID) | |
| 71 | + | w.putString(preview.Key) | |
| 72 | + | w.putUint8(uint8(preview.Status)) | |
| 73 | + | w.putUint16(preview.Width) | |
| 74 | + | w.putUint16(preview.Height) | |
| 75 | + | w.putUint32(uint32(len(preview.Cells))) | |
| 76 | + | for _, cell := range preview.Cells { | |
| 77 | + | putPreviewCell(&w, cell) | |
| 78 | + | } | |
| 79 | + | return w.b | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func UnmarshalPickerPreview(data []byte) (protocol.PickerPreview, error) { | |
| 83 | + | var preview protocol.PickerPreview | |
| 84 | + | if len(data) > protocol.PickerPreviewMaxBytes { | |
| 85 | + | return preview, protocol.ErrInvalidPickerPreview | |
| 86 | + | } | |
| 87 | + | r := payloadReader{b: data} | |
| 88 | + | var err error | |
| 89 | + | if preview.Version, err = r.getUint16(); err != nil { | |
| 90 | + | return preview, err | |
| 91 | + | } | |
| 92 | + | if preview.InteractionID, err = r.getUint64(); err != nil { | |
| 93 | + | return preview, err | |
| 94 | + | } | |
| 95 | + | if preview.SourceID, err = r.getString(); err != nil { | |
| 96 | + | return preview, err | |
| 97 | + | } | |
| 98 | + | if preview.Key, err = r.getString(); err != nil { | |
| 99 | + | return preview, err | |
| 100 | + | } | |
| 101 | + | status, err := r.getUint8() | |
| 102 | + | if err != nil { | |
| 103 | + | return preview, err | |
| 104 | + | } | |
| 105 | + | preview.Status = protocol.PickerPreviewStatus(status) | |
| 106 | + | if preview.Width, err = r.getUint16(); err != nil { | |
| 107 | + | return preview, err | |
| 108 | + | } | |
| 109 | + | if preview.Height, err = r.getUint16(); err != nil { | |
| 110 | + | return preview, err | |
| 111 | + | } | |
| 112 | + | count, err := r.getUint32() | |
| 113 | + | if err != nil { | |
| 114 | + | return preview, err | |
| 115 | + | } | |
| 116 | + | if count > protocol.PickerPreviewMaxCells || uint64(count) > uint64(len(r.b)/previewCellWireSize) { | |
| 117 | + | return preview, protocol.ErrInvalidPickerPreview | |
| 118 | + | } | |
| 119 | + | if count > 0 { | |
| 120 | + | preview.Cells = make([]renderer.Cell, 0, count) | |
| 121 | + | for range count { | |
| 122 | + | cell, cellErr := getPreviewCell(&r) | |
| 123 | + | if cellErr != nil { | |
| 124 | + | if errors.Is(cellErr, errPreviewCellFlags) { | |
| 125 | + | return preview, protocol.ErrInvalidPickerPreview | |
| 126 | + | } | |
| 127 | + | return preview, cellErr | |
| 128 | + | } | |
| 129 | + | preview.Cells = append(preview.Cells, cell) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | if err := r.done(); err != nil { | |
| 133 | + | return preview, err | |
| 134 | + | } | |
| 135 | + | if err := protocol.ValidatePickerPreview(preview); err != nil { | |
| 136 | + | return preview, err | |
| 137 | + | } | |
| 138 | + | return preview, nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // errPreviewCellFlags reports a cell whose flag byte carries a bit this | |
| 142 | + | // protocol does not define. Each preview kind maps it to its own error. | |
| 143 | + | var errPreviewCellFlags = errors.New("wire: unsupported preview cell flags") | |
| 144 | + | ||
| 145 | + | // putPreviewCell and getPreviewCell are the shared cell layout of both preview | |
| 146 | + | // kinds: rune, continuation flag, then the bounded style block. | |
| 147 | + | func putPreviewCell(w *payloadWriter, cell renderer.Cell) { | |
| 148 | + | var flags uint8 | |
| 149 | + | if cell.Continuation { | |
| 150 | + | flags = 1 | |
| 151 | + | } | |
| 152 | + | w.putUint32(uint32(cell.Rune)) | |
| 153 | + | w.putUint8(flags) | |
| 154 | + | putPreviewStyle(w, cell.Style) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func getPreviewCell(r *payloadReader) (renderer.Cell, error) { | |
| 158 | + | runeValue, err := r.getUint32() | |
| 159 | + | if err != nil { | |
| 160 | + | return renderer.Cell{}, err | |
| 161 | + | } | |
| 162 | + | flags, err := r.getUint8() | |
| 163 | + | if err != nil { | |
| 164 | + | return renderer.Cell{}, err | |
| 165 | + | } | |
| 166 | + | if flags&^uint8(1) != 0 { | |
| 167 | + | return renderer.Cell{}, errPreviewCellFlags | |
| 168 | + | } | |
| 169 | + | style, err := getPreviewStyle(r) | |
| 170 | + | if err != nil { | |
| 171 | + | return renderer.Cell{}, err | |
| 172 | + | } | |
| 173 | + | return renderer.Cell{Rune: rune(runeValue), Continuation: flags&1 != 0, Style: style}, nil | |
| 174 | + | } | |
A internal/protocol/wire/picker_preview_wire_test.go +198 -0
| @@ -0,0 +1,198 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "errors" | |
| 6 | + | "reflect" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func pickerPreviewRow(runes ...rune) []renderer.Cell { | |
| 14 | + | cellWidth := 0 | |
| 15 | + | for _, r := range runes { | |
| 16 | + | cellWidth += renderer.RuneWidth(r) | |
| 17 | + | } | |
| 18 | + | cells := make([]renderer.Cell, 0, cellWidth) | |
| 19 | + | for _, r := range runes { | |
| 20 | + | cells = append(cells, renderer.Cell{Rune: r}) | |
| 21 | + | for range renderer.RuneWidth(r) - 1 { | |
| 22 | + | cells = append(cells, renderer.Cell{Continuation: true}) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | return cells | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func pickerPreviewRequestFixture() protocol.PickerPreviewRequest { | |
| 29 | + | return protocol.PickerPreviewRequest{ | |
| 30 | + | Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first", | |
| 31 | + | Width: 80, Height: 24, | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func pickerPreviewFixture() protocol.PickerPreview { | |
| 36 | + | return protocol.PickerPreview{ | |
| 37 | + | Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first", | |
| 38 | + | Status: protocol.PickerPreviewOK, | |
| 39 | + | Width: 4, Height: 2, | |
| 40 | + | Cells: append(pickerPreviewRow('a', '界', 'b'), pickerPreviewRow('c', 'd', 'e', 'f')...), | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestPickerPreviewRequestGoldenAndRoundTrip(t *testing.T) { | |
| 45 | + | request := pickerPreviewRequestFixture() | |
| 46 | + | want := []byte{ | |
| 47 | + | 0x00, 0x01, // schema version | |
| 48 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, // interaction ID | |
| 49 | + | 0x00, 0x07, 's', 'e', 'r', 'v', 'i', 'n', 'g', | |
| 50 | + | 0x00, 0x08, 'a', 'a', '/', 'f', 'i', 'r', 's', 't', | |
| 51 | + | 0x00, 0x50, 0x00, 0x18, | |
| 52 | + | } | |
| 53 | + | got := MarshalPickerPreviewRequest(request) | |
| 54 | + | if !bytes.Equal(got, want) { | |
| 55 | + | t.Fatalf("PickerPreviewRequest bytes = %x, want %x", got, want) | |
| 56 | + | } | |
| 57 | + | back, err := UnmarshalPickerPreviewRequest(got) | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatalf("UnmarshalPickerPreviewRequest() error = %v", err) | |
| 60 | + | } | |
| 61 | + | if !reflect.DeepEqual(back, request) { | |
| 62 | + | t.Fatalf("request = %+v, want %+v", back, request) | |
| 63 | + | } | |
| 64 | + | assertAllPrefixesFail(t, got, UnmarshalPickerPreviewRequest) | |
| 65 | + | assertTrailingGarbageFails(t, got, UnmarshalPickerPreviewRequest) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestPickerPreviewRequestRejectsInvalid(t *testing.T) { | |
| 69 | + | if MarshalPickerPreviewRequest(protocol.PickerPreviewRequest{Version: protocol.PickerPreviewSchemaVersion}) != nil { | |
| 70 | + | t.Fatal("MarshalPickerPreviewRequest accepted an invalid request") | |
| 71 | + | } | |
| 72 | + | tests := []struct { | |
| 73 | + | name string | |
| 74 | + | mutate func(*protocol.PickerPreviewRequest) | |
| 75 | + | }{ | |
| 76 | + | {name: "schema version", mutate: func(r *protocol.PickerPreviewRequest) { r.Version = 0 }}, | |
| 77 | + | {name: "zero interaction", mutate: func(r *protocol.PickerPreviewRequest) { r.InteractionID = 0 }}, | |
| 78 | + | {name: "zero size", mutate: func(r *protocol.PickerPreviewRequest) { r.Height = 0 }}, | |
| 79 | + | {name: "oversized", mutate: func(r *protocol.PickerPreviewRequest) { r.Width = protocol.PickerPreviewMaxWidth + 1 }}, | |
| 80 | + | } | |
| 81 | + | for _, tt := range tests { | |
| 82 | + | t.Run(tt.name, func(t *testing.T) { | |
| 83 | + | request := pickerPreviewRequestFixture() | |
| 84 | + | tt.mutate(&request) | |
| 85 | + | payload := MarshalPickerPreviewRequest(request) | |
| 86 | + | if payload == nil { | |
| 87 | + | // The writer itself refuses to emit an invalid request. | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | if _, err := UnmarshalPickerPreviewRequest(payload); !errors.Is(err, protocol.ErrInvalidPickerPreviewRequest) { | |
| 91 | + | t.Fatalf("UnmarshalPickerPreviewRequest() error = %v, want ErrInvalidPickerPreviewRequest", err) | |
| 92 | + | } | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestPickerPreviewGoldenAndRoundTrip(t *testing.T) { | |
| 98 | + | preview := pickerPreviewFixture() | |
| 99 | + | got := MarshalPickerPreview(preview) | |
| 100 | + | if got == nil { | |
| 101 | + | t.Fatal("MarshalPickerPreview() = nil for a valid viewport") | |
| 102 | + | } | |
| 103 | + | back, err := UnmarshalPickerPreview(got) | |
| 104 | + | if err != nil { | |
| 105 | + | t.Fatalf("UnmarshalPickerPreview() error = %v", err) | |
| 106 | + | } | |
| 107 | + | if !reflect.DeepEqual(back, preview) { | |
| 108 | + | t.Fatalf("preview = %+v, want %+v", back, preview) | |
| 109 | + | } | |
| 110 | + | rows := back.FrameRows() | |
| 111 | + | if len(rows) != 2 || len(rows[0]) != 4 || rows[0][1].Rune != '界' || !rows[0][2].Continuation { | |
| 112 | + | t.Fatalf("FrameRows() = %+v, want the encoded double-width pair", rows) | |
| 113 | + | } | |
| 114 | + | assertAllPrefixesFail(t, got, UnmarshalPickerPreview) | |
| 115 | + | assertTrailingGarbageFails(t, got, UnmarshalPickerPreview) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestPickerPreviewRoundTripsStatusOnly(t *testing.T) { | |
| 119 | + | preview := protocol.PickerPreview{ | |
| 120 | + | Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first", | |
| 121 | + | Status: protocol.PickerPreviewNoSuchTarget, | |
| 122 | + | } | |
| 123 | + | got := MarshalPickerPreview(preview) | |
| 124 | + | if got == nil { | |
| 125 | + | t.Fatal("MarshalPickerPreview() = nil for a status-only preview") | |
| 126 | + | } | |
| 127 | + | back, err := UnmarshalPickerPreview(got) | |
| 128 | + | if err != nil { | |
| 129 | + | t.Fatalf("UnmarshalPickerPreview() error = %v", err) | |
| 130 | + | } | |
| 131 | + | if !reflect.DeepEqual(back, preview) { | |
| 132 | + | t.Fatalf("preview = %+v, want %+v", back, preview) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestPickerPreviewRejectsInvalid(t *testing.T) { | |
| 137 | + | valid := pickerPreviewFixture() | |
| 138 | + | if MarshalPickerPreview(protocol.PickerPreview{Version: protocol.PickerPreviewSchemaVersion}) != nil { | |
| 139 | + | t.Fatal("MarshalPickerPreview accepted a preview without a key") | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // A viewport with a cell count that disagrees with its dimensions must not | |
| 143 | + | // decode, and the reader must reject the oversized count before allocating. | |
| 144 | + | truncatedCells := append([]byte(nil), MarshalPickerPreview(valid)...) | |
| 145 | + | countOffset := len(truncatedCells) - len(valid.Cells)*previewCellWireSize - 4 | |
| 146 | + | for i := range 4 { | |
| 147 | + | truncatedCells[countOffset+i] = 0 | |
| 148 | + | } | |
| 149 | + | if _, err := UnmarshalPickerPreview(truncatedCells); err == nil { | |
| 150 | + | t.Fatal("UnmarshalPickerPreview accepted an empty cell run for a sized viewport") | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // An undefined cell flag bit is a picker-invalid payload, not a remote one. | |
| 154 | + | flagged := append([]byte(nil), MarshalPickerPreview(valid)...) | |
| 155 | + | flagOffset := len(flagged) - len(valid.Cells)*previewCellWireSize + 4 | |
| 156 | + | flagged[flagOffset] = 0x80 | |
| 157 | + | if _, err := UnmarshalPickerPreview(flagged); !errors.Is(err, protocol.ErrInvalidPickerPreview) { | |
| 158 | + | t.Fatalf("UnmarshalPickerPreview(flag byte) error = %v, want ErrInvalidPickerPreview", err) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // An oversized cell count fails closed before any allocation, and oversized | |
| 162 | + | // dimensions fail closed before the cell run is read. | |
| 163 | + | oversizedCount := append([]byte(nil), MarshalPickerPreview(valid)...) | |
| 164 | + | for i := range 4 { | |
| 165 | + | oversizedCount[countOffset+i] = 0xff | |
| 166 | + | } | |
| 167 | + | if _, err := UnmarshalPickerPreview(oversizedCount); !errors.Is(err, protocol.ErrInvalidPickerPreview) { | |
| 168 | + | t.Fatalf("UnmarshalPickerPreview(oversized count) error = %v, want ErrInvalidPickerPreview", err) | |
| 169 | + | } | |
| 170 | + | oversizedDims := append([]byte(nil), MarshalPickerPreview(valid)...) | |
| 171 | + | const widthOffset = 2 + 8 + 2 + 7 + 2 + 8 + 1 | |
| 172 | + | oversizedDims[widthOffset], oversizedDims[widthOffset+1] = 0xff, 0xff | |
| 173 | + | if _, err := UnmarshalPickerPreview(oversizedDims); !errors.Is(err, protocol.ErrInvalidPickerPreview) { | |
| 174 | + | t.Fatalf("UnmarshalPickerPreview(oversized dims) error = %v, want ErrInvalidPickerPreview", err) | |
| 175 | + | } | |
| 176 | + | if _, err := UnmarshalPickerPreview(make([]byte, protocol.PickerPreviewMaxBytes+1)); !errors.Is(err, protocol.ErrInvalidPickerPreview) { | |
| 177 | + | t.Fatalf("UnmarshalPickerPreview(oversize payload) error = %v, want ErrInvalidPickerPreview", err) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // TestPreviewCellCodecIsShared pins that both preview kinds encode a cell | |
| 182 | + | // identically: one layout, one style block. | |
| 183 | + | func TestPreviewCellCodecIsShared(t *testing.T) { | |
| 184 | + | cell := renderer.Cell{Rune: '界', Style: renderer.Style{Bold: true, Foreground: 3, HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}}} | |
| 185 | + | var w payloadWriter | |
| 186 | + | putPreviewCell(&w, cell) | |
| 187 | + | r := payloadReader{b: w.b} | |
| 188 | + | back, err := getPreviewCell(&r) | |
| 189 | + | if err != nil { | |
| 190 | + | t.Fatalf("getPreviewCell() error = %v", err) | |
| 191 | + | } | |
| 192 | + | if back.Rune != cell.Rune || !back.Style.Bold || back.Style.ForegroundRGB != cell.Style.ForegroundRGB { | |
| 193 | + | t.Fatalf("cell = %+v, want %+v", back, cell) | |
| 194 | + | } | |
| 195 | + | if err := r.done(); err != nil { | |
| 196 | + | t.Fatalf("cell payload has trailing bytes: %v", err) | |
| 197 | + | } | |
| 198 | + | } | |
M internal/protocol/wire/wire.go +6 -142
| @@ -462,7 +462,6 @@ func MarshalHello(h protocol.Hello) []byte { | |||
| 462 | 462 | marshalExactTargetSection(&w, h.ExactTarget) | |
| 463 | 463 | w.putString(string(h.PreferredTabID)) | |
| 464 | 464 | w.putUint8(uint8(h.NavigationCapabilities)) | |
| 465 | - | w.putUint8(uint8(h.StartupOverlay)) | |
| 466 | 465 | w.putBool(h.Remote) | |
| 467 | 466 | w.putBool(h.KittyDirectGraphics) | |
| 468 | 467 | return w.b | |
| @@ -538,9 +537,6 @@ func preflightHello(b []byte) error { | |||
| 538 | 537 | if _, err := r.getUint8(); err != nil { | |
| 539 | 538 | return err | |
| 540 | 539 | } | |
| 541 | - | if _, err := r.getUint8(); err != nil { | |
| 542 | - | return err | |
| 543 | - | } | |
| 544 | 540 | if _, err := r.getBool(); err != nil { | |
| 545 | 541 | return err | |
| 546 | 542 | } | |
| @@ -645,11 +641,6 @@ func UnmarshalHello(b []byte) (protocol.Hello, error) { | |||
| 645 | 641 | return protocol.Hello{}, err | |
| 646 | 642 | } | |
| 647 | 643 | h.NavigationCapabilities = protocol.NavigationCapabilities(capabilities) | |
| 648 | - | overlay, err := r.getUint8() | |
| 649 | - | if err != nil { | |
| 650 | - | return protocol.Hello{}, err | |
| 651 | - | } | |
| 652 | - | h.StartupOverlay = protocol.StartupOverlay(overlay) | |
| 653 | 644 | if h.Remote, err = r.getBool(); err != nil { | |
| 654 | 645 | return protocol.Hello{}, err | |
| 655 | 646 | } | |
| @@ -1661,13 +1652,7 @@ func MarshalRemotePreview(preview protocol.RemotePreview) []byte { | |||
| 1661 | 1652 | w.putUint16(preview.Height) | |
| 1662 | 1653 | w.putUint32(uint32(len(preview.Cells))) | |
| 1663 | 1654 | for _, cell := range preview.Cells { | |
| 1664 | - | var flags uint8 | |
| 1665 | - | if cell.Continuation { | |
| 1666 | - | flags = 1 | |
| 1667 | - | } | |
| 1668 | - | w.putUint32(uint32(cell.Rune)) | |
| 1669 | - | w.putUint8(flags) | |
| 1670 | - | putPreviewStyle(&w, cell.Style) | |
| 1655 | + | putPreviewCell(&w, cell) | |
| 1671 | 1656 | } | |
| 1672 | 1657 | return w.b | |
| 1673 | 1658 | } | |
| @@ -1716,22 +1701,14 @@ func UnmarshalRemotePreview(data []byte) (protocol.RemotePreview, error) { | |||
| 1716 | 1701 | if count != 0 { | |
| 1717 | 1702 | p.Cells = make([]renderer.Cell, 0, int(count)) | |
| 1718 | 1703 | for range int(count) { | |
| 1719 | - | runeValue, e := r.getUint32() | |
| 1704 | + | cell, e := getPreviewCell(&r) | |
| 1720 | 1705 | if e != nil { | |
| 1706 | + | if errors.Is(e, errPreviewCellFlags) { | |
| 1707 | + | return p, protocol.ErrInvalidRemotePreview | |
| 1708 | + | } | |
| 1721 | 1709 | return p, e | |
| 1722 | 1710 | } | |
| 1723 | - | flags, e := r.getUint8() | |
| 1724 | - | if e != nil { | |
| 1725 | - | return p, e | |
| 1726 | - | } | |
| 1727 | - | if flags&^uint8(1) != 0 { | |
| 1728 | - | return p, protocol.ErrInvalidRemotePreview | |
| 1729 | - | } | |
| 1730 | - | style, e := getPreviewStyle(&r) | |
| 1731 | - | if e != nil { | |
| 1732 | - | return p, e | |
| 1733 | - | } | |
| 1734 | - | p.Cells = append(p.Cells, renderer.Cell{Rune: rune(runeValue), Continuation: flags&1 != 0, Style: style}) | |
| 1711 | + | p.Cells = append(p.Cells, cell) | |
| 1735 | 1712 | } | |
| 1736 | 1713 | } | |
| 1737 | 1714 | if err := r.done(); err != nil { | |
| @@ -1742,116 +1719,3 @@ func UnmarshalRemotePreview(data []byte) (protocol.RemotePreview, error) { | |||
| 1742 | 1719 | } | |
| 1743 | 1720 | return p, nil | |
| 1744 | 1721 | } | |
| 1745 | - | ||
| 1746 | - | // MarshalNavigationDirective encodes one bounded navigation directive. | |
| 1747 | - | func MarshalNavigationDirective(directive protocol.NavigationDirective) []byte { | |
| 1748 | - | if directive.Action != protocol.NavigationOpenHomePicker && directive.Action != protocol.NavigationBack { | |
| 1749 | - | return nil | |
| 1750 | - | } | |
| 1751 | - | if directive.Action == protocol.NavigationOpenHomePicker && directive.LeaseID.IsZero() || directive.Action == protocol.NavigationBack && !directive.LeaseID.IsZero() { | |
| 1752 | - | return nil | |
| 1753 | - | } | |
| 1754 | - | w := payloadWriter{} | |
| 1755 | - | w.putUint8(uint8(directive.Action)) | |
| 1756 | - | w.putBytes(directive.LeaseID[:]) | |
| 1757 | - | w.putUint64(directive.CauseActionID) | |
| 1758 | - | return w.b | |
| 1759 | - | } | |
| 1760 | - | ||
| 1761 | - | // UnmarshalNavigationDirective decodes one strict navigation directive. | |
| 1762 | - | func UnmarshalNavigationDirective(b []byte) (protocol.NavigationDirective, error) { | |
| 1763 | - | r := payloadReader{b: b} | |
| 1764 | - | value, err := r.getUint8() | |
| 1765 | - | if err != nil { | |
| 1766 | - | return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation | |
| 1767 | - | } | |
| 1768 | - | lease, err := r.getBytes(len(protocol.ParkedRouteLeaseID{})) | |
| 1769 | - | if err != nil { | |
| 1770 | - | return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation | |
| 1771 | - | } | |
| 1772 | - | causeActionID, err := r.getUint64() | |
| 1773 | - | if err != nil { | |
| 1774 | - | return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation | |
| 1775 | - | } | |
| 1776 | - | if err := r.done(); err != nil { | |
| 1777 | - | return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation | |
| 1778 | - | } | |
| 1779 | - | directive := protocol.NavigationDirective{Action: protocol.NavigationAction(value), CauseActionID: causeActionID} | |
| 1780 | - | copy(directive.LeaseID[:], lease) | |
| 1781 | - | if MarshalNavigationDirective(directive) == nil { | |
| 1782 | - | return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation | |
| 1783 | - | } | |
| 1784 | - | return directive, nil | |
| 1785 | - | } | |
| 1786 | - | ||
| 1787 | - | // ValidateParkedRouteRequest enforces the closed action/target shape before a | |
| 1788 | - | // request reaches either side's route state machine. | |
| 1789 | - | ||
| 1790 | - | // MarshalParkedRouteRequest encodes one retained-route operation. | |
| 1791 | - | func MarshalParkedRouteRequest(request protocol.ParkedRouteRequest) []byte { | |
| 1792 | - | if protocol.ValidateParkedRouteRequest(request) != nil { | |
| 1793 | - | return nil | |
| 1794 | - | } | |
| 1795 | - | w := payloadWriter{} | |
| 1796 | - | w.putUint64(request.RequestID) | |
| 1797 | - | w.putBytes(request.LeaseID[:]) | |
| 1798 | - | w.putUint8(uint8(request.Action)) | |
| 1799 | - | marshalRemoteTargetSection(&w, request.Target, protocol.EnvironmentPolicyDaemonOwned) | |
| 1800 | - | return w.b | |
| 1801 | - | } | |
| 1802 | - | ||
| 1803 | - | // UnmarshalParkedRouteRequest decodes one strict retained-route operation. | |
| 1804 | - | func UnmarshalParkedRouteRequest(b []byte) (protocol.ParkedRouteRequest, error) { | |
| 1805 | - | r := payloadReader{b: b} | |
| 1806 | - | requestID, err := r.getUint64() | |
| 1807 | - | if err != nil { | |
| 1808 | - | return protocol.ParkedRouteRequest{}, protocol.ErrInvalidNavigation | |
| 1809 | - | } | |
| 1810 | - | lease, err := r.getBytes(len(protocol.ParkedRouteLeaseID{})) | |
| 1811 | - | if err != nil { | |
| 1812 | - | return protocol.ParkedRouteRequest{}, protocol.ErrInvalidNavigation | |
| 1813 | - | } | |
| 1814 | - | action, err := r.getUint8() | |
| 1815 | - | if err != nil { | |
| 1816 | - | return protocol.ParkedRouteRequest{}, protocol.ErrInvalidNavigation | |
| 1817 | - | } | |
| 1818 | - | target, policy, err := unmarshalRemoteTargetSection(&r) | |
| 1819 | - | if err != nil || policy != protocol.EnvironmentPolicyDaemonOwned || r.done() != nil { | |
| 1820 | - | return protocol.ParkedRouteRequest{}, protocol.ErrInvalidNavigation | |
| 1821 | - | } | |
| 1822 | - | request := protocol.ParkedRouteRequest{RequestID: requestID, Action: protocol.ParkedRouteAction(action), Target: target} | |
| 1823 | - | copy(request.LeaseID[:], lease) | |
| 1824 | - | if protocol.ValidateParkedRouteRequest(request) != nil { | |
| 1825 | - | return protocol.ParkedRouteRequest{}, protocol.ErrInvalidNavigation | |
| 1826 | - | } | |
| 1827 | - | return request, nil | |
| 1828 | - | } | |
| 1829 | - | ||
| 1830 | - | // MarshalParkedRouteResponse encodes one correlated retained-route outcome. | |
| 1831 | - | func MarshalParkedRouteResponse(response protocol.ParkedRouteResponse) []byte { | |
| 1832 | - | if response.RequestID == 0 || response.Status < protocol.ParkedRouteReady || response.Status > protocol.ParkedRouteStaleTarget { | |
| 1833 | - | return nil | |
| 1834 | - | } | |
| 1835 | - | w := payloadWriter{} | |
| 1836 | - | w.putUint64(response.RequestID) | |
| 1837 | - | w.putUint8(uint8(response.Status)) | |
| 1838 | - | return w.b | |
| 1839 | - | } | |
| 1840 | - | ||
| 1841 | - | // UnmarshalParkedRouteResponse decodes one strict retained-route outcome. | |
| 1842 | - | func UnmarshalParkedRouteResponse(b []byte) (protocol.ParkedRouteResponse, error) { | |
| 1843 | - | r := payloadReader{b: b} | |
| 1844 | - | requestID, err := r.getUint64() | |
| 1845 | - | if err != nil { | |
| 1846 | - | return protocol.ParkedRouteResponse{}, protocol.ErrInvalidNavigation | |
| 1847 | - | } | |
| 1848 | - | status, err := r.getUint8() | |
| 1849 | - | if err != nil || r.done() != nil { | |
| 1850 | - | return protocol.ParkedRouteResponse{}, protocol.ErrInvalidNavigation | |
| 1851 | - | } | |
| 1852 | - | response := protocol.ParkedRouteResponse{RequestID: requestID, Status: protocol.ParkedRouteStatus(status)} | |
| 1853 | - | if MarshalParkedRouteResponse(response) == nil { | |
| 1854 | - | return protocol.ParkedRouteResponse{}, protocol.ErrInvalidNavigation | |
| 1855 | - | } | |
| 1856 | - | return response, nil | |
| 1857 | - | } | |
M internal/protocol/wire/wire_final_test.go +4 -4
| @@ -12,8 +12,8 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 15 | - | if protocol.Version != 43 { | |
| 16 | - | t.Fatalf("ProtocolVersion = %d, want 43", protocol.Version) | |
| 15 | + | if protocol.Version != 51 { | |
| 16 | + | t.Fatalf("ProtocolVersion = %d, want 51", 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,9 +27,9 @@ 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, 43, 2}, make([]byte, 16+8)...) | |
| 30 | + | want := append([]byte{0, 51, 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 | - | want = append(want, 0, 0) | |
| 32 | + | want = append(want, 0) | |
| 33 | 33 | got := MarshalHello(msg) | |
| 34 | 34 | if !bytes.Equal(got, want) { | |
| 35 | 35 | t.Fatalf("Hello bytes = %x, want %x", got, want) | |
M internal/protocol/wire/wire_test.go +8 -8
| @@ -56,8 +56,8 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func | |||
| 56 | 56 | ||
| 57 | 57 | func appendNoNavigationTail(payload []byte) []byte { | |
| 58 | 58 | // exact-target absent, preferred-tab absent, navigation capabilities none, | |
| 59 | - | // startup overlay none, local carriage. | |
| 60 | - | return append(payload, 0, 0, 0, 0, 0, 0, 0) | |
| 59 | + | // local carriage. | |
| 60 | + | return append(payload, 0, 0, 0, 0, 0, 0) | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | 63 | func TestHelloGoldenAndRoundTrip(t *testing.T) { | |
| @@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 136 | 136 | } | |
| 137 | 137 | got := MarshalHello(msg) | |
| 138 | 138 | want := []byte{ | |
| 139 | - | 0x00, 0x2b, 0x00, // version, intent, first client ID byte | |
| 139 | + | 0x00, 0x33, 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(43), protocol.Version) | |
| 370 | + | require.Equal(t, uint16(51), 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, 0x2b, | |
| 531 | + | 0x00, 0x33, | |
| 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, 0x2b, | |
| 554 | + | 0x00, 0x33, | |
| 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, 0x2b, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 1002 | + | 0x00, 0x33, 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, 0x2b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1012 | + | 0x00, 0x33, 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 +906 -430
| @@ -136,11 +136,6 @@ const sendQueueDepth = 64 | |||
| 136 | 136 | // budget, which now also covers connect and initial publication. | |
| 137 | 137 | const preWelcomeTimeout = protocol.HandshakeTimeout | |
| 138 | 138 | ||
| 139 | - | // remoteHostLearnerShutdownTimeout bounds how long Run waits for async remote | |
| 140 | - | // host learning after terminal restoration. Learning is best-effort; a stalled | |
| 141 | - | // learner must not block shell prompt return indefinitely. | |
| 142 | - | const remoteHostLearnerShutdownTimeout = time.Second | |
| 143 | - | ||
| 144 | 139 | const kittyCapabilityProbeTimeout = 150 * time.Millisecond | |
| 145 | 140 | ||
| 146 | 141 | var reconnectSleep = sleepReconnect | |
| @@ -152,8 +147,6 @@ const ( | |||
| 152 | 147 | ) | |
| 153 | 148 | ||
| 154 | 149 | // Dependencies supplies the collaborators required by a Runner. | |
| 155 | - | type AttachHandoffFunc func(protocol.AttachTarget) (ports.ClientDialer, AttachRequest, error) | |
| 156 | - | ||
| 157 | 150 | type Dependencies struct { | |
| 158 | 151 | Dialer ports.ClientDialer | |
| 159 | 152 | Terminal ports.Terminal | |
| @@ -165,10 +158,10 @@ type Dependencies struct { | |||
| 165 | 158 | Clipboard ports.ClipboardReader | |
| 166 | 159 | Logger *slog.Logger | |
| 167 | 160 | RuntimeObserver ports.SerializedRuntimeObserver | |
| 168 | - | RemoteHostLearner ports.RemoteHostLearner | |
| 169 | - | // AttachHandoff keeps one Runner and one terminal/input ownership across a | |
| 170 | - | // local daemon's structured remote handoff. It is nil for direct CLI attach. | |
| 171 | - | AttachHandoff AttachHandoffFunc | |
| 161 | + | // HostRegistry resolves remote endpoints for handoffs and projects remote | |
| 162 | + | // discovery, so one Runner owns one observation lifetime. It is nil only | |
| 163 | + | // for embedders that accept no remote handoff at all. | |
| 164 | + | HostRegistry ports.ClientHostRegistry | |
| 172 | 165 | // LocalControlDialer is an optional dial-only control source for the | |
| 173 | 166 | // local daemon inventory. It must never start the daemon, create an | |
| 174 | 167 | // attachment, or call the CLI: a missing socket reports local control | |
| @@ -205,7 +198,69 @@ type AttachRequest struct { | |||
| 205 | 198 | // environment and is supplied only by explicit composition. | |
| 206 | 199 | Environment []string | |
| 207 | 200 | NavigationCapabilities protocol.NavigationCapabilities | |
| 208 | - | StartupOverlay protocol.StartupOverlay | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // resolveHandoff resolves one remote handoff target through the runner's host | |
| 204 | + | // registry. The registry owns the endpoint binding (dialer and environment); | |
| 205 | + | // the request is derived here from the validated target and that binding, so | |
| 206 | + | // every handoff shares one endpoint authority and one observation lifetime. | |
| 207 | + | func (r *Runner) resolveHandoff(ctx context.Context, target protocol.AttachTarget) (ports.ClientDialer, AttachRequest, error) { | |
| 208 | + | if err := validateHandoffTarget(target); err != nil { | |
| 209 | + | return nil, AttachRequest{}, err | |
| 210 | + | } | |
| 211 | + | binding, err := r.hostRegistry.ResolveEndpoint(ctx, target.Endpoint) | |
| 212 | + | if err != nil { | |
| 213 | + | return nil, AttachRequest{}, err | |
| 214 | + | } | |
| 215 | + | if binding.Dialer == nil { | |
| 216 | + | return nil, AttachRequest{}, errors.New("vev: remote endpoint resolved without a dialer") | |
| 217 | + | } | |
| 218 | + | hostLabel := domain.RemoteDisplayOrigin(target.Endpoint) | |
| 219 | + | if target.RemoteTarget != nil { | |
| 220 | + | hostLabel = target.RemoteTarget.DisplayOrigin | |
| 221 | + | } | |
| 222 | + | return binding.Dialer, AttachRequest{ | |
| 223 | + | Intent: target.Intent, | |
| 224 | + | SessionName: target.Session, | |
| 225 | + | Remote: true, | |
| 226 | + | Environment: binding.Environment, | |
| 227 | + | Origin: protocol.RouteOriginDiscovery, | |
| 228 | + | OriginKey: target.Endpoint, | |
| 229 | + | RemoteTarget: target.RemoteTarget, | |
| 230 | + | HostLabel: hostLabel, | |
| 231 | + | EnvironmentPolicy: r.handoffEnvironmentPolicy(target), | |
| 232 | + | }, nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // handoffEnvironmentPolicy mirrors the composition root's picker rule: a route | |
| 236 | + | // chosen from a locally launched client has no explicit remote selection, so | |
| 237 | + | // its environment belongs to the daemon; every other target keeps the policy | |
| 238 | + | // the serving daemon authorised. | |
| 239 | + | func (r *Runner) handoffEnvironmentPolicy(target protocol.AttachTarget) protocol.EnvironmentPolicy { | |
| 240 | + | if !r.launchedRemote && target.RemoteTarget == nil && target.Intent != protocol.IntentNew { | |
| 241 | + | return protocol.EnvironmentPolicyDaemonOwned | |
| 242 | + | } | |
| 243 | + | return target.EnvironmentPolicy | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // validateHandoffTarget revalidates one daemon-authored handoff before the | |
| 247 | + | // runner resolves it, so a malformed target never reaches registry policy. | |
| 248 | + | func validateHandoffTarget(target protocol.AttachTarget) error { | |
| 249 | + | if err := protocol.ValidateAttachTarget(target); err != nil { | |
| 250 | + | return fmt.Errorf("vev: invalid remote attach handoff: %w", err) | |
| 251 | + | } | |
| 252 | + | if err := domain.ValidateRemoteHostTarget(target.Endpoint); err != nil { | |
| 253 | + | return fmt.Errorf("vev: invalid remote attach handoff: %w", err) | |
| 254 | + | } | |
| 255 | + | if target.RemoteTarget != nil { | |
| 256 | + | if target.EnvironmentPolicy != protocol.EnvironmentPolicyDaemonOwned { | |
| 257 | + | return errors.New("vev: remote picker handoff must use daemon-owned environment") | |
| 258 | + | } | |
| 259 | + | if target.RemoteTarget.Endpoint != target.Endpoint || target.RemoteTarget.SessionName != target.Session { | |
| 260 | + | return errors.New("vev: remote picker handoff identity does not match route") | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | return nil | |
| 209 | 264 | } | |
| 210 | 265 | ||
| 211 | 266 | // attachRoute captures the dialer, request, and resume token needed to | |
| @@ -236,21 +291,26 @@ func bindAttachHandoff(target protocol.AttachTarget, source attachRoute) *attach | |||
| 236 | 291 | } | |
| 237 | 292 | ||
| 238 | 293 | type Runner struct { | |
| 239 | - | ui *UI | |
| 240 | - | dialer ports.ClientDialer | |
| 241 | - | term ports.Terminal | |
| 242 | - | clock ports.Clock | |
| 243 | - | clipboard ports.ClipboardReader | |
| 244 | - | logger *slog.Logger | |
| 245 | - | runtimeObserver ports.SerializedRuntimeObserver | |
| 246 | - | remoteHostLearner ports.RemoteHostLearner | |
| 247 | - | attachHandoff AttachHandoffFunc | |
| 248 | - | remote bool | |
| 294 | + | ui *UI | |
| 295 | + | dialer ports.ClientDialer | |
| 296 | + | term ports.Terminal | |
| 297 | + | clock ports.Clock | |
| 298 | + | clipboard ports.ClipboardReader | |
| 299 | + | logger *slog.Logger | |
| 300 | + | runtimeObserver ports.SerializedRuntimeObserver | |
| 301 | + | hostRegistry ports.ClientHostRegistry | |
| 302 | + | // launchedRemote records whether this runner was launched as a direct | |
| 303 | + | // remote attach. It decides the handoff environment policy exactly like the | |
| 304 | + | // composition root used to: a picker route from a locally launched client | |
| 305 | + | // has no explicit remote selection, so its environment is daemon-owned. | |
| 306 | + | launchedRemote bool | |
| 307 | + | remote bool | |
| 249 | 308 | // localControlDialer is the dial-only inventory source. It never | |
| 250 | 309 | // starts the daemon; the relay is its sole consumer. | |
| 251 | 310 | localControlDialer ports.ClientDialer | |
| 252 | 311 | origin protocol.RouteOrigin | |
| 253 | 312 | ledger *routeLedger | |
| 313 | + | routeObservers *routeObserverRegistry | |
| 254 | 314 | routeFailure *protocol.RouteNavigationFailure | |
| 255 | 315 | creationFailure *protocol.SessionCreationFailure | |
| 256 | 316 | // inventoryFailure reports a failed inventory handoff to the restored | |
| @@ -279,13 +339,14 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 279 | 339 | clipboard: deps.Clipboard, | |
| 280 | 340 | logger: log, | |
| 281 | 341 | runtimeObserver: deps.RuntimeObserver, | |
| 282 | - | remoteHostLearner: deps.RemoteHostLearner, | |
| 283 | - | attachHandoff: deps.AttachHandoff, | |
| 342 | + | hostRegistry: deps.HostRegistry, | |
| 343 | + | launchedRemote: deps.Remote, | |
| 284 | 344 | remote: deps.Remote, | |
| 285 | 345 | localControlDialer: deps.LocalControlDialer, | |
| 286 | 346 | origin: normalizeRouteOrigin(deps.Origin, deps.Remote), | |
| 287 | 347 | probeCapabilities: !deps.DisableCapabilityProbe, | |
| 288 | 348 | ledger: newRouteLedger(), | |
| 349 | + | routeObservers: newRouteObserverRegistry(), | |
| 289 | 350 | } | |
| 290 | 351 | } | |
| 291 | 352 | ||
| @@ -437,8 +498,7 @@ func validateAttachRequest(request AttachRequest) error { | |||
| 437 | 498 | if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil { | |
| 438 | 499 | return fmt.Errorf("vev: invalid session target: %w", err) | |
| 439 | 500 | } | |
| 440 | - | homePickerRoute := request.RemoteTarget != nil || request.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || request.Intent == protocol.IntentNew | |
| 441 | - | if err := protocol.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, homePickerRoute); err != nil { | |
| 501 | + | if err := protocol.ValidateNavigation(request.Intent, request.NavigationCapabilities); err != nil { | |
| 442 | 502 | return fmt.Errorf("vev: invalid navigation route: %w", err) | |
| 443 | 503 | } | |
| 444 | 504 | if request.RemoteTarget == nil { | |
| @@ -513,9 +573,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 513 | 573 | attemptRequest := request | |
| 514 | 574 | dialer := r.dialer | |
| 515 | 575 | var homeRoute *attachRoute | |
| 516 | - | var returnRoute *attachRoute | |
| 517 | 576 | returnResumeFallback := false | |
| 518 | - | homeNavigationPending := false | |
| 519 | 577 | returnNavigationPending := false | |
| 520 | 578 | // navTransition owns creation and recent-route fallback lifecycle: | |
| 521 | 579 | // the captured prior route, selected identity, and settle-once state. | |
| @@ -533,39 +591,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 533 | 591 | var routeNavigationAction *protocol.RouteNavigationAction | |
| 534 | 592 | backoff := defaultReconnectBackoff.initial | |
| 535 | 593 | themeState := &terminalThemeState{} | |
| 536 | - | var rememberOnce sync.Once | |
| 537 | - | var rememberWG sync.WaitGroup | |
| 538 | - | var learnerStarted bool | |
| 539 | - | rememberRemoteHost := func() { | |
| 540 | - | if r.remoteHostLearner == nil { | |
| 541 | - | return | |
| 542 | - | } | |
| 543 | - | rememberOnce.Do(func() { | |
| 544 | - | learnerStarted = true | |
| 545 | - | rememberWG.Go(func() { | |
| 546 | - | if err := r.remoteHostLearner.RememberRemoteHost(); err != nil { | |
| 547 | - | r.logger.Warn("remembering remote host failed", "err", err) | |
| 548 | - | } | |
| 549 | - | }) | |
| 550 | - | }) | |
| 551 | - | } | |
| 552 | - | defer func() { | |
| 553 | - | if !learnerStarted { | |
| 554 | - | return | |
| 555 | - | } | |
| 556 | - | done := make(chan struct{}) | |
| 557 | - | go func() { | |
| 558 | - | rememberWG.Wait() | |
| 559 | - | close(done) | |
| 560 | - | }() | |
| 561 | - | timer := r.clock.NewTimer(remoteHostLearnerShutdownTimeout) | |
| 562 | - | defer timer.Stop() | |
| 563 | - | select { | |
| 564 | - | case <-done: | |
| 565 | - | case <-timer.C(): | |
| 566 | - | r.logger.Warn("remote host learner stalled past shutdown timeout") | |
| 567 | - | } | |
| 568 | - | }() | |
| 569 | 594 | defer func() { | |
| 570 | 595 | if rawEntered { | |
| 571 | 596 | if rerr := restore(); rerr != nil && retErr == nil { | |
| @@ -594,26 +619,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 594 | 619 | } else { | |
| 595 | 620 | attemptRequest.Intent = protocol.IntentAttach | |
| 596 | 621 | } | |
| 597 | - | attemptRequest.StartupOverlay = protocol.StartupOverlayNone | |
| 598 | - | attemptRequest.NavigationCapabilities &^= protocol.NavigationCapabilityBack | protocol.NavigationCapabilityHomePicker | |
| 599 | 622 | resumeToken = route.resumeToken | |
| 600 | 623 | returnResumeFallback = route.resumeToken != 0 | |
| 601 | 624 | remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote) | |
| 602 | 625 | backoff = defaultReconnectBackoff.initial | |
| 603 | 626 | } | |
| 604 | - | enterHomePicker := func() { | |
| 605 | - | dialer = homeRoute.dialer | |
| 606 | - | attemptRequest = homeRoute.request | |
| 607 | - | attemptRequest.Intent = protocol.IntentAttach | |
| 608 | - | attemptRequest.NavigationCapabilities = protocol.NavigationCapabilityBack | |
| 609 | - | attemptRequest.StartupOverlay = protocol.StartupOverlaySessionPicker | |
| 610 | - | attemptRequest.RemoteTarget = nil | |
| 611 | - | attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned | |
| 612 | - | attemptRequest.Remote = homeRoute.request.Remote || r.remote | |
| 613 | - | resumeToken = 0 | |
| 614 | - | remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote) | |
| 615 | - | backoff = defaultReconnectBackoff.initial | |
| 616 | - | } | |
| 617 | 627 | var input *terminalInputPump | |
| 618 | 628 | defer func() { | |
| 619 | 629 | if input != nil { | |
| @@ -652,59 +662,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 652 | 662 | return true | |
| 653 | 663 | } | |
| 654 | 664 | ||
| 655 | - | runTransientHomePicker := func(attemptCtx context.Context) attachResult { | |
| 656 | - | if homeRoute == nil { | |
| 657 | - | return attachResult{err: errors.New("vev: home route unavailable")} | |
| 658 | - | } | |
| 659 | - | request := cloneAttachRequest(homeRoute.request) | |
| 660 | - | request.Intent = protocol.IntentAttach | |
| 661 | - | request.NavigationCapabilities = protocol.NavigationCapabilityBack | |
| 662 | - | request.StartupOverlay = protocol.StartupOverlaySessionPicker | |
| 663 | - | request.RemoteTarget = nil | |
| 664 | - | request.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned | |
| 665 | - | request.Remote = false | |
| 666 | - | if err := validateAttachRequest(request); err != nil { | |
| 667 | - | return attachResult{err: err} | |
| 668 | - | } | |
| 669 | - | handshakeCtx, timedOut, finishHandshake := newHandshakeContext(attemptCtx, r.clock) | |
| 670 | - | transport, err := boundedDial(handshakeCtx, homeRoute.dialer) | |
| 671 | - | if err != nil { | |
| 672 | - | err = handshakeContextError(attemptCtx, timedOut, err) | |
| 673 | - | finishHandshake() | |
| 674 | - | return attachResult{err: err} | |
| 675 | - | } | |
| 676 | - | connection, err := NewSessionConnection(transport, SessionTarget{Intent: request.Intent, SessionName: request.SessionName}) | |
| 677 | - | if err != nil { | |
| 678 | - | finishHandshake() | |
| 679 | - | _ = transport.Close() | |
| 680 | - | return attachResult{err: err} | |
| 681 | - | } | |
| 682 | - | stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport) | |
| 683 | - | localReconnect := &reconnectUI{term: r.term, rawEntered: &rawEntered} | |
| 684 | - | result := (&attachAttempt{ | |
| 685 | - | runner: r, dialer: homeRoute.dialer, connection: connection, | |
| 686 | - | handshakeCtx: handshakeCtx, handshakeTimedOut: timedOut, | |
| 687 | - | finishHandshake: finishHandshake, stopHandshakeTransport: stopHandshakeTransport, | |
| 688 | - | request: request, clientID: processClientID, milestones: &ms, | |
| 689 | - | themeState: themeState, enterRaw: enterRaw, reconnect: localReconnect, | |
| 690 | - | terminalInput: func() *terminalInputPump { return input }, | |
| 691 | - | }).run(attemptCtx) | |
| 692 | - | stopHandshakeTransport() | |
| 693 | - | finishHandshake() | |
| 694 | - | if !result.transportClosed { | |
| 695 | - | _ = connection.Close() | |
| 696 | - | } | |
| 697 | - | if result.target != nil { | |
| 698 | - | result.handoff = bindAttachHandoff(*result.target, attachRoute{ | |
| 699 | - | dialer: homeRoute.dialer, | |
| 700 | - | request: cloneAttachRequest(homeRoute.request), | |
| 701 | - | resumeToken: homeRoute.resumeToken, | |
| 702 | - | }) | |
| 703 | - | result.target = nil | |
| 704 | - | } | |
| 705 | - | return result | |
| 706 | - | } | |
| 707 | - | ||
| 708 | 665 | for { | |
| 709 | 666 | if err := validateAttachRequest(attemptRequest); err != nil { | |
| 710 | 667 | return err | |
| @@ -841,11 +798,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 841 | 798 | reconnect: reconnect, | |
| 842 | 799 | transition: transition, | |
| 843 | 800 | linkEvents: linkEvents, | |
| 844 | - | rememberRemoteHost: rememberRemoteHost, | |
| 845 | 801 | terminalInput: func() *terminalInputPump { | |
| 846 | 802 | return input | |
| 847 | 803 | }, | |
| 848 | - | openHomePicker: runTransientHomePicker, | |
| 849 | 804 | inventoryHome: homeRoute, | |
| 850 | 805 | inventoryDialer: r.localControlDialer, | |
| 851 | 806 | onInventoryCommitted: func() { | |
| @@ -906,6 +861,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 906 | 861 | if result.err == nil && result.welcomed && navTransition.pendingInventory() { | |
| 907 | 862 | navTransition.settleSuccess() | |
| 908 | 863 | } | |
| 864 | + | if result.err == nil && result.welcomed { | |
| 865 | + | r.routeObservers.register(attemptRequest, dialer) | |
| 866 | + | } | |
| 909 | 867 | if result.routeCreateAction != nil { | |
| 910 | 868 | action := *result.routeCreateAction | |
| 911 | 869 | source := attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken} | |
| @@ -931,7 +889,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 931 | 889 | attemptRequest.ExactTarget = nil | |
| 932 | 890 | attemptRequest.RemoteTarget = nil | |
| 933 | 891 | attemptRequest.PreferredTabID = "" | |
| 934 | - | attemptRequest.StartupOverlay = protocol.StartupOverlayNone | |
| 935 | 892 | attemptRequest.NavigationCapabilities = 0 | |
| 936 | 893 | attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned | |
| 937 | 894 | resumeToken = 0 | |
| @@ -961,14 +918,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 961 | 918 | } else { | |
| 962 | 919 | attemptRequest.Intent = protocol.IntentAttach | |
| 963 | 920 | } | |
| 964 | - | // Back is transient to the picker overlay. Re-derive the home-picker | |
| 965 | - | // capability from the selected route instead of trusting request | |
| 966 | - | // metadata, which may have been cleared after a daemon-side switch. | |
| 967 | - | attemptRequest.StartupOverlay = protocol.StartupOverlayNone | |
| 968 | 921 | attemptRequest.NavigationCapabilities = 0 | |
| 969 | - | if homeRoute != nil && selection.selected.presentation.kind == protocol.RouteKindRemote { | |
| 970 | - | attemptRequest.NavigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 971 | - | } | |
| 972 | 922 | resumeToken = selection.selected.resumeToken | |
| 973 | 923 | routeNavigationSelection = &selection | |
| 974 | 924 | routeNavigationResumeFallback = selection.selected.resumeToken != 0 | |
| @@ -976,41 +926,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 976 | 926 | backoff = defaultReconnectBackoff.initial | |
| 977 | 927 | continue | |
| 978 | 928 | } | |
| 979 | - | if result.action != 0 { | |
| 980 | - | switch result.action { | |
| 981 | - | case protocol.NavigationOpenHomePicker: | |
| 982 | - | // The daemon only sends this action after accepting a Hello that | |
| 983 | - | // advertised Home support. The durable client-side prerequisite is | |
| 984 | - | // the captured route itself; request metadata may have been rebased by | |
| 985 | - | // committed identity or cursor updates since that Hello. | |
| 986 | - | if homeRoute == nil { | |
| 987 | - | return errors.New("vev: stale home navigation action") | |
| 988 | - | } | |
| 989 | - | returnRequest := attemptRequest | |
| 990 | - | if result.sessionName != "" { | |
| 991 | - | returnRequest.SessionName = result.sessionName | |
| 992 | - | } | |
| 993 | - | returnRoute = &attachRoute{dialer: dialer, request: returnRequest, resumeToken: result.resumeToken} | |
| 994 | - | homeNavigationPending = true | |
| 995 | - | enterHomePicker() | |
| 996 | - | continue | |
| 997 | - | case protocol.NavigationBack: | |
| 998 | - | // As with Home, the daemon accepted Back in Hello before sending | |
| 999 | - | // this action. The retained concrete route is the durable client | |
| 1000 | - | // prerequisite; request metadata may have been rebased meanwhile. | |
| 1001 | - | if returnRoute == nil { | |
| 1002 | - | return errors.New("vev: stale return navigation action") | |
| 1003 | - | } | |
| 1004 | - | route := *returnRoute | |
| 1005 | - | returnRoute = nil | |
| 1006 | - | homeNavigationPending = false | |
| 1007 | - | returnNavigationPending = true | |
| 1008 | - | restoreReturnRoute(route) | |
| 1009 | - | continue | |
| 1010 | - | default: | |
| 1011 | - | return errors.New("vev: unsupported navigation action") | |
| 1012 | - | } | |
| 1013 | - | } | |
| 1014 | 929 | if result.target != nil || result.handoff != nil { | |
| 1015 | 930 | target := result.target | |
| 1016 | 931 | source := attachRoute{dialer: dialer, request: attemptRequest, resumeToken: resumeToken} | |
| @@ -1026,7 +941,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 1026 | 941 | } | |
| 1027 | 942 | routeRequest.Intent = protocol.IntentAttach | |
| 1028 | 943 | routeRequest.NavigationCapabilities = 0 | |
| 1029 | - | routeRequest.StartupOverlay = protocol.StartupOverlayNone | |
| 1030 | 944 | homeRoute = &attachRoute{dialer: dialer, request: routeRequest} | |
| 1031 | 945 | } | |
| 1032 | 946 | } | |
| @@ -1042,10 +956,12 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 1042 | 956 | nextDialer = source.dialer | |
| 1043 | 957 | nextRequest = r.ledger.samePeerHandoff(source.request, *target) | |
| 1044 | 958 | } else { | |
| 1045 | - | if r.attachHandoff == nil { | |
| 959 | + | if r.hostRegistry == nil { | |
| 960 | + | // A registry-less embedder keeps the historical handoff | |
| 961 | + | // error, so its caller can dial the endpoint itself. | |
| 1046 | 962 | return &AttachTargetError{Target: *target} | |
| 1047 | 963 | } | |
| 1048 | - | nextDialer, nextRequest, handoffErr = r.attachHandoff(*target) | |
| 964 | + | nextDialer, nextRequest, handoffErr = r.resolveHandoff(ctx, *target) | |
| 1049 | 965 | if handoffErr != nil { | |
| 1050 | 966 | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |
| 1051 | 967 | r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: routeFailureCode(handoffErr)} | |
| @@ -1059,15 +975,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 1059 | 975 | if target.ExactTarget != nil { | |
| 1060 | 976 | nextRequest.ExactTarget = target.ExactTarget | |
| 1061 | 977 | } | |
| 1062 | - | if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || target.Intent == protocol.IntentNew) { | |
| 1063 | - | nextRequest.NavigationCapabilities |= protocol.NavigationCapabilityHomePicker | |
| 1064 | - | } | |
| 1065 | - | if attemptRequest.StartupOverlay == protocol.StartupOverlaySessionPicker { | |
| 1066 | - | returnRoute = nil | |
| 1067 | - | returnResumeFallback = false | |
| 1068 | - | nextRequest.StartupOverlay = protocol.StartupOverlayNone | |
| 1069 | - | nextRequest.NavigationCapabilities &^= protocol.NavigationCapabilityBack | |
| 1070 | - | } | |
| 1071 | 978 | nextRequest = cloneAttachRequest(nextRequest) | |
| 1072 | 979 | if err := validateAttachRequest(nextRequest); err != nil { | |
| 1073 | 980 | if target.Intent == protocol.IntentNew && target.RequestID != 0 { | |
| @@ -1125,7 +1032,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 1125 | 1032 | killedSelection = &selection | |
| 1126 | 1033 | dialer = selection.selected.dialer | |
| 1127 | 1034 | attemptRequest = cloneAttachRequest(selection.selected.request) | |
| 1128 | - | attemptRequest.StartupOverlay = protocol.StartupOverlayNone | |
| 1129 | 1035 | attemptRequest.NavigationCapabilities = 0 | |
| 1130 | 1036 | resumeToken = selection.selected.resumeToken | |
| 1131 | 1037 | killedResumeFallback = resumeToken != 0 | |
| @@ -1135,8 +1041,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 1135 | 1041 | attemptRequest.Intent = protocol.IntentAttach | |
| 1136 | 1042 | } | |
| 1137 | 1043 | homeRoute = nil | |
| 1138 | - | returnRoute = nil | |
| 1139 | - | homeNavigationPending = false | |
| 1140 | 1044 | returnNavigationPending = false | |
| 1141 | 1045 | if navTransition.operation == navigationOperationRecent { | |
| 1142 | 1046 | navTransition.clear() | |
| @@ -1156,17 +1060,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 1156 | 1060 | } | |
| 1157 | 1061 | return errors.Join(result.err, r.ledger.retireKilled(*killedSelection)) | |
| 1158 | 1062 | } | |
| 1159 | - | if result.welcomed { | |
| 1160 | - | homeNavigationPending = false | |
| 1161 | - | } | |
| 1162 | - | if homeNavigationPending && returnRoute != nil { | |
| 1163 | - | route := *returnRoute | |
| 1164 | - | returnRoute = nil | |
| 1165 | - | homeNavigationPending = false | |
| 1166 | - | returnNavigationPending = true | |
| 1167 | - | restoreReturnRoute(route) | |
| 1168 | - | continue | |
| 1169 | - | } | |
| 1170 | 1063 | if navTransition.pendingCreation() { | |
| 1171 | 1064 | r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: routeFailureCode(result.err)} | |
| 1172 | 1065 | route, ok := navTransition.restore() | |
| @@ -1213,12 +1106,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 1213 | 1106 | returnResumeFallback = false | |
| 1214 | 1107 | continue | |
| 1215 | 1108 | } | |
| 1216 | - | if returnNavigationPending && !returnResumeFallback && homeRoute != nil { | |
| 1217 | - | returnRoute = nil | |
| 1218 | - | returnNavigationPending = false | |
| 1219 | - | enterHomePicker() | |
| 1220 | - | continue | |
| 1221 | - | } | |
| 1222 | 1109 | if attemptRequest.Intent == protocol.IntentResume && !result.welcomed && | |
| 1223 | 1110 | attemptRequest.ExactTarget != nil && resumeNeedsExactAttach(result.err) { | |
| 1224 | 1111 | attemptRequest.Intent = protocol.IntentAttach | |
| @@ -1397,9 +1284,7 @@ type attachAttempt struct { | |||
| 1397 | 1284 | reconnect *reconnectUI | |
| 1398 | 1285 | transition *transitionUI | |
| 1399 | 1286 | linkEvents <-chan ports.LinkEvent | |
| 1400 | - | rememberRemoteHost func() | |
| 1401 | 1287 | terminalInput func() *terminalInputPump | |
| 1402 | - | openHomePicker func(context.Context) attachResult | |
| 1403 | 1288 | // inventoryHome is the committed local route that authorises the | |
| 1404 | 1289 | // navigation-inventory relay and its handoffs. Nil disables the relay: | |
| 1405 | 1290 | // local-only and ledger-less attachments never dial control sources. | |
| @@ -1425,7 +1310,6 @@ type attachResult struct { | |||
| 1425 | 1310 | transportClosed bool | |
| 1426 | 1311 | target *protocol.AttachTarget | |
| 1427 | 1312 | handoff *attachHandoff | |
| 1428 | - | action protocol.NavigationAction | |
| 1429 | 1313 | routeAction *protocol.RouteNavigationAction | |
| 1430 | 1314 | routeCreateAction *protocol.RouteCreateSessionAction | |
| 1431 | 1315 | err error | |
| @@ -1473,9 +1357,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1473 | 1357 | transport = a.connection.Connection() | |
| 1474 | 1358 | } | |
| 1475 | 1359 | request := a.request | |
| 1476 | - | // Both parked UDP and close-and-dial home pickers only borrow a local | |
| 1477 | - | // attachment for rendering. They do not own the client's active route. | |
| 1478 | - | transientPicker := request.StartupOverlay == protocol.StartupOverlaySessionPicker | |
| 1479 | 1360 | term := a.runner.term | |
| 1480 | 1361 | clk := a.runner.clock | |
| 1481 | 1362 | intent := request.Intent | |
| @@ -1496,10 +1377,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1496 | 1377 | // resolve channel. Advertising the capability in Hello is what lets | |
| 1497 | 1378 | // the serving palette send demands at all. | |
| 1498 | 1379 | var inventory *inventoryRelay | |
| 1499 | - | if home := a.inventoryHome; home != nil && inventoryRelayEnabled(true, remote && !home.request.Remote, a.inventoryDialer) { | |
| 1380 | + | var hybridPicker *pickerControl | |
| 1381 | + | if home := a.inventoryHome; home != nil && inventoryRelayEnabled(true, remote, a.inventoryDialer) { | |
| 1500 | 1382 | inventory = newInventoryRelay(clk, a.inventoryDialer) | |
| 1383 | + | hybridPicker = &pickerControl{dialer: a.inventoryDialer} | |
| 1501 | 1384 | request.NavigationCapabilities |= protocol.NavigationCapabilityInventory | |
| 1502 | 1385 | } | |
| 1386 | + | // The client-picker loop owns navigate-intent presentation while the | |
| 1503 | 1387 | clipboard := a.runner.clipboard | |
| 1504 | 1388 | log := a.runner.logger | |
| 1505 | 1389 | observer := a.runner.runtimeObserver | |
| @@ -1589,7 +1473,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1589 | 1473 | PreferredTabID: request.PreferredTabID, | |
| 1590 | 1474 | EnvironmentPolicy: request.EnvironmentPolicy, | |
| 1591 | 1475 | NavigationCapabilities: request.NavigationCapabilities, | |
| 1592 | - | StartupOverlay: request.StartupOverlay, | |
| 1593 | 1476 | Remote: remote, | |
| 1594 | 1477 | } | |
| 1595 | 1478 | if err := sendHandshake(func() error { | |
| @@ -1628,11 +1511,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1628 | 1511 | committedIdentity = cloneCommittedIdentity(welcome.CommittedIdentity) | |
| 1629 | 1512 | ms.welcomed = true | |
| 1630 | 1513 | log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0) | |
| 1631 | - | if remote { | |
| 1632 | - | if remember := a.rememberRemoteHost; remember != nil { | |
| 1633 | - | remember() | |
| 1634 | - | } | |
| 1635 | - | } | |
| 1636 | 1514 | case protocol.ErrorMsg: | |
| 1637 | 1515 | return result(false, &ProtocolError{Code: message.Code, Text: message.Text}) | |
| 1638 | 1516 | default: | |
| @@ -1652,7 +1530,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1652 | 1530 | if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) { | |
| 1653 | 1531 | return welcomedResult(errRouteTargetChanged) | |
| 1654 | 1532 | } | |
| 1655 | - | if !transientPicker { | |
| 1533 | + | { | |
| 1656 | 1534 | candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken) | |
| 1657 | 1535 | var commitErr error | |
| 1658 | 1536 | if a.killedSelection != nil { | |
| @@ -1717,11 +1595,113 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1717 | 1595 | // completions carry their originating interaction and query identity. | |
| 1718 | 1596 | // The relay itself is created before Hello so the capability advertises. | |
| 1719 | 1597 | inventoryBox := newInventoryPollBox() | |
| 1598 | + | pickerControlBox := newPickerControlBox() | |
| 1599 | + | // pickerOutcomes carries consumed-op outcomes from the stdin-pump | |
| 1600 | + | // goroutine to this attach loop for repaint and typed sends. The | |
| 1601 | + | // queue lives for the attempt; the snapshot-open path below | |
| 1602 | + | // installs the pump-side hook alongside each open loop. Wired here | |
| 1603 | + | // (after ui is bound below) would split lifetime across the | |
| 1604 | + | // function, so the hook install stays with the loop open/close. | |
| 1605 | + | pickerOutcomes := newPickerOutcomeQueue(loopCtx.Done()) | |
| 1720 | 1606 | var inventoryResolveC chan inventoryResolveOutcome | |
| 1721 | 1607 | inventoryResolvePending := false | |
| 1722 | 1608 | var nextInventoryRequestID uint64 = 1 | |
| 1609 | + | type routeObservationOutcome struct { | |
| 1610 | + | authority routeObserverAuthority | |
| 1611 | + | observations []protocol.PickerRouteObservation | |
| 1612 | + | err error | |
| 1613 | + | } | |
| 1614 | + | routeObservationC := make(chan routeObservationOutcome, 1) | |
| 1615 | + | var routeObservationTimer ports.Timer | |
| 1616 | + | var routeObservationTick <-chan time.Time | |
| 1617 | + | var routeObservationRequestID uint64 = 1 | |
| 1618 | + | routeObservationPending := 0 | |
| 1619 | + | if a.runner.routeObservers == nil { | |
| 1620 | + | a.runner.routeObservers = newRouteObserverRegistry() | |
| 1621 | + | } | |
| 1622 | + | a.runner.routeObservers.register(request, a.dialer) | |
| 1623 | + | currentRouteOrigin := normalizeRouteOrigin(request.Origin, request.Remote) | |
| 1624 | + | currentRouteObserverKey := routeObserverKey{origin: currentRouteOrigin, originKey: normalizeRouteOriginKey(request.OriginKey, currentRouteOrigin)} | |
| 1625 | + | if home := a.inventoryHome; home != nil { | |
| 1626 | + | a.runner.routeObservers.register(home.request, home.dialer) | |
| 1627 | + | } | |
| 1723 | 1628 | inventoryOpen := false | |
| 1724 | 1629 | var inventoryInteraction uint64 | |
| 1630 | + | // Client-picker loop state. picker tracks the admitted interaction | |
| 1631 | + | // namespace; pickerCurrent owns the displayed model (nil when closed). | |
| 1632 | + | // The loop consumes terminal-pump records and renders through the | |
| 1633 | + | // attach loop's sole terminal writer; commits cross on controlCh. | |
| 1634 | + | // Interaction IDs are daemon-assigned: the client only admits the | |
| 1635 | + | // namespace it was handed and retires it permanently on close. | |
| 1636 | + | picker := &pickerInteraction{} | |
| 1637 | + | var pickerCurrent *pickerLoop | |
| 1638 | + | // pickerSort is the client-local ordering mode; the daemon never shares it | |
| 1639 | + | // between attachments. pickerPreviewFrame carries the newest preview the | |
| 1640 | + | // serving daemon published for the displayed source. | |
| 1641 | + | var pickerSort = defaultPickerSort() | |
| 1642 | + | var pickerPreviewFrame = emptyPickerPreview() | |
| 1643 | + | // pickerPreview owns the client half of the row preview: the row whose | |
| 1644 | + | // request is on the wire and the viewport the daemon published for it. | |
| 1645 | + | var pickerPreview pickerPreviewClient | |
| 1646 | + | // pickerAcquireBarrier is the daemon output boundary the offer named: the | |
| 1647 | + | // client displays admitted output through it before it owns the terminal. | |
| 1648 | + | var pickerAcquireBarrier pickerBarrier | |
| 1649 | + | // pickerInput publishes who owns the terminal input to the stdin pump: | |
| 1650 | + | // the open interaction, the release drain window, or the session. The | |
| 1651 | + | // pump decodes and identifies operations; this loop applies them. | |
| 1652 | + | pickerInput := &pickerConsumer{} | |
| 1653 | + | // pickerPresentation owns who writes the terminal for the current | |
| 1654 | + | // client-picker interaction: acquisition barrier, local ownership, | |
| 1655 | + | // and ordered release. The daemon keeps mutation authority. | |
| 1656 | + | var pickerPresentation pickerLease | |
| 1657 | + | pickerRenderer := newPickerRenderer() | |
| 1658 | + | termSize := func() domain.Size { | |
| 1659 | + | geometry, err := term.Geometry() | |
| 1660 | + | if err != nil { | |
| 1661 | + | return size | |
| 1662 | + | } | |
| 1663 | + | return geometry.Size | |
| 1664 | + | } | |
| 1665 | + | // The preview request is debounced: the cursor may pass several rows while | |
| 1666 | + | // the user scrolls, and only the row that rests is worth capturing. The | |
| 1667 | + | // daemon keeps publishing the requested row afterwards, so one request per | |
| 1668 | + | // settled cursor is enough. | |
| 1669 | + | var previewTimer ports.Timer | |
| 1670 | + | var previewTimerCancel chan struct{} | |
| 1671 | + | pickerPreviewC := func() <-chan time.Time { | |
| 1672 | + | if previewTimer == nil { | |
| 1673 | + | return nil | |
| 1674 | + | } | |
| 1675 | + | return previewTimer.C() | |
| 1676 | + | } | |
| 1677 | + | clearPreviewTimer := func() { | |
| 1678 | + | if previewTimer != nil { | |
| 1679 | + | previewTimer.Stop() | |
| 1680 | + | previewTimer = nil | |
| 1681 | + | } | |
| 1682 | + | if previewTimerCancel != nil { | |
| 1683 | + | close(previewTimerCancel) | |
| 1684 | + | previewTimerCancel = nil | |
| 1685 | + | } | |
| 1686 | + | } | |
| 1687 | + | previewEvents := make(chan struct{}, 1) | |
| 1688 | + | armPreviewTimer := func() { | |
| 1689 | + | clearPreviewTimer() | |
| 1690 | + | timer := clk.NewTimer(pickerPreviewDebounce) | |
| 1691 | + | cancel := make(chan struct{}) | |
| 1692 | + | previewTimer, previewTimerCancel = timer, cancel | |
| 1693 | + | go func() { | |
| 1694 | + | select { | |
| 1695 | + | case <-timer.C(): | |
| 1696 | + | select { | |
| 1697 | + | case previewEvents <- struct{}{}: | |
| 1698 | + | default: | |
| 1699 | + | } | |
| 1700 | + | case <-cancel: | |
| 1701 | + | case <-loopCtx.Done(): | |
| 1702 | + | } | |
| 1703 | + | }() | |
| 1704 | + | } | |
| 1725 | 1705 | var inventoryTimer ports.Timer | |
| 1726 | 1706 | var inventoryTickC <-chan time.Time | |
| 1727 | 1707 | stopInventoryTimer := func() { | |
| @@ -1751,6 +1731,63 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1751 | 1731 | } | |
| 1752 | 1732 | } | |
| 1753 | 1733 | defer stopInventoryPoll() | |
| 1734 | + | defer func() { | |
| 1735 | + | if routeObservationTimer != nil { | |
| 1736 | + | routeObservationTimer.Stop() | |
| 1737 | + | } | |
| 1738 | + | }() | |
| 1739 | + | hasRouteObservationTargets := func() bool { | |
| 1740 | + | for _, authority := range a.runner.routeObservers.snapshot() { | |
| 1741 | + | var excluded *protocol.ExactSessionTarget | |
| 1742 | + | if authority.key == currentRouteObserverKey { | |
| 1743 | + | excluded = request.ExactTarget | |
| 1744 | + | } | |
| 1745 | + | if len(a.runner.ledger.observationTargetsExcept(authority.key.origin, authority.key.originKey, excluded)) != 0 { | |
| 1746 | + | return true | |
| 1747 | + | } | |
| 1748 | + | } | |
| 1749 | + | return false | |
| 1750 | + | } | |
| 1751 | + | armRouteObservation := func() { | |
| 1752 | + | if !hasRouteObservationTargets() { | |
| 1753 | + | routeObservationTick = nil | |
| 1754 | + | return | |
| 1755 | + | } | |
| 1756 | + | if routeObservationTimer != nil { | |
| 1757 | + | routeObservationTimer.Stop() | |
| 1758 | + | } | |
| 1759 | + | routeObservationTimer = clk.NewTimer(inventoryPollInterval) | |
| 1760 | + | routeObservationTick = routeObservationTimer.C() | |
| 1761 | + | } | |
| 1762 | + | startRouteObservation := func() { | |
| 1763 | + | if routeObservationPending != 0 { | |
| 1764 | + | return | |
| 1765 | + | } | |
| 1766 | + | authorities := a.runner.routeObservers.snapshot() | |
| 1767 | + | for _, authority := range authorities { | |
| 1768 | + | var excluded *protocol.ExactSessionTarget | |
| 1769 | + | if authority.key == currentRouteObserverKey { | |
| 1770 | + | excluded = request.ExactTarget | |
| 1771 | + | } | |
| 1772 | + | targets := a.runner.ledger.observationTargetsExcept(authority.key.origin, authority.key.originKey, excluded) | |
| 1773 | + | if len(targets) == 0 { | |
| 1774 | + | continue | |
| 1775 | + | } | |
| 1776 | + | requestID := routeObservationRequestID | |
| 1777 | + | routeObservationRequestID++ | |
| 1778 | + | routeObservationPending++ | |
| 1779 | + | go func(authority routeObserverAuthority, targets []protocol.ExactSessionTarget) { | |
| 1780 | + | observations, err := (pickerControl{dialer: authority.dialer}).observe(loopCtx, requestID, targets) | |
| 1781 | + | select { | |
| 1782 | + | case routeObservationC <- routeObservationOutcome{authority: authority, observations: observations, err: err}: | |
| 1783 | + | case <-loopCtx.Done(): | |
| 1784 | + | } | |
| 1785 | + | }(authority, targets) | |
| 1786 | + | } | |
| 1787 | + | if routeObservationPending == 0 { | |
| 1788 | + | armRouteObservation() | |
| 1789 | + | } | |
| 1790 | + | } | |
| 1754 | 1791 | startInventoryPoll := func() { | |
| 1755 | 1792 | if inventory == nil || !inventoryOpen { | |
| 1756 | 1793 | return | |
| @@ -1908,17 +1945,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1908 | 1945 | transitionWaitingFull := false | |
| 1909 | 1946 | var samePeerSwitch *samePeerSwitchPending | |
| 1910 | 1947 | var nextSamePeerRequestID uint64 | |
| 1911 | - | type parkedRoutePending struct { | |
| 1912 | - | requestID uint64 | |
| 1913 | - | action protocol.ParkedRouteAction | |
| 1914 | - | leaseID protocol.ParkedRouteLeaseID | |
| 1915 | - | fallback *attachHandoff | |
| 1916 | - | timer ports.Timer | |
| 1917 | - | } | |
| 1918 | - | var parkedPending *parkedRoutePending | |
| 1919 | - | var nextParkedRequestID uint64 | |
| 1920 | - | parkedWaitingFull := false | |
| 1921 | - | var parkedFullTimer ports.Timer | |
| 1922 | 1948 | transportFailed := make(chan struct{}) | |
| 1923 | 1949 | if reconnect.showing { | |
| 1924 | 1950 | if reconnect.remote { | |
| @@ -1960,6 +1986,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1960 | 1986 | stdinDone, resizeDone chan struct{} | |
| 1961 | 1987 | } | |
| 1962 | 1988 | var foreground *foregroundRuntime | |
| 1989 | + | resizeEvents := make(chan domain.Geometry, 1) | |
| 1963 | 1990 | startForeground := func() { | |
| 1964 | 1991 | if foreground != nil { | |
| 1965 | 1992 | return | |
| @@ -1983,11 +2010,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1983 | 2010 | foregroundCancel() | |
| 1984 | 2011 | cancel() | |
| 1985 | 2012 | } | |
| 1986 | - | (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, uiGeneration: generation, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease}).run() | |
| 2013 | + | (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, uiGeneration: generation, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease, picker: pickerInput, pickerOutcomes: pickerOutcomes}).run() | |
| 1987 | 2014 | }() | |
| 1988 | 2015 | go func() { | |
| 1989 | 2016 | defer close(resizeDone) | |
| 1990 | - | runResize(foregroundCtx, term.ResizeEvents(), sendCh, sendLease, log) | |
| 2017 | + | runResize(foregroundCtx, term.ResizeEvents(), sendCh, resizeEvents, sendLease, log) | |
| 1991 | 2018 | }() | |
| 1992 | 2019 | } | |
| 1993 | 2020 | stopForeground := func() { | |
| @@ -2008,144 +2035,190 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2008 | 2035 | cancelTimer(completionTimers, id) | |
| 2009 | 2036 | } | |
| 2010 | 2037 | } | |
| 2011 | - | flushSender := func() error { | |
| 2012 | - | done := make(chan struct{}) | |
| 2013 | - | select { | |
| 2014 | - | case barrierCh <- done: | |
| 2015 | - | case <-loopCtx.Done(): | |
| 2016 | - | return context.Canceled | |
| 2017 | - | } | |
| 2018 | - | select { | |
| 2019 | - | case <-done: | |
| 2038 | + | // displayPickerFrame composes the local picker modal and publishes it | |
| 2039 | + | // through the sole terminal writer, inside the UI observation | |
| 2040 | + | // transaction, exactly like daemon output. A locally admitted action | |
| 2041 | + | // completes only after that write and flush succeeded. | |
| 2042 | + | displayPickerFrame := func(loop *pickerLoop, completeAction uint64) error { | |
| 2043 | + | data := pickerRenderer.render(loop, termSize(), pickerPreviewFrame) | |
| 2044 | + | if data == nil { | |
| 2020 | 2045 | return nil | |
| 2021 | - | case <-loopCtx.Done(): | |
| 2022 | - | return context.Canceled | |
| 2023 | 2046 | } | |
| 2024 | - | } | |
| 2025 | - | clearParkedFull := func() { | |
| 2026 | - | parkedWaitingFull = false | |
| 2027 | - | if parkedFullTimer != nil { | |
| 2028 | - | parkedFullTimer.Stop() | |
| 2029 | - | parkedFullTimer = nil | |
| 2047 | + | if uiOutput != nil { | |
| 2048 | + | uiContext = outputState.uiContext(uiContext, uiContext.Status) | |
| 2049 | + | uiOutput.BeginOutput(uiContext) | |
| 2050 | + | if err := uiOutput.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | |
| 2051 | + | uiOutput.EndOutput(false) | |
| 2052 | + | return fmt.Errorf("vev: publishing picker context: %w", err) | |
| 2053 | + | } | |
| 2030 | 2054 | } | |
| 2031 | - | } | |
| 2032 | - | awaitParkedFull := func() { | |
| 2033 | - | clearParkedFull() | |
| 2034 | - | parkedWaitingFull = true | |
| 2035 | - | parkedFullTimer = clk.NewTimer(protocol.HandshakeTimeout) | |
| 2036 | - | } | |
| 2037 | - | parkedFullC := func() <-chan time.Time { | |
| 2038 | - | if parkedFullTimer == nil { | |
| 2039 | - | return nil | |
| 2055 | + | data = append([]byte("\x1b[?25l"), data...) | |
| 2056 | + | _, werr := term.Out().Write(data) | |
| 2057 | + | if uiOutput != nil { | |
| 2058 | + | uiOutput.EndOutput(werr == nil) | |
| 2040 | 2059 | } | |
| 2041 | - | return parkedFullTimer.C() | |
| 2042 | - | } | |
| 2043 | - | defer clearParkedFull() | |
| 2044 | - | clearParkedPending := func() { | |
| 2045 | - | if parkedPending != nil && parkedPending.timer != nil { | |
| 2046 | - | parkedPending.timer.Stop() | |
| 2060 | + | if werr != nil { | |
| 2061 | + | return fmt.Errorf("vev: writing picker frame: %w", werr) | |
| 2047 | 2062 | } | |
| 2048 | - | parkedPending = nil | |
| 2063 | + | if ferr := term.Flush(); ferr != nil { | |
| 2064 | + | return fmt.Errorf("vev: flushing picker frame: %w", ferr) | |
| 2065 | + | } | |
| 2066 | + | if ui != nil { | |
| 2067 | + | ui.published(uiGeneration) | |
| 2068 | + | if completeAction != 0 { | |
| 2069 | + | ui.completeLocal(uiGeneration, completeAction) | |
| 2070 | + | } | |
| 2071 | + | } | |
| 2072 | + | return nil | |
| 2049 | 2073 | } | |
| 2050 | - | parkedResponseC := func() <-chan time.Time { | |
| 2051 | - | if parkedPending == nil || parkedPending.timer == nil { | |
| 2074 | + | // publishSuppressedContext applies one daemon frame the client must not | |
| 2075 | + | // write while it owns presentation. The frame content is dropped, but | |
| 2076 | + | // the committed context boundary still advances so the daemon's UI | |
| 2077 | + | // fences retire against the same boundary exactly once: an opener | |
| 2078 | + | // action admitted before the picker opened completes here, and no | |
| 2079 | + | // suppression can pair a receipt with a stale boundary. | |
| 2080 | + | publishSuppressedContext := func(next outputApplyState) error { | |
| 2081 | + | if uiOutput == nil { | |
| 2052 | 2082 | return nil | |
| 2053 | 2083 | } | |
| 2054 | - | return parkedPending.timer.C() | |
| 2055 | - | } | |
| 2056 | - | defer clearParkedPending() | |
| 2057 | - | ||
| 2058 | - | sendParkedRouteSize := func() error { | |
| 2059 | - | geometry, err := term.Geometry() | |
| 2060 | - | if err != nil { | |
| 2061 | - | return fmt.Errorf("reading terminal geometry for parked route: %w", err) | |
| 2084 | + | uiContext = next.uiContext(uiContext, uiContext.Status) | |
| 2085 | + | if err := uiOutput.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | |
| 2086 | + | return fmt.Errorf("vev: publishing suppressed picker context: %w", err) | |
| 2062 | 2087 | } | |
| 2063 | - | geometry = geometry.NormalizePixels() | |
| 2064 | - | message := protocol.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight} | |
| 2065 | - | if err := protocol.ValidateGeometry(message.Geometry()); err != nil { | |
| 2066 | - | return fmt.Errorf("validating terminal size for parked route: %w", err) | |
| 2088 | + | if ui != nil { | |
| 2089 | + | ui.published(uiGeneration) | |
| 2090 | + | } | |
| 2091 | + | return nil | |
| 2092 | + | } | |
| 2093 | + | drainPickerInput := func(interaction uint64) { | |
| 2094 | + | // The interaction is retired but its authoritative paint is still in | |
| 2095 | + | // flight: the pump consumes and drops every batch until the repaint | |
| 2096 | + | // lands, so a picker-bound key can never reach the session. The | |
| 2097 | + | // queued automation boundary is retired with it. | |
| 2098 | + | pickerInput.setDrain(interaction, uiGeneration) | |
| 2099 | + | if ui == nil { | |
| 2100 | + | return | |
| 2067 | 2101 | } | |
| 2102 | + | ui.mu.Lock() | |
| 2103 | + | inputGate.retiredUI.Store(ui.nextAction) | |
| 2104 | + | ui.mu.Unlock() | |
| 2105 | + | } | |
| 2106 | + | // sendPickerClose retires the daemon-side interaction so it stops owning | |
| 2107 | + | // input and repaints authoritatively. A duplicate close is harmless: the | |
| 2108 | + | // daemon rejects a close for an interaction it no longer holds. | |
| 2109 | + | sendPickerClose := func(interaction uint64) error { | |
| 2110 | + | closeMsg := protocol.PickerClose{InteractionID: interaction} | |
| 2068 | 2111 | select { | |
| 2069 | - | case controlCh <- message: | |
| 2112 | + | case controlCh <- closeMsg: | |
| 2070 | 2113 | return nil | |
| 2071 | 2114 | case <-loopCtx.Done(): | |
| 2072 | 2115 | return context.Canceled | |
| 2073 | 2116 | } | |
| 2074 | 2117 | } | |
| 2075 | - | sendParkedRouteRequest := func(action protocol.ParkedRouteAction, leaseID protocol.ParkedRouteLeaseID, target *domain.RemoteSessionTarget) error { | |
| 2076 | - | if parkedPending != nil { | |
| 2077 | - | return errors.New("vev: parked-route request already pending") | |
| 2118 | + | // schedulePickerPreview (re)arms the debounce for the row the modal | |
| 2119 | + | // displays. A row already requested is never asked for twice, so a | |
| 2120 | + | // background refresh of the same row keeps its published viewport. | |
| 2121 | + | schedulePickerPreview := func() { | |
| 2122 | + | if pickerCurrent == nil || !pickerPresentation.owns() { | |
| 2123 | + | return | |
| 2078 | 2124 | } | |
| 2079 | - | nextParkedRequestID++ | |
| 2080 | - | request := protocol.ParkedRouteRequest{RequestID: nextParkedRequestID, LeaseID: leaseID, Action: action, Target: target} | |
| 2081 | - | if protocol.ValidateParkedRouteRequest(request) != nil { | |
| 2082 | - | return errors.New("vev: invalid parked-route request") | |
| 2125 | + | if !pickerPreview.needsRequest(pickerPresentation.interaction, pickerCurrent.cursorKey()) { | |
| 2126 | + | return | |
| 2083 | 2127 | } | |
| 2084 | - | select { | |
| 2085 | - | case controlCh <- request: | |
| 2086 | - | parkedPending = &parkedRoutePending{ | |
| 2087 | - | requestID: request.RequestID, action: action, leaseID: leaseID, | |
| 2088 | - | timer: clk.NewTimer(protocol.HandshakeTimeout), | |
| 2089 | - | } | |
| 2128 | + | armPreviewTimer() | |
| 2129 | + | } | |
| 2130 | + | acquirePicker := func() error { | |
| 2131 | + | loop := pickerPresentation.pending | |
| 2132 | + | if loop == nil { | |
| 2133 | + | pickerPresentation.reset() | |
| 2090 | 2134 | return nil | |
| 2091 | - | case <-loopCtx.Done(): | |
| 2092 | - | return context.Canceled | |
| 2093 | 2135 | } | |
| 2094 | - | } | |
| 2095 | - | handleParkedPicker := func(leaseID protocol.ParkedRouteLeaseID) (attachResult, bool) { | |
| 2096 | - | pickerCtx, cancelPicker := context.WithCancel(ctx) | |
| 2097 | - | pickerDone := make(chan struct{}) | |
| 2098 | - | go func() { | |
| 2099 | - | select { | |
| 2100 | - | case <-transportFailed: | |
| 2101 | - | cancelPicker() | |
| 2102 | - | case <-pickerDone: | |
| 2103 | - | } | |
| 2104 | - | }() | |
| 2105 | - | selection := a.openHomePicker(pickerCtx) | |
| 2106 | - | close(pickerDone) | |
| 2107 | - | cancelPicker() | |
| 2108 | - | select { | |
| 2109 | - | case <-transportFailed: | |
| 2110 | - | return welcomedResult(errLinkOffline), true | |
| 2111 | - | default: | |
| 2136 | + | pickerPresentation.pending = nil | |
| 2137 | + | pickerCurrent = loop | |
| 2138 | + | pickerInput.setOwned(pickerPresentation.interaction, pickerPresentation.generation) | |
| 2139 | + | if err := displayPickerFrame(loop, 0); err != nil { | |
| 2140 | + | return err | |
| 2112 | 2141 | } | |
| 2113 | - | if selection.handoff != nil && transition != nil { | |
| 2114 | - | transitionWaitingFull = false | |
| 2115 | - | transition.start(selection.handoff.target) | |
| 2142 | + | schedulePickerPreview() | |
| 2143 | + | return nil | |
| 2144 | + | } | |
| 2145 | + | restoreTerminalModes := func() {} | |
| 2146 | + | finishPickerRelease := func() { | |
| 2147 | + | actionID, local := pickerPresentation.finishRelease() | |
| 2148 | + | pickerCurrent = nil | |
| 2149 | + | pickerPreview.resetFor() | |
| 2150 | + | pickerPreviewFrame = emptyPickerPreview() | |
| 2151 | + | clearPreviewTimer() | |
| 2152 | + | pickerInput.clear() | |
| 2153 | + | restoreTerminalModes() | |
| 2154 | + | if ui != nil && local && actionID != 0 { | |
| 2155 | + | ui.completeLocal(uiGeneration, actionID) | |
| 2156 | + | } | |
| 2157 | + | } | |
| 2158 | + | abortPickerLease := func(unknownOutcome bool) { | |
| 2159 | + | if !pickerPresentation.active() { | |
| 2160 | + | return | |
| 2116 | 2161 | } | |
| 2117 | - | if selection.handoff != nil && request.Remote && request.OriginKey != "" && selection.handoff.target.Endpoint == request.OriginKey && selection.handoff.target.RemoteTarget != nil { | |
| 2118 | - | target := *selection.handoff.target.RemoteTarget | |
| 2119 | - | if err := sendParkedRouteSize(); err != nil { | |
| 2120 | - | return welcomedResult(err), true | |
| 2121 | - | } | |
| 2122 | - | if err := sendParkedRouteRequest(protocol.ParkedRouteSwitch, leaseID, &target); err != nil { | |
| 2123 | - | return welcomedResult(fmt.Errorf("vev: switching parked route: %w", err)), true | |
| 2162 | + | actionID, local := pickerPresentation.abort() | |
| 2163 | + | pickerCurrent = nil | |
| 2164 | + | pickerPreview.resetFor() | |
| 2165 | + | pickerPreviewFrame = emptyPickerPreview() | |
| 2166 | + | clearPreviewTimer() | |
| 2167 | + | pickerInput.clear() | |
| 2168 | + | restoreTerminalModes() | |
| 2169 | + | if ui != nil { | |
| 2170 | + | if local && actionID != 0 { | |
| 2171 | + | ui.completeLocal(uiGeneration, actionID) | |
| 2172 | + | } else if unknownOutcome && actionID != 0 { | |
| 2173 | + | ui.mu.Lock() | |
| 2174 | + | if record, ok := ui.records[actionID]; ok && record.Status == ports.UIActionPending { | |
| 2175 | + | ui.finishLocked(actionID, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |
| 2176 | + | } | |
| 2177 | + | ui.mu.Unlock() | |
| 2124 | 2178 | } | |
| 2125 | - | fallback := *selection.handoff | |
| 2126 | - | parkedPending.fallback = &fallback | |
| 2127 | - | return attachResult{}, false | |
| 2128 | 2179 | } | |
| 2129 | - | if selection.handoff != nil { | |
| 2130 | - | return selection, true | |
| 2180 | + | } | |
| 2181 | + | // resolveSupersededLease ends a lease that a newer interaction replaced: | |
| 2182 | + | // its pending action can no longer complete through a release paint. The | |
| 2183 | + | // terminal stays owned until the replacing interaction displays, because | |
| 2184 | + | // the replaced picker frame is still on screen: input must not reach the | |
| 2185 | + | // session in that window. | |
| 2186 | + | resolveSupersededLease := func(superseded *pickerLease) { | |
| 2187 | + | if superseded == nil { | |
| 2188 | + | return | |
| 2189 | + | } | |
| 2190 | + | actionID, local := superseded.releaseAction, superseded.releaseLocal | |
| 2191 | + | pickerInput.setDrain(picker.interaction, uiGeneration) | |
| 2192 | + | if ui == nil || actionID == 0 { | |
| 2193 | + | return | |
| 2131 | 2194 | } | |
| 2132 | - | if selection.err != nil { | |
| 2133 | - | log.Warn("transient home picker failed; resuming parked route", "err", selection.err) | |
| 2195 | + | if local { | |
| 2196 | + | ui.completeLocal(superseded.generation, actionID) | |
| 2197 | + | return | |
| 2134 | 2198 | } | |
| 2135 | - | if err := sendParkedRouteSize(); err != nil { | |
| 2136 | - | return welcomedResult(err), true | |
| 2199 | + | ui.mu.Lock() | |
| 2200 | + | if record, ok := ui.records[actionID]; ok && record.Status == ports.UIActionPending { | |
| 2201 | + | ui.finishLocked(actionID, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |
| 2137 | 2202 | } | |
| 2138 | - | if err := sendParkedRouteRequest(protocol.ParkedRouteResume, leaseID, nil); err != nil { | |
| 2139 | - | return welcomedResult(fmt.Errorf("vev: resuming parked route: %w", err)), true | |
| 2203 | + | ui.mu.Unlock() | |
| 2204 | + | } | |
| 2205 | + | // syncPickerGeneration aborts a lease that belongs to a retired | |
| 2206 | + | // attachment generation instead of releasing a frame the daemon no | |
| 2207 | + | // longer associates with this interaction. No PickerClose is sent: a | |
| 2208 | + | // generation change means a new attachment attempt on a new connection, | |
| 2209 | + | // and the previous connection's interaction died with it (the daemon | |
| 2210 | + | // starts every attachment with no client interaction open). | |
| 2211 | + | syncPickerGeneration := func() { | |
| 2212 | + | if pickerPresentation.active() && pickerPresentation.generation != uiGeneration { | |
| 2213 | + | abortPickerLease(true) | |
| 2140 | 2214 | } | |
| 2141 | - | return attachResult{}, false | |
| 2142 | 2215 | } | |
| 2143 | 2216 | startForeground() | |
| 2144 | 2217 | defer stopForeground() | |
| 2145 | 2218 | ||
| 2146 | - | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| 2147 | 2219 | recvCh := make(chan recvResult, 1) | |
| 2148 | 2220 | go runRecv(loopCtx, transport, recvCh, transportFailed, log) | |
| 2221 | + | armRouteObservation() | |
| 2149 | 2222 | ||
| 2150 | 2223 | requestReconnectReset := func() error { | |
| 2151 | 2224 | if awaitingReconnectReset { | |
| @@ -2235,19 +2308,192 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2235 | 2308 | select { | |
| 2236 | 2309 | case <-loopCtx.Done(): | |
| 2237 | 2310 | return loopCanceledResult() | |
| 2311 | + | case <-resizeEvents: | |
| 2312 | + | if pickerCurrent != nil && pickerPresentation.owns() { | |
| 2313 | + | if err := displayPickerFrame(pickerCurrent, 0); err != nil { | |
| 2314 | + | return welcomedResult(err) | |
| 2315 | + | } | |
| 2316 | + | pickerPreview.sent = "" | |
| 2317 | + | schedulePickerPreview() | |
| 2318 | + | } | |
| 2319 | + | case <-routeObservationTick: | |
| 2320 | + | routeObservationTick = nil | |
| 2321 | + | startRouteObservation() | |
| 2322 | + | case outcome := <-routeObservationC: | |
| 2323 | + | routeObservationPending-- | |
| 2324 | + | if outcome.err == nil && a.runner.routeObservers.current(outcome.authority) && | |
| 2325 | + | a.runner.ledger.applyObservations(outcome.authority.key.origin, outcome.authority.key.originKey, outcome.observations) { | |
| 2326 | + | if err := publishRouteSnapshot(); err != nil { | |
| 2327 | + | return welcomedResult(err) | |
| 2328 | + | } | |
| 2329 | + | } | |
| 2330 | + | if routeObservationPending == 0 { | |
| 2331 | + | armRouteObservation() | |
| 2332 | + | } | |
| 2333 | + | case <-pickerPreviewC(): | |
| 2334 | + | clearPreviewTimer() | |
| 2335 | + | key := "" | |
| 2336 | + | if pickerCurrent != nil { | |
| 2337 | + | key = pickerCurrent.cursorKey() | |
| 2338 | + | } | |
| 2339 | + | if key == "" || !pickerPresentation.owns() || !pickerPreview.needsRequest(pickerPresentation.interaction, key) { | |
| 2340 | + | continue | |
| 2341 | + | } | |
| 2342 | + | // The preview pane decides the viewport: ask for exactly the | |
| 2343 | + | // rectangle the modal will blit it into, the same geometry the | |
| 2344 | + | // serving daemon sizes its capture from. | |
| 2345 | + | request, ok := pickerPreview.requestFor(pickerPresentation.interaction, key, pickerPreviewSize(termSize())) | |
| 2346 | + | if !ok { | |
| 2347 | + | continue | |
| 2348 | + | } | |
| 2349 | + | select { | |
| 2350 | + | case controlCh <- request: | |
| 2351 | + | pickerPreview.markSent(pickerPresentation.interaction, key) | |
| 2352 | + | case <-loopCtx.Done(): | |
| 2353 | + | return loopCanceledResult() | |
| 2354 | + | } | |
| 2355 | + | case <-pickerOutcomes.signal: | |
| 2356 | + | for _, outcome := range pickerOutcomes.take() { | |
| 2357 | + | if !outcome.acceptOutcome(picker.interaction, uiGeneration) || pickerCurrent == nil || !pickerPresentation.owns() { | |
| 2358 | + | // Retired generation, interaction, or presentation: the pump | |
| 2359 | + | // already consumed the batch, so nothing reaches the session. | |
| 2360 | + | // Complete the admitted action so it never stays pending. | |
| 2361 | + | if outcome.actionID != 0 && ui != nil { | |
| 2362 | + | ui.completeLocal(uiGeneration, outcome.actionID) | |
| 2363 | + | } | |
| 2364 | + | continue | |
| 2365 | + | } | |
| 2366 | + | op, changed := applyPickerBatch(pickerCurrent, outcome.events) | |
| 2367 | + | switch { | |
| 2368 | + | case op.close: | |
| 2369 | + | // The client retired the interaction: tell the daemon so it | |
| 2370 | + | // stops owning input and repaints authoritatively, then keep | |
| 2371 | + | // consuming and dropping until that paint lands. | |
| 2372 | + | if err := sendPickerClose(picker.interaction); err != nil { | |
| 2373 | + | return welcomedResult(err) | |
| 2374 | + | } | |
| 2375 | + | if pickerPresentation.beginRelease(picker.interaction, outcome.actionID, true, false, outputState) { | |
| 2376 | + | drainPickerInput(picker.interaction) | |
| 2377 | + | } else if outcome.actionID != 0 && ui != nil { | |
| 2378 | + | ui.completeLocal(uiGeneration, outcome.actionID) | |
| 2379 | + | } | |
| 2380 | + | case op.kill: | |
| 2381 | + | // x destroys the cursor row. The typed selection carries the | |
| 2382 | + | // revision of the model actually presented; the picker stays | |
| 2383 | + | // open and refreshes from the resulting revision. | |
| 2384 | + | selection, ok := killSelection(pickerCurrent, outcome.actionID) | |
| 2385 | + | if !ok { | |
| 2386 | + | if outcome.actionID != 0 && ui != nil { | |
| 2387 | + | ui.completeLocal(uiGeneration, outcome.actionID) | |
| 2388 | + | } | |
| 2389 | + | continue | |
| 2390 | + | } | |
| 2391 | + | select { | |
| 2392 | + | case controlCh <- selection: | |
| 2393 | + | case <-loopCtx.Done(): | |
| 2394 | + | return loopCanceledResult() | |
| 2395 | + | } | |
| 2396 | + | case op.commit: | |
| 2397 | + | // The typed selection crosses on controlCh: it carries the | |
| 2398 | + | // revision of the model actually presented. It stays pending | |
| 2399 | + | // through the daemon close and completes with the destination | |
| 2400 | + | // full paint or a typed failure. | |
| 2401 | + | action, ok := pickerCurrent.selectedAction() | |
| 2402 | + | selection, committed := protocol.PickerSelection{}, false | |
| 2403 | + | if ok { | |
| 2404 | + | selection, committed = commitSelection(pickerCurrent, action, outcome.actionID) | |
| 2405 | + | } | |
| 2406 | + | if !committed { | |
| 2407 | + | if err := displayPickerFrame(pickerCurrent, outcome.actionID); err != nil { | |
| 2408 | + | return welcomedResult(err) | |
| 2409 | + | } | |
| 2410 | + | continue | |
| 2411 | + | } | |
| 2412 | + | if hybridPicker != nil && selection.SourceID == protocol.PickerHomeSourceID { | |
| 2413 | + | requestID := nextInventoryRequestID | |
| 2414 | + | nextInventoryRequestID++ | |
| 2415 | + | go func(selection protocol.PickerSelection) { | |
| 2416 | + | target, err := hybridPicker.resolve(loopCtx, requestID, selection) | |
| 2417 | + | pickerControlBox.offer(pickerControlOutcome{selection: &selection, target: &target, err: err}) | |
| 2418 | + | }(selection) | |
| 2419 | + | continue | |
| 2420 | + | } | |
| 2421 | + | select { | |
| 2422 | + | case controlCh <- selection: | |
| 2423 | + | case <-loopCtx.Done(): | |
| 2424 | + | return loopCanceledResult() | |
| 2425 | + | } | |
| 2426 | + | case changed: | |
| 2427 | + | if err := displayPickerFrame(pickerCurrent, outcome.actionID); err != nil { | |
| 2428 | + | return welcomedResult(err) | |
| 2429 | + | } | |
| 2430 | + | schedulePickerPreview() | |
| 2431 | + | default: | |
| 2432 | + | // The batch changed nothing visible: complete its action | |
| 2433 | + | // without composing a frame. | |
| 2434 | + | if outcome.actionID != 0 && ui != nil { | |
| 2435 | + | ui.completeLocal(uiGeneration, outcome.actionID) | |
| 2436 | + | } | |
| 2437 | + | } | |
| 2438 | + | } | |
| 2238 | 2439 | case <-transition.tickC(): | |
| 2239 | 2440 | if err := transition.advance(); err != nil { | |
| 2240 | 2441 | return welcomedResult(err) | |
| 2241 | 2442 | } | |
| 2242 | - | case <-parkedResponseC(): | |
| 2243 | - | clearParkedPending() | |
| 2244 | - | return welcomedResult(errors.New("vev: timed out waiting for parked-route response")) | |
| 2245 | - | case <-parkedFullC(): | |
| 2246 | - | clearParkedFull() | |
| 2247 | - | return welcomedResult(errors.New("vev: timed out waiting for parked-route full output")) | |
| 2248 | 2443 | case <-inventoryTickC: | |
| 2249 | 2444 | startInventoryPoll() | |
| 2250 | 2445 | armInventoryPoll() | |
| 2446 | + | case <-pickerControlBox.wake: | |
| 2447 | + | outcome, ok := pickerControlBox.take() | |
| 2448 | + | if !ok { | |
| 2449 | + | continue | |
| 2450 | + | } | |
| 2451 | + | if outcome.snapshot != nil && picker.open && outcome.snapshot.InteractionID == picker.interaction { | |
| 2452 | + | snapshot := *outcome.snapshot | |
| 2453 | + | if !picker.admitSnapshot(snapshot, false) { | |
| 2454 | + | continue | |
| 2455 | + | } | |
| 2456 | + | if pickerCurrent != nil && pickerCurrent.interaction == snapshot.InteractionID && pickerPresentation.owns() { | |
| 2457 | + | pickerCurrent.replaceLines(snapshot) | |
| 2458 | + | if err := displayPickerFrame(pickerCurrent, 0); err != nil { | |
| 2459 | + | return welcomedResult(err) | |
| 2460 | + | } | |
| 2461 | + | continue | |
| 2462 | + | } | |
| 2463 | + | ready, superseded := pickerPresentation.admitSnapshot(snapshot, pickerAcquireBarrier, pickerLoopFromSnapshot(snapshot, picker.intent, pickerSort), outputState, uiGeneration) | |
| 2464 | + | if superseded != nil { | |
| 2465 | + | resolveSupersededLease(superseded) | |
| 2466 | + | } | |
| 2467 | + | if ready { | |
| 2468 | + | if err := acquirePicker(); err != nil { | |
| 2469 | + | return welcomedResult(err) | |
| 2470 | + | } | |
| 2471 | + | } | |
| 2472 | + | continue | |
| 2473 | + | } | |
| 2474 | + | if outcome.selection != nil && outcome.err != nil { | |
| 2475 | + | log.Debug("hybrid picker resolve rejected", "err", outcome.err, "source", outcome.selection.SourceID) | |
| 2476 | + | if outcome.selection.CauseActionID != 0 && ui != nil { | |
| 2477 | + | ui.completeLocal(uiGeneration, outcome.selection.CauseActionID) | |
| 2478 | + | } | |
| 2479 | + | continue | |
| 2480 | + | } | |
| 2481 | + | if outcome.target != nil && outcome.selection != nil { | |
| 2482 | + | if err := sendPickerClose(outcome.selection.InteractionID); err != nil { | |
| 2483 | + | return welcomedResult(err) | |
| 2484 | + | } | |
| 2485 | + | pickerPresentation.beginRelease(outcome.selection.InteractionID, outcome.selection.CauseActionID, false, false, outputState) | |
| 2486 | + | handoff := welcomedResult(nil) | |
| 2487 | + | handoff.target = outcome.target | |
| 2488 | + | if outcome.selection.CauseActionID != 0 && ui != nil { | |
| 2489 | + | ui.follow(uiGeneration, outcome.selection.CauseActionID) | |
| 2490 | + | publishUIStatus(ports.UIStatusTransitioning) | |
| 2491 | + | } | |
| 2492 | + | if home := a.inventoryHome; home != nil { | |
| 2493 | + | handoff.handoff = bindAttachHandoff(*outcome.target, *home) | |
| 2494 | + | } | |
| 2495 | + | return handoff | |
| 2496 | + | } | |
| 2251 | 2497 | case <-inventoryBox.wake: | |
| 2252 | 2498 | pollOutcome, ok := inventoryBox.take() | |
| 2253 | 2499 | if !ok { | |
| @@ -2443,10 +2689,23 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2443 | 2689 | } | |
| 2444 | 2690 | continue | |
| 2445 | 2691 | } | |
| 2446 | - | if parkedWaitingFull && !o.Full { | |
| 2447 | - | return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output")) | |
| 2692 | + | syncPickerGeneration() | |
| 2693 | + | pickerEvent := pickerPresentation.observe(o, nextState) | |
| 2694 | + | if pickerEvent == pickerLeaseSuppress { | |
| 2695 | + | // The client owns presentation: apply and ACK the daemon frame | |
| 2696 | + | // inside the bounded window without writing it, and advance the | |
| 2697 | + | // published context boundary so daemon-side fences retire. | |
| 2698 | + | outputState = nextState | |
| 2699 | + | if o.New != 0 { | |
| 2700 | + | ackQueue.offer(o.Epoch, o.New) | |
| 2701 | + | outputResetRequested = false | |
| 2702 | + | } | |
| 2703 | + | if err := publishSuppressedContext(nextState); err != nil { | |
| 2704 | + | return welcomedResult(err) | |
| 2705 | + | } | |
| 2706 | + | continue | |
| 2448 | 2707 | } | |
| 2449 | - | if ui != nil && o.Full && foreground == nil && (parkedWaitingFull || samePeerSwitch == nil) { | |
| 2708 | + | if ui != nil && o.Full && foreground == nil && samePeerSwitch == nil { | |
| 2450 | 2709 | startForeground() | |
| 2451 | 2710 | } | |
| 2452 | 2711 | if uiOutput != nil { | |
| @@ -2502,10 +2761,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2502 | 2761 | transitionWaitingFull = false | |
| 2503 | 2762 | transition.stop() | |
| 2504 | 2763 | } | |
| 2505 | - | if parkedWaitingFull { | |
| 2506 | - | clearParkedFull() | |
| 2507 | - | startForeground() | |
| 2508 | - | } | |
| 2509 | 2764 | if o.New != 0 { | |
| 2510 | 2765 | ackQueue.offer(o.Epoch, o.New) | |
| 2511 | 2766 | outputResetRequested = false | |
| @@ -2519,6 +2774,20 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2519 | 2774 | log.Debug("received first output") | |
| 2520 | 2775 | endHandshake() | |
| 2521 | 2776 | } | |
| 2777 | + | switch pickerEvent { | |
| 2778 | + | case pickerLeaseAcquire: | |
| 2779 | + | // The acquisition barrier is displayed: the client may now | |
| 2780 | + | // paint and own the picker frame. | |
| 2781 | + | if err := acquirePicker(); err != nil { | |
| 2782 | + | return welcomedResult(err) | |
| 2783 | + | } | |
| 2784 | + | case pickerLeaseRelease: | |
| 2785 | + | // The authoritative full paint was written and published: | |
| 2786 | + | // release the terminal, resume input, and complete the | |
| 2787 | + | // pending cancel locally. A commit completes through the | |
| 2788 | + | // handoff instead. | |
| 2789 | + | finishPickerRelease() | |
| 2790 | + | } | |
| 2522 | 2791 | case protocol.UIViewUpdate: | |
| 2523 | 2792 | nextState, accepted, needsReset := outputState.nextView(message) | |
| 2524 | 2793 | if needsReset && !outputResetRequested { | |
| @@ -2552,11 +2821,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2552 | 2821 | publishUIStatus(ports.UIStatusTransitioning) | |
| 2553 | 2822 | } | |
| 2554 | 2823 | target := message | |
| 2555 | - | if transientPicker { | |
| 2556 | - | handoff := welcomedResult(nil) | |
| 2557 | - | handoff.target = &target | |
| 2558 | - | return handoff | |
| 2559 | - | } | |
| 2560 | 2824 | if transition != nil { | |
| 2561 | 2825 | transitionWaitingFull = false | |
| 2562 | 2826 | transition.start(target) | |
| @@ -2593,72 +2857,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2593 | 2857 | handoff := welcomedResult(nil) | |
| 2594 | 2858 | handoff.target = &target | |
| 2595 | 2859 | return handoff | |
| 2596 | - | case protocol.ParkedRouteResponse: | |
| 2597 | - | response := message | |
| 2598 | - | if parkedPending == nil || response.RequestID != parkedPending.requestID { | |
| 2599 | - | return welcomedResult(errors.New("vev: unexpected parked-route response")) | |
| 2600 | - | } | |
| 2601 | - | pending := *parkedPending | |
| 2602 | - | clearParkedPending() | |
| 2603 | - | switch pending.action { | |
| 2604 | - | case protocol.ParkedRoutePrepare: | |
| 2605 | - | if response.Status != protocol.ParkedRouteReady { | |
| 2606 | - | fallback := welcomedResult(nil) | |
| 2607 | - | fallback.action = protocol.NavigationOpenHomePicker | |
| 2608 | - | return fallback | |
| 2609 | - | } | |
| 2610 | - | if selection, done := handleParkedPicker(pending.leaseID); done { | |
| 2611 | - | return selection | |
| 2612 | - | } | |
| 2613 | - | case protocol.ParkedRouteResume: | |
| 2614 | - | if response.Status != protocol.ParkedRouteResumed { | |
| 2615 | - | return welcomedResult(errors.New("vev: parked route could not resume")) | |
| 2616 | - | } | |
| 2617 | - | if err := publishRouteSnapshot(); err != nil { | |
| 2618 | - | return welcomedResult(err) | |
| 2619 | - | } | |
| 2620 | - | awaitParkedFull() | |
| 2621 | - | case protocol.ParkedRouteSwitch: | |
| 2622 | - | switch response.Status { | |
| 2623 | - | case protocol.ParkedRouteSwitched: | |
| 2624 | - | awaitParkedFull() | |
| 2625 | - | continue | |
| 2626 | - | case protocol.ParkedRouteStaleTarget: | |
| 2627 | - | if transition != nil { | |
| 2628 | - | transition.stop() | |
| 2629 | - | } | |
| 2630 | - | if selection, done := handleParkedPicker(pending.leaseID); done { | |
| 2631 | - | return selection | |
| 2632 | - | } | |
| 2633 | - | default: | |
| 2634 | - | if pending.fallback == nil { | |
| 2635 | - | return welcomedResult(errors.New("vev: parked route switch failed")) | |
| 2636 | - | } | |
| 2637 | - | fallback := welcomedResult(nil) | |
| 2638 | - | fallback.handoff = pending.fallback | |
| 2639 | - | return fallback | |
| 2640 | - | } | |
| 2641 | - | } | |
| 2642 | - | case protocol.NavigationDirective: | |
| 2643 | - | if ui != nil { | |
| 2644 | - | ui.follow(uiGeneration, message.CauseActionID) | |
| 2645 | - | publishUIStatus(ports.UIStatusTransitioning) | |
| 2646 | - | } | |
| 2647 | - | directive := message | |
| 2648 | - | datagramRoute := transport.Capabilities().PreferredOutputWindow == 1 | |
| 2649 | - | if directive.Action == protocol.NavigationOpenHomePicker && a.openHomePicker != nil && datagramRoute { | |
| 2650 | - | stopForeground() | |
| 2651 | - | if err := flushSender(); err != nil { | |
| 2652 | - | return welcomedResult(fmt.Errorf("vev: parking remote foreground: %w", err)) | |
| 2653 | - | } | |
| 2654 | - | if err := sendParkedRouteRequest(protocol.ParkedRoutePrepare, directive.LeaseID, nil); err != nil { | |
| 2655 | - | return welcomedResult(fmt.Errorf("vev: preparing parked route: %w", err)) | |
| 2656 | - | } | |
| 2657 | - | continue | |
| 2658 | - | } | |
| 2659 | - | navigation := welcomedResult(nil) | |
| 2660 | - | navigation.action = directive.Action | |
| 2661 | - | return navigation | |
| 2662 | 2860 | case protocol.NavigationInventoryDemand: | |
| 2663 | 2861 | demand := message | |
| 2664 | 2862 | if inventory == nil { | |
| @@ -2718,6 +2916,164 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2718 | 2916 | // Overlay-owned liveness. The relay never waits on it: | |
| 2719 | 2917 | // publications carry the only state it tracks. | |
| 2720 | 2918 | continue | |
| 2919 | + | case protocol.PickerOffer: | |
| 2920 | + | offer := message | |
| 2921 | + | if offer.InteractionID <= picker.retired { | |
| 2922 | + | continue | |
| 2923 | + | } | |
| 2924 | + | if !picker.open || picker.interaction != offer.InteractionID { | |
| 2925 | + | picker.setOpen(true, offer.InteractionID) | |
| 2926 | + | } | |
| 2927 | + | picker.setIntent(offer.InteractionID, offer.Intent) | |
| 2928 | + | pickerAcquireBarrier = pickerBarrier{epoch: offer.BarrierEpoch, state: offer.BarrierState} | |
| 2929 | + | syncPickerGeneration() | |
| 2930 | + | if hybridPicker != nil && offer.Intent == protocol.PickerIntentNavigation { | |
| 2931 | + | requestID := nextInventoryRequestID | |
| 2932 | + | nextInventoryRequestID++ | |
| 2933 | + | go func(interaction uint64) { | |
| 2934 | + | snapshot, err := hybridPicker.snapshot(loopCtx, requestID, interaction) | |
| 2935 | + | if err != nil { | |
| 2936 | + | log.Warn("hybrid picker authority unavailable", "err", err) | |
| 2937 | + | return | |
| 2938 | + | } | |
| 2939 | + | pickerControlBox.offer(pickerControlOutcome{snapshot: &snapshot}) | |
| 2940 | + | }(offer.InteractionID) | |
| 2941 | + | } | |
| 2942 | + | continue | |
| 2943 | + | case protocol.PickerSnapshot: | |
| 2944 | + | snapshot := message | |
| 2945 | + | // A hybrid client presents only the stable home authority for | |
| 2946 | + | // navigation. The serving daemon still owns the offer/barrier and | |
| 2947 | + | // close lifecycle, but its rows must never be merged or duplicated. | |
| 2948 | + | if hybridPicker != nil && picker.intent == protocol.PickerIntentNavigation && snapshot.SourceID == protocol.PickerServingSourceID { | |
| 2949 | + | continue | |
| 2950 | + | } | |
| 2951 | + | if snapshot.InteractionID <= picker.retired { | |
| 2952 | + | // A late snapshot from before a close never reopens the | |
| 2953 | + | // interaction: retiring it is permanent. | |
| 2954 | + | continue | |
| 2955 | + | } | |
| 2956 | + | // Snapshots are self-opening: the daemon publishes them | |
| 2957 | + | // only for an interaction it admitted, so the first | |
| 2958 | + | // snapshot for an unknown ID opens the namespace. | |
| 2959 | + | if !picker.open || picker.interaction != snapshot.InteractionID { | |
| 2960 | + | picker.setOpen(true, snapshot.InteractionID) | |
| 2961 | + | } | |
| 2962 | + | syncPickerGeneration() | |
| 2963 | + | if !picker.admitSnapshot(snapshot, false) { | |
| 2964 | + | continue | |
| 2965 | + | } | |
| 2966 | + | // A newer publication of the source already on display replaces | |
| 2967 | + | // its lines in place, so the local cursor and search survive a | |
| 2968 | + | // background refresh. A first-or-foreign source installs a new | |
| 2969 | + | // model through the lease below. | |
| 2970 | + | if pickerCurrent != nil && pickerCurrent.interaction == snapshot.InteractionID && pickerPresentation.owns() { | |
| 2971 | + | pickerCurrent.replaceLines(snapshot) | |
| 2972 | + | if err := displayPickerFrame(pickerCurrent, 0); err != nil { | |
| 2973 | + | return welcomedResult(err) | |
| 2974 | + | } | |
| 2975 | + | schedulePickerPreview() | |
| 2976 | + | continue | |
| 2977 | + | } | |
| 2978 | + | // The lease decides presentation: a barrier still ahead keeps | |
| 2979 | + | // displaying daemon output until the exact epoch/state the | |
| 2980 | + | // snapshot was built against is applied, so no older paint can | |
| 2981 | + | // land after the picker frame. | |
| 2982 | + | ready, superseded := pickerPresentation.admitSnapshot(snapshot, pickerAcquireBarrier, pickerLoopFromSnapshot(snapshot, picker.intent, pickerSort), outputState, uiGeneration) | |
| 2983 | + | if superseded != nil { | |
| 2984 | + | // A superseding interaction ended the previous one: resolve | |
| 2985 | + | // that lease, never the one just installed. | |
| 2986 | + | resolveSupersededLease(superseded) | |
| 2987 | + | } | |
| 2988 | + | if !ready { | |
| 2989 | + | continue | |
| 2990 | + | } | |
| 2991 | + | if err := acquirePicker(); err != nil { | |
| 2992 | + | return welcomedResult(err) | |
| 2993 | + | } | |
| 2994 | + | continue | |
| 2995 | + | case protocol.PickerClosed: | |
| 2996 | + | closed := message | |
| 2997 | + | if !pickerPresentation.beginRelease(closed.InteractionID, 0, false, true, outputState) { | |
| 2998 | + | // The client already retired this interaction: the daemon's own | |
| 2999 | + | // close is the ordering barrier its release waits for, so | |
| 3000 | + | // record it and re-capture the boundary the daemon named. | |
| 3001 | + | pickerPresentation.confirmDaemonClose(closed.InteractionID, outputState) | |
| 3002 | + | } | |
| 3003 | + | if pickerPresentation.releasingFor(closed.InteractionID) { | |
| 3004 | + | // The daemon retired the interaction (a resolved selection, | |
| 3005 | + | // a superseding overlay, or the echo of the client's own | |
| 3006 | + | // cancel). Keep consuming and dropping input until the | |
| 3007 | + | // authoritative paint lands. | |
| 3008 | + | drainPickerInput(closed.InteractionID) | |
| 3009 | + | } | |
| 3010 | + | if picker.interaction == closed.InteractionID || !picker.open { | |
| 3011 | + | picker.setOpen(false, closed.InteractionID) | |
| 3012 | + | } | |
| 3013 | + | continue | |
| 3014 | + | case protocol.PickerPreview: | |
| 3015 | + | if pickerCurrent == nil || !pickerPresentation.owns() { | |
| 3016 | + | continue | |
| 3017 | + | } | |
| 3018 | + | if !pickerPreview.accept(message, pickerPresentation.interaction, pickerCurrent.cursorKey()) { | |
| 3019 | + | // A late answer for a row the user already left, or a | |
| 3020 | + | // viewport that cannot be displayed: keep the current one. | |
| 3021 | + | continue | |
| 3022 | + | } | |
| 3023 | + | pickerPreviewFrame = pickerPreview.frame | |
| 3024 | + | if err := displayPickerFrame(pickerCurrent, 0); err != nil { | |
| 3025 | + | return welcomedResult(err) | |
| 3026 | + | } | |
| 3027 | + | continue | |
| 3028 | + | case protocol.PickerResult: | |
| 3029 | + | // A completed in-place mutation (kill or move). The daemon | |
| 3030 | + | // republishes the source right after, so the picker needs no | |
| 3031 | + | // local model change: complete the action and keep presenting. | |
| 3032 | + | result := message | |
| 3033 | + | if result.CauseActionID != 0 && ui != nil { | |
| 3034 | + | ui.completeLocal(uiGeneration, result.CauseActionID) | |
| 3035 | + | } | |
| 3036 | + | continue | |
| 3037 | + | case protocol.PickerFailure: | |
| 3038 | + | failure := message | |
| 3039 | + | // Commit rejections fail the exact ui-driver action | |
| 3040 | + | // that caused them via CauseActionID. Stale-revision | |
| 3041 | + | // and retired-target close the loop: the cursor it | |
| 3042 | + | // committed from no longer exists. Unknown-key and | |
| 3043 | + | // navigation failures keep the admitted revision so | |
| 3044 | + | // the user can retry. | |
| 3045 | + | if failure.InteractionID == picker.interaction && failure.CauseActionID != 0 && ui != nil { | |
| 3046 | + | ui.mu.Lock() | |
| 3047 | + | if record, ok := ui.records[failure.CauseActionID]; ok && record.Status == ports.UIActionPending { | |
| 3048 | + | ui.finishLocked(failure.CauseActionID, ports.UIActionNavigationFailed, ports.UIActionResult{}) | |
| 3049 | + | } | |
| 3050 | + | ui.mu.Unlock() | |
| 3051 | + | } | |
| 3052 | + | switch failure.Code { | |
| 3053 | + | case protocol.PickerStaleRevision, protocol.PickerRetiredTarget: | |
| 3054 | + | // The interaction can no longer be committed from. Close it | |
| 3055 | + | // daemon-side so it stops owning input and restores the screen | |
| 3056 | + | // authoritatively, then drain until that paint lands instead of | |
| 3057 | + | // dropping the lease and leaving the daemon interaction open. | |
| 3058 | + | // A second rejection for an interaction already draining must | |
| 3059 | + | // not restart or cancel that release. | |
| 3060 | + | if failure.InteractionID != picker.interaction && picker.open { | |
| 3061 | + | continue | |
| 3062 | + | } | |
| 3063 | + | if pickerPresentation.releasingFor(failure.InteractionID) { | |
| 3064 | + | continue | |
| 3065 | + | } | |
| 3066 | + | picker.setOpen(false, failure.InteractionID) | |
| 3067 | + | if err := sendPickerClose(failure.InteractionID); err != nil { | |
| 3068 | + | return welcomedResult(err) | |
| 3069 | + | } | |
| 3070 | + | if pickerPresentation.beginRelease(failure.InteractionID, 0, false, false, outputState) { | |
| 3071 | + | drainPickerInput(failure.InteractionID) | |
| 3072 | + | } else { | |
| 3073 | + | abortPickerLease(false) | |
| 3074 | + | } | |
| 3075 | + | } | |
| 3076 | + | continue | |
| 2721 | 3077 | case protocol.RouteCreateSessionAction: | |
| 2722 | 3078 | action := message | |
| 2723 | 3079 | if action.Validate() != nil { | |
| @@ -2774,9 +3130,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2774 | 3130 | } | |
| 2775 | 3131 | name = identity.Target.SessionName | |
| 2776 | 3132 | committedIdentity = cloneCommittedIdentity(&identity) | |
| 2777 | - | if transientPicker { | |
| 2778 | - | continue | |
| 2779 | - | } | |
| 2780 | 3133 | if a.runner.ledger == nil { | |
| 2781 | 3134 | return welcomedResult(errors.New("vev: route ledger unavailable")) | |
| 2782 | 3135 | } | |
| @@ -3084,6 +3437,11 @@ type terminalReadResult struct { | |||
| 3084 | 3437 | generation uint64 | |
| 3085 | 3438 | actionID uint64 | |
| 3086 | 3439 | endBatch bool | |
| 3440 | + | // keys/text carry the decoded ui-driver op for picker interception. | |
| 3441 | + | // Physical input leaves both empty; the picker consumer then decodes the | |
| 3442 | + | // raw bytes itself. | |
| 3443 | + | keys []string | |
| 3444 | + | text string | |
| 3087 | 3445 | } | |
| 3088 | 3446 | ||
| 3089 | 3447 | // terminalAutomationRequest is admitted only by the foreground scanner owner. | |
| @@ -3456,6 +3814,12 @@ type stdinPump struct { | |||
| 3456 | 3814 | afterInputTake func() // test synchronization hook | |
| 3457 | 3815 | afterInputDelivered func() // test synchronization hook | |
| 3458 | 3816 | sendLease *foregroundSendLease | |
| 3817 | + | // picker publishes who owns terminal input and decodes batches into | |
| 3818 | + | // identified operations. The pump never touches the picker model. | |
| 3819 | + | picker *pickerConsumer | |
| 3820 | + | // pickerOutcomes carries those operations to the attach loop, which | |
| 3821 | + | // applies them and flushes typed sends in controlCh order. | |
| 3822 | + | pickerOutcomes *pickerOutcomeQueue | |
| 3459 | 3823 | } | |
| 3460 | 3824 | ||
| 3461 | 3825 | func (p *stdinPump) run() { | |
| @@ -3538,6 +3902,7 @@ func (p *stdinPump) run() { | |||
| 3538 | 3902 | sink := coalescer.Scan | |
| 3539 | 3903 | if p.clipboard != nil { | |
| 3540 | 3904 | ci := &clipboardIntercept{ | |
| 3905 | + | ctx: p.ctx, | |
| 3541 | 3906 | coalescer: coalescer, | |
| 3542 | 3907 | reader: p.clipboard, | |
| 3543 | 3908 | log: p.logger, | |
| @@ -3552,6 +3917,20 @@ func (p *stdinPump) run() { | |||
| 3552 | 3917 | } | |
| 3553 | 3918 | sink = ci.Scan | |
| 3554 | 3919 | } | |
| 3920 | + | // routeInput keeps the marker/palette demultiplexing intact while a | |
| 3921 | + | // client-owned picker owns the terminal: the scanner still resolves | |
| 3922 | + | // probe replies, but every byte that would have become session input is | |
| 3923 | + | // decoded for the picker instead. There is no fallback to the PTY | |
| 3924 | + | // pipeline while the picker owns input. | |
| 3925 | + | routeInput := func(data []byte) { | |
| 3926 | + | if p.picker != nil { | |
| 3927 | + | if outcome, consumed := p.picker.consume(terminalReadResult{data: data, source: terminalInputHuman}); consumed { | |
| 3928 | + | p.pickerOutcomes.offer(outcome) | |
| 3929 | + | return | |
| 3930 | + | } | |
| 3931 | + | } | |
| 3932 | + | sink(data) | |
| 3933 | + | } | |
| 3555 | 3934 | input := p.input | |
| 3556 | 3935 | ownedInput := input == nil | |
| 3557 | 3936 | if ownedInput { | |
| @@ -3587,7 +3966,7 @@ func (p *stdinPump) run() { | |||
| 3587 | 3966 | var markerTimer ports.Timer | |
| 3588 | 3967 | var markerTimerC <-chan time.Time | |
| 3589 | 3968 | flushMarkerPrefix := func() { | |
| 3590 | - | markers.flush(sink) | |
| 3969 | + | markers.flush(routeInput) | |
| 3591 | 3970 | markerTimer = nil | |
| 3592 | 3971 | markerTimerC = nil | |
| 3593 | 3972 | } | |
| @@ -3621,7 +4000,61 @@ func (p *stdinPump) run() { | |||
| 3621 | 4000 | } | |
| 3622 | 4001 | defer disarmMarkerDeadline() | |
| 3623 | 4002 | ||
| 4003 | + | var pickerTimer ports.Timer | |
| 4004 | + | var pickerTimerC <-chan time.Time | |
| 4005 | + | disarmPickerDeadline := func() { | |
| 4006 | + | if pickerTimer == nil { | |
| 4007 | + | return | |
| 4008 | + | } | |
| 4009 | + | pickerTimer.Stop() | |
| 4010 | + | pickerTimer = nil | |
| 4011 | + | pickerTimerC = nil | |
| 4012 | + | } | |
| 4013 | + | // armPickerDeadline bounds the escape ambiguity window while the picker | |
| 4014 | + | // owns input: a lone ESC is Escape unless a sequence byte follows before | |
| 4015 | + | // the deadline. The withheld prefix never falls back to the session. | |
| 4016 | + | armPickerDeadline := func() { | |
| 4017 | + | disarmPickerDeadline() | |
| 4018 | + | pickerTimer = p.clock.NewTimer(pickerEscapeDeadline) | |
| 4019 | + | pickerTimerC = pickerTimer.C() | |
| 4020 | + | } | |
| 4021 | + | defer disarmPickerDeadline() | |
| 4022 | + | // syncPickerDeadline arms the escape window once per withheld prefix: | |
| 4023 | + | // re-arming on every loop turn would postpone the deadline whenever an | |
| 4024 | + | // unrelated event wakes the pump. | |
| 4025 | + | syncPickerDeadline := func() { | |
| 4026 | + | if !p.picker.hasPending() { | |
| 4027 | + | disarmPickerDeadline() | |
| 4028 | + | return | |
| 4029 | + | } | |
| 4030 | + | if pickerTimer == nil { | |
| 4031 | + | armPickerDeadline() | |
| 4032 | + | } | |
| 4033 | + | } | |
| 4034 | + | // resolveExpiredPickerDeadline applies a deadline that already fired | |
| 4035 | + | // before a later suffix may complete the withheld prefix. | |
| 4036 | + | resolveExpiredPickerDeadline := func() { | |
| 4037 | + | if pickerTimerC == nil { | |
| 4038 | + | return | |
| 4039 | + | } | |
| 4040 | + | select { | |
| 4041 | + | case <-pickerTimerC: | |
| 4042 | + | disarmPickerDeadline() | |
| 4043 | + | if outcome, ok := p.picker.flushPending(); ok { | |
| 4044 | + | p.pickerOutcomes.offer(outcome) | |
| 4045 | + | } | |
| 4046 | + | default: | |
| 4047 | + | } | |
| 4048 | + | } | |
| 4049 | + | ||
| 3624 | 4050 | for { | |
| 4051 | + | // A withheld prefix needs a bounded disambiguation window whichever | |
| 4052 | + | // path withheld it: the palette-marker scanner can flush ordinary | |
| 4053 | + | // bytes into the picker decoder, so the deadline is synced here rather | |
| 4054 | + | // than at one producer site. An already-expired deadline resolves | |
| 4055 | + | // first so a later suffix cannot complete the prefix. | |
| 4056 | + | resolveExpiredPickerDeadline() | |
| 4057 | + | syncPickerDeadline() | |
| 3625 | 4058 | var result terminalReadResult | |
| 3626 | 4059 | var batch *terminalAutomationRequest | |
| 3627 | 4060 | select { | |
| @@ -3630,6 +4063,11 @@ func (p *stdinPump) run() { | |||
| 3630 | 4063 | case <-markerTimerC: | |
| 3631 | 4064 | flushMarkerPrefix() | |
| 3632 | 4065 | continue | |
| 4066 | + | case <-pickerTimerC: | |
| 4067 | + | disarmPickerDeadline() | |
| 4068 | + | if outcome, ok := p.picker.flushPending(); ok { | |
| 4069 | + | p.pickerOutcomes.offer(outcome) | |
| 4070 | + | } | |
| 3633 | 4071 | case request := <-input.automation: | |
| 3634 | 4072 | input.mu.Lock() | |
| 3635 | 4073 | clean := input.consumer == consumer && request.consumer == consumer && input.pending == nil && len(input.residual) == 0 && input.delivering == 0 | |
| @@ -3647,6 +4085,27 @@ func (p *stdinPump) run() { | |||
| 3647 | 4085 | activeBatch = batch | |
| 3648 | 4086 | batchSent.Store(false) | |
| 3649 | 4087 | batchComplete = false | |
| 4088 | + | outcome, consumed := pickerConsumeOutcome{}, false | |
| 4089 | + | if p.picker != nil { | |
| 4090 | + | outcome, consumed = p.picker.consume(request.record) | |
| 4091 | + | } | |
| 4092 | + | if consumed { | |
| 4093 | + | p.pickerOutcomes.offer(outcome) | |
| 4094 | + | // The open picker loop consumed this admitted op: | |
| 4095 | + | // cursor/search repainted locally or a typed | |
| 4096 | + | // commit/cancel crossed on controlCh. Zero picker | |
| 4097 | + | // bytes enter the PTY pipeline, so the coalescer | |
| 4098 | + | // and fence below are skipped. Completion follows | |
| 4099 | + | // the action path: locally for presentation steps, | |
| 4100 | + | // via PickerFailure or the navigation handoff for | |
| 4101 | + | // commits and cancels. | |
| 4102 | + | request.admitted <- true | |
| 4103 | + | batch.dispatched <- true | |
| 4104 | + | activeBatch = nil | |
| 4105 | + | actionID.Store(0) | |
| 4106 | + | automated = false | |
| 4107 | + | continue | |
| 4108 | + | } | |
| 3650 | 4109 | result = request.record | |
| 3651 | 4110 | automated = true | |
| 3652 | 4111 | actionID.Store(result.actionID) | |
| @@ -3693,7 +4152,7 @@ func (p *stdinPump) run() { | |||
| 3693 | 4152 | }, func(light bool) { | |
| 3694 | 4153 | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light}) | |
| 3695 | 4154 | }, func(data []byte) { | |
| 3696 | - | markers.scan(data, sink, func() { | |
| 4155 | + | markers.scan(data, routeInput, func() { | |
| 3697 | 4156 | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) | |
| 3698 | 4157 | }) | |
| 3699 | 4158 | }) | |
| @@ -3708,9 +4167,9 @@ func (p *stdinPump) run() { | |||
| 3708 | 4167 | } | |
| 3709 | 4168 | if batch != nil { | |
| 3710 | 4169 | scanner.EndBatch(func(data []byte) { | |
| 3711 | - | markers.scan(data, sink, func() { sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) }) | |
| 4170 | + | markers.scan(data, routeInput, func() { sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) }) | |
| 3712 | 4171 | }) | |
| 3713 | - | markers.flush(sink) | |
| 4172 | + | markers.flush(routeInput) | |
| 3714 | 4173 | ok := coalescer.EndBatch() && sendOK.Load() | |
| 3715 | 4174 | batchComplete = ok && pendingEvents.Load() == 0 | |
| 3716 | 4175 | applied := make(chan struct{}) | |
| @@ -3744,7 +4203,7 @@ func (p *stdinPump) run() { | |||
| 3744 | 4203 | } | |
| 3745 | 4204 | if result.err != nil { | |
| 3746 | 4205 | disarmMarkerDeadline() | |
| 3747 | - | markers.flush(sink) | |
| 4206 | + | markers.flush(routeInput) | |
| 3748 | 4207 | select { | |
| 3749 | 4208 | case p.out <- protocol.Detach{}: | |
| 3750 | 4209 | case <-p.ctx.Done(): | |
| @@ -3763,10 +4222,26 @@ func (p *stdinPump) run() { | |||
| 3763 | 4222 | } | |
| 3764 | 4223 | } | |
| 3765 | 4224 | ||
| 4225 | + | func offerLatestGeometry(out chan domain.Geometry, geometry domain.Geometry) { | |
| 4226 | + | select { | |
| 4227 | + | case out <- geometry: | |
| 4228 | + | return | |
| 4229 | + | default: | |
| 4230 | + | } | |
| 4231 | + | select { | |
| 4232 | + | case <-out: | |
| 4233 | + | default: | |
| 4234 | + | } | |
| 4235 | + | select { | |
| 4236 | + | case out <- geometry: | |
| 4237 | + | default: | |
| 4238 | + | } | |
| 4239 | + | } | |
| 4240 | + | ||
| 3766 | 4241 | // runResize forwards coalesced terminal resize events to the daemon. It | |
| 3767 | 4242 | // tolerates an already-closed resize channel (which the terminal adapter | |
| 3768 | 4243 | // hands back when restore ran before ResizeEvents was first called). | |
| 3769 | - | func runResize(ctx context.Context, events <-chan domain.Geometry, out chan<- protocol.ClientMessage, sendLease *foregroundSendLease, log *slog.Logger) { | |
| 4244 | + | func runResize(ctx context.Context, events <-chan domain.Geometry, out chan<- protocol.ClientMessage, local chan domain.Geometry, sendLease *foregroundSendLease, log *slog.Logger) { | |
| 3770 | 4245 | defer log.Debug("resize pump exited") | |
| 3771 | 4246 | for { | |
| 3772 | 4247 | select { | |
| @@ -3783,6 +4258,7 @@ func runResize(ctx context.Context, events <-chan domain.Geometry, out chan<- pr | |||
| 3783 | 4258 | if !sendLease.send(func() bool { | |
| 3784 | 4259 | select { | |
| 3785 | 4260 | case out <- message: | |
| 4261 | + | offerLatestGeometry(local, geometry) | |
| 3786 | 4262 | return true | |
| 3787 | 4263 | case <-ctx.Done(): | |
| 3788 | 4264 | return false | |
M internal/usecase/client/client_test.go +171 -1133
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 7 | 8 | "io" | |
| 8 | 9 | "log/slog" | |
| 9 | 10 | "os" | |
| @@ -239,6 +240,64 @@ func isType(typ wire.MsgType) any { | |||
| 239 | 240 | return mock.MatchedBy(func(f wire.Frame) bool { return f.Type == typ }) | |
| 240 | 241 | } | |
| 241 | 242 | ||
| 243 | + | // stubHostRegistry is the test host registry: it resolves endpoints from a | |
| 244 | + | // table and projects an empty directory. Run parks until the runner exits, so | |
| 245 | + | // the registry lifecycle is joined exactly like production. | |
| 246 | + | type stubHostRegistry struct { | |
| 247 | + | resolve func(endpoint string) (ports.RemoteEndpointBinding, error) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func (r stubHostRegistry) ResolveEndpoint(_ context.Context, endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 251 | + | if r.resolve == nil { | |
| 252 | + | return ports.RemoteEndpointBinding{}, fmt.Errorf("stub host registry has no resolver for %q", endpoint) | |
| 253 | + | } | |
| 254 | + | return r.resolve(endpoint) | |
| 255 | + | } | |
| 256 | + | func (stubHostRegistry) Snapshot() ports.RemoteDirectorySnapshot { | |
| 257 | + | return ports.RemoteDirectorySnapshot{} | |
| 258 | + | } | |
| 259 | + | func (stubHostRegistry) Subscribe() ports.RemoteDirectorySubscription { return nil } | |
| 260 | + | func (stubHostRegistry) RequestReconcile(string) {} | |
| 261 | + | func (stubHostRegistry) RegistryChanged() {} | |
| 262 | + | func (stubHostRegistry) Run(ctx context.Context) error { | |
| 263 | + | <-ctx.Done() | |
| 264 | + | return nil | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // countingHostRegistry records the discovery loop's lifetime, so a test can | |
| 268 | + | // prove one loop serves a whole run and is joined before the client returns. | |
| 269 | + | type countingHostRegistry struct { | |
| 270 | + | runs atomic.Int32 | |
| 271 | + | joined chan struct{} | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func (r *countingHostRegistry) ResolveEndpoint(context.Context, string) (ports.RemoteEndpointBinding, error) { | |
| 275 | + | return ports.RemoteEndpointBinding{Dialer: &sequenceDialer{}}, nil | |
| 276 | + | } | |
| 277 | + | func (*countingHostRegistry) Snapshot() ports.RemoteDirectorySnapshot { | |
| 278 | + | return ports.RemoteDirectorySnapshot{} | |
| 279 | + | } | |
| 280 | + | func (*countingHostRegistry) Subscribe() ports.RemoteDirectorySubscription { return nil } | |
| 281 | + | func (*countingHostRegistry) RequestReconcile(string) {} | |
| 282 | + | func (*countingHostRegistry) RegistryChanged() {} | |
| 283 | + | func (r *countingHostRegistry) Run(ctx context.Context) error { | |
| 284 | + | r.runs.Add(1) | |
| 285 | + | <-ctx.Done() | |
| 286 | + | close(r.joined) | |
| 287 | + | return nil | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // dialerForEndpoints builds a registry that serves one dialer per endpoint. | |
| 291 | + | func dialerForEndpoints(dialers map[string]ports.ClientDialer) stubHostRegistry { | |
| 292 | + | return stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 293 | + | dialer := dialers[endpoint] | |
| 294 | + | if dialer == nil { | |
| 295 | + | return ports.RemoteEndpointBinding{}, fmt.Errorf("hybrid picker endpoint %q has no dialer", endpoint) | |
| 296 | + | } | |
| 297 | + | return ports.RemoteEndpointBinding{Dialer: dialer}, nil | |
| 298 | + | }} | |
| 299 | + | } | |
| 300 | + | ||
| 242 | 301 | // transportDialer adapts one already-open test transport to the Runner API. | |
| 243 | 302 | type transportDialer struct{ transport wire.Transport } | |
| 244 | 303 | ||
| @@ -557,6 +616,66 @@ func TestKilledSessionReturnsToPreviousLocalRoute(t *testing.T) { | |||
| 557 | 616 | require.Equal(t, int32(1), term.restoreCount.Load()) | |
| 558 | 617 | } | |
| 559 | 618 | ||
| 619 | + | // TestSamePeerSwitchFailurePreservesSourceRouteHistory pins the | |
| 620 | + | // precommit-rejection boundary: a daemon SamePeerSwitchFailure leaves the | |
| 621 | + | // source attachment unchanged, commits no history, and dials no replacement | |
| 622 | + | // transport. The client keeps serving the source route it already owned. | |
| 623 | + | func TestSamePeerSwitchFailurePreservesSourceRouteHistory(t *testing.T) { | |
| 624 | + | term := newRunTerminal() | |
| 625 | + | defer term.in.unblock() | |
| 626 | + | ||
| 627 | + | sourceTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "source"} | |
| 628 | + | destTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "destination"} | |
| 629 | + | welcome := func(target protocol.ExactSessionTarget) wire.Frame { | |
| 630 | + | return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{ | |
| 631 | + | SessionID: target.SessionName, SessionName: target.SessionName, ResumeToken: 1, | |
| 632 | + | Capabilities: protocol.CapabilityResume, | |
| 633 | + | CommittedIdentity: &protocol.CommittedRouteIdentity{Target: target}, | |
| 634 | + | })) | |
| 635 | + | } | |
| 636 | + | switchSent := make(chan struct{}) | |
| 637 | + | var switchOnce sync.Once | |
| 638 | + | active := &recordingTransport{recvs: []recvItem{ | |
| 639 | + | {f: welcome(sourceTarget)}, | |
| 640 | + | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 641 | + | Session: "destination", Intent: protocol.IntentAttach, ExactTarget: &destTarget, | |
| 642 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true, | |
| 643 | + | }))}, | |
| 644 | + | {f: frameOf(wire.MsgSamePeerSwitchFailure, mustMarshalSamePeerSwitchFailure(protocol.SamePeerSwitchFailure{ | |
| 645 | + | RequestID: 1, Code: protocol.SamePeerSwitchUnavailable, | |
| 646 | + | })), wait: switchSent}, | |
| 647 | + | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 648 | + | }} | |
| 649 | + | active.onSend = func(frame wire.Frame) { | |
| 650 | + | if frame.Type == wire.MsgSamePeerSwitchRequest { | |
| 651 | + | switchOnce.Do(func() { close(switchSent) }) | |
| 652 | + | } | |
| 653 | + | } | |
| 654 | + | dialer := &sequenceDialer{trs: []wire.Transport{active}} | |
| 655 | + | ||
| 656 | + | err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{ | |
| 657 | + | Intent: protocol.IntentAttach, SessionName: "source", Origin: protocol.RouteOriginLocal, | |
| 658 | + | OriginKey: "local", EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 659 | + | }) | |
| 660 | + | ||
| 661 | + | require.NoError(t, err) | |
| 662 | + | require.Equal(t, int32(1), dialer.calls.Load(), "a rejected same-peer switch must not dial a replacement transport") | |
| 663 | + | var snapshots []protocol.RecentRouteSnapshot | |
| 664 | + | for _, sent := range active.Sends() { | |
| 665 | + | if sent.Type != wire.MsgRecentRouteSnapshot { | |
| 666 | + | continue | |
| 667 | + | } | |
| 668 | + | snapshot, err := wire.UnmarshalRecentRouteSnapshot(sent.Payload) | |
| 669 | + | require.NoError(t, err) | |
| 670 | + | snapshots = append(snapshots, snapshot) | |
| 671 | + | } | |
| 672 | + | require.NotEmpty(t, snapshots, "the welcome commit must publish a route snapshot") | |
| 673 | + | for _, snapshot := range snapshots { | |
| 674 | + | require.Equal(t, sourceTarget, snapshot.ActiveEntry.Target, "a rejected switch must not change the active route") | |
| 675 | + | require.Empty(t, snapshot.Entries, "a rejected switch must not append destination history") | |
| 676 | + | } | |
| 677 | + | } | |
| 678 | + | ||
| 560 | 679 | func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) { | |
| 561 | 680 | term := newRunTerminal() | |
| 562 | 681 | defer term.in.unblock() | |
| @@ -568,8 +687,7 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) { | |||
| 568 | 687 | request := client.AttachRequest{ | |
| 569 | 688 | Intent: protocol.IntentAttach, SessionName: "work", Remote: true, | |
| 570 | 689 | RemoteTarget: target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 571 | - | NavigationCapabilities: protocol.NavigationCapabilityHomePicker, | |
| 572 | - | StartupOverlay: protocol.StartupOverlayNone, | |
| 690 | + | NavigationCapabilities: protocol.NavigationCapabilityInventory, | |
| 573 | 691 | } | |
| 574 | 692 | tr := &recordingTransport{recvs: []recvItem{ | |
| 575 | 693 | {f: welcomeFrame(0)}, | |
| @@ -582,8 +700,7 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) { | |||
| 582 | 700 | require.Equal(t, request.SessionName, hello.Name) | |
| 583 | 701 | require.Equal(t, target, hello.RemoteTarget) | |
| 584 | 702 | require.Equal(t, request.EnvironmentPolicy, hello.EnvironmentPolicy) | |
| 585 | - | require.Equal(t, request.NavigationCapabilities, hello.NavigationCapabilities) | |
| 586 | - | require.Equal(t, request.StartupOverlay, hello.StartupOverlay) | |
| 703 | + | require.Equal(t, request.NavigationCapabilities|protocol.NavigationCapabilityInventory, hello.NavigationCapabilities) | |
| 587 | 704 | } | |
| 588 | 705 | ||
| 589 | 706 | func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) { | |
| @@ -625,56 +742,6 @@ func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) { | |||
| 625 | 742 | require.Equal(t, &target, helloFromSend(t, second).ExactTarget) | |
| 626 | 743 | } | |
| 627 | 744 | ||
| 628 | - | func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) { | |
| 629 | - | term := newRunTerminal() | |
| 630 | - | defer term.in.unblock() | |
| 631 | - | localLifecycle := domain.SessionLifecycleID{1} | |
| 632 | - | remoteLifecycle := domain.SessionLifecycleID{2} | |
| 633 | - | welcome := func(name string, lifecycle domain.SessionLifecycleID) wire.Frame { | |
| 634 | - | return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{ | |
| 635 | - | SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: protocol.CapabilityResume, | |
| 636 | - | CommittedIdentity: &protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}}, | |
| 637 | - | })) | |
| 638 | - | } | |
| 639 | - | remoteTarget := domain.RemoteSessionTarget{ | |
| 640 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle, | |
| 641 | - | SessionName: "work", LiveTabID: "tab-1", | |
| 642 | - | } | |
| 643 | - | local1 := &recordingTransport{recvs: []recvItem{ | |
| 644 | - | {f: welcome("local", localLifecycle)}, | |
| 645 | - | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 646 | - | Endpoint: "remote", Session: "work", Intent: protocol.IntentAttach, | |
| 647 | - | RemoteTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 648 | - | }))}, | |
| 649 | - | }} | |
| 650 | - | remote := &recordingTransport{recvs: []recvItem{ | |
| 651 | - | {f: welcome("work", remoteLifecycle)}, | |
| 652 | - | {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)}, | |
| 653 | - | }} | |
| 654 | - | local2 := &recordingTransport{recvs: []recvItem{ | |
| 655 | - | {f: welcome("local", localLifecycle)}, | |
| 656 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 657 | - | }} | |
| 658 | - | localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}} | |
| 659 | - | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}} | |
| 660 | - | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |
| 661 | - | deps.AttachHandoff = func(protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |
| 662 | - | target := protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "work"} | |
| 663 | - | return remoteDialer, client.AttachRequest{ | |
| 664 | - | Intent: protocol.IntentAttach, SessionName: "work", Remote: true, | |
| 665 | - | Origin: protocol.RouteOriginRemote, OriginKey: "remote", ExactTarget: &target, | |
| 666 | - | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 667 | - | // Deliberately omit the copied Home capability. The accepted server | |
| 668 | - | // action remains valid because Runner retained the concrete home route. | |
| 669 | - | }, nil | |
| 670 | - | } | |
| 671 | - | ||
| 672 | - | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 673 | - | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 674 | - | })) | |
| 675 | - | require.Equal(t, protocol.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay) | |
| 676 | - | } | |
| 677 | - | ||
| 678 | 745 | func hybridWelcomeFrame(name string, lifecycle domain.SessionLifecycleID) wire.Frame { | |
| 679 | 746 | return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{ | |
| 680 | 747 | SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: protocol.CapabilityResume, | |
| @@ -694,136 +761,10 @@ func hybridLocalBootstrap(lifecycle domain.SessionLifecycleID, target domain.Rem | |||
| 694 | 761 | ||
| 695 | 762 | func hybridPickerDependencies(localDialer ports.ClientDialer, term ports.Terminal, clock ports.Clock, remoteByEndpoint map[string]ports.ClientDialer) client.Dependencies { | |
| 696 | 763 | deps := testDependencies(localDialer, term, clock, nil, nil) | |
| 697 | - | deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |
| 698 | - | if target.RemoteTarget == nil { | |
| 699 | - | return nil, client.AttachRequest{}, errors.New("hybrid picker target is not remote") | |
| 700 | - | } | |
| 701 | - | dialer := remoteByEndpoint[target.Endpoint] | |
| 702 | - | if dialer == nil { | |
| 703 | - | return nil, client.AttachRequest{}, errors.New("hybrid picker endpoint has no dialer") | |
| 704 | - | } | |
| 705 | - | selection := *target.RemoteTarget | |
| 706 | - | return dialer, client.AttachRequest{ | |
| 707 | - | Intent: protocol.IntentAttach, SessionName: target.Session, Remote: true, | |
| 708 | - | Origin: protocol.RouteOriginDiscovery, OriginKey: target.Endpoint, | |
| 709 | - | RemoteTarget: &selection, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 710 | - | }, nil | |
| 711 | - | } | |
| 764 | + | deps.HostRegistry = dialerForEndpoints(remoteByEndpoint) | |
| 712 | 765 | return deps | |
| 713 | 766 | } | |
| 714 | 767 | ||
| 715 | - | func hybridParkedRequestHandler(requests chan<- protocol.ParkedRouteRequest, requestErrors chan<- error, signals map[protocol.ParkedRouteAction]chan struct{}) func(wire.Frame) { | |
| 716 | - | once := make(map[protocol.ParkedRouteAction]*sync.Once, len(signals)) | |
| 717 | - | for action := range signals { | |
| 718 | - | once[action] = &sync.Once{} | |
| 719 | - | } | |
| 720 | - | return func(frame wire.Frame) { | |
| 721 | - | if frame.Type != wire.MsgParkedRouteRequest { | |
| 722 | - | return | |
| 723 | - | } | |
| 724 | - | request, err := wire.UnmarshalParkedRouteRequest(frame.Payload) | |
| 725 | - | if err != nil { | |
| 726 | - | if requestErrors != nil { | |
| 727 | - | requestErrors <- err | |
| 728 | - | } | |
| 729 | - | return | |
| 730 | - | } | |
| 731 | - | if requests != nil { | |
| 732 | - | requests <- request | |
| 733 | - | } | |
| 734 | - | if signal := signals[request.Action]; signal != nil { | |
| 735 | - | once[request.Action].Do(func() { close(signal) }) | |
| 736 | - | } | |
| 737 | - | } | |
| 738 | - | } | |
| 739 | - | ||
| 740 | - | func TestHybridPickerSameHostSwitchReusesRemoteTransport(t *testing.T) { | |
| 741 | - | term := newRunTerminal() | |
| 742 | - | defer term.in.unblock() | |
| 743 | - | ||
| 744 | - | localLifecycle := domain.SessionLifecycleID{1} | |
| 745 | - | remoteSourceLifecycle := domain.SessionLifecycleID{2} | |
| 746 | - | remoteTargetLifecycle := domain.SessionLifecycleID{3} | |
| 747 | - | sourceTarget := domain.RemoteSessionTarget{ | |
| 748 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteSourceLifecycle, | |
| 749 | - | SessionName: "source", LiveTabID: "source-tab", | |
| 750 | - | } | |
| 751 | - | targetTarget := domain.RemoteSessionTarget{ | |
| 752 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteTargetLifecycle, | |
| 753 | - | SessionName: "target", LiveTabID: "target-tab", | |
| 754 | - | } | |
| 755 | - | localInitial := hybridLocalBootstrap(localLifecycle, sourceTarget) | |
| 756 | - | parkSent := make(chan struct{}) | |
| 757 | - | switchSent := make(chan struct{}) | |
| 758 | - | parkedRequests := make(chan protocol.ParkedRouteRequest, 2) | |
| 759 | - | parkedRequestErrors := make(chan error, 2) | |
| 760 | - | remote := &recordingTransport{recvs: []recvItem{ | |
| 761 | - | {f: hybridWelcomeFrame("source", remoteSourceLifecycle)}, | |
| 762 | - | {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)}, | |
| 763 | - | {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady})), wait: parkSent}, | |
| 764 | - | {f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: remoteTargetLifecycle, SessionName: "target"}})), wait: switchSent}, | |
| 765 | - | {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteSwitched}))}, | |
| 766 | - | {f: frameOf(wire.MsgOutput, mustMarshalOutput(protocol.Output{Epoch: 2, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("target frame")}))}, | |
| 767 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 768 | - | }} | |
| 769 | - | remote.onSend = hybridParkedRequestHandler(parkedRequests, parkedRequestErrors, map[protocol.ParkedRouteAction]chan struct{}{ | |
| 770 | - | protocol.ParkedRoutePrepare: parkSent, | |
| 771 | - | protocol.ParkedRouteSwitch: switchSent, | |
| 772 | - | }) | |
| 773 | - | localPicker := &recordingTransport{recvs: []recvItem{ | |
| 774 | - | {f: hybridWelcomeFrame("local", localLifecycle)}, | |
| 775 | - | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 776 | - | Endpoint: "remote", Session: "target", Intent: protocol.IntentAttach, | |
| 777 | - | RemoteTarget: &targetTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 778 | - | }))}, | |
| 779 | - | }} | |
| 780 | - | var resizeOnce sync.Once | |
| 781 | - | localPicker.onSend = func(frame wire.Frame) { | |
| 782 | - | if frame.Type == wire.MsgHello { | |
| 783 | - | resizeOnce.Do(func() { term.setSize(domain.Size{Cols: 100, Rows: 40}) }) | |
| 784 | - | } | |
| 785 | - | } | |
| 786 | - | localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}} | |
| 787 | - | remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}} | |
| 788 | - | deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer}) | |
| 789 | - | ||
| 790 | - | err := runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 791 | - | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 792 | - | }) | |
| 793 | - | ||
| 794 | - | require.NoError(t, err) | |
| 795 | - | select { | |
| 796 | - | case requestErr := <-parkedRequestErrors: | |
| 797 | - | require.NoError(t, requestErr) | |
| 798 | - | default: | |
| 799 | - | } | |
| 800 | - | prepareRequest := <-parkedRequests | |
| 801 | - | switchRequest := <-parkedRequests | |
| 802 | - | require.Equal(t, protocol.ParkedRoutePrepare, prepareRequest.Action) | |
| 803 | - | require.Equal(t, protocol.ParkedRouteSwitch, switchRequest.Action) | |
| 804 | - | require.NotNil(t, switchRequest.Target) | |
| 805 | - | require.Equal(t, targetTarget, *switchRequest.Target) | |
| 806 | - | frames := remote.Sends() | |
| 807 | - | switchIndex := -1 | |
| 808 | - | for i, frame := range frames { | |
| 809 | - | if frame.Type != wire.MsgParkedRouteRequest { | |
| 810 | - | continue | |
| 811 | - | } | |
| 812 | - | request, requestErr := wire.UnmarshalParkedRouteRequest(frame.Payload) | |
| 813 | - | require.NoError(t, requestErr) | |
| 814 | - | if request.Action == protocol.ParkedRouteSwitch { | |
| 815 | - | switchIndex = i | |
| 816 | - | break | |
| 817 | - | } | |
| 818 | - | } | |
| 819 | - | require.Positive(t, switchIndex) | |
| 820 | - | require.Equal(t, wire.MsgResize, frames[switchIndex-1].Type, "current size must precede the parked switch") | |
| 821 | - | resize, resizeErr := wire.UnmarshalResize(frames[switchIndex-1].Payload) | |
| 822 | - | require.NoError(t, resizeErr) | |
| 823 | - | require.Equal(t, domain.Size{Cols: 100, Rows: 40}, resize.Size) | |
| 824 | - | require.Equal(t, int32(1), remoteDialer.calls.Load(), "same-host picker switch must retain the authenticated remote transport") | |
| 825 | - | } | |
| 826 | - | ||
| 827 | 768 | func TestHybridBackSessionSamePeerOfferReusesRemoteTransport(t *testing.T) { | |
| 828 | 769 | term := newRunTerminal() | |
| 829 | 770 | defer term.in.unblock() | |
| @@ -879,381 +820,10 @@ func TestHybridBackSessionSamePeerOfferReusesRemoteTransport(t *testing.T) { | |||
| 879 | 820 | require.Equal(t, int32(1), remoteDialer.calls.Load(), "BCK must retain the authenticated remote transport") | |
| 880 | 821 | } | |
| 881 | 822 | ||
| 882 | - | func TestHybridPickerLocalSelectionCommitsLocalRoute(t *testing.T) { | |
| 883 | - | term := newRunTerminal() | |
| 884 | - | defer term.in.unblock() | |
| 885 | - | ||
| 886 | - | localHomeLifecycle := domain.SessionLifecycleID{1} | |
| 887 | - | localMiscLifecycle := domain.SessionLifecycleID{2} | |
| 888 | - | remoteSourceLifecycle := domain.SessionLifecycleID{3} | |
| 889 | - | remoteMiscLifecycle := domain.SessionLifecycleID{4} | |
| 890 | - | remoteVev := protocol.ExactSessionTarget{LifecycleID: remoteSourceLifecycle, SessionName: "vev"} | |
| 891 | - | remoteMisc := domain.RemoteSessionTarget{ | |
| 892 | - | Endpoint: "remote", DisplayOrigin: "arch", LifecycleID: remoteMiscLifecycle, | |
| 893 | - | SessionName: "misc", LiveTabID: "misc-tab", | |
| 894 | - | } | |
| 895 | - | localInitial := hybridLocalBootstrap(localHomeLifecycle, remoteMisc) | |
| 896 | - | switchSent := make(chan struct{}) | |
| 897 | - | prepareSent := make(chan struct{}) | |
| 898 | - | resumeOrClosed := make(chan struct{}) | |
| 899 | - | remoteClosed := make(chan struct{}) | |
| 900 | - | var remoteCloseOnce sync.Once | |
| 901 | - | var resumeOrCloseOnce sync.Once | |
| 902 | - | remote := &recordingTransport{recvs: []recvItem{ | |
| 903 | - | {f: hybridWelcomeFrame("misc", remoteMiscLifecycle)}, | |
| 904 | - | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 905 | - | Session: "vev", Intent: protocol.IntentAttach, | |
| 906 | - | ExactTarget: &remoteVev, | |
| 907 | - | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true, | |
| 908 | - | }))}, | |
| 909 | - | {f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{ | |
| 910 | - | Target: remoteVev, | |
| 911 | - | })), wait: switchSent}, | |
| 912 | - | {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)}, | |
| 913 | - | {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady})), wait: prepareSent}, | |
| 914 | - | {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteResumed})), wait: resumeOrClosed}, | |
| 915 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 916 | - | }, stall: remoteClosed} | |
| 917 | - | remote.onClose = func() { | |
| 918 | - | remoteCloseOnce.Do(func() { close(remoteClosed) }) | |
| 919 | - | resumeOrCloseOnce.Do(func() { close(resumeOrClosed) }) | |
| 920 | - | } | |
| 921 | - | parkedHandler := hybridParkedRequestHandler(nil, nil, map[protocol.ParkedRouteAction]chan struct{}{ | |
| 922 | - | protocol.ParkedRoutePrepare: prepareSent, | |
| 923 | - | }) | |
| 924 | - | var switchOnce sync.Once | |
| 925 | - | remote.onSend = func(frame wire.Frame) { | |
| 926 | - | parkedHandler(frame) | |
| 927 | - | if frame.Type == wire.MsgSamePeerSwitchRequest { | |
| 928 | - | switchOnce.Do(func() { close(switchSent) }) | |
| 929 | - | } | |
| 930 | - | if frame.Type != wire.MsgParkedRouteRequest { | |
| 931 | - | return | |
| 932 | - | } | |
| 933 | - | request, err := wire.UnmarshalParkedRouteRequest(frame.Payload) | |
| 934 | - | if err == nil && request.Action == protocol.ParkedRouteResume { | |
| 935 | - | resumeOrCloseOnce.Do(func() { close(resumeOrClosed) }) | |
| 936 | - | } | |
| 937 | - | } | |
| 938 | - | localTarget := protocol.ExactSessionTarget{LifecycleID: localMiscLifecycle, SessionName: "misc"} | |
| 939 | - | localPickerClosed := make(chan struct{}) | |
| 940 | - | var localPickerCloseOnce sync.Once | |
| 941 | - | localPicker := &recordingTransport{recvs: []recvItem{ | |
| 942 | - | {f: hybridWelcomeFrame("local", localHomeLifecycle)}, | |
| 943 | - | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 944 | - | Session: "misc", Intent: protocol.IntentAttach, ExactTarget: &localTarget, | |
| 945 | - | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true, | |
| 946 | - | }))}, | |
| 947 | - | }, stall: localPickerClosed} | |
| 948 | - | localPicker.onClose = func() { localPickerCloseOnce.Do(func() { close(localPickerClosed) }) } | |
| 949 | - | localMisc := &recordingTransport{recvs: []recvItem{ | |
| 950 | - | {f: hybridWelcomeFrame("misc", localMiscLifecycle)}, | |
| 951 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 952 | - | }} | |
| 953 | - | localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker, localMisc}} | |
| 954 | - | remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}} | |
| 955 | - | deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer}) | |
| 956 | - | ||
| 957 | - | err := runTestClient(t.Context(), deps, client.AttachRequest{ | |
| 958 | - | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 959 | - | }) | |
| 960 | - | require.NoError(t, err) | |
| 961 | - | require.Equal(t, int32(3), localDialer.calls.Load()) | |
| 962 | - | require.Equal(t, int32(1), remoteDialer.calls.Load()) | |
| 963 | - | var remoteSwitches []protocol.SamePeerSwitchRequest | |
| 964 | - | for _, sent := range remote.Sends() { | |
| 965 | - | if sent.Type != wire.MsgSamePeerSwitchRequest { | |
| 966 | - | continue | |
| 967 | - | } | |
| 968 | - | switchRequest, err := wire.UnmarshalSamePeerSwitchRequest(sent.Payload) | |
| 969 | - | require.NoError(t, err) | |
| 970 | - | remoteSwitches = append(remoteSwitches, switchRequest) | |
| 971 | - | } | |
| 972 | - | require.Len(t, remoteSwitches, 1) | |
| 973 | - | require.Equal(t, remoteVev, remoteSwitches[0].Target, "the local misc target must never be switched on the remote peer") | |
| 974 | - | ||
| 975 | - | var snapshot protocol.RecentRouteSnapshot | |
| 976 | - | for _, sent := range localMisc.Sends() { | |
| 977 | - | if sent.Type != wire.MsgRecentRouteSnapshot { | |
| 978 | - | continue | |
| 979 | - | } | |
| 980 | - | var err error | |
| 981 | - | snapshot, err = wire.UnmarshalRecentRouteSnapshot(sent.Payload) | |
| 982 | - | require.NoError(t, err) | |
| 983 | - | } | |
| 984 | - | require.Equal(t, localTarget, snapshot.ActiveEntry.Target) | |
| 985 | - | require.Equal(t, protocol.RouteKindLocal, snapshot.ActiveEntry.Kind) | |
| 986 | - | require.Empty(t, snapshot.ActiveEntry.HostLabel) | |
| 987 | - | remoteMiscFound := false | |
| 988 | - | for _, entry := range snapshot.Entries { | |
| 989 | - | if entry.Target != (protocol.ExactSessionTarget{LifecycleID: remoteMiscLifecycle, SessionName: "misc"}) { | |
| 990 | - | continue | |
| 991 | - | } | |
| 992 | - | remoteMiscFound = true | |
| 993 | - | require.Equal(t, "arch", entry.HostLabel) | |
| 994 | - | require.Equal(t, protocol.RouteKindRemote, entry.Kind) | |
| 995 | - | } | |
| 996 | - | require.True(t, remoteMiscFound, "the distinct remote misc route must remain qualified in history") | |
| 997 | - | } | |
| 998 | - | ||
| 999 | - | func TestHybridPickerExpiredSwitchFallsBackToNewDial(t *testing.T) { | |
| 1000 | - | term := newRunTerminal() | |
| 1001 | - | defer term.in.unblock() | |
| 1002 | - | ||
| 1003 | - | localLifecycle := domain.SessionLifecycleID{1} | |
| 1004 | - | sourceLifecycle := domain.SessionLifecycleID{2} | |
| 1005 | - | targetLifecycle := domain.SessionLifecycleID{3} | |
| 1006 | - | sourceTarget := domain.RemoteSessionTarget{ | |
| 1007 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: sourceLifecycle, | |
| 1008 | - | SessionName: "source", LiveTabID: "source-tab", | |
| 1009 | - | } | |
| 1010 | - | targetTarget := domain.RemoteSessionTarget{ | |
| 1011 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle, | |
| 1012 | - | SessionName: "target", LiveTabID: "target-tab", | |
| 1013 | - | } | |
| 1014 | - | localInitial := hybridLocalBootstrap(localLifecycle, sourceTarget) | |
| 1015 | - | prepareSent := make(chan struct{}) | |
| 1016 | - | switchSent := make(chan struct{}) | |
| 1017 | - | parkedRequestErrors := make(chan error, 1) | |
| 1018 | - | sourceRemote := &recordingTransport{recvs: []recvItem{ | |
| 1019 | - | {f: hybridWelcomeFrame("source", sourceLifecycle)}, | |
| 1020 | - | {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)}, | |
| 1021 | - | {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady})), wait: prepareSent}, | |
| 1022 | - | {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteExpired})), wait: switchSent}, | |
| 1023 | - | }} | |
| 1024 | - | sourceRemote.onSend = hybridParkedRequestHandler(nil, parkedRequestErrors, map[protocol.ParkedRouteAction]chan struct{}{ | |
| 1025 | - | protocol.ParkedRoutePrepare: prepareSent, | |
| 1026 | - | protocol.ParkedRouteSwitch: switchSent, | |
| 1027 | - | }) | |
| 1028 | - | localPicker := &recordingTransport{recvs: []recvItem{ | |
| 1029 | - | {f: hybridWelcomeFrame("local", localLifecycle)}, | |
| 1030 | - | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 1031 | - | Endpoint: "remote", Session: "target", Intent: protocol.IntentAttach, | |
| 1032 | - | RemoteTarget: &targetTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 1033 | - | }))}, | |
| 1034 | - | }} | |
| 1035 | - | targetRemote := &recordingTransport{recvs: []recvItem{ | |
| 1036 | - | {f: hybridWelcomeFrame("target", targetLifecycle)}, | |
| 1037 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 1038 | - | }} | |
| 1039 | - | localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}} | |
| 1040 | - | remoteDialer := &sequenceDialer{trs: []wire.Transport{ | |
| 1041 | - | markedDatagramTransport{Transport: sourceRemote}, targetRemote, | |
| 1042 | - | }} | |
| 1043 | - | deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer}) | |
| 1044 | - | ||
| 1045 | - | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 1046 | - | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 1047 | - | })) | |
| 1048 | - | select { | |
| 1049 | - | case requestErr := <-parkedRequestErrors: | |
| 1050 | - | require.NoError(t, requestErr) | |
| 1051 | - | default: | |
| 1052 | - | } | |
| 1053 | - | require.Equal(t, int32(2), remoteDialer.calls.Load(), "an expired parked lease must use the traditional dial path") | |
| 1054 | - | } | |
| 1055 | - | ||
| 1056 | - | func TestHybridPickerPrepareResponseTimeoutClosesRetainedTransport(t *testing.T) { | |
| 1057 | - | term := newRunTerminal() | |
| 1058 | - | defer term.in.unblock() | |
| 1059 | - | prepareSent := make(chan struct{}) | |
| 1060 | - | type deadlineTimer struct { | |
| 1061 | - | ch chan time.Time | |
| 1062 | - | stopped atomic.Bool | |
| 1063 | - | } | |
| 1064 | - | var deadlineMu sync.Mutex | |
| 1065 | - | var deadlineRecords []*deadlineTimer | |
| 1066 | - | clock := portsmocks.NewMockClock(t) | |
| 1067 | - | clock.EXPECT().Now().Return(time.Time{}).Maybe() | |
| 1068 | - | clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(duration time.Duration) ports.Timer { | |
| 1069 | - | timer := portsmocks.NewMockTimer(t) | |
| 1070 | - | ch := make(chan time.Time, 1) | |
| 1071 | - | timer.EXPECT().C().Return(ch).Maybe() | |
| 1072 | - | timer.EXPECT().Reset(mock.Anything).Return(true).Maybe() | |
| 1073 | - | if duration == protocol.HandshakeTimeout { | |
| 1074 | - | record := &deadlineTimer{ch: ch} | |
| 1075 | - | timer.EXPECT().Stop().Run(func() { record.stopped.Store(true) }).Return(true).Maybe() | |
| 1076 | - | deadlineMu.Lock() | |
| 1077 | - | deadlineRecords = append(deadlineRecords, record) | |
| 1078 | - | deadlineMu.Unlock() | |
| 1079 | - | } else { | |
| 1080 | - | timer.EXPECT().Stop().Return(true).Maybe() | |
| 1081 | - | } | |
| 1082 | - | return timer | |
| 1083 | - | }).Maybe() | |
| 1084 | - | ||
| 1085 | - | localLifecycle := domain.SessionLifecycleID{1} | |
| 1086 | - | remoteLifecycle := domain.SessionLifecycleID{2} | |
| 1087 | - | remoteTarget := domain.RemoteSessionTarget{ | |
| 1088 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle, | |
| 1089 | - | SessionName: "work", LiveTabID: "tab-1", | |
| 1090 | - | } | |
| 1091 | - | localInitial := hybridLocalBootstrap(localLifecycle, remoteTarget) | |
| 1092 | - | remoteClosed := make(chan struct{}) | |
| 1093 | - | var closeOnce sync.Once | |
| 1094 | - | remote := &recordingTransport{ | |
| 1095 | - | recvs: []recvItem{ | |
| 1096 | - | {f: hybridWelcomeFrame("work", remoteLifecycle)}, | |
| 1097 | - | {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)}, | |
| 1098 | - | }, | |
| 1099 | - | stall: remoteClosed, | |
| 1100 | - | } | |
| 1101 | - | remote.onSend = hybridParkedRequestHandler(nil, nil, map[protocol.ParkedRouteAction]chan struct{}{ | |
| 1102 | - | protocol.ParkedRoutePrepare: prepareSent, | |
| 1103 | - | }) | |
| 1104 | - | ||
| 1105 | - | localDialer := &sequenceDialer{trs: []wire.Transport{localInitial}} | |
| 1106 | - | remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}} | |
| 1107 | - | deps := hybridPickerDependencies(localDialer, term, clock, map[string]ports.ClientDialer{"remote": remoteDialer}) | |
| 1108 | - | ||
| 1109 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 1110 | - | defer cancel() | |
| 1111 | - | remote.onClose = func() { | |
| 1112 | - | closeOnce.Do(func() { close(remoteClosed) }) | |
| 1113 | - | cancel() | |
| 1114 | - | } | |
| 1115 | - | result := make(chan error, 1) | |
| 1116 | - | go func() { | |
| 1117 | - | result <- runTestClient(ctx, deps, client.AttachRequest{ | |
| 1118 | - | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 1119 | - | }) | |
| 1120 | - | }() | |
| 1121 | - | select { | |
| 1122 | - | case <-prepareSent: | |
| 1123 | - | case <-time.After(time.Second): | |
| 1124 | - | t.Fatal("timed out waiting for parked Prepare request") | |
| 1125 | - | } | |
| 1126 | - | var deadline chan time.Time | |
| 1127 | - | require.Eventually(t, func() bool { | |
| 1128 | - | deadlineMu.Lock() | |
| 1129 | - | defer deadlineMu.Unlock() | |
| 1130 | - | var active []*deadlineTimer | |
| 1131 | - | for _, record := range deadlineRecords { | |
| 1132 | - | if !record.stopped.Load() { | |
| 1133 | - | active = append(active, record) | |
| 1134 | - | } | |
| 1135 | - | } | |
| 1136 | - | if len(active) != 1 { | |
| 1137 | - | return false | |
| 1138 | - | } | |
| 1139 | - | deadline = active[0].ch | |
| 1140 | - | return true | |
| 1141 | - | }, time.Second, time.Millisecond, "expected exactly one active parked-response deadline") | |
| 1142 | - | deadline <- time.Time{} | |
| 1143 | - | select { | |
| 1144 | - | case err := <-result: | |
| 1145 | - | require.ErrorContains(t, err, "timed out waiting for parked-route response") | |
| 1146 | - | case <-time.After(time.Second): | |
| 1147 | - | t.Fatal("client did not leave the parked request after its deadline") | |
| 1148 | - | } | |
| 1149 | - | require.Positive(t, remote.closed.Load()) | |
| 1150 | - | } | |
| 1151 | - | ||
| 1152 | - | func TestHybridPickerBackResumesRetainedRemoteTransport(t *testing.T) { | |
| 1153 | - | term := newRunTerminal() | |
| 1154 | - | defer term.in.unblock() | |
| 1155 | - | ||
| 1156 | - | localLifecycle := domain.SessionLifecycleID{1} | |
| 1157 | - | remoteLifecycle := domain.SessionLifecycleID{2} | |
| 1158 | - | remoteTarget := domain.RemoteSessionTarget{ | |
| 1159 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle, | |
| 1160 | - | SessionName: "work", LiveTabID: "tab-1", | |
| 1161 | - | } | |
| 1162 | - | localInitial := hybridLocalBootstrap(localLifecycle, remoteTarget) | |
| 1163 | - | parkSent := make(chan struct{}) | |
| 1164 | - | resumeSent := make(chan struct{}) | |
| 1165 | - | parkedRequestErrors := make(chan error, 2) | |
| 1166 | - | remote := &recordingTransport{recvs: []recvItem{ | |
| 1167 | - | {f: hybridWelcomeFrame("work", remoteLifecycle)}, | |
| 1168 | - | {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)}, | |
| 1169 | - | {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady})), wait: parkSent}, | |
| 1170 | - | {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteResumed})), wait: resumeSent}, | |
| 1171 | - | {f: frameOf(wire.MsgOutput, mustMarshalOutput(protocol.Output{Epoch: 1, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("source frame")}))}, | |
| 1172 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 1173 | - | }} | |
| 1174 | - | remote.onSend = hybridParkedRequestHandler(nil, parkedRequestErrors, map[protocol.ParkedRouteAction]chan struct{}{ | |
| 1175 | - | protocol.ParkedRoutePrepare: parkSent, | |
| 1176 | - | protocol.ParkedRouteResume: resumeSent, | |
| 1177 | - | }) | |
| 1178 | - | localPicker := &recordingTransport{recvs: []recvItem{ | |
| 1179 | - | {f: hybridWelcomeFrame("local", localLifecycle)}, | |
| 1180 | - | {f: navigationDirectiveFrame(protocol.NavigationBack)}, | |
| 1181 | - | }} | |
| 1182 | - | localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}} | |
| 1183 | - | remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}} | |
| 1184 | - | deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer}) | |
| 1185 | - | ||
| 1186 | - | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 1187 | - | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 1188 | - | })) | |
| 1189 | - | select { | |
| 1190 | - | case requestErr := <-parkedRequestErrors: | |
| 1191 | - | require.NoError(t, requestErr) | |
| 1192 | - | default: | |
| 1193 | - | } | |
| 1194 | - | require.Equal(t, int32(1), remoteDialer.calls.Load()) | |
| 1195 | - | } | |
| 1196 | - | ||
| 1197 | - | func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) { | |
| 1198 | - | term := newRunTerminal() | |
| 1199 | - | defer term.in.unblock() | |
| 1200 | - | ||
| 1201 | - | localLifecycle := domain.SessionLifecycleID{1} | |
| 1202 | - | sourceLifecycle := domain.SessionLifecycleID{2} | |
| 1203 | - | targetLifecycle := domain.SessionLifecycleID{3} | |
| 1204 | - | sourceTarget := domain.RemoteSessionTarget{ | |
| 1205 | - | Endpoint: "source-host", DisplayOrigin: "source-host", LifecycleID: sourceLifecycle, | |
| 1206 | - | SessionName: "source", LiveTabID: "source-tab", | |
| 1207 | - | } | |
| 1208 | - | targetTarget := domain.RemoteSessionTarget{ | |
| 1209 | - | Endpoint: "target-host", DisplayOrigin: "target-host", LifecycleID: targetLifecycle, | |
| 1210 | - | SessionName: "target", LiveTabID: "target-tab", | |
| 1211 | - | } | |
| 1212 | - | localInitial := hybridLocalBootstrap(localLifecycle, sourceTarget) | |
| 1213 | - | parkSent := make(chan struct{}) | |
| 1214 | - | keepSourceOpen := make(chan struct{}) | |
| 1215 | - | parkedRequestErrors := make(chan error, 1) | |
| 1216 | - | defer close(keepSourceOpen) | |
| 1217 | - | sourceRemote := &recordingTransport{recvs: []recvItem{ | |
| 1218 | - | {f: hybridWelcomeFrame("source", sourceLifecycle)}, | |
| 1219 | - | {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)}, | |
| 1220 | - | {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady})), wait: parkSent}, | |
| 1221 | - | {err: io.EOF, wait: keepSourceOpen}, | |
| 1222 | - | }} | |
| 1223 | - | sourceRemote.onSend = hybridParkedRequestHandler(nil, parkedRequestErrors, map[protocol.ParkedRouteAction]chan struct{}{ | |
| 1224 | - | protocol.ParkedRoutePrepare: parkSent, | |
| 1225 | - | }) | |
| 1226 | - | localPicker := &recordingTransport{recvs: []recvItem{ | |
| 1227 | - | {f: hybridWelcomeFrame("local", localLifecycle)}, | |
| 1228 | - | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 1229 | - | Endpoint: targetTarget.Endpoint, Session: targetTarget.SessionName, Intent: protocol.IntentAttach, | |
| 1230 | - | RemoteTarget: &targetTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 1231 | - | }))}, | |
| 1232 | - | }} | |
| 1233 | - | targetRemote := &recordingTransport{recvs: []recvItem{ | |
| 1234 | - | {f: hybridWelcomeFrame("target", targetLifecycle)}, | |
| 1235 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 1236 | - | }} | |
| 1237 | - | localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}} | |
| 1238 | - | sourceDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: sourceRemote}}} | |
| 1239 | - | targetDialer := &sequenceDialer{trs: []wire.Transport{targetRemote}} | |
| 1240 | - | deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{ | |
| 1241 | - | sourceTarget.Endpoint: sourceDialer, | |
| 1242 | - | targetTarget.Endpoint: targetDialer, | |
| 1243 | - | }) | |
| 1244 | - | ||
| 1245 | - | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 1246 | - | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 1247 | - | })) | |
| 1248 | - | select { | |
| 1249 | - | case requestErr := <-parkedRequestErrors: | |
| 1250 | - | require.NoError(t, requestErr) | |
| 1251 | - | default: | |
| 1252 | - | } | |
| 1253 | - | require.Equal(t, int32(1), sourceDialer.calls.Load()) | |
| 1254 | - | require.Equal(t, int32(1), targetDialer.calls.Load()) | |
| 1255 | - | require.Positive(t, sourceRemote.closed.Load()) | |
| 1256 | - | } | |
| 823 | + | // TestHybridStdioHomeOpenSkipsParking pins the SSH-stdio pairing: a home | |
| 824 | + | // directive on a non-datagram serving transport must not send a parked-route | |
| 825 | + | // Prepare. The client closes the serving connection and dials the retained | |
| 826 | + | // home route instead, and Back redials the remote endpoint from scratch. | |
| 1257 | 827 | ||
| 1258 | 828 | func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) { | |
| 1259 | 829 | tests := []struct { | |
| @@ -1286,17 +856,15 @@ func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) { | |||
| 1286 | 856 | sourceDialer := &sequenceDialer{trs: []wire.Transport{initial, restored}} | |
| 1287 | 857 | targetDialer := &sequenceDialer{errs: []error{tt.dialErr}} | |
| 1288 | 858 | deps := testDependencies(sourceDialer, term, realClock{}, nil, nil) | |
| 1289 | - | deps.AttachHandoff = func(got protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |
| 1290 | - | require.Equal(t, target, got) | |
| 859 | + | resolved := 0 | |
| 860 | + | deps.HostRegistry = stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 861 | + | resolved++ | |
| 862 | + | require.Equal(t, target.Endpoint, endpoint) | |
| 1291 | 863 | if tt.handoffErr != nil { | |
| 1292 | - | return nil, client.AttachRequest{}, tt.handoffErr | |
| 864 | + | return ports.RemoteEndpointBinding{}, tt.handoffErr | |
| 1293 | 865 | } | |
| 1294 | - | return targetDialer, client.AttachRequest{ | |
| 1295 | - | Intent: protocol.IntentNew, SessionName: "example", Remote: true, | |
| 1296 | - | Origin: protocol.RouteOriginDiscovery, OriginKey: "host-a", HostLabel: "host-a", | |
| 1297 | - | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 1298 | - | }, nil | |
| 1299 | - | } | |
| 866 | + | return ports.RemoteEndpointBinding{Dialer: targetDialer}, nil | |
| 867 | + | }} | |
| 1300 | 868 | ||
| 1301 | 869 | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 1302 | 870 | Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| @@ -1349,138 +917,41 @@ func TestRouteNavigationReturnsToCreatedRemoteSession(t *testing.T) { | |||
| 1349 | 917 | }} | |
| 1350 | 918 | local2 := &recordingTransport{recvs: []recvItem{ | |
| 1351 | 919 | {f: welcome("misc", localLifecycle)}, | |
| 1352 | - | {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{ | |
| 1353 | - | SnapshotGeneration: 3, Key: 2, Generation: 2, | |
| 1354 | - | }))}, | |
| 1355 | - | }} | |
| 1356 | - | remote2 := &recordingTransport{recvs: []recvItem{ | |
| 1357 | - | {f: welcome("created", remoteLifecycle)}, | |
| 1358 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 1359 | - | }} | |
| 1360 | - | localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}} | |
| 1361 | - | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2}} | |
| 1362 | - | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |
| 1363 | - | deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |
| 1364 | - | require.Equal(t, createRemote, target) | |
| 1365 | - | return remoteDialer, client.AttachRequest{ | |
| 1366 | - | Intent: protocol.IntentNew, SessionName: target.Session, Remote: true, | |
| 1367 | - | Origin: protocol.RouteOriginDiscovery, OriginKey: target.Endpoint, | |
| 1368 | - | EnvironmentPolicy: target.EnvironmentPolicy, | |
| 1369 | - | }, nil | |
| 1370 | - | } | |
| 1371 | - | ||
| 1372 | - | err := runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 1373 | - | Intent: protocol.IntentAttach, SessionName: "misc", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 1374 | - | }) | |
| 1375 | - | require.NoError(t, err) | |
| 1376 | - | require.Equal(t, int32(2), remoteDialer.calls.Load()) | |
| 1377 | - | createHello := helloFromSend(t, remote1) | |
| 1378 | - | require.Equal(t, protocol.IntentNew, createHello.Intent) | |
| 1379 | - | require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, createHello.EnvironmentPolicy) | |
| 1380 | - | returnHello := helloFromSend(t, remote2) | |
| 1381 | - | require.Equal(t, protocol.IntentResume, returnHello.Intent) | |
| 1382 | - | require.Equal(t, "created", returnHello.Name) | |
| 1383 | - | require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "created"}, returnHello.ExactTarget) | |
| 1384 | - | require.Nil(t, returnHello.RemoteTarget) | |
| 1385 | - | require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, returnHello.EnvironmentPolicy) | |
| 1386 | - | require.Equal(t, protocol.NavigationCapabilityHomePicker, returnHello.NavigationCapabilities) | |
| 1387 | - | } | |
| 1388 | - | ||
| 1389 | - | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | |
| 1390 | - | term := newRunTerminal() | |
| 1391 | - | defer term.in.unblock() | |
| 1392 | - | ||
| 1393 | - | localLifecycle := domain.SessionLifecycleID{1, 1, 1} | |
| 1394 | - | remoteLifecycle := domain.SessionLifecycleID{2, 2, 2} | |
| 1395 | - | newLifecycle := domain.SessionLifecycleID{3, 3, 3} | |
| 1396 | - | remoteNewLifecycle := domain.SessionLifecycleID{4, 4, 4} | |
| 1397 | - | welcome := func(name string, lifecycle domain.SessionLifecycleID, token uint64) wire.Frame { | |
| 1398 | - | return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{ | |
| 1399 | - | SessionID: name + "-id", | |
| 1400 | - | SessionName: name, | |
| 1401 | - | ResumeToken: token, | |
| 1402 | - | Capabilities: protocol.CapabilityResume, | |
| 1403 | - | CommittedIdentity: &protocol.CommittedRouteIdentity{ | |
| 1404 | - | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}, | |
| 1405 | - | }, | |
| 1406 | - | })) | |
| 1407 | - | } | |
| 1408 | - | remoteTarget := domain.RemoteSessionTarget{ | |
| 1409 | - | Endpoint: "remote", | |
| 1410 | - | DisplayOrigin: "remote", | |
| 1411 | - | LifecycleID: remoteLifecycle, | |
| 1412 | - | SessionName: "remote-manual", | |
| 1413 | - | LiveTabID: "tab-1", | |
| 1414 | - | } | |
| 1415 | - | remoteHandoff := protocol.AttachTarget{ | |
| 1416 | - | Endpoint: "remote", Session: "remote-manual", Intent: protocol.IntentAttach, | |
| 1417 | - | RemoteTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 1418 | - | } | |
| 1419 | - | ||
| 1420 | - | local1 := &recordingTransport{recvs: []recvItem{ | |
| 1421 | - | {f: welcome("test", localLifecycle, 11)}, | |
| 1422 | - | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(remoteHandoff))}, | |
| 1423 | - | }} | |
| 1424 | - | local2 := &recordingTransport{recvs: []recvItem{ | |
| 1425 | - | {f: welcome("test", localLifecycle, 11)}, | |
| 1426 | - | {f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{ | |
| 1427 | - | Target: protocol.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"}, | |
| 1428 | - | }))}, | |
| 1429 | - | {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: 2, Generation: 2}))}, | |
| 1430 | - | }} | |
| 1431 | - | local3 := &recordingTransport{recvs: []recvItem{ | |
| 1432 | - | {f: welcome("test", localLifecycle, 11)}, | |
| 1433 | - | {f: navigationDirectiveFrame(protocol.NavigationBack)}, | |
| 1434 | - | }} | |
| 1435 | - | remote1 := &recordingTransport{recvs: []recvItem{ | |
| 1436 | - | {f: welcome("remote-manual", remoteLifecycle, 22)}, | |
| 1437 | - | {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}))}, | |
| 1438 | - | }} | |
| 1439 | - | remote2 := &recordingTransport{recvs: []recvItem{ | |
| 1440 | - | {f: welcome("remote-manual", remoteLifecycle, 22)}, | |
| 1441 | - | {f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{ | |
| 1442 | - | Target: protocol.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, | |
| 1443 | - | }))}, | |
| 1444 | - | {f: frameOf(wire.MsgRoutePosition, mustMarshalRoutePosition(protocol.RoutePosition{ | |
| 1445 | - | Target: protocol.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2", | |
| 920 | + | {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{ | |
| 921 | + | SnapshotGeneration: 3, Key: 2, Generation: 2, | |
| 1446 | 922 | }))}, | |
| 1447 | - | {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)}, | |
| 1448 | 923 | }} | |
| 1449 | - | remote3 := &recordingTransport{recvs: []recvItem{ | |
| 1450 | - | {f: welcome("remote-new", remoteNewLifecycle, 22)}, | |
| 924 | + | remote2 := &recordingTransport{recvs: []recvItem{ | |
| 925 | + | {f: welcome("created", remoteLifecycle)}, | |
| 1451 | 926 | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 1452 | 927 | }} | |
| 1453 | - | localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2, local3}} | |
| 1454 | - | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2, remote3}} | |
| 1455 | - | ||
| 928 | + | localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}} | |
| 929 | + | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2}} | |
| 1456 | 930 | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |
| 1457 | - | deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |
| 1458 | - | require.Equal(t, remoteHandoff, target) | |
| 1459 | - | return remoteDialer, client.AttachRequest{ | |
| 1460 | - | Intent: protocol.IntentAttach, SessionName: target.Session, Remote: true, | |
| 1461 | - | Origin: protocol.RouteOriginDiscovery, OriginKey: target.Endpoint, | |
| 1462 | - | RemoteTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 1463 | - | }, nil | |
| 1464 | - | } | |
| 931 | + | // The remote return attach relays the palette inventory from the retained | |
| 932 | + | // local home route; without a control source it must not claim the | |
| 933 | + | // capability. The mock admits no dial: this route receives no demand. | |
| 934 | + | deps.LocalControlDialer = portsmocks.NewMockClientDialer(t) | |
| 935 | + | deps.HostRegistry = stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 936 | + | require.Equal(t, createRemote.Endpoint, endpoint) | |
| 937 | + | return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil | |
| 938 | + | }} | |
| 1465 | 939 | ||
| 1466 | 940 | err := runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 1467 | - | Intent: protocol.IntentAttach, SessionName: "test", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 941 | + | Intent: protocol.IntentAttach, SessionName: "misc", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 1468 | 942 | }) | |
| 1469 | - | remoteHello := helloFromSend(t, remote2) | |
| 1470 | 943 | require.NoError(t, err) | |
| 1471 | - | ||
| 1472 | - | require.Equal(t, protocol.NavigationCapabilityHomePicker, remoteHello.NavigationCapabilities) | |
| 1473 | - | remoteNewHello := helloFromSend(t, remote3) | |
| 1474 | - | require.Equal(t, "remote-new", remoteNewHello.Name) | |
| 1475 | - | require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, remoteNewHello.ExactTarget) | |
| 1476 | - | } | |
| 1477 | - | ||
| 1478 | - | func navigationDirectiveFrame(action protocol.NavigationAction) wire.Frame { | |
| 1479 | - | directive := protocol.NavigationDirective{Action: action} | |
| 1480 | - | if action == protocol.NavigationOpenHomePicker { | |
| 1481 | - | directive.LeaseID = protocol.ParkedRouteLeaseID{1} | |
| 1482 | - | } | |
| 1483 | - | return frameOf(wire.MsgNavigationAction, wire.MarshalNavigationDirective(directive)) | |
| 944 | + | require.Equal(t, int32(2), remoteDialer.calls.Load()) | |
| 945 | + | createHello := helloFromSend(t, remote1) | |
| 946 | + | require.Equal(t, protocol.IntentNew, createHello.Intent) | |
| 947 | + | require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, createHello.EnvironmentPolicy) | |
| 948 | + | returnHello := helloFromSend(t, remote2) | |
| 949 | + | require.Equal(t, protocol.IntentResume, returnHello.Intent) | |
| 950 | + | require.Equal(t, "created", returnHello.Name) | |
| 951 | + | require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "created"}, returnHello.ExactTarget) | |
| 952 | + | require.Nil(t, returnHello.RemoteTarget) | |
| 953 | + | require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, returnHello.EnvironmentPolicy) | |
| 954 | + | require.Equal(t, protocol.NavigationCapabilityInventory, returnHello.NavigationCapabilities) | |
| 1484 | 955 | } | |
| 1485 | 956 | ||
| 1486 | 957 | func mustMarshalCommittedIdentity(identity protocol.CommittedRouteIdentity) []byte { | |
| @@ -1507,6 +978,14 @@ func mustMarshalRoutePosition(position protocol.RoutePosition) []byte { | |||
| 1507 | 978 | return payload | |
| 1508 | 979 | } | |
| 1509 | 980 | ||
| 981 | + | func mustMarshalSamePeerSwitchFailure(failure protocol.SamePeerSwitchFailure) []byte { | |
| 982 | + | payload, err := wire.MarshalSamePeerSwitchFailure(failure) | |
| 983 | + | if err != nil { | |
| 984 | + | panic(err) | |
| 985 | + | } | |
| 986 | + | return payload | |
| 987 | + | } | |
| 988 | + | ||
| 1510 | 989 | func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) { | |
| 1511 | 990 | var out bytes.Buffer | |
| 1512 | 991 | var restoreCount atomic.Int32 | |
| @@ -2217,451 +1696,6 @@ func TestRemoteReconnectDropsStalePickerTargetAfterDaemonSessionSwitch(t *testin | |||
| 2217 | 1696 | require.Nil(t, reconnectHello.RemoteTarget) | |
| 2218 | 1697 | } | |
| 2219 | 1698 | ||
| 2220 | - | func TestAttachRememberRemoteHost(t *testing.T) { | |
| 2221 | - | tests := []struct { | |
| 2222 | - | name string | |
| 2223 | - | request client.AttachRequest | |
| 2224 | - | remember func() error | |
| 2225 | - | recv []recvItem | |
| 2226 | - | preWelcomeReject bool | |
| 2227 | - | wantCalled bool | |
| 2228 | - | wantErr bool | |
| 2229 | - | }{ | |
| 2230 | - | { | |
| 2231 | - | name: "remote success", | |
| 2232 | - | request: client.AttachRequest{ | |
| 2233 | - | Intent: protocol.IntentAttach, | |
| 2234 | - | SessionName: "main", | |
| 2235 | - | Remote: true, | |
| 2236 | - | }, | |
| 2237 | - | remember: func() error { return nil }, | |
| 2238 | - | recv: []recvItem{ | |
| 2239 | - | {f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))}, | |
| 2240 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 2241 | - | }, | |
| 2242 | - | wantCalled: true, | |
| 2243 | - | }, | |
| 2244 | - | { | |
| 2245 | - | name: "callback failure does not fail attach", | |
| 2246 | - | request: client.AttachRequest{ | |
| 2247 | - | Intent: protocol.IntentAttach, | |
| 2248 | - | SessionName: "main", | |
| 2249 | - | Remote: true, | |
| 2250 | - | }, | |
| 2251 | - | remember: func() error { return errors.New("disk full") }, | |
| 2252 | - | recv: []recvItem{ | |
| 2253 | - | {f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))}, | |
| 2254 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 2255 | - | }, | |
| 2256 | - | wantCalled: true, | |
| 2257 | - | }, | |
| 2258 | - | { | |
| 2259 | - | name: "local attach does not call callback", | |
| 2260 | - | request: client.AttachRequest{ | |
| 2261 | - | Intent: protocol.IntentAttach, | |
| 2262 | - | SessionName: "main", | |
| 2263 | - | Remote: false, | |
| 2264 | - | }, | |
| 2265 | - | remember: func() error { | |
| 2266 | - | t.Fatal("remember callback must not run for local attach") | |
| 2267 | - | return nil | |
| 2268 | - | }, | |
| 2269 | - | recv: []recvItem{ | |
| 2270 | - | {f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))}, | |
| 2271 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 2272 | - | }, | |
| 2273 | - | }, | |
| 2274 | - | { | |
| 2275 | - | name: "rejected before welcome does not call callback", | |
| 2276 | - | request: client.AttachRequest{ | |
| 2277 | - | Intent: protocol.IntentAttach, | |
| 2278 | - | SessionName: "main", | |
| 2279 | - | Remote: true, | |
| 2280 | - | }, | |
| 2281 | - | remember: func() error { | |
| 2282 | - | t.Fatal("remember callback must not run before welcome") | |
| 2283 | - | return nil | |
| 2284 | - | }, | |
| 2285 | - | recv: []recvItem{ | |
| 2286 | - | {f: frameOf(wire.MsgError, wire.MarshalErrorMsg(protocol.ErrorMsg{Code: protocol.ErrVersionMismatch, Text: "version mismatch"}))}, | |
| 2287 | - | }, | |
| 2288 | - | preWelcomeReject: true, | |
| 2289 | - | wantErr: true, | |
| 2290 | - | }, | |
| 2291 | - | { | |
| 2292 | - | name: "post-welcome error still uses happy terminal fixture", | |
| 2293 | - | request: client.AttachRequest{ | |
| 2294 | - | Intent: protocol.IntentAttach, | |
| 2295 | - | SessionName: "main", | |
| 2296 | - | Remote: true, | |
| 2297 | - | }, | |
| 2298 | - | remember: func() error { return nil }, | |
| 2299 | - | recv: []recvItem{ | |
| 2300 | - | {f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))}, | |
| 2301 | - | {err: errors.New("connection reset")}, | |
| 2302 | - | }, | |
| 2303 | - | wantCalled: true, | |
| 2304 | - | wantErr: true, | |
| 2305 | - | }, | |
| 2306 | - | } | |
| 2307 | - | ||
| 2308 | - | for _, tt := range tests { | |
| 2309 | - | t.Run(tt.name, func(t *testing.T) { | |
| 2310 | - | var tm *portsmocks.MockTerminal | |
| 2311 | - | var unblockIn func() | |
| 2312 | - | if tt.preWelcomeReject { | |
| 2313 | - | tm = portsmocks.NewMockTerminal(t) | |
| 2314 | - | tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once() | |
| 2315 | - | // EnterRaw must NOT be called when the daemon rejects Hello before Welcome. | |
| 2316 | - | } else { | |
| 2317 | - | var out bytes.Buffer | |
| 2318 | - | var restoreCount atomic.Int32 | |
| 2319 | - | resizeCh := make(chan domain.Geometry) | |
| 2320 | - | var in *blockingReader | |
| 2321 | - | tm, in = newHappyTerminal(t, &out, &restoreCount, resizeCh) | |
| 2322 | - | unblockIn = in.unblock | |
| 2323 | - | } | |
| 2324 | - | if unblockIn != nil { | |
| 2325 | - | defer unblockIn() | |
| 2326 | - | } | |
| 2327 | - | ||
| 2328 | - | var called atomic.Bool | |
| 2329 | - | learnerDone := make(chan struct{}) | |
| 2330 | - | rememberHook := func() error { | |
| 2331 | - | called.Store(true) | |
| 2332 | - | defer close(learnerDone) | |
| 2333 | - | if tt.remember != nil { | |
| 2334 | - | return tt.remember() | |
| 2335 | - | } | |
| 2336 | - | return nil | |
| 2337 | - | } | |
| 2338 | - | ||
| 2339 | - | tr := newMockClientConnection(t) | |
| 2340 | - | if !tt.preWelcomeReject { | |
| 2341 | - | tr.EXPECT().Send(isType(wire.MsgTheme)).Return(nil).Maybe() | |
| 2342 | - | } | |
| 2343 | - | tr.EXPECT().Send(isType(wire.MsgHello)).Return(nil).Once() | |
| 2344 | - | if tt.preWelcomeReject { | |
| 2345 | - | tr.EXPECT().Recv().Return(tt.recv[0].f, tt.recv[0].err).Once() | |
| 2346 | - | } else { | |
| 2347 | - | unblock := scriptRecv(tr, tt.recv...) | |
| 2348 | - | defer unblock() | |
| 2349 | - | } | |
| 2350 | - | tr.EXPECT().Close().Return(nil).Once() | |
| 2351 | - | ||
| 2352 | - | deps := attachTestDependencies(tr, tm, realClock{}) | |
| 2353 | - | learner := portsmocks.NewMockRemoteHostLearner(t) | |
| 2354 | - | if tt.wantCalled { | |
| 2355 | - | learner.EXPECT().RememberRemoteHost().RunAndReturn(rememberHook).Once() | |
| 2356 | - | } | |
| 2357 | - | deps.RemoteHostLearner = learner | |
| 2358 | - | ||
| 2359 | - | err := runTestClient(context.Background(), deps, tt.request) | |
| 2360 | - | if tt.wantCalled { | |
| 2361 | - | select { | |
| 2362 | - | case <-learnerDone: | |
| 2363 | - | case <-time.After(2 * time.Second): | |
| 2364 | - | t.Fatal("remote host learner did not complete") | |
| 2365 | - | } | |
| 2366 | - | } | |
| 2367 | - | if tt.wantErr { | |
| 2368 | - | require.Error(t, err) | |
| 2369 | - | } else { | |
| 2370 | - | require.NoError(t, err) | |
| 2371 | - | } | |
| 2372 | - | require.Equal(t, tt.wantCalled, called.Load()) | |
| 2373 | - | }) | |
| 2374 | - | } | |
| 2375 | - | } | |
| 2376 | - | ||
| 2377 | - | func TestRunRememberRemoteHostAtMostOnceAcrossReconnects(t *testing.T) { | |
| 2378 | - | term := newRunTerminal() | |
| 2379 | - | defer term.in.unblock() | |
| 2380 | - | ||
| 2381 | - | var rememberCalls atomic.Int32 | |
| 2382 | - | learnerDone := make(chan struct{}) | |
| 2383 | - | ||
| 2384 | - | tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}} | |
| 2385 | - | tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}}} | |
| 2386 | - | d := &sequenceDialer{trs: []wire.Transport{tr1, tr2}} | |
| 2387 | - | ||
| 2388 | - | deps := testDependencies(d, term, realClock{}, nil, nil) | |
| 2389 | - | learner := portsmocks.NewMockRemoteHostLearner(t) | |
| 2390 | - | learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error { | |
| 2391 | - | rememberCalls.Add(1) | |
| 2392 | - | close(learnerDone) | |
| 2393 | - | return nil | |
| 2394 | - | }).Once() | |
| 2395 | - | deps.RemoteHostLearner = learner | |
| 2396 | - | err := runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 2397 | - | Intent: protocol.IntentAttach, | |
| 2398 | - | SessionName: "main", | |
| 2399 | - | Remote: true, | |
| 2400 | - | }) | |
| 2401 | - | require.NoError(t, err) | |
| 2402 | - | select { | |
| 2403 | - | case <-learnerDone: | |
| 2404 | - | case <-time.After(2 * time.Second): | |
| 2405 | - | t.Fatal("remote host learner did not complete") | |
| 2406 | - | } | |
| 2407 | - | require.Equal(t, int32(1), rememberCalls.Load()) | |
| 2408 | - | require.Equal(t, int32(2), d.calls.Load()) | |
| 2409 | - | } | |
| 2410 | - | ||
| 2411 | - | func TestAttachRememberRemoteHostDoesNotBlockAfterWelcome(t *testing.T) { | |
| 2412 | - | var out bytes.Buffer | |
| 2413 | - | var restoreCount atomic.Int32 | |
| 2414 | - | resizeCh := make(chan domain.Geometry) | |
| 2415 | - | tm := portsmocks.NewMockTerminal(t) | |
| 2416 | - | tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once() | |
| 2417 | - | enteredRaw := make(chan struct{}) | |
| 2418 | - | tm.EXPECT().EnterRaw().Run(func() { close(enteredRaw) }).Return(func() error { | |
| 2419 | - | restoreCount.Add(1) | |
| 2420 | - | return nil | |
| 2421 | - | }, nil).Once() | |
| 2422 | - | in := newBlockingReader() | |
| 2423 | - | tm.EXPECT().In().Return(in).Maybe() | |
| 2424 | - | tm.EXPECT().Out().Return(&out).Maybe() | |
| 2425 | - | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 2426 | - | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 2427 | - | defer in.unblock() | |
| 2428 | - | ||
| 2429 | - | learnerStarted := make(chan struct{}) | |
| 2430 | - | releaseLearner := make(chan struct{}) | |
| 2431 | - | learnerDone := make(chan struct{}) | |
| 2432 | - | ||
| 2433 | - | tr := newMockClientConnection(t) | |
| 2434 | - | tr.EXPECT().Send(isType(wire.MsgTheme)).Return(nil).Maybe() | |
| 2435 | - | tr.EXPECT().Send(isType(wire.MsgHello)).Return(nil).Once() | |
| 2436 | - | unblock := scriptRecv(tr, | |
| 2437 | - | recvItem{f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))}, | |
| 2438 | - | recvItem{f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 2439 | - | ) | |
| 2440 | - | defer unblock() | |
| 2441 | - | tr.EXPECT().Close().Return(nil).Once() | |
| 2442 | - | ||
| 2443 | - | deps := attachTestDependencies(tr, tm, realClock{}) | |
| 2444 | - | learner := portsmocks.NewMockRemoteHostLearner(t) | |
| 2445 | - | learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error { | |
| 2446 | - | close(learnerStarted) | |
| 2447 | - | <-releaseLearner | |
| 2448 | - | close(learnerDone) | |
| 2449 | - | return nil | |
| 2450 | - | }).Once() | |
| 2451 | - | deps.RemoteHostLearner = learner | |
| 2452 | - | ||
| 2453 | - | errCh := make(chan error, 1) | |
| 2454 | - | go func() { | |
| 2455 | - | errCh <- runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 2456 | - | Intent: protocol.IntentAttach, | |
| 2457 | - | SessionName: "main", | |
| 2458 | - | Remote: true, | |
| 2459 | - | }) | |
| 2460 | - | }() | |
| 2461 | - | ||
| 2462 | - | select { | |
| 2463 | - | case <-learnerStarted: | |
| 2464 | - | case <-time.After(2 * time.Second): | |
| 2465 | - | t.Fatal("remote host learner did not start") | |
| 2466 | - | } | |
| 2467 | - | select { | |
| 2468 | - | case <-enteredRaw: | |
| 2469 | - | case <-time.After(2 * time.Second): | |
| 2470 | - | t.Fatal("attach did not enter raw mode while remote host learner was blocked") | |
| 2471 | - | } | |
| 2472 | - | select { | |
| 2473 | - | case err := <-errCh: | |
| 2474 | - | t.Fatalf("run returned before remote host learner completed: %v", err) | |
| 2475 | - | default: | |
| 2476 | - | } | |
| 2477 | - | close(releaseLearner) | |
| 2478 | - | select { | |
| 2479 | - | case <-learnerDone: | |
| 2480 | - | case <-time.After(2 * time.Second): | |
| 2481 | - | t.Fatal("remote host learner did not complete after release") | |
| 2482 | - | } | |
| 2483 | - | select { | |
| 2484 | - | case err := <-errCh: | |
| 2485 | - | require.NoError(t, err) | |
| 2486 | - | case <-time.After(2 * time.Second): | |
| 2487 | - | t.Fatal("run did not return after remote host learner completed") | |
| 2488 | - | } | |
| 2489 | - | } | |
| 2490 | - | ||
| 2491 | - | func TestRunRestoresTerminalBeforeWaitingForLearner(t *testing.T) { | |
| 2492 | - | var out bytes.Buffer | |
| 2493 | - | var restoreCount atomic.Int32 | |
| 2494 | - | restored := make(chan struct{}) | |
| 2495 | - | resizeCh := make(chan domain.Geometry) | |
| 2496 | - | tm := portsmocks.NewMockTerminal(t) | |
| 2497 | - | tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once() | |
| 2498 | - | tm.EXPECT().EnterRaw().Return(func() error { | |
| 2499 | - | restoreCount.Add(1) | |
| 2500 | - | close(restored) | |
| 2501 | - | return nil | |
| 2502 | - | }, nil).Once() | |
| 2503 | - | in := newBlockingReader() | |
| 2504 | - | tm.EXPECT().In().Return(in).Maybe() | |
| 2505 | - | tm.EXPECT().Out().Return(&out).Maybe() | |
| 2506 | - | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 2507 | - | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 2508 | - | defer in.unblock() | |
| 2509 | - | ||
| 2510 | - | learnerStarted := make(chan struct{}) | |
| 2511 | - | releaseLearner := make(chan struct{}) | |
| 2512 | - | learnerDone := make(chan struct{}) | |
| 2513 | - | ||
| 2514 | - | tr := newMockClientConnection(t) | |
| 2515 | - | tr.EXPECT().Send(isType(wire.MsgTheme)).Return(nil).Maybe() | |
| 2516 | - | tr.EXPECT().Send(isType(wire.MsgHello)).Return(nil).Once() | |
| 2517 | - | unblock := scriptRecv(tr, | |
| 2518 | - | recvItem{f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))}, | |
| 2519 | - | recvItem{f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 2520 | - | ) | |
| 2521 | - | defer unblock() | |
| 2522 | - | tr.EXPECT().Close().Return(nil).Once() | |
| 2523 | - | ||
| 2524 | - | deps := attachTestDependencies(tr, tm, realClock{}) | |
| 2525 | - | learner := portsmocks.NewMockRemoteHostLearner(t) | |
| 2526 | - | learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error { | |
| 2527 | - | close(learnerStarted) | |
| 2528 | - | <-releaseLearner | |
| 2529 | - | close(learnerDone) | |
| 2530 | - | return nil | |
| 2531 | - | }).Once() | |
| 2532 | - | deps.RemoteHostLearner = learner | |
| 2533 | - | ||
| 2534 | - | errCh := make(chan error, 1) | |
| 2535 | - | go func() { | |
| 2536 | - | errCh <- runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 2537 | - | Intent: protocol.IntentAttach, | |
| 2538 | - | SessionName: "main", | |
| 2539 | - | Remote: true, | |
| 2540 | - | }) | |
| 2541 | - | }() | |
| 2542 | - | ||
| 2543 | - | select { | |
| 2544 | - | case <-learnerStarted: | |
| 2545 | - | case <-time.After(2 * time.Second): | |
| 2546 | - | t.Fatal("remote host learner did not start") | |
| 2547 | - | } | |
| 2548 | - | select { | |
| 2549 | - | case <-restored: | |
| 2550 | - | case <-time.After(2 * time.Second): | |
| 2551 | - | t.Fatal("terminal was not restored while remote host learner was blocked") | |
| 2552 | - | } | |
| 2553 | - | require.Equal(t, int32(1), restoreCount.Load()) | |
| 2554 | - | select { | |
| 2555 | - | case err := <-errCh: | |
| 2556 | - | t.Fatalf("run returned before remote host learner completed: %v", err) | |
| 2557 | - | default: | |
| 2558 | - | } | |
| 2559 | - | close(releaseLearner) | |
| 2560 | - | select { | |
| 2561 | - | case <-learnerDone: | |
| 2562 | - | case <-time.After(2 * time.Second): | |
| 2563 | - | t.Fatal("remote host learner did not complete after release") | |
| 2564 | - | } | |
| 2565 | - | select { | |
| 2566 | - | case err := <-errCh: | |
| 2567 | - | require.NoError(t, err) | |
| 2568 | - | case <-time.After(2 * time.Second): | |
| 2569 | - | t.Fatal("run did not return after remote host learner completed") | |
| 2570 | - | } | |
| 2571 | - | } | |
| 2572 | - | ||
| 2573 | - | func TestRunReturnsWhenRemoteHostLearnerStalls(t *testing.T) { | |
| 2574 | - | var out bytes.Buffer | |
| 2575 | - | var restoreCount atomic.Int32 | |
| 2576 | - | restored := make(chan struct{}) | |
| 2577 | - | resizeCh := make(chan domain.Geometry) | |
| 2578 | - | tm := portsmocks.NewMockTerminal(t) | |
| 2579 | - | tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once() | |
| 2580 | - | tm.EXPECT().EnterRaw().Return(func() error { | |
| 2581 | - | restoreCount.Add(1) | |
| 2582 | - | close(restored) | |
| 2583 | - | return nil | |
| 2584 | - | }, nil).Once() | |
| 2585 | - | in := newBlockingReader() | |
| 2586 | - | tm.EXPECT().In().Return(in).Maybe() | |
| 2587 | - | tm.EXPECT().Out().Return(&out).Maybe() | |
| 2588 | - | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 2589 | - | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 2590 | - | defer in.unblock() | |
| 2591 | - | ||
| 2592 | - | learnerStarted := make(chan struct{}) | |
| 2593 | - | blockLearner := make(chan struct{}) | |
| 2594 | - | defer close(blockLearner) | |
| 2595 | - | ||
| 2596 | - | shutdownTimerC := make(chan time.Time, 1) | |
| 2597 | - | shutdownTimerCreated := make(chan struct{}) | |
| 2598 | - | shutdownTimer := portsmocks.NewMockTimer(t) | |
| 2599 | - | shutdownTimer.EXPECT().C().Return((<-chan time.Time)(shutdownTimerC)).Once() | |
| 2600 | - | shutdownTimer.EXPECT().Stop().Return(true).Once() | |
| 2601 | - | clock := portsmocks.NewMockClock(t) | |
| 2602 | - | clock.EXPECT().Now().Return(time.Now()).Maybe() | |
| 2603 | - | clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer { | |
| 2604 | - | if d == time.Second { | |
| 2605 | - | close(shutdownTimerCreated) | |
| 2606 | - | return shutdownTimer | |
| 2607 | - | } | |
| 2608 | - | return realClock{}.NewTimer(d) | |
| 2609 | - | }).Maybe() | |
| 2610 | - | ||
| 2611 | - | tr := newMockClientConnection(t) | |
| 2612 | - | tr.EXPECT().Send(isType(wire.MsgTheme)).Return(nil).Maybe() | |
| 2613 | - | tr.EXPECT().Send(isType(wire.MsgHello)).Return(nil).Once() | |
| 2614 | - | unblock := scriptRecv(tr, | |
| 2615 | - | recvItem{f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))}, | |
| 2616 | - | recvItem{f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 2617 | - | ) | |
| 2618 | - | defer unblock() | |
| 2619 | - | tr.EXPECT().Close().Return(nil).Once() | |
| 2620 | - | ||
| 2621 | - | deps := attachTestDependencies(tr, tm, clock) | |
| 2622 | - | learner := portsmocks.NewMockRemoteHostLearner(t) | |
| 2623 | - | learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error { | |
| 2624 | - | close(learnerStarted) | |
| 2625 | - | <-blockLearner | |
| 2626 | - | return nil | |
| 2627 | - | }).Once() | |
| 2628 | - | deps.RemoteHostLearner = learner | |
| 2629 | - | ||
| 2630 | - | errCh := make(chan error, 1) | |
| 2631 | - | go func() { | |
| 2632 | - | errCh <- runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 2633 | - | Intent: protocol.IntentAttach, | |
| 2634 | - | SessionName: "main", | |
| 2635 | - | Remote: true, | |
| 2636 | - | }) | |
| 2637 | - | }() | |
| 2638 | - | ||
| 2639 | - | select { | |
| 2640 | - | case <-learnerStarted: | |
| 2641 | - | case <-time.After(2 * time.Second): | |
| 2642 | - | t.Fatal("remote host learner did not start") | |
| 2643 | - | } | |
| 2644 | - | select { | |
| 2645 | - | case <-restored: | |
| 2646 | - | case <-time.After(2 * time.Second): | |
| 2647 | - | t.Fatal("terminal was not restored while remote host learner was stalled") | |
| 2648 | - | } | |
| 2649 | - | require.Equal(t, int32(1), restoreCount.Load()) | |
| 2650 | - | ||
| 2651 | - | select { | |
| 2652 | - | case <-shutdownTimerCreated: | |
| 2653 | - | case <-time.After(2 * time.Second): | |
| 2654 | - | t.Fatal("remote host learner shutdown timer was not created") | |
| 2655 | - | } | |
| 2656 | - | shutdownTimerC <- time.Now() | |
| 2657 | - | select { | |
| 2658 | - | case err := <-errCh: | |
| 2659 | - | require.NoError(t, err) | |
| 2660 | - | case <-time.After(2 * time.Second): | |
| 2661 | - | t.Fatal("run did not return after remote host learner shutdown timer fired") | |
| 2662 | - | } | |
| 2663 | - | } | |
| 2664 | - | ||
| 2665 | 1699 | func TestRunExitsCleanlyWhenKilledSessionHasNoPriorRoute(t *testing.T) { | |
| 2666 | 1700 | term := newRunTerminal() | |
| 2667 | 1701 | defer term.in.unblock() | |
| @@ -2673,3 +1707,7 @@ func TestRunExitsCleanlyWhenKilledSessionHasNoPriorRoute(t *testing.T) { | |||
| 2673 | 1707 | require.Equal(t, int32(1), d.calls.Load()) | |
| 2674 | 1708 | require.Equal(t, int32(1), term.restoreCount.Load()) | |
| 2675 | 1709 | } | |
| 1710 | + | ||
| 1711 | + | // TestRunnerOwnsOneRegistryLoopPerRun pins the discovery lifetime: the runner | |
| 1712 | + | // starts the registry once, keeps it across a handoff and a reconnect, and | |
| 1713 | + | // joins it before it returns, including on the failure path. | |
A internal/usecase/client/clipboard_foreground_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "log/slog" | |
| 6 | + | "sync/atomic" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | + | "github.com/bnema/vev/internal/protocol" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // TestTerminalInputPumpClipboardReadBlockedDoesNotHoldForegroundSwitch pins | |
| 19 | + | // the P2.1 foreground-ownership boundary: a slow workstation clipboard read | |
| 20 | + | // (e.g. a hung wl-paste) triggered by Ctrl+V must abort when the foreground | |
| 21 | + | // is stopped for a route switch, so the pump cannot hold the switch waiting | |
| 22 | + | // and a late result cannot reach a replacement owner. | |
| 23 | + | func TestTerminalInputPumpClipboardReadBlockedDoesNotHoldForegroundSwitch(t *testing.T) { | |
| 24 | + | input := newTerminalInputPump(nil) | |
| 25 | + | input.enqueue(terminalReadResult{data: []byte{ctrlV}}, 1) | |
| 26 | + | ||
| 27 | + | started := make(chan struct{}) | |
| 28 | + | reader := portsmocks.NewMockClipboardReader(t) | |
| 29 | + | reader.EXPECT().ReadImage(mock.Anything).RunAndReturn(func(ctx context.Context) (string, []byte, error) { | |
| 30 | + | close(started) | |
| 31 | + | <-ctx.Done() | |
| 32 | + | // Adapters map cancellation onto ErrNoClipboardImage so a cancelled | |
| 33 | + | // read silently forwards the keystroke; mirror that contract here. | |
| 34 | + | return "", nil, ports.ErrNoClipboardImage | |
| 35 | + | }).Once() | |
| 36 | + | ||
| 37 | + | var activeGeneration atomic.Uint64 | |
| 38 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 39 | + | out := make(chan protocol.ClientMessage, 16) | |
| 40 | + | done := make(chan struct{}) | |
| 41 | + | go func() { | |
| 42 | + | (&stdinPump{ | |
| 43 | + | ctx: ctx, cancel: cancel, input: input, | |
| 44 | + | out: out, clock: newAttachPaletteClock(), | |
| 45 | + | clipboard: reader, | |
| 46 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent, 32), | |
| 47 | + | activeGeneration: &activeGeneration, | |
| 48 | + | sendLease: newForegroundSendLease(), | |
| 49 | + | }).run() | |
| 50 | + | close(done) | |
| 51 | + | }() | |
| 52 | + | ||
| 53 | + | select { | |
| 54 | + | case <-started: | |
| 55 | + | case <-time.After(2 * time.Second): | |
| 56 | + | t.Fatal("clipboard read did not start") | |
| 57 | + | } | |
| 58 | + | // Route switch: stopForeground cancels the foreground context, then | |
| 59 | + | // waits for the pump. A read bound to the foreground must release it. | |
| 60 | + | cancel() | |
| 61 | + | requireSignal(t, done, "cancelled stdin pump stayed blocked in a clipboard read after foreground stop") | |
| 62 | + | ||
| 63 | + | // The cancelled read resolves through the standard ErrNoClipboardImage | |
| 64 | + | // contract. Delivery races the foreground cancellation: either the | |
| 65 | + | // Ctrl+V won the send race and sits in out as ordinary input, or the | |
| 66 | + | // pump surrendered it into the input residual for the replacement | |
| 67 | + | // foreground. Both outcomes preserve the keystroke; neither may | |
| 68 | + | // produce an image or a notice to a replacement owner. | |
| 69 | + | forwarded := false | |
| 70 | + | for { | |
| 71 | + | select { | |
| 72 | + | case message := <-out: | |
| 73 | + | switch got := message.(type) { | |
| 74 | + | case protocol.Input: | |
| 75 | + | require.Equal(t, []byte{ctrlV}, got.Data, "cancelled Ctrl+V must be forwarded verbatim as ordinary input") | |
| 76 | + | forwarded = true | |
| 77 | + | case protocol.ImagePush: | |
| 78 | + | t.Fatal("cancelled clipboard read delivered an image to the replacement owner") | |
| 79 | + | case protocol.ClientNotice: | |
| 80 | + | t.Fatal("cancelled clipboard read delivered a notice to the replacement owner") | |
| 81 | + | default: | |
| 82 | + | t.Fatalf("unexpected message after cancelled clipboard read: %T", message) | |
| 83 | + | } | |
| 84 | + | if forwarded { | |
| 85 | + | goto drained | |
| 86 | + | } | |
| 87 | + | default: | |
| 88 | + | goto drained | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | drained: | |
| 92 | + | if !forwarded { | |
| 93 | + | // Send lost the race to cancellation: the byte must wait in the | |
| 94 | + | // input residual for the replacement foreground, not vanish. | |
| 95 | + | replacement := input.claim() | |
| 96 | + | defer input.revoke(replacement) | |
| 97 | + | got, ok := input.take(context.Background(), replacement) | |
| 98 | + | require.True(t, ok, "cancelled Ctrl+V was neither forwarded nor preserved: keystroke lost") | |
| 99 | + | require.Equal(t, []byte{ctrlV}, got.data) | |
| 100 | + | input.ack(replacement) | |
| 101 | + | } | |
| 102 | + | select { | |
| 103 | + | case message := <-out: | |
| 104 | + | t.Fatalf("cancelled clipboard read delivered a second frame: %T", message) | |
| 105 | + | default: | |
| 106 | + | } | |
| 107 | + | } | |
M internal/usecase/client/clipboard_intercept.go +13 -1
| @@ -26,6 +26,14 @@ const maxClipboardImagePush = 1 << 20 // 1 MiB | |||
| 26 | 26 | // text may legitimately contain it) — is forwarded to next unchanged, so it | |
| 27 | 27 | // still gets the coalescer's marker-splitting treatment. | |
| 28 | 28 | type clipboardIntercept struct { | |
| 29 | + | // ctx is the foreground lifetime owning this scanner. A slow | |
| 30 | + | // workstation read (e.g. a hung wl-paste) must abort when the | |
| 31 | + | // foreground is stopped for a route switch, so the pump cannot hold | |
| 32 | + | // the switch waiting and a late result cannot reach a replacement | |
| 33 | + | // owner. Adapters map cancellation onto ErrNoClipboardImage, which | |
| 34 | + | // takes the existing forward path: the keystroke stays ordinary | |
| 35 | + | // input and follows the standard undelivered-byte handoff. | |
| 36 | + | ctx context.Context | |
| 29 | 37 | coalescer *pasteCoalescer | |
| 30 | 38 | reader ports.ClipboardReader | |
| 31 | 39 | log *slog.Logger | |
| @@ -97,7 +105,11 @@ func pasteMarkerBoundary(data []byte) int { | |||
| 97 | 105 | } | |
| 98 | 106 | ||
| 99 | 107 | func (c *clipboardIntercept) handleCtrlV() { | |
| 100 | - | mime, data, err := c.reader.ReadImage(context.Background()) | |
| 108 | + | ctx := c.ctx | |
| 109 | + | if ctx == nil { | |
| 110 | + | ctx = context.Background() | |
| 111 | + | } | |
| 112 | + | mime, data, err := c.reader.ReadImage(ctx) | |
| 101 | 113 | if err != nil { | |
| 102 | 114 | if !errors.Is(err, ports.ErrNoClipboardImage) { | |
| 103 | 115 | c.log.Warn("clipboard image read failed", "err", err) | |
D internal/usecase/client/home_picker_context_test.go +0 -198
| @@ -1,198 +0,0 @@ | |||
| 1 | - | package client_test | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/stretchr/testify/require" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/ports" | |
| 10 | - | "github.com/bnema/vev/internal/protocol" | |
| 11 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 12 | - | "github.com/bnema/vev/internal/usecase/client" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) { | |
| 16 | - | for _, tc := range []struct { | |
| 17 | - | name string | |
| 18 | - | udp bool | |
| 19 | - | rename bool | |
| 20 | - | selectLocal bool | |
| 21 | - | retireLocal bool | |
| 22 | - | }{ | |
| 23 | - | {name: "UDP", udp: true}, | |
| 24 | - | {name: "stdio"}, | |
| 25 | - | {name: "UDP local deletion", udp: true, retireLocal: true}, | |
| 26 | - | {name: "stdio local deletion", retireLocal: true}, | |
| 27 | - | {name: "UDP backing session renamed", udp: true, rename: true}, | |
| 28 | - | {name: "stdio backing session renamed", rename: true}, | |
| 29 | - | {name: "stdio select new local destination", selectLocal: true}, | |
| 30 | - | {name: "UDP select new local destination", udp: true, selectLocal: true}, | |
| 31 | - | } { | |
| 32 | - | t.Run(tc.name, func(t *testing.T) { | |
| 33 | - | term := newRunTerminal() | |
| 34 | - | defer term.in.unblock() | |
| 35 | - | localLifecycle := domain.SessionLifecycleID{1} | |
| 36 | - | remoteLifecycle := domain.SessionLifecycleID{2} | |
| 37 | - | target := domain.RemoteSessionTarget{ | |
| 38 | - | Endpoint: "igor", DisplayOrigin: "igor", LifecycleID: remoteLifecycle, | |
| 39 | - | SessionName: "misc", LiveTabID: "remote-tab", | |
| 40 | - | } | |
| 41 | - | initial := hybridLocalBootstrap(localLifecycle, target) | |
| 42 | - | remote := &recordingTransport{recvs: []recvItem{ | |
| 43 | - | {f: hybridWelcomeFrame("misc", remoteLifecycle)}, | |
| 44 | - | {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)}, | |
| 45 | - | }} | |
| 46 | - | remoteReturn := &recordingTransport{recvs: []recvItem{ | |
| 47 | - | {f: hybridWelcomeFrame("misc", remoteLifecycle)}, | |
| 48 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 49 | - | }} | |
| 50 | - | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote, remoteReturn}} | |
| 51 | - | if tc.udp { | |
| 52 | - | prepareSent, resumeSent := make(chan struct{}), make(chan struct{}) | |
| 53 | - | var retiredSnapshot chan struct{} | |
| 54 | - | if tc.retireLocal { | |
| 55 | - | retiredSnapshot = make(chan struct{}) | |
| 56 | - | } | |
| 57 | - | remote.onSend = hybridParkedRequestHandler(nil, nil, map[protocol.ParkedRouteAction]chan struct{}{ | |
| 58 | - | protocol.ParkedRoutePrepare: prepareSent, | |
| 59 | - | protocol.ParkedRouteResume: resumeSent, | |
| 60 | - | }) | |
| 61 | - | if tc.retireLocal { | |
| 62 | - | parkedHandler := remote.onSend | |
| 63 | - | remote.onSend = func(frame wire.Frame) { | |
| 64 | - | parkedHandler(frame) | |
| 65 | - | if frame.Type == wire.MsgRecentRouteSnapshot { | |
| 66 | - | snapshot, err := wire.UnmarshalRecentRouteSnapshot(frame.Payload) | |
| 67 | - | if err == nil && snapshot.Generation > 2 && len(snapshot.Entries) == 0 { | |
| 68 | - | select { | |
| 69 | - | case <-retiredSnapshot: | |
| 70 | - | default: | |
| 71 | - | close(retiredSnapshot) | |
| 72 | - | } | |
| 73 | - | } | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | } | |
| 77 | - | remote.recvs = append(remote.recvs, | |
| 78 | - | recvItem{f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady})), wait: prepareSent}, | |
| 79 | - | recvItem{f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteResumed})), wait: resumeSent}, | |
| 80 | - | recvItem{f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})), wait: retiredSnapshot}, | |
| 81 | - | ) | |
| 82 | - | remoteDialer.trs = []wire.Transport{markedDatagramTransport{Transport: remote}} | |
| 83 | - | } | |
| 84 | - | localPicker := &recordingTransport{recvs: []recvItem{{f: hybridWelcomeFrame("local", localLifecycle)}}} | |
| 85 | - | if tc.rename { | |
| 86 | - | localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{ | |
| 87 | - | Target: protocol.ExactSessionTarget{LifecycleID: localLifecycle, SessionName: "renamed-local"}, | |
| 88 | - | }))}) | |
| 89 | - | } | |
| 90 | - | var localRetiredSnapshot chan struct{} | |
| 91 | - | if tc.retireLocal { | |
| 92 | - | localRetiredSnapshot = make(chan struct{}) | |
| 93 | - | localPicker.onSend = func(frame wire.Frame) { | |
| 94 | - | if frame.Type == wire.MsgRecentRouteSnapshot { | |
| 95 | - | snapshot, err := wire.UnmarshalRecentRouteSnapshot(frame.Payload) | |
| 96 | - | if err == nil && snapshot.Generation > 2 && len(snapshot.Entries) == 0 { | |
| 97 | - | select { | |
| 98 | - | case <-localRetiredSnapshot: | |
| 99 | - | default: | |
| 100 | - | close(localRetiredSnapshot) | |
| 101 | - | } | |
| 102 | - | } | |
| 103 | - | } | |
| 104 | - | } | |
| 105 | - | payload, err := wire.MarshalRouteRetired(protocol.RouteRetired{Ref: protocol.RouteRef{Key: 1, Generation: 1}, Target: protocol.ExactSessionTarget{LifecycleID: localLifecycle, SessionName: "local"}}) | |
| 106 | - | require.NoError(t, err) | |
| 107 | - | localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgRouteRetired, payload)}) | |
| 108 | - | } | |
| 109 | - | localDialer := &sequenceDialer{trs: []wire.Transport{initial, localPicker}} | |
| 110 | - | var localDestination *recordingTransport | |
| 111 | - | if tc.selectLocal { | |
| 112 | - | selected := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "destination"} | |
| 113 | - | localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 114 | - | Session: selected.SessionName, Intent: protocol.IntentAttach, ExactTarget: &selected, | |
| 115 | - | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, PreferredTabID: "chosen-tab", | |
| 116 | - | }))}) | |
| 117 | - | localDestination = &recordingTransport{recvs: []recvItem{ | |
| 118 | - | {f: hybridWelcomeFrame(selected.SessionName, selected.LifecycleID)}, | |
| 119 | - | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 120 | - | }} | |
| 121 | - | localDialer.trs = append(localDialer.trs, localDestination) | |
| 122 | - | } else { | |
| 123 | - | localPicker.recvs = append(localPicker.recvs, recvItem{f: navigationDirectiveFrame(protocol.NavigationBack), wait: localRetiredSnapshot}) | |
| 124 | - | } | |
| 125 | - | clock := &reconnectTestClock{} | |
| 126 | - | deps := hybridPickerDependencies(localDialer, term, clock, map[string]ports.ClientDialer{"igor": remoteDialer}) | |
| 127 | - | require.NoError(t, runTestClient(t.Context(), deps, client.AttachRequest{ | |
| 128 | - | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 129 | - | })) | |
| 130 | - | ||
| 131 | - | var source protocol.RecentRouteSnapshot | |
| 132 | - | for _, sent := range remote.Sends() { | |
| 133 | - | if sent.Type == wire.MsgRecentRouteSnapshot { | |
| 134 | - | var err error | |
| 135 | - | source, err = wire.UnmarshalRecentRouteSnapshot(sent.Payload) | |
| 136 | - | require.NoError(t, err) | |
| 137 | - | break | |
| 138 | - | } | |
| 139 | - | } | |
| 140 | - | require.Equal(t, "misc", source.ActiveEntry.Name) | |
| 141 | - | publications := 0 | |
| 142 | - | for _, sent := range localPicker.Sends() { | |
| 143 | - | if sent.Type != wire.MsgRecentRouteSnapshot { | |
| 144 | - | continue | |
| 145 | - | } | |
| 146 | - | publications++ | |
| 147 | - | snapshot, err := wire.UnmarshalRecentRouteSnapshot(sent.Payload) | |
| 148 | - | require.NoError(t, err) | |
| 149 | - | if tc.retireLocal && publications > 1 { | |
| 150 | - | require.Empty(t, snapshot.Entries) | |
| 151 | - | require.Equal(t, source.Active, snapshot.Active) | |
| 152 | - | } else { | |
| 153 | - | require.Equal(t, source, snapshot, "rendering the home picker must not commit its backing session as the active route") | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | require.Positive(t, publications) | |
| 157 | - | if tc.retireLocal { | |
| 158 | - | require.Greater(t, publications, 1) | |
| 159 | - | returned := remoteReturn | |
| 160 | - | if tc.udp { | |
| 161 | - | returned = remote | |
| 162 | - | } | |
| 163 | - | var latest protocol.RecentRouteSnapshot | |
| 164 | - | for _, sent := range returned.Sends() { | |
| 165 | - | if sent.Type == wire.MsgRecentRouteSnapshot { | |
| 166 | - | var err error | |
| 167 | - | latest, err = wire.UnmarshalRecentRouteSnapshot(sent.Payload) | |
| 168 | - | require.NoError(t, err) | |
| 169 | - | } | |
| 170 | - | } | |
| 171 | - | require.Equal(t, source.ActiveEntry.Target, latest.ActiveEntry.Target) | |
| 172 | - | require.Empty(t, latest.Entries, "returning to remote must publish the pruned ledger") | |
| 173 | - | } | |
| 174 | - | wantDials := int32(2) | |
| 175 | - | if tc.udp || tc.selectLocal { | |
| 176 | - | wantDials = 1 | |
| 177 | - | } | |
| 178 | - | require.Equal(t, wantDials, remoteDialer.calls.Load()) | |
| 179 | - | if localDestination != nil { | |
| 180 | - | hello := helloFromSend(t, localDestination) | |
| 181 | - | require.Equal(t, protocol.StartupOverlayNone, hello.StartupOverlay) | |
| 182 | - | require.Equal(t, domain.TabStableID("chosen-tab"), hello.PreferredTabID) | |
| 183 | - | require.Zero(t, hello.NavigationCapabilities&protocol.NavigationCapabilityBack) | |
| 184 | - | var selected protocol.RecentRouteSnapshot | |
| 185 | - | for _, sent := range localDestination.Sends() { | |
| 186 | - | if sent.Type == wire.MsgRecentRouteSnapshot { | |
| 187 | - | var err error | |
| 188 | - | selected, err = wire.UnmarshalRecentRouteSnapshot(sent.Payload) | |
| 189 | - | require.NoError(t, err) | |
| 190 | - | } | |
| 191 | - | } | |
| 192 | - | require.Equal(t, "destination", selected.ActiveEntry.Name) | |
| 193 | - | require.Equal(t, protocol.RouteKindLocal, selected.ActiveEntry.Kind) | |
| 194 | - | require.Equal(t, source.Active, selected.Previous) | |
| 195 | - | } | |
| 196 | - | }) | |
| 197 | - | } | |
| 198 | - | } | |
A internal/usecase/client/host_registry_internal_test.go +154 -0
| @@ -0,0 +1,154 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // recordingDialer is a typed dialer that never dials: these tests only assert | |
| 16 | + | // how a handoff request is derived from the endpoint binding. | |
| 17 | + | type recordingDialer struct{ name string } | |
| 18 | + | ||
| 19 | + | func (d recordingDialer) Dial(context.Context) (ports.ClientConnection, error) { | |
| 20 | + | return nil, errors.New("recording dialer is never dialed") | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | var _ ports.ClientDialer = recordingDialer{} | |
| 24 | + | ||
| 25 | + | func TestRunnerHandoffRequestDerivesFromBindingAndTarget(t *testing.T) { | |
| 26 | + | source := domain.RemoteSessionTarget{ | |
| 27 | + | Endpoint: "arch", DisplayOrigin: "arch.example", LifecycleID: domain.SessionLifecycleID{2}, | |
| 28 | + | SessionName: "work", LiveTabID: "tab-1", | |
| 29 | + | } | |
| 30 | + | target := protocol.AttachTarget{ | |
| 31 | + | Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, | |
| 32 | + | RemoteTarget: &source, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 33 | + | } | |
| 34 | + | binding := ports.RemoteEndpointBinding{Dialer: recordingDialer{name: "arch"}, Environment: []string{"FOO=1"}} | |
| 35 | + | var resolvedEndpoint string | |
| 36 | + | runner := &Runner{ | |
| 37 | + | hostRegistry: internalStubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 38 | + | resolvedEndpoint = endpoint | |
| 39 | + | return binding, nil | |
| 40 | + | }}, | |
| 41 | + | } | |
| 42 | + | dialer, request, err := runner.resolveHandoff(context.Background(), target) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | require.Equal(t, "arch", resolvedEndpoint) | |
| 45 | + | require.Equal(t, binding.Dialer, dialer) | |
| 46 | + | require.Equal(t, AttachRequest{ | |
| 47 | + | Intent: protocol.IntentAttach, SessionName: "work", Remote: true, | |
| 48 | + | Environment: []string{"FOO=1"}, | |
| 49 | + | Origin: protocol.RouteOriginDiscovery, | |
| 50 | + | OriginKey: "arch", | |
| 51 | + | RemoteTarget: &source, | |
| 52 | + | HostLabel: "arch.example", | |
| 53 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 54 | + | }, request) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestRunnerHandoffRequestUsesEndpointLabelWithoutSelection(t *testing.T) { | |
| 58 | + | target := protocol.AttachTarget{ | |
| 59 | + | Endpoint: "arch", Session: "work", Intent: protocol.IntentNew, RequestID: 1, | |
| 60 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 61 | + | } | |
| 62 | + | runner := &Runner{ | |
| 63 | + | launchedRemote: true, | |
| 64 | + | hostRegistry: internalStubHostRegistry{resolve: func(string) (ports.RemoteEndpointBinding, error) { | |
| 65 | + | return ports.RemoteEndpointBinding{Dialer: recordingDialer{}}, nil | |
| 66 | + | }}, | |
| 67 | + | } | |
| 68 | + | _, request, err := runner.resolveHandoff(context.Background(), target) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | require.Equal(t, domain.RemoteDisplayOrigin("arch"), request.HostLabel) | |
| 71 | + | require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // TestRunnerHandoffEnvironmentPolicy pins the picker environment rule the | |
| 75 | + | // composition root used to own: a route chosen from a locally launched client | |
| 76 | + | // has no explicit remote selection, so its environment belongs to the daemon. | |
| 77 | + | func TestRunnerHandoffEnvironmentPolicy(t *testing.T) { | |
| 78 | + | source := &domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", SessionName: "work"} | |
| 79 | + | tests := []struct { | |
| 80 | + | name string | |
| 81 | + | launchedRemote bool | |
| 82 | + | target protocol.AttachTarget | |
| 83 | + | want protocol.EnvironmentPolicy | |
| 84 | + | }{ | |
| 85 | + | { | |
| 86 | + | name: "local picker attach is daemon-owned", | |
| 87 | + | target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}, | |
| 88 | + | want: protocol.EnvironmentPolicyDaemonOwned, | |
| 89 | + | }, | |
| 90 | + | { | |
| 91 | + | name: "local picker create keeps the daemon policy", | |
| 92 | + | target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentNew, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, | |
| 93 | + | want: protocol.EnvironmentPolicyDaemonOwned, | |
| 94 | + | }, | |
| 95 | + | { | |
| 96 | + | name: "local picker with a remote selection keeps its policy", | |
| 97 | + | target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, RemoteTarget: source, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, | |
| 98 | + | want: protocol.EnvironmentPolicyDaemonOwned, | |
| 99 | + | }, | |
| 100 | + | { | |
| 101 | + | name: "directly launched remote keeps the client policy", | |
| 102 | + | launchedRemote: true, | |
| 103 | + | target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}, | |
| 104 | + | want: protocol.EnvironmentPolicyClientOwned, | |
| 105 | + | }, | |
| 106 | + | } | |
| 107 | + | for _, tt := range tests { | |
| 108 | + | t.Run(tt.name, func(t *testing.T) { | |
| 109 | + | runner := &Runner{launchedRemote: tt.launchedRemote} | |
| 110 | + | require.Equal(t, tt.want, runner.handoffEnvironmentPolicy(tt.target)) | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // TestRunnerValidatesHandoffTargetsBeforeResolving pins that a malformed | |
| 116 | + | // daemon-authored target never reaches registry policy: resolution is not even | |
| 117 | + | // attempted. | |
| 118 | + | func TestRunnerValidatesHandoffTargetsBeforeResolving(t *testing.T) { | |
| 119 | + | mismatched := domain.RemoteSessionTarget{ | |
| 120 | + | Endpoint: "other", DisplayOrigin: "other", LifecycleID: domain.SessionLifecycleID{3}, SessionName: "elsewhere", | |
| 121 | + | } | |
| 122 | + | tests := []struct { | |
| 123 | + | name string | |
| 124 | + | target protocol.AttachTarget | |
| 125 | + | }{ | |
| 126 | + | {name: "invalid endpoint", target: protocol.AttachTarget{Endpoint: "bad host", Session: "work", Intent: protocol.IntentAttach}}, | |
| 127 | + | {name: "invalid session name", target: protocol.AttachTarget{Endpoint: "arch", Session: "bad name", Intent: protocol.IntentAttach}}, | |
| 128 | + | {name: "selection identity mismatch", target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, RemoteTarget: &mismatched, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}}, | |
| 129 | + | {name: "selection without daemon-owned environment", target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, RemoteTarget: &mismatched, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}}, | |
| 130 | + | } | |
| 131 | + | for _, tt := range tests { | |
| 132 | + | t.Run(tt.name, func(t *testing.T) { | |
| 133 | + | resolved := false | |
| 134 | + | runner := &Runner{hostRegistry: internalStubHostRegistry{resolve: func(string) (ports.RemoteEndpointBinding, error) { | |
| 135 | + | resolved = true | |
| 136 | + | return ports.RemoteEndpointBinding{Dialer: recordingDialer{}}, nil | |
| 137 | + | }}} | |
| 138 | + | _, _, err := runner.resolveHandoff(context.Background(), tt.target) | |
| 139 | + | require.Error(t, err) | |
| 140 | + | require.False(t, resolved, "an invalid target must be refused before endpoint policy runs") | |
| 141 | + | }) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestRunnerHandoffReportsResolutionFailure(t *testing.T) { | |
| 146 | + | want := errors.New("launch policy refused this endpoint") | |
| 147 | + | runner := &Runner{hostRegistry: internalStubHostRegistry{resolve: func(string) (ports.RemoteEndpointBinding, error) { | |
| 148 | + | return ports.RemoteEndpointBinding{}, want | |
| 149 | + | }}} | |
| 150 | + | _, _, err := runner.resolveHandoff(context.Background(), protocol.AttachTarget{ | |
| 151 | + | Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, | |
| 152 | + | }) | |
| 153 | + | require.ErrorIs(t, err, want) | |
| 154 | + | } | |
A internal/usecase/client/hybrid_picker_route_test.go +539 -0
| @@ -0,0 +1,539 @@ | |||
| 1 | + | package client_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "sync" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/domain" | |
| 16 | + | "github.com/bnema/vev/internal/ports" | |
| 17 | + | "github.com/bnema/vev/internal/protocol" | |
| 18 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 19 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // This file drives the hybrid route-switch journeys through the client-owned | |
| 23 | + | // picker: a client attached to a remote serving daemon opens the picker from | |
| 24 | + | // the daemon's offer, commits a row, and then follows whichever handoff the | |
| 25 | + | // serving daemon authorises. It replaces the deleted parked-route suites, | |
| 26 | + | // whose journeys are now the daemon's decision plus the client's same-peer | |
| 27 | + | // confirmation or endpoint dial. | |
| 28 | + | ||
| 29 | + | // hybridPickerTransport is a live wire.Transport the test drives frame by | |
| 30 | + | // frame. Recv yields queued frames in order and parks when the queue is | |
| 31 | + | // empty, so the journey can react to what the client actually sent. | |
| 32 | + | type hybridPickerTransport struct { | |
| 33 | + | mu sync.Mutex | |
| 34 | + | queue []wire.Frame | |
| 35 | + | sends []wire.Frame | |
| 36 | + | signal chan struct{} | |
| 37 | + | done chan struct{} | |
| 38 | + | closeOnce sync.Once | |
| 39 | + | closed atomic.Int32 | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func newHybridPickerTransport() *hybridPickerTransport { | |
| 43 | + | return &hybridPickerTransport{signal: make(chan struct{}, 1), done: make(chan struct{})} | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (t *hybridPickerTransport) Send(frame wire.Frame) error { | |
| 47 | + | t.mu.Lock() | |
| 48 | + | t.sends = append(t.sends, frame) | |
| 49 | + | t.mu.Unlock() | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (t *hybridPickerTransport) Recv() (wire.Frame, error) { | |
| 54 | + | for { | |
| 55 | + | t.mu.Lock() | |
| 56 | + | if len(t.queue) > 0 { | |
| 57 | + | frame := t.queue[0] | |
| 58 | + | t.queue = t.queue[1:] | |
| 59 | + | t.mu.Unlock() | |
| 60 | + | return frame, nil | |
| 61 | + | } | |
| 62 | + | t.mu.Unlock() | |
| 63 | + | select { | |
| 64 | + | case <-t.signal: | |
| 65 | + | case <-t.done: | |
| 66 | + | return wire.Frame{}, io.EOF | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (t *hybridPickerTransport) Close() error { | |
| 72 | + | t.closed.Add(1) | |
| 73 | + | t.closeOnce.Do(func() { close(t.done) }) | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (t *hybridPickerTransport) push(frame wire.Frame) { | |
| 78 | + | t.mu.Lock() | |
| 79 | + | t.queue = append(t.queue, frame) | |
| 80 | + | t.mu.Unlock() | |
| 81 | + | select { | |
| 82 | + | case t.signal <- struct{}{}: | |
| 83 | + | default: | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (t *hybridPickerTransport) sent() []wire.Frame { | |
| 88 | + | t.mu.Lock() | |
| 89 | + | defer t.mu.Unlock() | |
| 90 | + | return append([]wire.Frame(nil), t.sends...) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // awaitSend waits for one client frame of the requested type and returns its | |
| 94 | + | // payload. | |
| 95 | + | func (t *hybridPickerTransport) awaitSend(typ wire.MsgType) ([]byte, bool) { | |
| 96 | + | deadline := time.Now().Add(2 * time.Second) | |
| 97 | + | for time.Now().Before(deadline) { | |
| 98 | + | for _, frame := range t.sent() { | |
| 99 | + | if frame.Type == typ { | |
| 100 | + | return frame.Payload, true | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | time.Sleep(time.Millisecond) | |
| 104 | + | } | |
| 105 | + | return nil, false | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (t *hybridPickerTransport) sentType(typ wire.MsgType) bool { | |
| 109 | + | for _, frame := range t.sent() { | |
| 110 | + | if frame.Type == typ { | |
| 111 | + | return true | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | return false | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // hybridPickerReader yields one carriage return only when the test releases | |
| 118 | + | // it, so a keypress can never reach the session before the picker owns input. | |
| 119 | + | type hybridPickerReader struct { | |
| 120 | + | mu sync.Mutex | |
| 121 | + | delivered bool | |
| 122 | + | press chan struct{} | |
| 123 | + | done chan struct{} | |
| 124 | + | once sync.Once | |
| 125 | + | closeOnce sync.Once | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func newHybridPickerReader() *hybridPickerReader { | |
| 129 | + | return &hybridPickerReader{press: make(chan struct{}), done: make(chan struct{})} | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (r *hybridPickerReader) pressEnter() { r.once.Do(func() { close(r.press) }) } | |
| 133 | + | func (r *hybridPickerReader) unblock() { r.closeOnce.Do(func() { close(r.done) }) } | |
| 134 | + | ||
| 135 | + | func (r *hybridPickerReader) Read(p []byte) (int, error) { | |
| 136 | + | select { | |
| 137 | + | case <-r.press: | |
| 138 | + | r.mu.Lock() | |
| 139 | + | deliver := !r.delivered | |
| 140 | + | r.delivered = true | |
| 141 | + | r.mu.Unlock() | |
| 142 | + | if deliver { | |
| 143 | + | return copy(p, "\r"), nil | |
| 144 | + | } | |
| 145 | + | case <-r.done: | |
| 146 | + | return 0, io.EOF | |
| 147 | + | } | |
| 148 | + | <-r.done | |
| 149 | + | return 0, io.EOF | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // hybridPickerTerminal is the headless terminal the runner drives: the picker | |
| 153 | + | // frame is written to its buffer and one scripted keypress is its input. | |
| 154 | + | type hybridPickerTerminal struct { | |
| 155 | + | reader *hybridPickerReader | |
| 156 | + | mu sync.Mutex | |
| 157 | + | out bytes.Buffer | |
| 158 | + | resize chan domain.Geometry | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func newHybridPickerTerminal() *hybridPickerTerminal { | |
| 162 | + | return &hybridPickerTerminal{reader: newHybridPickerReader(), resize: make(chan domain.Geometry)} | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (*hybridPickerTerminal) EnterRaw() (func() error, error) { | |
| 166 | + | return func() error { return nil }, nil | |
| 167 | + | } | |
| 168 | + | func (*hybridPickerTerminal) Geometry() (domain.Geometry, error) { | |
| 169 | + | return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil | |
| 170 | + | } | |
| 171 | + | func (t *hybridPickerTerminal) ResizeEvents() <-chan domain.Geometry { return t.resize } | |
| 172 | + | func (t *hybridPickerTerminal) In() io.Reader { return t.reader } | |
| 173 | + | func (t *hybridPickerTerminal) Out() io.Writer { return hybridPickerWriter{t} } | |
| 174 | + | func (*hybridPickerTerminal) Flush() error { return nil } | |
| 175 | + | ||
| 176 | + | func (t *hybridPickerTerminal) screen() string { | |
| 177 | + | t.mu.Lock() | |
| 178 | + | defer t.mu.Unlock() | |
| 179 | + | return t.out.String() | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | type hybridPickerWriter struct{ t *hybridPickerTerminal } | |
| 183 | + | ||
| 184 | + | func (w hybridPickerWriter) Write(p []byte) (int, error) { | |
| 185 | + | w.t.mu.Lock() | |
| 186 | + | defer w.t.mu.Unlock() | |
| 187 | + | return w.t.out.Write(p) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // awaitDisplay waits until the composed picker frame is on the terminal: the | |
| 191 | + | // row labels are rendered verbatim. | |
| 192 | + | func (term *hybridPickerTerminal) awaitDisplay(t *testing.T, label string) { | |
| 193 | + | t.Helper() | |
| 194 | + | deadline := time.Now().Add(2 * time.Second) | |
| 195 | + | for time.Now().Before(deadline) { | |
| 196 | + | if bytes.Contains([]byte(term.screen()), []byte(label)) { | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | time.Sleep(time.Millisecond) | |
| 200 | + | } | |
| 201 | + | t.Fatalf("the picker frame never showed %q; terminal output was %q", label, term.screen()) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func hybridPickerWelcome(name string, lifecycle domain.SessionLifecycleID) wire.Frame { | |
| 205 | + | return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{ | |
| 206 | + | SessionID: name + "-id", SessionName: name, ResumeToken: 1, | |
| 207 | + | Capabilities: protocol.CapabilityResume, | |
| 208 | + | CommittedIdentity: &protocol.CommittedRouteIdentity{ | |
| 209 | + | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}, | |
| 210 | + | }, | |
| 211 | + | })) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // hybridPickerPaint pushes one authoritative full paint, which is both the | |
| 215 | + | // output boundary the picker barrier names and the release paint. | |
| 216 | + | func hybridPickerPaint(t *testing.T, transport *hybridPickerTransport, epoch uint64, target protocol.ExactSessionTarget, text string) { | |
| 217 | + | t.Helper() | |
| 218 | + | view := protocol.ViewContext{Publication: epoch, Route: protocol.CommittedRouteIdentity{Target: target}, TabID: "t_abc123", FocusedPaneID: "p_def456"} | |
| 219 | + | payload, err := wire.MarshalOutput(protocol.Output{ | |
| 220 | + | Epoch: epoch, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &view, | |
| 221 | + | Data: []byte("\x1b[2J\x1b[H" + text), | |
| 222 | + | }) | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | transport.push(frameOf(wire.MsgOutput, payload)) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | const hybridPickerInteraction = uint64(7) | |
| 228 | + | ||
| 229 | + | func hybridPickerOffer() protocol.PickerOffer { | |
| 230 | + | return protocol.PickerOffer{ | |
| 231 | + | InteractionID: hybridPickerInteraction, Intent: protocol.PickerIntentNavigation, | |
| 232 | + | Title: " Sessions · recent ", BarrierEpoch: 1, BarrierState: 1, SizeEpoch: 1, | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func hybridPickerSnapshot() protocol.PickerSnapshot { | |
| 237 | + | return protocol.PickerSnapshot{ | |
| 238 | + | InteractionID: hybridPickerInteraction, SourceID: "serving", SourceRevision: 1, Status: protocol.PickerSourceOK, | |
| 239 | + | Lines: []protocol.PickerLine{ | |
| 240 | + | {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanNavigate}, | |
| 241 | + | {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanNavigate}, | |
| 242 | + | }, | |
| 243 | + | Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0}, | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // openHybridPicker scripts the serving daemon's offer and first snapshot after | |
| 248 | + | // the barrier paint, so the client takes presentation over. | |
| 249 | + | func openHybridPicker(t *testing.T, transport *hybridPickerTransport, serving protocol.ExactSessionTarget) { | |
| 250 | + | t.Helper() | |
| 251 | + | hybridPickerPaint(t, transport, 1, serving, "ready") | |
| 252 | + | transport.push(frameOf(wire.MsgPickerOffer, wire.MarshalPickerOffer(hybridPickerOffer()))) | |
| 253 | + | transport.push(frameOf(wire.MsgPickerSnapshot, wire.MarshalPickerSnapshot(hybridPickerSnapshot()))) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func hybridPickerTargets() (domain.RemoteSessionTarget, protocol.ExactSessionTarget) { | |
| 257 | + | source := domain.RemoteSessionTarget{ | |
| 258 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{2}, | |
| 259 | + | SessionName: "source", LiveTabID: "source-tab", | |
| 260 | + | } | |
| 261 | + | target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "target"} | |
| 262 | + | return source, target | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // TestHybridPickerSameHostSwitchKeepsTheServingTransport pins the hybrid | |
| 266 | + | // same-host journey through the client picker: the client commits a row to a | |
| 267 | + | // remote serving daemon, the daemon retires the interaction and offers the | |
| 268 | + | // same-host target, and the client confirms it on the authenticated | |
| 269 | + | // connection instead of dialing a replacement. | |
| 270 | + | func TestHybridPickerSameHostSwitchKeepsTheServingTransport(t *testing.T) { | |
| 271 | + | term := newHybridPickerTerminal() | |
| 272 | + | defer term.reader.unblock() | |
| 273 | + | ||
| 274 | + | source, target := hybridPickerTargets() | |
| 275 | + | local := newHybridPickerTransport() | |
| 276 | + | remote := newHybridPickerTransport() | |
| 277 | + | handoffEndpoints := make(chan string, 4) | |
| 278 | + | ||
| 279 | + | // The bootstrap attaches locally, then hands the client to the remote | |
| 280 | + | // endpoint the serving daemon names. | |
| 281 | + | local.push(hybridPickerWelcome("local", domain.SessionLifecycleID{1})) | |
| 282 | + | local.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 283 | + | Endpoint: "remote", Session: "source", Intent: protocol.IntentAttach, RemoteTarget: &source, | |
| 284 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 285 | + | }))) | |
| 286 | + | ||
| 287 | + | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}} | |
| 288 | + | localDialer := &sequenceDialer{trs: []wire.Transport{local}} | |
| 289 | + | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |
| 290 | + | deps.HostRegistry = stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 291 | + | handoffEndpoints <- endpoint | |
| 292 | + | return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil | |
| 293 | + | }} | |
| 294 | + | ||
| 295 | + | done := make(chan error, 1) | |
| 296 | + | go func() { | |
| 297 | + | done <- runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 298 | + | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 299 | + | }) | |
| 300 | + | }() | |
| 301 | + | ||
| 302 | + | remote.push(hybridPickerWelcome("source", source.LifecycleID)) | |
| 303 | + | openHybridPicker(t, remote, protocol.ExactSessionTarget{LifecycleID: source.LifecycleID, SessionName: "source"}) | |
| 304 | + | term.awaitDisplay(t, "second") | |
| 305 | + | require.Contains(t, term.screen(), "\x1b[?25l", "the client-owned picker must hide the hardware cursor") | |
| 306 | + | ||
| 307 | + | // The commit crosses as a typed selection; the daemon then retires the | |
| 308 | + | // interaction and offers the same-host target on the same connection. | |
| 309 | + | term.reader.pressEnter() | |
| 310 | + | selectionPayload, ok := remote.awaitSend(wire.MsgPickerSelection) | |
| 311 | + | require.True(t, ok, "the picker commit never crossed the wire") | |
| 312 | + | selection, err := wire.UnmarshalPickerSelection(selectionPayload) | |
| 313 | + | require.NoError(t, err) | |
| 314 | + | require.Equal(t, hybridPickerInteraction, selection.InteractionID) | |
| 315 | + | ||
| 316 | + | remote.push(frameOf(wire.MsgPickerClosedServer, wire.MarshalPickerClosed(protocol.PickerClosed{ | |
| 317 | + | InteractionID: hybridPickerInteraction, BarrierEpoch: 1, BarrierState: 1, | |
| 318 | + | }))) | |
| 319 | + | remote.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 320 | + | Session: target.SessionName, Intent: protocol.IntentAttach, ExactTarget: &target, | |
| 321 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true, CauseActionID: selection.CauseActionID, | |
| 322 | + | }))) | |
| 323 | + | ||
| 324 | + | switchPayload, ok := remote.awaitSend(wire.MsgSamePeerSwitchRequest) | |
| 325 | + | require.True(t, ok, "the same-host target must be confirmed on the serving connection") | |
| 326 | + | switchRequest, err := wire.UnmarshalSamePeerSwitchRequest(switchPayload) | |
| 327 | + | require.NoError(t, err) | |
| 328 | + | require.Equal(t, target, switchRequest.Target) | |
| 329 | + | ||
| 330 | + | remote.push(frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{Target: target}))) | |
| 331 | + | hybridPickerPaint(t, remote, 2, target, "target frame") | |
| 332 | + | ||
| 333 | + | // The released destination paint is displayed while the client is still on | |
| 334 | + | // the authenticated serving connection: nothing dialed a replacement and | |
| 335 | + | // nothing closed the source to get here. | |
| 336 | + | term.awaitDisplay(t, "target frame") | |
| 337 | + | require.Equal(t, int32(1), remoteDialer.calls.Load(), "a same-host switch must keep the authenticated serving transport") | |
| 338 | + | require.Zero(t, remote.closed.Load(), "a same-host switch must not close the serving transport") | |
| 339 | + | ||
| 340 | + | remote.push(frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))) | |
| 341 | + | ||
| 342 | + | select { | |
| 343 | + | case err := <-done: | |
| 344 | + | require.NoError(t, err) | |
| 345 | + | case <-time.After(5 * time.Second): | |
| 346 | + | t.Fatal("client did not finish the same-host picker switch") | |
| 347 | + | } | |
| 348 | + | require.Equal(t, []string{"remote"}, drainStrings(handoffEndpoints)) | |
| 349 | + | require.False(t, local.sentType(wire.MsgSamePeerSwitchRequest), "the switch belongs to the serving connection") | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // TestHybridPickerDifferentHostDialsTheTargetEndpoint pins the cross-host | |
| 353 | + | // journey through the client picker: the daemon retires the interaction and | |
| 354 | + | // hands the client to another endpoint, which the runner dials through the | |
| 355 | + | // composed handoff while the source attachment closes. | |
| 356 | + | func TestHybridPickerDifferentHostDialsTheTargetEndpoint(t *testing.T) { | |
| 357 | + | term := newHybridPickerTerminal() | |
| 358 | + | defer term.reader.unblock() | |
| 359 | + | ||
| 360 | + | source, _ := hybridPickerTargets() | |
| 361 | + | targetHost := domain.RemoteSessionTarget{ | |
| 362 | + | Endpoint: "target-host", DisplayOrigin: "target-host", LifecycleID: domain.SessionLifecycleID{4}, | |
| 363 | + | SessionName: "target", LiveTabID: "target-tab", | |
| 364 | + | } | |
| 365 | + | local := newHybridPickerTransport() | |
| 366 | + | remote := newHybridPickerTransport() | |
| 367 | + | targetTransport := newHybridPickerTransport() | |
| 368 | + | handoffEndpoints := make(chan string, 4) | |
| 369 | + | ||
| 370 | + | local.push(hybridPickerWelcome("local", domain.SessionLifecycleID{1})) | |
| 371 | + | local.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 372 | + | Endpoint: "remote", Session: "source", Intent: protocol.IntentAttach, RemoteTarget: &source, | |
| 373 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 374 | + | }))) | |
| 375 | + | ||
| 376 | + | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}} | |
| 377 | + | targetDialer := &sequenceDialer{trs: []wire.Transport{targetTransport}} | |
| 378 | + | localDialer := &sequenceDialer{trs: []wire.Transport{local}} | |
| 379 | + | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |
| 380 | + | deps.HostRegistry = stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 381 | + | handoffEndpoints <- endpoint | |
| 382 | + | if endpoint == targetHost.Endpoint { | |
| 383 | + | return ports.RemoteEndpointBinding{Dialer: targetDialer}, nil | |
| 384 | + | } | |
| 385 | + | return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil | |
| 386 | + | }} | |
| 387 | + | ||
| 388 | + | done := make(chan error, 1) | |
| 389 | + | go func() { | |
| 390 | + | done <- runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 391 | + | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 392 | + | }) | |
| 393 | + | }() | |
| 394 | + | ||
| 395 | + | remote.push(hybridPickerWelcome("source", source.LifecycleID)) | |
| 396 | + | openHybridPicker(t, remote, protocol.ExactSessionTarget{LifecycleID: source.LifecycleID, SessionName: "source"}) | |
| 397 | + | term.awaitDisplay(t, "second") | |
| 398 | + | ||
| 399 | + | term.reader.pressEnter() | |
| 400 | + | selectionPayload, ok := remote.awaitSend(wire.MsgPickerSelection) | |
| 401 | + | require.True(t, ok, "the picker commit never crossed the wire") | |
| 402 | + | selection, err := wire.UnmarshalPickerSelection(selectionPayload) | |
| 403 | + | require.NoError(t, err) | |
| 404 | + | ||
| 405 | + | remote.push(frameOf(wire.MsgPickerClosedServer, wire.MarshalPickerClosed(protocol.PickerClosed{ | |
| 406 | + | InteractionID: hybridPickerInteraction, BarrierEpoch: 1, BarrierState: 1, | |
| 407 | + | }))) | |
| 408 | + | remote.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 409 | + | Endpoint: targetHost.Endpoint, Session: targetHost.SessionName, Intent: protocol.IntentAttach, | |
| 410 | + | RemoteTarget: &targetHost, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 411 | + | CauseActionID: selection.CauseActionID, | |
| 412 | + | }))) | |
| 413 | + | ||
| 414 | + | targetTransport.push(hybridPickerWelcome("target", targetHost.LifecycleID)) | |
| 415 | + | targetTransport.push(frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))) | |
| 416 | + | ||
| 417 | + | select { | |
| 418 | + | case err := <-done: | |
| 419 | + | require.NoError(t, err) | |
| 420 | + | case <-time.After(5 * time.Second): | |
| 421 | + | t.Fatal("client did not finish the cross-host picker handoff") | |
| 422 | + | } | |
| 423 | + | require.Equal(t, []string{"remote", "target-host"}, drainStrings(handoffEndpoints)) | |
| 424 | + | require.Equal(t, int32(1), targetDialer.calls.Load(), "the cross-host target must be dialed once") | |
| 425 | + | require.Positive(t, remote.closed.Load(), "the source attachment must close after the cross-host handoff") | |
| 426 | + | require.False(t, remote.sentType(wire.MsgSamePeerSwitchRequest), "a cross-host target is never confirmed on the source connection") | |
| 427 | + | helloPayload, ok := targetTransport.awaitSend(wire.MsgHello) | |
| 428 | + | require.True(t, ok, "the target attachment never sent a Hello") | |
| 429 | + | hello, err := wire.UnmarshalHello(helloPayload) | |
| 430 | + | require.NoError(t, err) | |
| 431 | + | require.Equal(t, "target", hello.Name) | |
| 432 | + | require.NotNil(t, hello.RemoteTarget) | |
| 433 | + | require.Equal(t, targetHost, *hello.RemoteTarget) | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func drainStrings(ch chan string) []string { | |
| 437 | + | var out []string | |
| 438 | + | for { | |
| 439 | + | select { | |
| 440 | + | case value := <-ch: | |
| 441 | + | out = append(out, value) | |
| 442 | + | default: | |
| 443 | + | return out | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers pins the registry | |
| 449 | + | // invariant the client now owns: a picker route that moves to another serving | |
| 450 | + | // peer resolves that endpoint once, and returning to the first peer reuses the | |
| 451 | + | // binding it already had instead of rebuilding the carriage. | |
| 452 | + | func TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers(t *testing.T) { | |
| 453 | + | term := newHybridPickerTerminal() | |
| 454 | + | defer term.reader.unblock() | |
| 455 | + | ||
| 456 | + | source, _ := hybridPickerTargets() | |
| 457 | + | otherHost := domain.RemoteSessionTarget{ | |
| 458 | + | Endpoint: "target-host", DisplayOrigin: "target-host", LifecycleID: domain.SessionLifecycleID{4}, | |
| 459 | + | SessionName: "other", LiveTabID: "other-tab", | |
| 460 | + | } | |
| 461 | + | local := newHybridPickerTransport() | |
| 462 | + | remote := newHybridPickerTransport() | |
| 463 | + | remoteReturn := newHybridPickerTransport() | |
| 464 | + | other := newHybridPickerTransport() | |
| 465 | + | resolutions := make(chan string, 8) | |
| 466 | + | ||
| 467 | + | local.push(hybridPickerWelcome("local", domain.SessionLifecycleID{1})) | |
| 468 | + | local.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 469 | + | Endpoint: "remote", Session: "source", Intent: protocol.IntentAttach, RemoteTarget: &source, | |
| 470 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 471 | + | }))) | |
| 472 | + | ||
| 473 | + | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote, remoteReturn}} | |
| 474 | + | otherDialer := &sequenceDialer{trs: []wire.Transport{other}} | |
| 475 | + | localDialer := &sequenceDialer{trs: []wire.Transport{local}} | |
| 476 | + | dialers := map[string]ports.ClientDialer{"remote": remoteDialer, "target-host": otherDialer} | |
| 477 | + | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |
| 478 | + | deps.HostRegistry = stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 479 | + | resolutions <- endpoint | |
| 480 | + | dialer := dialers[endpoint] | |
| 481 | + | if dialer == nil { | |
| 482 | + | return ports.RemoteEndpointBinding{}, errors.New("unknown endpoint") | |
| 483 | + | } | |
| 484 | + | return ports.RemoteEndpointBinding{Dialer: dialer}, nil | |
| 485 | + | }} | |
| 486 | + | ||
| 487 | + | done := make(chan error, 1) | |
| 488 | + | go func() { | |
| 489 | + | done <- runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 490 | + | Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 491 | + | }) | |
| 492 | + | }() | |
| 493 | + | ||
| 494 | + | remote.push(hybridPickerWelcome("source", source.LifecycleID)) | |
| 495 | + | openHybridPicker(t, remote, protocol.ExactSessionTarget{LifecycleID: source.LifecycleID, SessionName: "source"}) | |
| 496 | + | term.awaitDisplay(t, "second") | |
| 497 | + | ||
| 498 | + | // The serving peer hands the client to another host. | |
| 499 | + | term.reader.pressEnter() | |
| 500 | + | selection, err := wire.UnmarshalPickerSelection(mustAwaitSend(t, remote, wire.MsgPickerSelection)) | |
| 501 | + | require.NoError(t, err) | |
| 502 | + | remote.push(frameOf(wire.MsgPickerClosedServer, wire.MarshalPickerClosed(protocol.PickerClosed{ | |
| 503 | + | InteractionID: hybridPickerInteraction, BarrierEpoch: 1, BarrierState: 1, | |
| 504 | + | }))) | |
| 505 | + | remote.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 506 | + | Endpoint: otherHost.Endpoint, Session: otherHost.SessionName, Intent: protocol.IntentAttach, | |
| 507 | + | RemoteTarget: &otherHost, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 508 | + | CauseActionID: selection.CauseActionID, | |
| 509 | + | }))) | |
| 510 | + | other.push(hybridPickerWelcome("other", otherHost.LifecycleID)) | |
| 511 | + | // The new peer offers the return route to the first host. | |
| 512 | + | other.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 513 | + | Endpoint: "remote", Session: source.SessionName, Intent: protocol.IntentAttach, | |
| 514 | + | RemoteTarget: &source, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 515 | + | }))) | |
| 516 | + | remoteReturn.push(hybridPickerWelcome("source", source.LifecycleID)) | |
| 517 | + | remoteReturn.push(frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))) | |
| 518 | + | ||
| 519 | + | select { | |
| 520 | + | case err := <-done: | |
| 521 | + | require.NoError(t, err) | |
| 522 | + | case <-time.After(5 * time.Second): | |
| 523 | + | t.Fatal("client did not finish the cross-peer picker route") | |
| 524 | + | } | |
| 525 | + | // Returning to the first peer resolves that endpoint again and dials a fresh | |
| 526 | + | // connection through it: the registry owns binding reuse, and a route | |
| 527 | + | // crossing serving peers never invalidates the client's own resolution. | |
| 528 | + | require.Equal(t, int32(2), remoteDialer.calls.Load()) | |
| 529 | + | require.Equal(t, int32(1), otherDialer.calls.Load()) | |
| 530 | + | require.Equal(t, []string{"remote", "target-host", "remote"}, drainStrings(resolutions)) | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | // mustAwaitSend waits for one client frame of the requested type. | |
| 534 | + | func mustAwaitSend(t *testing.T, transport *hybridPickerTransport, typ wire.MsgType) []byte { | |
| 535 | + | t.Helper() | |
| 536 | + | payload, ok := transport.awaitSend(typ) | |
| 537 | + | require.True(t, ok, "frame %d never crossed the wire", typ) | |
| 538 | + | return payload | |
| 539 | + | } | |
M internal/usecase/client/inventory_attempt_test.go +199 -11
| @@ -197,6 +197,22 @@ func (t *inventoryAttemptTransport) LinkState() ports.LinkState { return | |||
| 197 | 197 | func (t *inventoryAttemptTransport) LinkEvents() <-chan ports.LinkEvent { return nil } | |
| 198 | 198 | func (t *inventoryAttemptTransport) Close() error { return nil } | |
| 199 | 199 | ||
| 200 | + | func (t *inventoryAttemptTransport) latestRouteSnapshot() (protocol.RecentRouteSnapshot, bool) { | |
| 201 | + | t.mu.Lock() | |
| 202 | + | defer t.mu.Unlock() | |
| 203 | + | for i := len(t.sent) - 1; i >= 0; i-- { | |
| 204 | + | if t.sent[i].Type != wire.MsgRecentRouteSnapshot { | |
| 205 | + | continue | |
| 206 | + | } | |
| 207 | + | snapshot, err := wire.UnmarshalRecentRouteSnapshot(t.sent[i].Payload) | |
| 208 | + | if err != nil { | |
| 209 | + | panic(err) | |
| 210 | + | } | |
| 211 | + | return snapshot, true | |
| 212 | + | } | |
| 213 | + | return protocol.RecentRouteSnapshot{}, false | |
| 214 | + | } | |
| 215 | + | ||
| 200 | 216 | func (t *inventoryAttemptTransport) publicationCount() int { | |
| 201 | 217 | t.mu.Lock() | |
| 202 | 218 | defer t.mu.Unlock() | |
| @@ -221,23 +237,39 @@ func (t *inventoryAttemptTransport) hello() protocol.Hello { | |||
| 221 | 237 | // inventoryTestControlConn answers one snapshot or resolve query from canned | |
| 222 | 238 | // groups without Hello, session creation, or attachment. | |
| 223 | 239 | type inventoryTestControlConn struct { | |
| 224 | - | request protocol.NavigationInventoryRequest | |
| 240 | + | inventoryRequest protocol.NavigationInventoryRequest | |
| 241 | + | pickerRequest protocol.PickerControlRequest | |
| 242 | + | onPicker func() | |
| 225 | 243 | } | |
| 226 | 244 | ||
| 227 | 245 | func (c *inventoryTestControlConn) SendClient(message protocol.ClientMessage) error { | |
| 228 | - | request, ok := message.(protocol.NavigationInventoryRequest) | |
| 229 | - | if !ok { | |
| 246 | + | switch request := message.(type) { | |
| 247 | + | case protocol.NavigationInventoryRequest: | |
| 248 | + | c.inventoryRequest = request | |
| 249 | + | case protocol.PickerControlRequest: | |
| 250 | + | c.pickerRequest = request | |
| 251 | + | if c.onPicker != nil { | |
| 252 | + | c.onPicker() | |
| 253 | + | } | |
| 254 | + | default: | |
| 230 | 255 | return io.ErrUnexpectedEOF | |
| 231 | 256 | } | |
| 232 | - | c.request = request | |
| 233 | 257 | return nil | |
| 234 | 258 | } | |
| 235 | 259 | ||
| 236 | 260 | func (c *inventoryTestControlConn) ReceiveServer() (protocol.ServerMessage, error) { | |
| 237 | - | switch c.request.Operation { | |
| 261 | + | if c.pickerRequest.Operation == protocol.PickerControlObserve { | |
| 262 | + | observations := make([]protocol.PickerRouteObservation, len(c.pickerRequest.Targets)) | |
| 263 | + | for i, target := range c.pickerRequest.Targets { | |
| 264 | + | observations[i] = protocol.PickerRouteObservation{Target: target, Presence: protocol.PickerRoutePresent, Attention: true} | |
| 265 | + | } | |
| 266 | + | return protocol.PickerControlResponse{RequestID: c.pickerRequest.RequestID, Operation: protocol.PickerControlObserve, Status: protocol.PickerSourceOK, Observations: observations}, nil | |
| 267 | + | } | |
| 268 | + | request := c.inventoryRequest | |
| 269 | + | switch request.Operation { | |
| 238 | 270 | case protocol.NavigationInventorySnapshot: | |
| 239 | 271 | return protocol.NavigationInventoryResponse{ | |
| 240 | - | RequestID: c.request.RequestID, Operation: protocol.NavigationInventorySnapshot, | |
| 272 | + | RequestID: request.RequestID, Operation: protocol.NavigationInventorySnapshot, | |
| 241 | 273 | Status: protocol.NavigationInventoryOK, | |
| 242 | 274 | Groups: []protocol.NavigationInventorySourceGroup{{ | |
| 243 | 275 | SourceKey: "local", Status: protocol.NavigationInventorySourceOK, | |
| @@ -249,7 +281,7 @@ func (c *inventoryTestControlConn) ReceiveServer() (protocol.ServerMessage, erro | |||
| 249 | 281 | case protocol.NavigationInventoryResolve: | |
| 250 | 282 | lifecycle := domain.SessionLifecycleID{7} | |
| 251 | 283 | return protocol.NavigationInventoryResponse{ | |
| 252 | - | RequestID: c.request.RequestID, Operation: protocol.NavigationInventoryResolve, | |
| 284 | + | RequestID: request.RequestID, Operation: protocol.NavigationInventoryResolve, | |
| 253 | 285 | Status: protocol.NavigationInventoryOK, | |
| 254 | 286 | Resolved: &protocol.AttachTarget{ | |
| 255 | 287 | Session: "alpha", Intent: protocol.IntentAttach, | |
| @@ -269,15 +301,28 @@ func (c *inventoryTestControlConn) LinkEvents() <-chan ports.LinkEvent { return | |||
| 269 | 301 | func (c *inventoryTestControlConn) Close() error { return nil } | |
| 270 | 302 | ||
| 271 | 303 | type inventoryTestDialer struct { | |
| 272 | - | mu sync.Mutex | |
| 273 | - | dials int | |
| 304 | + | mu sync.Mutex | |
| 305 | + | dials int | |
| 306 | + | pickerRequests int | |
| 274 | 307 | } | |
| 275 | 308 | ||
| 276 | 309 | func (d *inventoryTestDialer) Dial(context.Context) (ports.ClientConnection, error) { | |
| 277 | 310 | d.mu.Lock() | |
| 278 | 311 | d.dials++ | |
| 279 | 312 | d.mu.Unlock() | |
| 280 | - | return &inventoryTestControlConn{}, nil | |
| 313 | + | return &inventoryTestControlConn{onPicker: d.notePickerRequest}, nil | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func (d *inventoryTestDialer) notePickerRequest() { | |
| 317 | + | d.mu.Lock() | |
| 318 | + | d.pickerRequests++ | |
| 319 | + | d.mu.Unlock() | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func (d *inventoryTestDialer) pickerCount() int { | |
| 323 | + | d.mu.Lock() | |
| 324 | + | defer d.mu.Unlock() | |
| 325 | + | return d.pickerRequests | |
| 281 | 326 | } | |
| 282 | 327 | ||
| 283 | 328 | func (d *inventoryTestDialer) count() int { | |
| @@ -291,7 +336,7 @@ func runInventoryAttempt(t *testing.T, clock *inventoryTestClock, transport *inv | |||
| 291 | 336 | input := newPaletteAttachReader(nil) | |
| 292 | 337 | t.Cleanup(input.close) | |
| 293 | 338 | term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)} | |
| 294 | - | runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)} | |
| 339 | + | runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler), ledger: newRouteLedger()} | |
| 295 | 340 | attempt := &attachAttempt{ | |
| 296 | 341 | runner: runner, dialer: dialer, transport: transport, | |
| 297 | 342 | request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote-work"}, | |
| @@ -313,6 +358,106 @@ func msForInventoryTest() *milestones { return &milestones{} } | |||
| 313 | 358 | // TestInventoryPollBoxKeepsNewestCompletion pins out-of-order delivery: | |
| 314 | 359 | // a late outcome from an older query must not overwrite the newer one, | |
| 315 | 360 | // or the newer completion is lost while its slot stays busy. | |
| 361 | + | func TestRemoteAttemptPublishesLocalAttentionObservation(t *testing.T) { | |
| 362 | + | clock := newInventoryTestClock() | |
| 363 | + | dialer := &inventoryTestDialer{} | |
| 364 | + | transport := &inventoryAttemptTransport{ | |
| 365 | + | published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), releaseClose: make(chan struct{}), script: inventoryScriptSilent, | |
| 366 | + | } | |
| 367 | + | t.Cleanup(func() { close(transport.release) }) | |
| 368 | + | input := newPaletteAttachReader(nil) | |
| 369 | + | t.Cleanup(input.close) | |
| 370 | + | term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)} | |
| 371 | + | ledger := newRouteLedger() | |
| 372 | + | localTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"} | |
| 373 | + | _, err := ledger.commitAttach(routeCandidateForAttach(AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}, protocol.CommittedRouteIdentity{Target: localTarget}, dialer, 0)) | |
| 374 | + | require.NoError(t, err) | |
| 375 | + | runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler), ledger: ledger} | |
| 376 | + | attempt := &attachAttempt{ | |
| 377 | + | runner: runner, dialer: dialer, transport: transport, remote: true, | |
| 378 | + | request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote-work", Origin: protocol.RouteOriginRemote, OriginKey: "remote", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, | |
| 379 | + | inventoryHome: &attachRoute{dialer: dialer, request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}}, | |
| 380 | + | inventoryDialer: dialer, milestones: msForInventoryTest(), themeState: &terminalThemeState{}, enterRaw: func() error { return nil }, reconnect: &reconnectUI{term: term, rawEntered: new(bool)}, | |
| 381 | + | } | |
| 382 | + | done := make(chan attachResult, 1) | |
| 383 | + | go func() { done <- attempt.run(context.Background()) }() | |
| 384 | + | ||
| 385 | + | require.Eventually(t, func() bool { return clock.timerCount() >= 2 }, 5*time.Second, 10*time.Millisecond) | |
| 386 | + | require.Eventually(t, func() bool { | |
| 387 | + | clock.fire() | |
| 388 | + | return dialer.pickerCount() > 0 | |
| 389 | + | }, 5*time.Second, 10*time.Millisecond, "the observation worker must query the stable authority") | |
| 390 | + | require.Eventually(t, func() bool { | |
| 391 | + | clock.fire() | |
| 392 | + | snapshot, ok := transport.latestRouteSnapshot() | |
| 393 | + | if !ok { | |
| 394 | + | return false | |
| 395 | + | } | |
| 396 | + | for _, entry := range append(snapshot.Entries, snapshot.ActiveEntry) { | |
| 397 | + | if entry.Target == localTarget && entry.Attention { | |
| 398 | + | return true | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | return false | |
| 402 | + | }, 5*time.Second, 10*time.Millisecond) | |
| 403 | + | close(transport.detach) | |
| 404 | + | result := <-done | |
| 405 | + | require.NoError(t, result.err) | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func TestLocalAttemptPublishesRemoteAttentionObservation(t *testing.T) { | |
| 409 | + | clock := newInventoryTestClock() | |
| 410 | + | localDialer := &inventoryTestDialer{} | |
| 411 | + | remoteDialer := &inventoryTestDialer{} | |
| 412 | + | transport := &inventoryAttemptTransport{ | |
| 413 | + | published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), releaseClose: make(chan struct{}), script: inventoryScriptSilent, | |
| 414 | + | } | |
| 415 | + | t.Cleanup(func() { close(transport.release) }) | |
| 416 | + | input := newPaletteAttachReader(nil) | |
| 417 | + | t.Cleanup(input.close) | |
| 418 | + | term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)} | |
| 419 | + | ledger := newRouteLedger() | |
| 420 | + | remoteTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "work"} | |
| 421 | + | remoteRequest := AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginDiscovery, OriginKey: "remote.test", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned} | |
| 422 | + | _, err := ledger.commitAttach(routeCandidateForAttach(remoteRequest, protocol.CommittedRouteIdentity{Target: remoteTarget}, remoteDialer, 0)) | |
| 423 | + | require.NoError(t, err) | |
| 424 | + | observers := newRouteObserverRegistry() | |
| 425 | + | observers.register(remoteRequest, remoteDialer) | |
| 426 | + | require.Len(t, ledger.observationTargets(protocol.RouteOriginDiscovery, "remote.test"), 1) | |
| 427 | + | require.Len(t, observers.snapshot(), 1) | |
| 428 | + | runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler), ledger: ledger, routeObservers: observers} | |
| 429 | + | attempt := &attachAttempt{ | |
| 430 | + | runner: runner, dialer: localDialer, transport: transport, | |
| 431 | + | request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}, | |
| 432 | + | milestones: msForInventoryTest(), themeState: &terminalThemeState{}, enterRaw: func() error { return nil }, reconnect: &reconnectUI{term: term, rawEntered: new(bool)}, | |
| 433 | + | } | |
| 434 | + | done := make(chan attachResult, 1) | |
| 435 | + | go func() { done <- attempt.run(context.Background()) }() | |
| 436 | + | ||
| 437 | + | require.Eventually(t, func() bool { return clock.timerCount() >= 2 }, 5*time.Second, 10*time.Millisecond) | |
| 438 | + | require.Eventually(t, func() bool { | |
| 439 | + | clock.fire() | |
| 440 | + | return remoteDialer.pickerCount() > 0 | |
| 441 | + | }, 5*time.Second, 10*time.Millisecond, "the local attempt must query the retained remote authority") | |
| 442 | + | require.Eventually(t, func() bool { | |
| 443 | + | clock.fire() | |
| 444 | + | snapshot, ok := transport.latestRouteSnapshot() | |
| 445 | + | if !ok { | |
| 446 | + | return false | |
| 447 | + | } | |
| 448 | + | for _, entry := range append(snapshot.Entries, snapshot.ActiveEntry) { | |
| 449 | + | if entry.Target == remoteTarget && entry.Attention { | |
| 450 | + | return true | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | return false | |
| 454 | + | }, 5*time.Second, 10*time.Millisecond) | |
| 455 | + | close(transport.detach) | |
| 456 | + | result := <-done | |
| 457 | + | require.NoError(t, result.err) | |
| 458 | + | require.Zero(t, localDialer.pickerCount(), "the active local authority has no committed target to observe") | |
| 459 | + | } | |
| 460 | + | ||
| 316 | 461 | func TestInventoryPollBoxKeepsNewestCompletion(t *testing.T) { | |
| 317 | 462 | box := newInventoryPollBox() | |
| 318 | 463 | box.offer(inventoryPollOutcome{interaction: 2, query: 2}) | |
| @@ -436,3 +581,46 @@ func TestInventoryAttemptIgnoresDemandsWhenLocal(t *testing.T) { | |||
| 436 | 581 | require.Zero(t, dialer.count()) | |
| 437 | 582 | require.Zero(t, transport.publicationCount()) | |
| 438 | 583 | } | |
| 584 | + | ||
| 585 | + | // TestInventoryAttemptDirectRemoteNeverDialsControl pins the direct-remote | |
| 586 | + | // pairing: a serving daemon that sends inventory demands against an | |
| 587 | + | // attachment with no committed home route must not trigger a local control | |
| 588 | + | // dial. The relay is hybrid-only; without a home route no relay exists, so | |
| 589 | + | // demands decode to no-ops and the attachment lifecycle is unaffected. | |
| 590 | + | func TestInventoryAttemptDirectRemoteNeverDialsControl(t *testing.T) { | |
| 591 | + | clock := newInventoryTestClock() | |
| 592 | + | dialer := &inventoryTestDialer{} | |
| 593 | + | transport := &inventoryAttemptTransport{ | |
| 594 | + | published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), script: inventoryScriptLocalIgnore, | |
| 595 | + | } | |
| 596 | + | t.Cleanup(func() { close(transport.release) }) | |
| 597 | + | ||
| 598 | + | input := newPaletteAttachReader(nil) | |
| 599 | + | t.Cleanup(input.close) | |
| 600 | + | term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)} | |
| 601 | + | runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)} | |
| 602 | + | attempt := &attachAttempt{ | |
| 603 | + | runner: runner, dialer: dialer, transport: transport, | |
| 604 | + | request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote-work"}, | |
| 605 | + | remote: true, | |
| 606 | + | // No inventoryHome: a direct-remote start has no committed home | |
| 607 | + | // route, so no relay may exist even when demands arrive. | |
| 608 | + | inventoryDialer: dialer, | |
| 609 | + | milestones: msForInventoryTest(), themeState: &terminalThemeState{}, | |
| 610 | + | enterRaw: func() error { return nil }, | |
| 611 | + | reconnect: &reconnectUI{term: term, rawEntered: new(bool)}, | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | done := make(chan attachResult, 1) | |
| 615 | + | go func() { done <- attempt.run(context.Background()) }() | |
| 616 | + | close(transport.detach) | |
| 617 | + | ||
| 618 | + | result := <-done | |
| 619 | + | require.NoError(t, result.err) | |
| 620 | + | require.Nil(t, result.handoff) | |
| 621 | + | require.Zero(t, dialer.count(), "direct remote without a home route must never dial local control") | |
| 622 | + | require.Zero(t, transport.publicationCount()) | |
| 623 | + | hello := transport.hello() | |
| 624 | + | require.Zero(t, hello.NavigationCapabilities&protocol.NavigationCapabilityInventory, | |
| 625 | + | "direct remote must not advertise inventory without a home route") | |
| 626 | + | } | |
M internal/usecase/client/inventory_recovery_test.go +1 -3
| @@ -76,9 +76,7 @@ func TestInventoryFailureRestoresLatestRemoteIdentityAfterResumeRejection(t *tes | |||
| 76 | 76 | }).Twice() | |
| 77 | 77 | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |
| 78 | 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 | - | } | |
| 79 | + | deps.HostRegistry = dialerForEndpoints(map[string]ports.ClientDialer{"remote": remoteDialer}) | |
| 82 | 80 | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: local.SessionName, Origin: protocol.RouteOriginLocal, OriginKey: "local"})) | |
| 83 | 81 | for _, tr := range []*recordingTransport{resume, restored} { | |
| 84 | 82 | hello := helloFromSend(t, tr) | |
M internal/usecase/client/navigation_inventory.go +7 -1
| @@ -164,7 +164,13 @@ func (r *inventoryRelay) preparePublication(groups []protocol.NavigationInventor | |||
| 164 | 164 | if r == nil { | |
| 165 | 165 | return nil, 0, false | |
| 166 | 166 | } | |
| 167 | - | canonical := canonicalInventoryGroups(groups) | |
| 167 | + | localGroups := make([]protocol.NavigationInventorySourceGroup, 0, 1) | |
| 168 | + | for _, group := range groups { | |
| 169 | + | if group.SourceKey == protocol.NavigationInventoryLocalSourceKey { | |
| 170 | + | localGroups = append(localGroups, group) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | canonical := canonicalInventoryGroups(localGroups) | |
| 168 | 174 | if inventoryGroupsEqual(canonical, r.published) { | |
| 169 | 175 | return nil, 0, false | |
| 170 | 176 | } | |
M internal/usecase/client/navigation_inventory_test.go +18 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | 9 | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | // manualClock is a fake ports.Clock with explicit time control: no sleeps. | |
| @@ -178,6 +179,23 @@ func TestInventoryRelaySnapshotAdmitsAndSuppressesUnchanged(t *testing.T) { | |||
| 178 | 179 | } | |
| 179 | 180 | } | |
| 180 | 181 | ||
| 182 | + | func TestInventoryRelayPublishesOnlyLocalSource(t *testing.T) { | |
| 183 | + | relay := newInventoryRelay(&manualClock{now: time.Unix(1_000, 0)}, &inventoryFakeDialer{}) | |
| 184 | + | relay.setOpen(true, 5) | |
| 185 | + | groups := append(inventoryTestGroups(), protocol.NavigationInventorySourceGroup{ | |
| 186 | + | SourceKey: "remote", Status: protocol.NavigationInventorySourceOK, | |
| 187 | + | Entries: []protocol.NavigationInventoryEntry{{SourceKey: "remote", EntryKey: "ticker", Name: "ticker"}}, | |
| 188 | + | }) | |
| 189 | + | ||
| 190 | + | published, _, changed := relay.preparePublication(groups) | |
| 191 | + | require.True(t, changed) | |
| 192 | + | require.Equal(t, inventoryTestGroups(), published) | |
| 193 | + | ||
| 194 | + | groups[1].Entries[0].Name = "changed-remote" | |
| 195 | + | _, _, changed = relay.preparePublication(groups) | |
| 196 | + | require.False(t, changed, "unsupported remote inventory must not alter local publication") | |
| 197 | + | } | |
| 198 | + | ||
| 181 | 199 | func TestInventoryRelaySelectionSurvivesRefreshLag(t *testing.T) { | |
| 182 | 200 | clock := &manualClock{now: time.Unix(1_000, 0)} | |
| 183 | 201 | groups := inventoryTestGroups() | |
M internal/usecase/client/navigation_test.go +3 -12
| @@ -27,24 +27,15 @@ func TestSyncReconnectRemoteKeepsRequestAndToastClassificationTogether(t *testin | |||
| 27 | 27 | func TestValidateAttachRequestNavigationTable(t *testing.T) { | |
| 28 | 28 | var lifecycle domain.SessionLifecycleID | |
| 29 | 29 | lifecycle[0] = 1 | |
| 30 | - | remoteRoute := &domain.RemoteSessionTarget{ | |
| 31 | - | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, | |
| 32 | - | SessionName: "work", LiveTabID: "tab-1", | |
| 33 | - | } | |
| 34 | 30 | tests := []struct { | |
| 35 | 31 | name string | |
| 36 | 32 | request AttachRequest | |
| 37 | 33 | valid bool | |
| 38 | 34 | }{ | |
| 39 | 35 | {name: "ordinary route", request: AttachRequest{}, valid: true}, | |
| 40 | - | {name: "home picker without remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | |
| 41 | - | {name: "home picker on daemon-owned remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true}, | |
| 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 | - | {name: "back on client-owned route", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: true}, | |
| 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: 8}, valid: false}, | |
| 46 | - | {name: "back without startup picker", request: AttachRequest{NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false}, | |
| 47 | - | {name: "startup picker without back", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | |
| 36 | + | {name: "inventory capability", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", NavigationCapabilities: protocol.NavigationCapabilityInventory}, valid: true}, | |
| 37 | + | {name: "ephemeral inventory capability", request: AttachRequest{NavigationCapabilities: protocol.NavigationCapabilityInventory}}, | |
| 38 | + | {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 16}, valid: false}, | |
| 48 | 39 | } | |
| 49 | 40 | ||
| 50 | 41 | for _, tt := range tests { | |
M internal/usecase/client/palette_attach_generation_test.go +1 -1
| @@ -129,7 +129,7 @@ func newAttachPaletteTransport() *attachPaletteTransport { | |||
| 129 | 129 | return &attachPaletteTransport{ | |
| 130 | 130 | themeCh: make(chan protocol.Theme, 16), | |
| 131 | 131 | inputCh: make(chan protocol.Input, 16), | |
| 132 | - | detached: make(chan wire.Frame, 1), | |
| 132 | + | detached: make(chan wire.Frame, 16), | |
| 133 | 133 | } | |
| 134 | 134 | } | |
| 135 | 135 | func (t *attachPaletteTransport) Send(frame wire.Frame) error { | |
A internal/usecase/client/picker_control.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "sync" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type pickerControl struct{ dialer ports.ClientDialer } | |
| 14 | + | ||
| 15 | + | func (c pickerControl) request(ctx context.Context, request protocol.PickerControlRequest) (protocol.PickerControlResponse, error) { | |
| 16 | + | if c.dialer == nil { | |
| 17 | + | return protocol.PickerControlResponse{}, errors.New("vev: picker authority unavailable") | |
| 18 | + | } | |
| 19 | + | ctx, cancel := context.WithTimeout(ctx, inventoryQueryTimeout) | |
| 20 | + | defer cancel() | |
| 21 | + | conn, err := c.dialer.Dial(ctx) | |
| 22 | + | if err != nil { | |
| 23 | + | return protocol.PickerControlResponse{}, err | |
| 24 | + | } | |
| 25 | + | var once sync.Once | |
| 26 | + | closeConn := func() { once.Do(func() { _ = conn.Close() }) } | |
| 27 | + | defer closeConn() | |
| 28 | + | done := make(chan struct{}) | |
| 29 | + | go func() { | |
| 30 | + | select { | |
| 31 | + | case <-ctx.Done(): | |
| 32 | + | closeConn() | |
| 33 | + | case <-done: | |
| 34 | + | } | |
| 35 | + | }() | |
| 36 | + | defer close(done) | |
| 37 | + | if err := conn.SendClient(request); err != nil { | |
| 38 | + | return protocol.PickerControlResponse{}, err | |
| 39 | + | } | |
| 40 | + | message, err := conn.ReceiveServer() | |
| 41 | + | if err != nil { | |
| 42 | + | return protocol.PickerControlResponse{}, err | |
| 43 | + | } | |
| 44 | + | response, ok := message.(protocol.PickerControlResponse) | |
| 45 | + | if !ok || protocol.ValidatePickerControlResponse(response) != nil || response.RequestID != request.RequestID || response.Operation != request.Operation { | |
| 46 | + | return protocol.PickerControlResponse{}, errors.New("vev: invalid picker authority response") | |
| 47 | + | } | |
| 48 | + | return response, nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (c pickerControl) snapshot(ctx context.Context, requestID, interaction uint64) (protocol.PickerSnapshot, error) { | |
| 52 | + | response, err := c.request(ctx, protocol.PickerControlRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.PickerControlSnapshot}) | |
| 53 | + | if err != nil || response.Status != protocol.PickerSourceOK || response.Snapshot == nil { | |
| 54 | + | return protocol.PickerSnapshot{}, errors.New("vev: picker authority snapshot unavailable") | |
| 55 | + | } | |
| 56 | + | snapshot := *response.Snapshot | |
| 57 | + | snapshot.InteractionID = interaction | |
| 58 | + | return snapshot, nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (c pickerControl) observe(ctx context.Context, requestID uint64, targets []protocol.ExactSessionTarget) ([]protocol.PickerRouteObservation, error) { | |
| 62 | + | response, err := c.request(ctx, protocol.PickerControlRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.PickerControlObserve, Targets: targets}) | |
| 63 | + | if err != nil { | |
| 64 | + | return nil, fmt.Errorf("vev: picker authority observation unavailable: %w", err) | |
| 65 | + | } | |
| 66 | + | if response.Status != protocol.PickerSourceOK || len(response.Observations) != len(targets) { | |
| 67 | + | return nil, errors.New("vev: picker authority observation unavailable") | |
| 68 | + | } | |
| 69 | + | requested := make(map[protocol.ExactSessionTarget]struct{}, len(targets)) | |
| 70 | + | for _, target := range targets { | |
| 71 | + | requested[target] = struct{}{} | |
| 72 | + | } | |
| 73 | + | for _, observation := range response.Observations { | |
| 74 | + | if _, ok := requested[observation.Target]; !ok { | |
| 75 | + | return nil, errors.New("vev: picker authority returned an unexpected observation target") | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | return response.Observations, nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (c pickerControl) resolve(ctx context.Context, requestID uint64, selection protocol.PickerSelection) (protocol.AttachTarget, error) { | |
| 82 | + | response, err := c.request(ctx, protocol.PickerControlRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.PickerControlResolve, SourceRevision: selection.SourceRevision, SourceID: selection.SourceID, Key: selection.Key}) | |
| 83 | + | if err != nil || response.Status != protocol.PickerSourceOK || response.Resolved == nil { | |
| 84 | + | return protocol.AttachTarget{}, errors.New("vev: picker authority rejected selection") | |
| 85 | + | } | |
| 86 | + | return *response.Resolved, nil | |
| 87 | + | } | |
A internal/usecase/client/picker_control_box.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/protocol" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type pickerControlOutcome struct { | |
| 10 | + | snapshot *protocol.PickerSnapshot | |
| 11 | + | selection *protocol.PickerSelection | |
| 12 | + | target *protocol.AttachTarget | |
| 13 | + | err error | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | type pickerControlBox struct { | |
| 17 | + | mu sync.Mutex | |
| 18 | + | outcomes []pickerControlOutcome | |
| 19 | + | wake chan struct{} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func newPickerControlBox() *pickerControlBox { return &pickerControlBox{wake: make(chan struct{}, 1)} } | |
| 23 | + | func (b *pickerControlBox) offer(outcome pickerControlOutcome) { | |
| 24 | + | b.mu.Lock() | |
| 25 | + | b.outcomes = append(b.outcomes, outcome) | |
| 26 | + | b.mu.Unlock() | |
| 27 | + | select { | |
| 28 | + | case b.wake <- struct{}{}: | |
| 29 | + | default: | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | func (b *pickerControlBox) take() (pickerControlOutcome, bool) { | |
| 33 | + | b.mu.Lock() | |
| 34 | + | defer b.mu.Unlock() | |
| 35 | + | if len(b.outcomes) == 0 { | |
| 36 | + | return pickerControlOutcome{}, false | |
| 37 | + | } | |
| 38 | + | outcome := b.outcomes[0] | |
| 39 | + | b.outcomes = b.outcomes[1:] | |
| 40 | + | if len(b.outcomes) != 0 { | |
| 41 | + | select { | |
| 42 | + | case b.wake <- struct{}{}: | |
| 43 | + | default: | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | return outcome, true | |
| 47 | + | } | |
A internal/usecase/client/picker_e2e_support_test.go +335 -0
| @@ -0,0 +1,335 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 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 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // internalStubHostRegistry is the internal tests' host registry: it resolves | |
| 21 | + | // endpoints from a table and parks its discovery loop until the runner exits. | |
| 22 | + | type internalStubHostRegistry struct { | |
| 23 | + | resolve func(endpoint string) (ports.RemoteEndpointBinding, error) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (r internalStubHostRegistry) ResolveEndpoint(_ context.Context, endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 27 | + | if r.resolve == nil { | |
| 28 | + | return ports.RemoteEndpointBinding{}, errors.New("stub host registry has no resolver") | |
| 29 | + | } | |
| 30 | + | return r.resolve(endpoint) | |
| 31 | + | } | |
| 32 | + | func (internalStubHostRegistry) Snapshot() ports.RemoteDirectorySnapshot { | |
| 33 | + | return ports.RemoteDirectorySnapshot{} | |
| 34 | + | } | |
| 35 | + | func (internalStubHostRegistry) Subscribe() ports.RemoteDirectorySubscription { return nil } | |
| 36 | + | func (internalStubHostRegistry) RequestReconcile(string) {} | |
| 37 | + | func (internalStubHostRegistry) RegistryChanged() {} | |
| 38 | + | func (internalStubHostRegistry) Run(ctx context.Context) error { | |
| 39 | + | <-ctx.Done() | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // newPickerTestTerminal builds the headless terminal backing both the UI | |
| 44 | + | // state and the test's snapshot observations. | |
| 45 | + | func newPickerTestTerminal(t *testing.T, ctx context.Context) (*uiterm.Terminal, *uiterm.Terminal) { | |
| 46 | + | t.Helper() | |
| 47 | + | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "") | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | t.Cleanup(terminal.Close) | |
| 50 | + | return terminal, terminal | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // waitForText waits for the UI to publish a frame containing the fragment | |
| 54 | + | // after the given action committed. | |
| 55 | + | func waitForText(t *testing.T, ctx context.Context, ui *UI, afterAction uint64, fragment string) { | |
| 56 | + | t.Helper() | |
| 57 | + | attached := ports.UIStatusAttached | |
| 58 | + | matched, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), AfterAction: afterAction, Expect: ports.UIExpect{TextContains: &fragment, Status: &attached}}) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | require.NotZero(t, matched.Revision) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // pickerE2EHarness is one running attach attempt driven through the real | |
| 64 | + | // wire transport, with the first full frame already committed so automation | |
| 65 | + | // batches admit and the foreground owns the terminal. | |
| 66 | + | type pickerE2EHarness struct { | |
| 67 | + | attempt *attachAttempt | |
| 68 | + | transport *attachPaletteTransport | |
| 69 | + | ui *UI | |
| 70 | + | terminal *uiterm.Terminal | |
| 71 | + | input *terminalInputPump | |
| 72 | + | clock *attachPaletteClock | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // startPickerE2E boots the attach attempt and pushes the first full frame. | |
| 76 | + | func startPickerE2E(t *testing.T) *pickerE2EHarness { | |
| 77 | + | return startPickerE2EHarness(t, nil, true) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // startPickerE2EWithInput boots the same harness over a caller-owned | |
| 81 | + | // terminal reader, so tests can drive physical input instead of the | |
| 82 | + | // ui-driver automation channel. | |
| 83 | + | func startPickerE2EWithInput(t *testing.T, reader io.Reader) *pickerE2EHarness { | |
| 84 | + | return startPickerE2EHarness(t, reader, true) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // startPickerE2EHeadless boots the harness without the UI-driver | |
| 88 | + | // composition (runner.ui is nil), which is how the ordinary CLI runs. The | |
| 89 | + | // picker must own and complete physical input there too. | |
| 90 | + | func startPickerE2EHeadless(t *testing.T, reader io.Reader) *pickerE2EHarness { | |
| 91 | + | return startPickerE2EHarness(t, reader, false) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func startPickerE2EHarness(t *testing.T, reader io.Reader, withUI bool) *pickerE2EHarness { | |
| 95 | + | t.Helper() | |
| 96 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 97 | + | t.Cleanup(cancel) | |
| 98 | + | clock := newAttachPaletteClock() | |
| 99 | + | transport := newAttachPaletteTransport() | |
| 100 | + | ms := &milestones{} | |
| 101 | + | state := &terminalThemeState{} | |
| 102 | + | input := newTerminalInputPump(reader) | |
| 103 | + | if reader != nil { | |
| 104 | + | // The harness owns this reader: start its loop so physical bytes | |
| 105 | + | // reach the attach attempt. The nil case keeps the automation-only | |
| 106 | + | // path used by the ui-driver scenarios. | |
| 107 | + | input.start() | |
| 108 | + | } | |
| 109 | + | t.Cleanup(input.stop) | |
| 110 | + | uiTerminal, _ := newPickerTestTerminal(t, ctx) | |
| 111 | + | var ui *UI | |
| 112 | + | runner := &Runner{term: uiTerminal, clock: clock, logger: slog.New(slog.DiscardHandler), ledger: newRouteLedger()} | |
| 113 | + | if withUI { | |
| 114 | + | ui = NewUI(uiTerminal, systemClock{}) | |
| 115 | + | runner.ui = ui | |
| 116 | + | } | |
| 117 | + | source := routeTestCandidate(0, protocol.RouteOriginLocal) | |
| 118 | + | source.target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"} | |
| 119 | + | source.presentation.name = "fixture" | |
| 120 | + | source.request.SessionName = "fixture" | |
| 121 | + | _, err := runner.ledger.commit(source) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | attempt := &attachAttempt{ | |
| 124 | + | runner: runner, transport: transport, request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "fixture"}, | |
| 125 | + | milestones: ms, themeState: state, | |
| 126 | + | enterRaw: func() error { ms.rawEntered = true; return nil }, | |
| 127 | + | reconnect: &reconnectUI{term: uiTerminal, rawEntered: new(bool)}, | |
| 128 | + | terminalInput: func() *terminalInputPump { | |
| 129 | + | return input | |
| 130 | + | }, | |
| 131 | + | } | |
| 132 | + | done := make(chan attachResult, 1) | |
| 133 | + | go func() { done <- attempt.run(context.Background()) }() | |
| 134 | + | t.Cleanup(func() { | |
| 135 | + | transport.detached <- wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})} | |
| 136 | + | select { | |
| 137 | + | case result := <-done: | |
| 138 | + | require.NoError(t, result.err) | |
| 139 | + | case <-time.After(2 * time.Second): | |
| 140 | + | t.Error("attempt did not detach") | |
| 141 | + | } | |
| 142 | + | }) | |
| 143 | + | handshakeTimer := <-clock.handshakeTimers | |
| 144 | + | select { | |
| 145 | + | case <-handshakeTimer.stopped: | |
| 146 | + | // Welcome and synchronous client publications commit the handshake. | |
| 147 | + | case <-time.After(2 * time.Second): | |
| 148 | + | t.Fatal("handshake deadline still owned the runtime") | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // The synthetic Welcome carries no Output frame, so push one full frame | |
| 152 | + | // through the wire: it starts the foreground, publishes the UI boundary, | |
| 153 | + | // and lets automation batches admit. | |
| 154 | + | view0 := protocol.ViewContext{Publication: 1, Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}}, TabID: "t_abc123", FocusedPaneID: "p_def456"} | |
| 155 | + | output0, err := wire.MarshalOutput(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &view0, Data: []byte("\x1b[Hready")}) | |
| 156 | + | require.NoError(t, err) | |
| 157 | + | transport.detached <- wire.Frame{Type: wire.MsgOutput, Payload: output0} | |
| 158 | + | ||
| 159 | + | return &pickerE2EHarness{attempt: attempt, transport: transport, ui: ui, terminal: uiTerminal, input: input, clock: clock} | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // awaitTerminalText waits until the terminal shows the fragment. It is the | |
| 163 | + | // headless replacement for UI.Wait: the picker frame is written to the same | |
| 164 | + | // terminal, so the drawn text is the barrier. | |
| 165 | + | func awaitTerminalText(t *testing.T, terminal *uiterm.Terminal, fragment string) { | |
| 166 | + | t.Helper() | |
| 167 | + | deadline := time.After(2 * time.Second) | |
| 168 | + | for { | |
| 169 | + | snapshot, err := terminal.Snapshot() | |
| 170 | + | if err == nil && strings.Contains(uiSnapshotText(snapshot), fragment) { | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | select { | |
| 174 | + | case <-deadline: | |
| 175 | + | t.Fatalf("terminal never showed %q", fragment) | |
| 176 | + | case <-terminal.Changes(): | |
| 177 | + | case <-time.After(5 * time.Millisecond): | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // frameCount reports how many client frames crossed the wire so far. | |
| 183 | + | func (t *attachPaletteTransport) frameCount() int { | |
| 184 | + | t.mu.Lock() | |
| 185 | + | defer t.mu.Unlock() | |
| 186 | + | return len(t.frames) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // awaitAfterAmbiguityDeadlines fires the 20 ms ambiguity timers the pump | |
| 190 | + | // arms until the wanted frame crosses the wire. The palette-marker scanner | |
| 191 | + | // and the picker decoder share that deadline, so an escape withheld as a | |
| 192 | + | // possible marker is flushed into the picker decoder before its own window | |
| 193 | + | // starts. More than a few deadlines for one escape means the window is | |
| 194 | + | // being re-armed instead of expiring, so the helper fails instead of | |
| 195 | + | // firing forever. | |
| 196 | + | func (h *pickerE2EHarness) awaitAfterAmbiguityDeadlines(t *testing.T, transport *attachPaletteTransport, frameType wire.MsgType) { | |
| 197 | + | t.Helper() | |
| 198 | + | const maxDeadlines = 4 | |
| 199 | + | hasFrame := func() bool { | |
| 200 | + | transport.mu.Lock() | |
| 201 | + | defer transport.mu.Unlock() | |
| 202 | + | for _, frame := range transport.frames { | |
| 203 | + | if frame.Type == frameType { | |
| 204 | + | return true | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | return false | |
| 208 | + | } | |
| 209 | + | deadline := time.After(3 * time.Second) | |
| 210 | + | fired := 0 | |
| 211 | + | for { | |
| 212 | + | select { | |
| 213 | + | case timer := <-h.clock.timers: | |
| 214 | + | if timer.duration == pickerEscapeDeadline { | |
| 215 | + | timer.fire() | |
| 216 | + | fired++ | |
| 217 | + | if fired > maxDeadlines { | |
| 218 | + | t.Fatalf("the escape deadline was re-armed %d times for one prefix", fired) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | default: | |
| 222 | + | } | |
| 223 | + | if hasFrame() { | |
| 224 | + | return | |
| 225 | + | } | |
| 226 | + | select { | |
| 227 | + | case <-deadline: | |
| 228 | + | t.Fatalf("frame %d never crossed the wire", frameType) | |
| 229 | + | case <-time.After(time.Millisecond): | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // pickerOffer opens the interaction both scenarios drive. | |
| 235 | + | func pickerOffer() protocol.PickerOffer { | |
| 236 | + | return protocol.PickerOffer{ | |
| 237 | + | InteractionID: 7, Intent: protocol.PickerIntentNavigation, Title: " Sessions · recent ", | |
| 238 | + | BarrierEpoch: 1, BarrierState: 1, SizeEpoch: 1, | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // pickerSnapshot returns the two-row source publication both scenarios open | |
| 243 | + | // the picker with. | |
| 244 | + | func pickerSnapshot() protocol.PickerSnapshot { | |
| 245 | + | return protocol.PickerSnapshot{ | |
| 246 | + | InteractionID: 7, SourceID: "serving", SourceRevision: 1, Status: protocol.PickerSourceOK, | |
| 247 | + | Lines: []protocol.PickerLine{ | |
| 248 | + | {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanNavigate}, | |
| 249 | + | {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanNavigate}, | |
| 250 | + | }, | |
| 251 | + | Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0}, | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // openPickerOnHarness publishes the offer and first snapshot the serving daemon | |
| 256 | + | // would send, so the client takes over presentation. | |
| 257 | + | func openPickerOnHarness(t *testing.T, transport *attachPaletteTransport) protocol.PickerSnapshot { | |
| 258 | + | t.Helper() | |
| 259 | + | offer := pickerOffer() | |
| 260 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerOffer, Payload: wire.MarshalPickerOffer(offer)} | |
| 261 | + | snapshot := pickerSnapshot() | |
| 262 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(snapshot)} | |
| 263 | + | return snapshot | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // awaitPreviewTimer returns the first armed preview debounce. Other timers | |
| 267 | + | // (palette deadlines, escape windows) are left untouched for their own helpers. | |
| 268 | + | func (h *pickerE2EHarness) awaitPreviewTimer(t *testing.T) *attachPaletteTimer { | |
| 269 | + | t.Helper() | |
| 270 | + | deadline := time.After(2 * time.Second) | |
| 271 | + | var held []*attachPaletteTimer | |
| 272 | + | defer func() { | |
| 273 | + | for _, timer := range held { | |
| 274 | + | h.clock.timers <- timer | |
| 275 | + | } | |
| 276 | + | }() | |
| 277 | + | for { | |
| 278 | + | select { | |
| 279 | + | case timer := <-h.clock.timers: | |
| 280 | + | if timer.duration != pickerPreviewDebounce { | |
| 281 | + | held = append(held, timer) | |
| 282 | + | continue | |
| 283 | + | } | |
| 284 | + | return timer | |
| 285 | + | case <-deadline: | |
| 286 | + | t.Fatal("the picker preview debounce was never armed") | |
| 287 | + | return nil | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // awaitWireFrame waits for one client frame of the requested type. | |
| 293 | + | func awaitWireFrame(t *testing.T, transport *attachPaletteTransport, frameType wire.MsgType) []byte { | |
| 294 | + | t.Helper() | |
| 295 | + | deadline := time.After(2 * time.Second) | |
| 296 | + | for { | |
| 297 | + | select { | |
| 298 | + | case <-deadline: | |
| 299 | + | t.Fatalf("frame %d never crossed the wire", frameType) | |
| 300 | + | return nil | |
| 301 | + | default: | |
| 302 | + | transport.mu.Lock() | |
| 303 | + | var payload []byte | |
| 304 | + | for _, frame := range transport.frames { | |
| 305 | + | if frame.Type == frameType { | |
| 306 | + | payload = frame.Payload | |
| 307 | + | break | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | transport.mu.Unlock() | |
| 311 | + | if payload != nil { | |
| 312 | + | return payload | |
| 313 | + | } | |
| 314 | + | time.Sleep(time.Millisecond) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // awaitPickerSelection waits for the typed commit on the wire transport. | |
| 320 | + | func awaitPickerSelection(t *testing.T, transport *attachPaletteTransport) protocol.PickerSelection { | |
| 321 | + | t.Helper() | |
| 322 | + | selection, err := wire.UnmarshalPickerSelection(awaitWireFrame(t, transport, wire.MsgPickerSelection)) | |
| 323 | + | require.NoError(t, err) | |
| 324 | + | return selection | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // requireNoPickerInput fails when any MsgInput frame reached the PTY. | |
| 328 | + | func requireNoPickerInput(t *testing.T, transport *attachPaletteTransport) { | |
| 329 | + | t.Helper() | |
| 330 | + | select { | |
| 331 | + | case frame := <-transport.inputCh: | |
| 332 | + | t.Fatalf("picker bytes leaked to the PTY: %q", frame.Data) | |
| 333 | + | case <-time.After(200 * time.Millisecond): | |
| 334 | + | } | |
| 335 | + | } | |
A internal/usecase/client/picker_e2e_test.go +313 -0
| @@ -0,0 +1,313 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // TestClientPickerLoopEndToEnd drives the full client-picker path through | |
| 19 | + | // the real attach attempt with the palette wire transport: Welcome, then a | |
| 20 | + | // PickerSnapshot opens the loop and installs the pump-side hook, a Down op | |
| 21 | + | // repaints locally and completes without daemon evidence, Enter queues a | |
| 22 | + | // typed PickerSelection on the wire, and the daemon-side PickerClose + | |
| 23 | + | // same-peer handoff resolve the session. Zero picker bytes reach the PTY: | |
| 24 | + | // no MsgInput frame carries picker data. | |
| 25 | + | func TestClientPickerLoopEndToEnd(t *testing.T) { | |
| 26 | + | ctx := context.Background() | |
| 27 | + | harness := startPickerE2E(t) | |
| 28 | + | ui, transport := harness.ui, harness.transport | |
| 29 | + | ||
| 30 | + | // Daemon opens the picker: two rows, cursor on the first. | |
| 31 | + | snapshot := openPickerOnHarness(t, transport) | |
| 32 | + | attached := ports.UIStatusAttached | |
| 33 | + | pickerText := "first" | |
| 34 | + | _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &pickerText}}) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | ||
| 37 | + | // Cursor op completes locally once the repaint flushes: no daemon | |
| 38 | + | // evidence needed, and the frame carries the second row. | |
| 39 | + | downDone := make(chan struct{}) | |
| 40 | + | var downResult ports.UIActionResult | |
| 41 | + | var downErr error | |
| 42 | + | go func() { | |
| 43 | + | defer close(downDone) | |
| 44 | + | downResult, downErr = ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Down"}}) | |
| 45 | + | }() | |
| 46 | + | <-downDone | |
| 47 | + | require.NoError(t, downErr) | |
| 48 | + | require.Equal(t, ports.UIActionProcessed, downResult.Status) | |
| 49 | + | waitForText(t, ctx, ui, downResult.ActionID, "second") | |
| 50 | + | ||
| 51 | + | // Commit op stays pending: the typed selection crosses the wire with | |
| 52 | + | // the action as cause, and zero picker bytes reach the PTY. | |
| 53 | + | committed := make(chan ports.UIActionResult, 1) | |
| 54 | + | commitErr := make(chan error, 1) | |
| 55 | + | go func() { | |
| 56 | + | action, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Enter"}}) | |
| 57 | + | if err != nil { | |
| 58 | + | commitErr <- err | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | committed <- action | |
| 62 | + | }() | |
| 63 | + | selection := awaitPickerSelection(t, transport) | |
| 64 | + | require.Equal(t, uint64(7), selection.InteractionID) | |
| 65 | + | require.Equal(t, "serving", selection.SourceID) | |
| 66 | + | require.Equal(t, uint64(1), selection.SourceRevision) | |
| 67 | + | require.Equal(t, protocol.PickerActionNavigate, selection.Action) | |
| 68 | + | require.Equal(t, "bb/second", selection.Key) | |
| 69 | + | require.NotZero(t, selection.CauseActionID) | |
| 70 | + | requireNoPickerInput(t, transport) | |
| 71 | + | ||
| 72 | + | // Daemon resolves: the close confirmation ends the client interaction | |
| 73 | + | // exactly like a resolved selection, then the same-peer handoff and full | |
| 74 | + | // paint commit the session. | |
| 75 | + | second := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "second"} | |
| 76 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: snapshot.InteractionID, BarrierEpoch: 2, BarrierState: 1})} | |
| 77 | + | transport.detached <- wire.Frame{Type: wire.MsgAttachTarget, Payload: wire.MarshalAttachTarget(protocol.AttachTarget{Session: second.SessionName, Intent: protocol.IntentAttach, ExactTarget: &second, SamePeer: true, CauseActionID: selection.CauseActionID})} | |
| 78 | + | identityPayload, err := wire.MarshalCommittedRouteIdentity(protocol.CommittedRouteIdentity{Target: second}) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | transport.detached <- wire.Frame{Type: wire.MsgCommittedRouteIdentity, Payload: identityPayload} | |
| 81 | + | view := protocol.ViewContext{Publication: 2, Route: protocol.CommittedRouteIdentity{Target: second}, TabID: "t_abc123", FocusedPaneID: "p_def456"} | |
| 82 | + | outputPayload, err := wire.MarshalOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &view, Data: []byte("\x1b[2J\x1b[Hsecond")}) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | transport.detached <- wire.Frame{Type: wire.MsgOutput, Payload: outputPayload} | |
| 85 | + | select { | |
| 86 | + | case action := <-committed: | |
| 87 | + | require.Equal(t, ports.UIActionProcessed, action.Status) | |
| 88 | + | require.Equal(t, "second", action.Context.Route.Target.SessionName) | |
| 89 | + | case err := <-commitErr: | |
| 90 | + | t.Fatalf("commit failed after daemon resolve: %v", err) | |
| 91 | + | case <-time.After(2 * time.Second): | |
| 92 | + | t.Fatal("commit never completed after daemon resolve") | |
| 93 | + | } | |
| 94 | + | requireNoPickerInput(t, transport) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // TestClientPickerCompositeMoveCompletesFromResult drives the ui-driver action | |
| 98 | + | // through the composite move+follow sequence: the daemon closes the | |
| 99 | + | // interaction, publishes the destination route and its full paint on the same | |
| 100 | + | // connection, then reports the correlated PickerResult. The action must | |
| 101 | + | // complete as processed without a same-peer handoff, exactly like an in-place | |
| 102 | + | // mutation. | |
| 103 | + | func TestClientPickerCompositeMoveCompletesFromResult(t *testing.T) { | |
| 104 | + | ctx := context.Background() | |
| 105 | + | harness := startPickerE2E(t) | |
| 106 | + | ui, transport := harness.ui, harness.transport | |
| 107 | + | ||
| 108 | + | offer := pickerOffer() | |
| 109 | + | offer.Intent = protocol.PickerIntentMoveTab | |
| 110 | + | offer.Title = " Move " | |
| 111 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerOffer, Payload: wire.MarshalPickerOffer(offer)} | |
| 112 | + | snapshot := pickerSnapshot() | |
| 113 | + | for i := range snapshot.Lines { | |
| 114 | + | snapshot.Lines[i].Actions = protocol.PickerCanMove | |
| 115 | + | } | |
| 116 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(snapshot)} | |
| 117 | + | attached := ports.UIStatusAttached | |
| 118 | + | first := "first" | |
| 119 | + | _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}}) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | ||
| 122 | + | committed := make(chan ports.UIActionResult, 1) | |
| 123 | + | commitErr := make(chan error, 1) | |
| 124 | + | go func() { | |
| 125 | + | action, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Enter"}}) | |
| 126 | + | if err != nil { | |
| 127 | + | commitErr <- err | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | committed <- action | |
| 131 | + | }() | |
| 132 | + | selection := awaitPickerSelection(t, transport) | |
| 133 | + | require.Equal(t, protocol.PickerActionMove, selection.Action) | |
| 134 | + | require.NotZero(t, selection.CauseActionID) | |
| 135 | + | requireNoPickerInput(t, transport) | |
| 136 | + | ||
| 137 | + | // The daemon retires the interaction, then publishes the destination route | |
| 138 | + | // and full paint without a same-peer attach offer. | |
| 139 | + | second := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "second"} | |
| 140 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: snapshot.InteractionID, BarrierEpoch: 2, BarrierState: 1})} | |
| 141 | + | identityPayload, err := wire.MarshalCommittedRouteIdentity(protocol.CommittedRouteIdentity{Target: second}) | |
| 142 | + | require.NoError(t, err) | |
| 143 | + | transport.detached <- wire.Frame{Type: wire.MsgCommittedRouteIdentity, Payload: identityPayload} | |
| 144 | + | view := protocol.ViewContext{Publication: 2, Route: protocol.CommittedRouteIdentity{Target: second}, TabID: "t_abc123", FocusedPaneID: "p_def456"} | |
| 145 | + | outputPayload, err := wire.MarshalOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &view, Data: []byte("\x1b[2J\x1b[Hsecond")}) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | transport.detached <- wire.Frame{Type: wire.MsgOutput, Payload: outputPayload} | |
| 148 | + | // Completion rides the fresh effect the move postcommit admitted on the | |
| 149 | + | // published destination capability. | |
| 150 | + | resultPayload := wire.MarshalPickerResult(protocol.PickerResult{ | |
| 151 | + | CauseActionID: selection.CauseActionID, InteractionID: snapshot.InteractionID, | |
| 152 | + | SourceID: "serving", Key: selection.Key, Action: protocol.PickerActionMove, | |
| 153 | + | }) | |
| 154 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerResult, Payload: resultPayload} | |
| 155 | + | ||
| 156 | + | select { | |
| 157 | + | case action := <-committed: | |
| 158 | + | require.Equal(t, ports.UIActionProcessed, action.Status) | |
| 159 | + | require.Equal(t, "second", action.Context.Route.Target.SessionName) | |
| 160 | + | case err := <-commitErr: | |
| 161 | + | t.Fatalf("composite move failed: %v", err) | |
| 162 | + | case <-time.After(2 * time.Second): | |
| 163 | + | t.Fatal("move action never completed from the correlated PickerResult") | |
| 164 | + | } | |
| 165 | + | requireNoPickerInput(t, transport) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // TestClientPickerRequestsAndRendersTheDisplayedRowPreview drives the preview | |
| 169 | + | // path end to end: the debounce settles on the displayed row, the request names | |
| 170 | + | // it, the daemon's viewport is drawn in the modal, and a late answer for a row | |
| 171 | + | // the user already left leaves the displayed viewport untouched. | |
| 172 | + | func TestClientPickerRequestsAndRendersTheDisplayedRowPreview(t *testing.T) { | |
| 173 | + | ctx := context.Background() | |
| 174 | + | harness := startPickerE2E(t) | |
| 175 | + | ui, transport := harness.ui, harness.transport | |
| 176 | + | ||
| 177 | + | snapshot := openPickerOnHarness(t, transport) | |
| 178 | + | attached := ports.UIStatusAttached | |
| 179 | + | first := "first" | |
| 180 | + | _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}}) | |
| 181 | + | require.NoError(t, err) | |
| 182 | + | ||
| 183 | + | harness.awaitPreviewTimer(t).fire() | |
| 184 | + | request, err := wire.UnmarshalPickerPreviewRequest(awaitWireFrame(t, transport, wire.MsgPickerPreviewRequest)) | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | require.Equal(t, snapshot.InteractionID, request.InteractionID) | |
| 187 | + | require.Equal(t, "serving", request.SourceID) | |
| 188 | + | require.Equal(t, "aa/first", request.Key) | |
| 189 | + | // The client asks for the preview pane it will draw into, not the whole | |
| 190 | + | // terminal: both sides resolve the same modal geometry. | |
| 191 | + | preview := picker.PreviewRect(domain.Size{Cols: 80, Rows: 24}) | |
| 192 | + | require.Equal(t, uint16(preview.Width), request.Width) | |
| 193 | + | require.Equal(t, uint16(preview.Height), request.Height) | |
| 194 | + | ||
| 195 | + | viewport := protocol.PickerPreview{ | |
| 196 | + | Version: protocol.PickerPreviewSchemaVersion, InteractionID: snapshot.InteractionID, SourceID: "serving", | |
| 197 | + | Key: "aa/first", Status: protocol.PickerPreviewOK, Width: 4, Height: 1, | |
| 198 | + | Cells: []renderer.Cell{{Rune: 'v'}, {Rune: 'i'}, {Rune: 'e'}, {Rune: 'w'}}, | |
| 199 | + | } | |
| 200 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerPreview, Payload: wire.MarshalPickerPreview(viewport)} | |
| 201 | + | awaitTerminalText(t, harness.terminal, "view") | |
| 202 | + | ||
| 203 | + | // A late viewport for a row the user left must not replace the displayed one. | |
| 204 | + | late := viewport | |
| 205 | + | late.Key = "bb/second" | |
| 206 | + | late.Cells = []renderer.Cell{{Rune: 's'}, {Rune: 't'}, {Rune: 'a'}, {Rune: 'l'}} | |
| 207 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerPreview, Payload: wire.MarshalPickerPreview(late)} | |
| 208 | + | awaitTerminalText(t, harness.terminal, "view") | |
| 209 | + | require.NotContains(t, uiSnapshotText(mustCapture(t, ui)), "stal") | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func mustCapture(t *testing.T, ui *UI) ports.UISnapshot { | |
| 213 | + | t.Helper() | |
| 214 | + | snapshot, err := ui.Capture(ui.Handle()) | |
| 215 | + | require.NoError(t, err) | |
| 216 | + | return snapshot | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // TestClientPickerCancelReleasesOnAuthoritativeFullPaint drives the cancel | |
| 220 | + | // path through the same real attempt: an idle Escape retires the | |
| 221 | + | // interaction locally and types the close on the wire, the picker frame | |
| 222 | + | // stays and the action stays pending, and only the daemon's authoritative | |
| 223 | + | // full paint releases the terminal and completes the cancel. | |
| 224 | + | func TestClientPickerCancelReleasesOnAuthoritativeFullPaint(t *testing.T) { | |
| 225 | + | ctx := context.Background() | |
| 226 | + | harness := startPickerE2E(t) | |
| 227 | + | ui, transport := harness.ui, harness.transport | |
| 228 | + | ||
| 229 | + | snapshot := openPickerOnHarness(t, transport) | |
| 230 | + | attached := ports.UIStatusAttached | |
| 231 | + | pickerText := "first" | |
| 232 | + | _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &pickerText}}) | |
| 233 | + | require.NoError(t, err) | |
| 234 | + | requireNoPickerInput(t, transport) | |
| 235 | + | ||
| 236 | + | cancelled := make(chan ports.UIActionResult, 1) | |
| 237 | + | cancelErr := make(chan error, 1) | |
| 238 | + | go func() { | |
| 239 | + | action, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Escape"}}) | |
| 240 | + | if err != nil { | |
| 241 | + | cancelErr <- err | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | cancelled <- action | |
| 245 | + | }() | |
| 246 | + | closePayload := awaitWireFrame(t, transport, wire.MsgPickerCloseClient) | |
| 247 | + | closeMessage, err := wire.UnmarshalPickerClose(closePayload) | |
| 248 | + | require.NoError(t, err) | |
| 249 | + | require.Equal(t, snapshot.InteractionID, closeMessage.InteractionID) | |
| 250 | + | requireNoPickerInput(t, transport) | |
| 251 | + | ||
| 252 | + | // The close alone releases nothing: the action is still pending and the | |
| 253 | + | // picker frame still owns the screen. | |
| 254 | + | select { | |
| 255 | + | case action := <-cancelled: | |
| 256 | + | t.Fatalf("cancel completed before the release paint: %+v", action) | |
| 257 | + | case <-time.After(100 * time.Millisecond): | |
| 258 | + | } | |
| 259 | + | snapshotState, err := ui.Capture(ui.Handle()) | |
| 260 | + | require.NoError(t, err) | |
| 261 | + | require.Contains(t, uiSnapshotText(snapshotState), "second") | |
| 262 | + | ||
| 263 | + | // The daemon confirms and repaints authoritatively: the frame is | |
| 264 | + | // displayed and the pending cancel completes against that boundary. | |
| 265 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: snapshot.InteractionID, BarrierEpoch: 2, BarrierState: 1})} | |
| 266 | + | view := protocol.ViewContext{Publication: 2, Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}}, TabID: "t_abc123", FocusedPaneID: "p_def456"} | |
| 267 | + | outputPayload, err := wire.MarshalOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &view, Data: []byte("\x1b[2J\x1b[Hreleased")}) | |
| 268 | + | require.NoError(t, err) | |
| 269 | + | transport.detached <- wire.Frame{Type: wire.MsgOutput, Payload: outputPayload} | |
| 270 | + | select { | |
| 271 | + | case action := <-cancelled: | |
| 272 | + | require.Equal(t, ports.UIActionProcessed, action.Status) | |
| 273 | + | case err := <-cancelErr: | |
| 274 | + | t.Fatalf("cancel failed after the release paint: %v", err) | |
| 275 | + | case <-time.After(2 * time.Second): | |
| 276 | + | t.Fatal("cancel never completed after the release paint") | |
| 277 | + | } | |
| 278 | + | after, err := ui.Capture(ui.Handle()) | |
| 279 | + | require.NoError(t, err) | |
| 280 | + | require.NotContains(t, uiSnapshotText(after), "second") | |
| 281 | + | require.Contains(t, uiSnapshotText(after), "released") | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // TestClientPickerDrawsAFloatingModalOverTheSession pins the presentation the | |
| 285 | + | // picker must keep: a centred bordered box over the session, not a full-screen | |
| 286 | + | // takeover. The session text painted before the picker opened stays visible | |
| 287 | + | // around the box, and only the box's own cells are written. | |
| 288 | + | func TestClientPickerDrawsAFloatingModalOverTheSession(t *testing.T) { | |
| 289 | + | ctx := context.Background() | |
| 290 | + | harness := startPickerE2E(t) | |
| 291 | + | ui, transport := harness.ui, harness.transport | |
| 292 | + | attached := ports.UIStatusAttached | |
| 293 | + | ready := "ready" | |
| 294 | + | _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &ready}}) | |
| 295 | + | require.NoError(t, err, "the session owns the screen before the picker opens") | |
| 296 | + | ||
| 297 | + | openPickerOnHarness(t, transport) | |
| 298 | + | first := "first" | |
| 299 | + | _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}}) | |
| 300 | + | require.NoError(t, err) | |
| 301 | + | ||
| 302 | + | text := uiSnapshotText(mustCapture(t, ui)) | |
| 303 | + | require.Contains(t, text, "first", "the picker rows must be drawn") | |
| 304 | + | require.Contains(t, text, "ready", "the session must stay visible around the floating box") | |
| 305 | + | require.Contains(t, text, "─", "the modal border must be drawn") | |
| 306 | + | ||
| 307 | + | // The box is the shared picker modal, not the whole terminal. | |
| 308 | + | size := domain.Size{Cols: 80, Rows: 24} | |
| 309 | + | presentation := picker.Modal.Resolve(size) | |
| 310 | + | require.Less(t, presentation.Bounds.Width, size.Cols) | |
| 311 | + | require.Less(t, presentation.Bounds.Height, size.Rows) | |
| 312 | + | require.Contains(t, text, picker.Modal.Title, "the picker box keeps its title") | |
| 313 | + | } | |
A internal/usecase/client/picker_input.go +358 -0
| @@ -0,0 +1,358 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | "unicode/utf8" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // This file owns user-input routing for a client-owned picker interaction. | |
| 11 | + | // | |
| 12 | + | // The attach loop is the single owner of the picker model: it publishes who | |
| 13 | + | // owns user input through pickerConsumer, and the stdin pump only decodes | |
| 14 | + | // batches into identified operations. The pump never mutates the model, so | |
| 15 | + | // the two goroutines never share it. | |
| 16 | + | // | |
| 17 | + | // While an interaction owns input, every byte read from the terminal | |
| 18 | + | // belongs to the picker: unknown or oversized sequences are consumed and | |
| 19 | + | // dropped rather than forwarded to the session. There is deliberately no | |
| 20 | + | // fallback to the PTY pipeline. | |
| 21 | + | ||
| 22 | + | // pickerPendingLimit bounds a withheld escape-sequence prefix. A longer | |
| 23 | + | // incomplete sequence is unrecognized input and is discarded. | |
| 24 | + | const pickerPendingLimit = 8 | |
| 25 | + | ||
| 26 | + | // pickerEscapeDeadline bounds how long a lone ESC is withheld while the | |
| 27 | + | // picker owns input, so ESC and the start of a CSI/SS3 sequence are told | |
| 28 | + | // apart without ever falling back to the session pipeline. | |
| 29 | + | const pickerEscapeDeadline = 20 * time.Millisecond | |
| 30 | + | ||
| 31 | + | // Bracketed-paste delimiters. Their content is dropped: a paste must never | |
| 32 | + | // become a run of modal commands, and the picker has no text field that | |
| 33 | + | // wants pasted input. | |
| 34 | + | const ( | |
| 35 | + | pasteOpenMarker = "\x1b[200~" | |
| 36 | + | pasteCloseMarker = "\x1b[201~" | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // pickerInputState is the attach loop's publication of the current input | |
| 40 | + | // owner: zero interaction with drain unset means the session owns input. | |
| 41 | + | type pickerInputState struct { | |
| 42 | + | interaction uint64 | |
| 43 | + | generation uint64 | |
| 44 | + | // drain consumes and drops every batch while the interaction is | |
| 45 | + | // retired but its authoritative repaint is still in flight. | |
| 46 | + | drain bool | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // pickerEventKind distinguishes a command token from a typed rune. Events | |
| 50 | + | // keep their arrival order: a batch must never reorder input. | |
| 51 | + | type pickerEventKind uint8 | |
| 52 | + | ||
| 53 | + | const ( | |
| 54 | + | pickerEventKey pickerEventKind = iota | |
| 55 | + | pickerEventRune | |
| 56 | + | ) | |
| 57 | + | ||
| 58 | + | // pickerEvent is one decoded input event. | |
| 59 | + | type pickerEvent struct { | |
| 60 | + | kind pickerEventKind | |
| 61 | + | key string | |
| 62 | + | r rune | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // pickerConsumer is the slot shared between the attach loop and the stdin | |
| 66 | + | // pump. It protects the published owner, the withheld escape or UTF-8 | |
| 67 | + | // prefix, and the paste state; it holds no picker model. | |
| 68 | + | type pickerConsumer struct { | |
| 69 | + | mu sync.Mutex | |
| 70 | + | state pickerInputState | |
| 71 | + | pending []byte | |
| 72 | + | pasting bool | |
| 73 | + | pasteTail []byte | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // setOwned publishes the interaction that now owns user input. | |
| 77 | + | func (c *pickerConsumer) setOwned(interaction, generation uint64) { | |
| 78 | + | if c == nil { | |
| 79 | + | return | |
| 80 | + | } | |
| 81 | + | c.mu.Lock() | |
| 82 | + | c.state = pickerInputState{interaction: interaction, generation: generation} | |
| 83 | + | c.resetDecoderLocked() | |
| 84 | + | c.mu.Unlock() | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // setDrain publishes the release window: the interaction is retired, its | |
| 88 | + | // repaint is in flight, and every batch must be consumed and dropped so a | |
| 89 | + | // picker-bound key can never reach the session. | |
| 90 | + | func (c *pickerConsumer) setDrain(interaction, generation uint64) { | |
| 91 | + | if c == nil { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | c.mu.Lock() | |
| 95 | + | c.state = pickerInputState{interaction: interaction, generation: generation, drain: true} | |
| 96 | + | c.resetDecoderLocked() | |
| 97 | + | c.mu.Unlock() | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // clear releases input back to the session and purges the decoder state. | |
| 101 | + | func (c *pickerConsumer) clear() { | |
| 102 | + | if c == nil { | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | c.mu.Lock() | |
| 106 | + | c.state = pickerInputState{} | |
| 107 | + | c.resetDecoderLocked() | |
| 108 | + | c.mu.Unlock() | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // resetDecoderLocked drops a withheld prefix and any paste state. A retired | |
| 112 | + | // owner must never leak a half-decoded sequence into the next one. | |
| 113 | + | func (c *pickerConsumer) resetDecoderLocked() { | |
| 114 | + | c.pending = nil | |
| 115 | + | c.pasting = false | |
| 116 | + | c.pasteTail = nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // consume decodes one read or admitted automation batch into an identified | |
| 120 | + | // operation. The second result is false when the session owns input, so the | |
| 121 | + | // caller keeps its normal pipeline. During the release window every batch is | |
| 122 | + | // consumed and dropped. | |
| 123 | + | func (c *pickerConsumer) consume(record terminalReadResult) (pickerConsumeOutcome, bool) { | |
| 124 | + | if c == nil { | |
| 125 | + | return pickerConsumeOutcome{}, false | |
| 126 | + | } | |
| 127 | + | c.mu.Lock() | |
| 128 | + | state := c.state | |
| 129 | + | if state.interaction == 0 && !state.drain { | |
| 130 | + | c.mu.Unlock() | |
| 131 | + | return pickerConsumeOutcome{}, false | |
| 132 | + | } | |
| 133 | + | outcome := pickerConsumeOutcome{ | |
| 134 | + | consumed: true, actionID: record.actionID, | |
| 135 | + | generation: state.generation, interaction: state.interaction, | |
| 136 | + | } | |
| 137 | + | switch { | |
| 138 | + | case state.drain: | |
| 139 | + | // Retired interaction: consume and drop, including any prefix. | |
| 140 | + | c.resetDecoderLocked() | |
| 141 | + | case record.keys == nil && record.text == "": | |
| 142 | + | var batch pickerInputBatch | |
| 143 | + | c.pending, c.pasting, c.pasteTail = c.decodeLocked(record.data, &batch) | |
| 144 | + | outcome.events = batch.events | |
| 145 | + | default: | |
| 146 | + | outcome.events = automationEvents(record.keys, record.text) | |
| 147 | + | } | |
| 148 | + | c.mu.Unlock() | |
| 149 | + | return outcome, true | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // flushPending resolves a withheld prefix once its disambiguation window | |
| 153 | + | // expired. A bare escape becomes Escape; an incomplete escape sequence is | |
| 154 | + | // dropped; an incomplete UTF-8 prefix is dropped. A paste in progress stays | |
| 155 | + | // in progress: its content is never replayed as commands. | |
| 156 | + | func (c *pickerConsumer) flushPending() (pickerConsumeOutcome, bool) { | |
| 157 | + | if c == nil { | |
| 158 | + | return pickerConsumeOutcome{}, false | |
| 159 | + | } | |
| 160 | + | c.mu.Lock() | |
| 161 | + | state, pending := c.state, c.pending | |
| 162 | + | c.pending = nil | |
| 163 | + | c.mu.Unlock() | |
| 164 | + | if len(pending) == 0 || state.drain { | |
| 165 | + | return pickerConsumeOutcome{}, false | |
| 166 | + | } | |
| 167 | + | outcome := pickerConsumeOutcome{consumed: true, generation: state.generation, interaction: state.interaction} | |
| 168 | + | if string(pending) == "\x1b" { | |
| 169 | + | outcome.events = []pickerEvent{{kind: pickerEventKey, key: "Escape"}} | |
| 170 | + | } | |
| 171 | + | return outcome, true | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // hasPending reports whether an escape or UTF-8 prefix is withheld. | |
| 175 | + | func (c *pickerConsumer) hasPending() bool { | |
| 176 | + | if c == nil { | |
| 177 | + | return false | |
| 178 | + | } | |
| 179 | + | c.mu.Lock() | |
| 180 | + | defer c.mu.Unlock() | |
| 181 | + | return len(c.pending) != 0 | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // automationEvents converts one admitted ui-driver op into ordered events. | |
| 185 | + | func automationEvents(keys []string, text string) []pickerEvent { | |
| 186 | + | events := make([]pickerEvent, 0, len(keys)+len(text)) | |
| 187 | + | for _, key := range keys { | |
| 188 | + | events = append(events, pickerEvent{kind: pickerEventKey, key: key}) | |
| 189 | + | } | |
| 190 | + | for _, r := range text { | |
| 191 | + | events = append(events, pickerEvent{kind: pickerEventRune, r: r}) | |
| 192 | + | } | |
| 193 | + | return events | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // decoder carries the paste state across reads: a paste's boundaries can | |
| 197 | + | // span several terminal reads, and its content must never be interpreted. | |
| 198 | + | type decoder struct { | |
| 199 | + | pending []byte | |
| 200 | + | pasting bool | |
| 201 | + | tail []byte | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // decodeLocked decodes bytes under the consumer lock and returns the next | |
| 205 | + | // pending prefix, paste state, and paste tail. | |
| 206 | + | func (c *pickerConsumer) decodeLocked(data []byte, batch *pickerInputBatch) ([]byte, bool, []byte) { | |
| 207 | + | d := decoder{pending: c.pending, pasting: c.pasting, tail: c.pasteTail} | |
| 208 | + | d.decode(data, batch) | |
| 209 | + | return d.pending, d.pasting, d.tail | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // decode consumes one read into ordered events. A non-nil pending result is | |
| 213 | + | // an incomplete escape or UTF-8 prefix to withhold until the next read. | |
| 214 | + | func (d *decoder) decode(data []byte, batch *pickerInputBatch) { | |
| 215 | + | buf := data | |
| 216 | + | if len(d.pending) != 0 { | |
| 217 | + | buf = append(append([]byte(nil), d.pending...), data...) | |
| 218 | + | d.pending = nil | |
| 219 | + | } | |
| 220 | + | if d.pasting { | |
| 221 | + | buf = append(append([]byte(nil), d.tail...), buf...) | |
| 222 | + | d.tail = nil | |
| 223 | + | } | |
| 224 | + | for i := 0; i < len(buf); { | |
| 225 | + | if d.pasting { | |
| 226 | + | end := bytes.Index(buf[i:], []byte(pasteCloseMarker)) | |
| 227 | + | if end < 0 { | |
| 228 | + | // Hold only enough trailing bytes to recognize a marker split | |
| 229 | + | // across reads; everything else in the paste is dropped. | |
| 230 | + | keep := len(pasteCloseMarker) - 1 | |
| 231 | + | if len(buf)-i > keep { | |
| 232 | + | d.tail = append([]byte(nil), buf[len(buf)-keep:]...) | |
| 233 | + | } else { | |
| 234 | + | d.tail = append([]byte(nil), buf[i:]...) | |
| 235 | + | } | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | i += end + len(pasteCloseMarker) | |
| 239 | + | d.pasting = false | |
| 240 | + | continue | |
| 241 | + | } | |
| 242 | + | switch b := buf[i]; { | |
| 243 | + | case b == '\r' || b == '\n': | |
| 244 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Enter"}) | |
| 245 | + | i++ | |
| 246 | + | case b == 0x7f || b == 0x08: | |
| 247 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Backspace"}) | |
| 248 | + | i++ | |
| 249 | + | case b == 0x03: | |
| 250 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Ctrl+C"}) | |
| 251 | + | i++ | |
| 252 | + | case b == 0x1b: | |
| 253 | + | consumed, held, paste := decodeEscape(buf[i:], batch) | |
| 254 | + | if held != nil { | |
| 255 | + | d.pending = held | |
| 256 | + | return | |
| 257 | + | } | |
| 258 | + | if paste { | |
| 259 | + | d.pasting = true | |
| 260 | + | } | |
| 261 | + | i += consumed | |
| 262 | + | case b < 0x20: | |
| 263 | + | // Other control bytes carry no picker meaning; consume them so | |
| 264 | + | // they cannot reach the session. | |
| 265 | + | i++ | |
| 266 | + | case b < utf8.RuneSelf: | |
| 267 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: string(rune(b))}) | |
| 268 | + | i++ | |
| 269 | + | default: | |
| 270 | + | r, size := utf8.DecodeRune(buf[i:]) | |
| 271 | + | if r == utf8.RuneError && size <= 1 { | |
| 272 | + | if !utf8PrefixIncomplete(buf[i:]) { | |
| 273 | + | i++ // Invalid UTF-8: consume the byte and keep scanning. | |
| 274 | + | continue | |
| 275 | + | } | |
| 276 | + | d.pending = append([]byte(nil), buf[i:]...) | |
| 277 | + | return | |
| 278 | + | } | |
| 279 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventRune, r: r}) | |
| 280 | + | i += size | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // decodeEscape consumes one escape-led sequence starting at buf[0]. It | |
| 286 | + | // returns the number of bytes consumed; a non-nil result is an incomplete | |
| 287 | + | // prefix to withhold until the next read. paste reports the bracketed-paste | |
| 288 | + | // start marker. | |
| 289 | + | func decodeEscape(buf []byte, batch *pickerInputBatch) (consumed int, held []byte, paste bool) { | |
| 290 | + | if len(buf) == 1 { | |
| 291 | + | return 0, append([]byte(nil), buf...), false | |
| 292 | + | } | |
| 293 | + | if buf[1] != '[' && buf[1] != 'O' { | |
| 294 | + | // ESC followed by an ordinary byte: the escape stands alone. | |
| 295 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Escape"}) | |
| 296 | + | return 1, nil, false | |
| 297 | + | } | |
| 298 | + | if len(buf) == 2 { | |
| 299 | + | return 0, append([]byte(nil), buf...), false | |
| 300 | + | } | |
| 301 | + | switch buf[2] { | |
| 302 | + | case 'A': | |
| 303 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Up"}) | |
| 304 | + | return 3, nil, false | |
| 305 | + | case 'B': | |
| 306 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Down"}) | |
| 307 | + | return 3, nil, false | |
| 308 | + | } | |
| 309 | + | // Any other CSI/SS3 sequence (mouse reports, probes, paste markers) is | |
| 310 | + | // consumed through its final byte and dropped. | |
| 311 | + | final := -1 | |
| 312 | + | for i := 2; i < len(buf); i++ { | |
| 313 | + | if buf[i] >= 0x40 && buf[i] <= 0x7e { | |
| 314 | + | final = i | |
| 315 | + | break | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | if final < 0 { | |
| 319 | + | if len(buf) > pickerPendingLimit { | |
| 320 | + | return len(buf), nil, false | |
| 321 | + | } | |
| 322 | + | return 0, append([]byte(nil), buf...), false | |
| 323 | + | } | |
| 324 | + | seq := buf[:final+1] | |
| 325 | + | if string(seq) == pasteOpenMarker { | |
| 326 | + | return final + 1, nil, true | |
| 327 | + | } | |
| 328 | + | return final + 1, nil, false | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // utf8PrefixIncomplete reports whether b is a valid but incomplete UTF-8 | |
| 332 | + | // sequence start, which must be withheld until its continuation bytes | |
| 333 | + | // arrive instead of being dropped. | |
| 334 | + | func utf8PrefixIncomplete(b []byte) bool { | |
| 335 | + | if len(b) == 0 || !utf8.RuneStart(b[0]) { | |
| 336 | + | return false | |
| 337 | + | } | |
| 338 | + | need := 0 | |
| 339 | + | switch { | |
| 340 | + | case b[0]&0xe0 == 0xc0: | |
| 341 | + | need = 2 | |
| 342 | + | case b[0]&0xf0 == 0xe0: | |
| 343 | + | need = 3 | |
| 344 | + | case b[0]&0xf8 == 0xf0: | |
| 345 | + | need = 4 | |
| 346 | + | default: | |
| 347 | + | return false | |
| 348 | + | } | |
| 349 | + | if len(b) >= need { | |
| 350 | + | return false | |
| 351 | + | } | |
| 352 | + | for _, c := range b[1:] { | |
| 353 | + | if c&0xc0 != 0x80 { | |
| 354 | + | return false | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | return true | |
| 358 | + | } | |
A internal/usecase/client/picker_input_test.go +336 -0
| @@ -0,0 +1,336 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // keyEvents builds one key batch for applyPickerBatch. | |
| 18 | + | func keyEvents(keys ...string) []pickerEvent { | |
| 19 | + | events := make([]pickerEvent, 0, len(keys)) | |
| 20 | + | for _, key := range keys { | |
| 21 | + | events = append(events, pickerEvent{kind: pickerEventKey, key: key}) | |
| 22 | + | } | |
| 23 | + | return events | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // TestPickerPhysicalInputDecoding pins the byte-level decoder: recognized | |
| 27 | + | // keys, fragmented sequences, fragmented UTF-8, paste state, and the | |
| 28 | + | // deliberate consumption of everything the picker does not understand. | |
| 29 | + | // Nothing is left for the session pipeline. | |
| 30 | + | func TestPickerPhysicalInputDecoding(t *testing.T) { | |
| 31 | + | tests := []struct { | |
| 32 | + | name string | |
| 33 | + | pending []byte | |
| 34 | + | tail string | |
| 35 | + | pasting bool | |
| 36 | + | data []byte | |
| 37 | + | wantKeys []string | |
| 38 | + | wantText string | |
| 39 | + | wantPending string | |
| 40 | + | wantTail string | |
| 41 | + | wantPasting bool | |
| 42 | + | }{ | |
| 43 | + | {name: "up arrow csi", data: []byte("\x1b[A"), wantKeys: []string{"Up"}}, | |
| 44 | + | {name: "up arrow ss3", data: []byte("\x1bOA"), wantKeys: []string{"Up"}}, | |
| 45 | + | {name: "down arrow csi", data: []byte("\x1b[B"), wantKeys: []string{"Down"}}, | |
| 46 | + | {name: "lone escape is withheld", data: []byte("\x1b"), wantPending: "\x1b"}, | |
| 47 | + | {name: "csi prefix is withheld", data: []byte("\x1b["), wantPending: "\x1b["}, | |
| 48 | + | {name: "fragmented arrow", pending: []byte("\x1b["), data: []byte("A"), wantKeys: []string{"Up"}}, | |
| 49 | + | {name: "enter", data: []byte("\r"), wantKeys: []string{"Enter"}}, | |
| 50 | + | {name: "backspace", data: []byte("\x7f"), wantKeys: []string{"Backspace"}}, | |
| 51 | + | {name: "ctrl-c", data: []byte("\x03"), wantKeys: []string{"Ctrl+C"}}, | |
| 52 | + | {name: "printable ascii", data: []byte("jkq"), wantKeys: []string{"j", "k", "q"}}, | |
| 53 | + | {name: "utf8 rune is text", data: []byte("é"), wantText: "é"}, | |
| 54 | + | {name: "fragmented utf8 first byte", data: []byte{0xc3}, wantPending: string([]byte{0xc3})}, | |
| 55 | + | {name: "fragmented utf8 completed", pending: []byte{0xc3}, data: []byte{0xa9}, wantText: "é"}, | |
| 56 | + | {name: "ordered ascii then rune", data: []byte("aé"), wantKeys: []string{"a"}, wantText: "é"}, | |
| 57 | + | {name: "mouse report is consumed", data: []byte("\x1b[<0;5;5M")}, | |
| 58 | + | {name: "unknown csi is consumed", data: []byte("\x1b[Z")}, | |
| 59 | + | {name: "oversized incomplete sequence is dropped", data: []byte("\x1b[1234567890"), wantPending: ""}, | |
| 60 | + | {name: "escape before ordinary byte", data: []byte("\x1bq"), wantKeys: []string{"Escape", "q"}}, | |
| 61 | + | // A paste is a state, not a heuristic: the content between the | |
| 62 | + | // markers is consumed even when it arrives across reads, and even | |
| 63 | + | // when it spells a command. | |
| 64 | + | {name: "paste start alone", data: []byte("\x1b[200~"), wantPasting: true}, | |
| 65 | + | {name: "paste content and end", pasting: true, data: []byte("q\x1b[201~"), wantPasting: false}, | |
| 66 | + | // Only the marker-relevant suffix of a paste is retained: the rest is | |
| 67 | + | // dropped, and a split end marker still resolves on the next read. | |
| 68 | + | {name: "paste content split across reads", pasting: true, data: []byte("hidden\x1b["), wantPasting: true, wantTail: "den\x1b["}, | |
| 69 | + | {name: "paste end split across reads", pasting: true, tail: "\x1b[", data: []byte("201~"), wantPasting: false}, | |
| 70 | + | } | |
| 71 | + | for _, test := range tests { | |
| 72 | + | t.Run(test.name, func(t *testing.T) { | |
| 73 | + | var batch pickerInputBatch | |
| 74 | + | d := decoder{pending: test.pending, pasting: test.pasting, tail: []byte(test.tail)} | |
| 75 | + | d.decode(test.data, &batch) | |
| 76 | + | var keys []string | |
| 77 | + | var text string | |
| 78 | + | for _, event := range batch.events { | |
| 79 | + | if event.kind == pickerEventKey { | |
| 80 | + | keys = append(keys, event.key) | |
| 81 | + | continue | |
| 82 | + | } | |
| 83 | + | text += string(event.r) | |
| 84 | + | } | |
| 85 | + | require.Equal(t, test.wantKeys, keys) | |
| 86 | + | require.Equal(t, test.wantText, text) | |
| 87 | + | require.Equal(t, test.wantPending, string(d.pending)) | |
| 88 | + | require.Equal(t, test.wantTail, string(d.tail)) | |
| 89 | + | require.Equal(t, test.wantPasting, d.pasting) | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // TestPickerInputConsumerOwnership pins who owns terminal input and what | |
| 95 | + | // the release window does with it: consume and drop, never replay. | |
| 96 | + | func TestPickerInputConsumerOwnership(t *testing.T) { | |
| 97 | + | var consumer pickerConsumer | |
| 98 | + | _, consumed := consumer.consume(terminalReadResult{data: []byte("x")}) | |
| 99 | + | require.False(t, consumed, "the session owns input while no interaction is open") | |
| 100 | + | ||
| 101 | + | consumer.setOwned(7, 3) | |
| 102 | + | outcome, consumed := consumer.consume(terminalReadResult{data: []byte("\x1b[B")}) | |
| 103 | + | require.True(t, consumed) | |
| 104 | + | require.Equal(t, uint64(7), outcome.interaction) | |
| 105 | + | require.Equal(t, uint64(3), outcome.generation) | |
| 106 | + | require.Equal(t, keyEvents("Down"), outcome.events) | |
| 107 | + | ||
| 108 | + | // The release window consumes and drops, and purges any withheld prefix. | |
| 109 | + | consumer.setDrain(7, 3) | |
| 110 | + | outcome, consumed = consumer.consume(terminalReadResult{data: []byte("y")}) | |
| 111 | + | require.True(t, consumed) | |
| 112 | + | require.Empty(t, outcome.events) | |
| 113 | + | ||
| 114 | + | // Clearing hands input back to the session. | |
| 115 | + | consumer.clear() | |
| 116 | + | _, consumed = consumer.consume(terminalReadResult{data: []byte("z")}) | |
| 117 | + | require.False(t, consumed) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // TestPickerApplyBatchAppliesEveryEvent pins that a terminal read carrying | |
| 121 | + | // several legitimate keystrokes is applied in order: batch size never | |
| 122 | + | // decides whether input is a command. Paste protection is the decoder's | |
| 123 | + | // paste state, which is covered by the decoding table. | |
| 124 | + | func TestPickerApplyBatchAppliesEveryEvent(t *testing.T) { | |
| 125 | + | open := func() *pickerLoop { | |
| 126 | + | return pickerLoopFromSnapshot(pickerSnapshot(), protocol.PickerIntentNavigation, picker.SortRecent) | |
| 127 | + | } | |
| 128 | + | selectedKey := func(t *testing.T, loop *pickerLoop) string { | |
| 129 | + | t.Helper() | |
| 130 | + | selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 0) | |
| 131 | + | require.True(t, ok) | |
| 132 | + | return selection.Key | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | loop := open() | |
| 136 | + | op, changed := applyPickerBatch(loop, keyEvents("Down")) | |
| 137 | + | require.False(t, op.commit) | |
| 138 | + | require.False(t, op.close) | |
| 139 | + | require.True(t, changed, "a cursor move repaints") | |
| 140 | + | ||
| 141 | + | // Two keystrokes in one read both act, in order. | |
| 142 | + | loop = open() | |
| 143 | + | _, changed = applyPickerBatch(loop, keyEvents("Down", "Down")) | |
| 144 | + | require.True(t, changed) | |
| 145 | + | require.Equal(t, "bb/second", selectedKey(t, loop), "the second Down is clamped at the last row") | |
| 146 | + | loop.up() | |
| 147 | + | require.Equal(t, "aa/first", selectedKey(t, loop), "both Downs moved the cursor") | |
| 148 | + | ||
| 149 | + | loop = open() | |
| 150 | + | op, _ = applyPickerBatch(loop, keyEvents("Escape")) | |
| 151 | + | require.True(t, op.close) | |
| 152 | + | ||
| 153 | + | // A close inside a batch ends it: events after it do not act. | |
| 154 | + | loop = open() | |
| 155 | + | op, _ = applyPickerBatch(loop, keyEvents("Escape", "Down")) | |
| 156 | + | require.True(t, op.close) | |
| 157 | + | ||
| 158 | + | // Search inserts runes and printable keys in arrival order. | |
| 159 | + | loop = open() | |
| 160 | + | loop.enterSearch() | |
| 161 | + | _, changed = applyPickerBatch(loop, []pickerEvent{ | |
| 162 | + | {kind: pickerEventRune, r: 'é'}, | |
| 163 | + | {kind: pickerEventKey, key: "a"}, | |
| 164 | + | }) | |
| 165 | + | require.True(t, changed) | |
| 166 | + | op, _ = applyPickerBatch(loop, keyEvents("Escape")) | |
| 167 | + | require.False(t, op.close, "Escape leaves search first") | |
| 168 | + | require.False(t, loop.model.SearchActive()) | |
| 169 | + | op, _ = applyPickerBatch(loop, keyEvents("Escape")) | |
| 170 | + | require.True(t, op.close, "a second Escape closes the picker") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // TestPickerOutcomeScope pins that a decoded operation only applies to the | |
| 174 | + | // interaction and attachment generation that admitted it: this is what makes | |
| 175 | + | // a generation change (reconnect) and a superseded interaction safe without | |
| 176 | + | // forwarding their input anywhere. | |
| 177 | + | func TestPickerOutcomeScope(t *testing.T) { | |
| 178 | + | outcome := pickerConsumeOutcome{consumed: true, interaction: 7, generation: 3} | |
| 179 | + | require.True(t, outcome.acceptOutcome(7, 3)) | |
| 180 | + | require.False(t, outcome.acceptOutcome(7, 4), "a new generation must not apply the old operation") | |
| 181 | + | require.False(t, outcome.acceptOutcome(8, 3), "a superseding interaction must not apply it either") | |
| 182 | + | require.False(t, pickerConsumeOutcome{interaction: 7, generation: 3}.acceptOutcome(7, 3), "an unconsumed outcome never applies") | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // TestPickerPhysicalInputWithoutUIDriver pins that the picker owns and | |
| 186 | + | // completes physical input in the ordinary CLI composition, where the | |
| 187 | + | // ui-driver (and its automation channel) does not exist: the picker frame | |
| 188 | + | // is observed on the terminal itself, then real bytes drive it. | |
| 189 | + | func TestPickerPhysicalInputWithoutUIDriver(t *testing.T) { | |
| 190 | + | reader, writer := io.Pipe() | |
| 191 | + | t.Cleanup(func() { _ = writer.Close() }) | |
| 192 | + | harness := startPickerE2EHeadless(t, reader) | |
| 193 | + | require.Nil(t, harness.ui, "this harness has no ui-driver") | |
| 194 | + | transport := harness.transport | |
| 195 | + | ||
| 196 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(pickerSnapshot())} | |
| 197 | + | // The drawn picker is the barrier: write only once it owns the screen. | |
| 198 | + | awaitTerminalText(t, harness.terminal, "second") | |
| 199 | + | ||
| 200 | + | // Down then Enter commits the row the physical keys selected. | |
| 201 | + | writeTerminal(t, writer, "\x1b[B") | |
| 202 | + | writeTerminal(t, writer, "\r") | |
| 203 | + | selection := awaitPickerSelection(t, transport) | |
| 204 | + | require.Equal(t, "bb/second", selection.Key) | |
| 205 | + | requireNoPickerInput(t, transport) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // TestPickerPhysicalInputOwnsSession drives real terminal bytes through the | |
| 209 | + | // pump: the picker reacts by committing the row the physical keys selected, | |
| 210 | + | // the close crosses the wire, and no byte ever reaches the PTY pipeline. | |
| 211 | + | func TestPickerPhysicalInputOwnsSession(t *testing.T) { | |
| 212 | + | ctx := context.Background() | |
| 213 | + | reader, writer := io.Pipe() | |
| 214 | + | t.Cleanup(func() { _ = writer.Close() }) | |
| 215 | + | harness := startPickerE2EWithInput(t, reader) | |
| 216 | + | ui, transport := harness.ui, harness.transport | |
| 217 | + | ||
| 218 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(pickerSnapshot())} | |
| 219 | + | attached := ports.UIStatusAttached | |
| 220 | + | first := "first" | |
| 221 | + | _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}}) | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | ||
| 224 | + | // A physical Down arrow then Enter commits the row the cursor reached: | |
| 225 | + | // both rows are on screen from the start, so the committed key is the | |
| 226 | + | // only proof the movement happened. | |
| 227 | + | writeTerminal(t, writer, "\x1b[B") | |
| 228 | + | writeTerminal(t, writer, "\r") | |
| 229 | + | selection := awaitPickerSelection(t, transport) | |
| 230 | + | require.Equal(t, "bb/second", selection.Key) | |
| 231 | + | requireNoPickerInput(t, transport) | |
| 232 | + | ||
| 233 | + | // A physical Escape closes the picker once the disambiguation window | |
| 234 | + | // expires: the client sends the typed close and never leaks the byte. | |
| 235 | + | writeTerminal(t, writer, "\x1b") | |
| 236 | + | harness.awaitAfterAmbiguityDeadlines(t, transport, wire.MsgPickerCloseClient) | |
| 237 | + | requireNoPickerInput(t, transport) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // TestPickerPhysicalPasteNeverActs drives a bracketed paste through the pump | |
| 241 | + | // in fragments: its markers and content are consumed, and the command it | |
| 242 | + | // spells never fires. | |
| 243 | + | func TestPickerPhysicalPasteNeverActs(t *testing.T) { | |
| 244 | + | ctx := context.Background() | |
| 245 | + | reader, writer := io.Pipe() | |
| 246 | + | t.Cleanup(func() { _ = writer.Close() }) | |
| 247 | + | harness := startPickerE2EWithInput(t, reader) | |
| 248 | + | ui, transport := harness.ui, harness.transport | |
| 249 | + | ||
| 250 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(pickerSnapshot())} | |
| 251 | + | attached := ports.UIStatusAttached | |
| 252 | + | first := "first" | |
| 253 | + | _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}}) | |
| 254 | + | require.NoError(t, err) | |
| 255 | + | ||
| 256 | + | before := transport.frameCount() | |
| 257 | + | writeTerminal(t, writer, "\x1b[200~") | |
| 258 | + | writeTerminal(t, writer, "q\r") | |
| 259 | + | writeTerminal(t, writer, "\x1b[201~") | |
| 260 | + | time.Sleep(200 * time.Millisecond) | |
| 261 | + | require.Equal(t, before, transport.frameCount(), "paste produced a client frame") | |
| 262 | + | requireNoPickerInput(t, transport) | |
| 263 | + | ||
| 264 | + | // The picker is still open: a real Escape closes it. | |
| 265 | + | writeTerminal(t, writer, "\x1b") | |
| 266 | + | harness.awaitAfterAmbiguityDeadlines(t, transport, wire.MsgPickerCloseClient) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // TestPickerReleaseDropsInputWithoutReplay pins the release window: bytes | |
| 270 | + | // arriving after the close are consumed and dropped, never replayed into | |
| 271 | + | // the session, and routing resumes only after the authoritative paint. | |
| 272 | + | func TestPickerReleaseDropsInputWithoutReplay(t *testing.T) { | |
| 273 | + | ctx := context.Background() | |
| 274 | + | reader, writer := io.Pipe() | |
| 275 | + | t.Cleanup(func() { _ = writer.Close() }) | |
| 276 | + | harness := startPickerE2EWithInput(t, reader) | |
| 277 | + | ui, transport := harness.ui, harness.transport | |
| 278 | + | ||
| 279 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(pickerSnapshot())} | |
| 280 | + | attached := ports.UIStatusAttached | |
| 281 | + | first := "first" | |
| 282 | + | _, err := ui.Wait(ctx, utilWait(ui, &attached, &first)) | |
| 283 | + | require.NoError(t, err) | |
| 284 | + | ||
| 285 | + | // Close the interaction from the daemon side, then keep typing: the | |
| 286 | + | // release paint has not landed, so those bytes belong to the picker. | |
| 287 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: 7, BarrierEpoch: 2, BarrierState: 1})} | |
| 288 | + | writeTerminal(t, writer, "leaked") | |
| 289 | + | requireNoPickerInput(t, transport) | |
| 290 | + | ||
| 291 | + | // The authoritative full paint releases the terminal; later input is | |
| 292 | + | // session input again, and only the new bytes are delivered. | |
| 293 | + | require.NotNil(t, pickerReleasePaint(transport, "released")) | |
| 294 | + | released := "released" | |
| 295 | + | _, err = ui.Wait(ctx, utilWait(ui, &attached, &released)) | |
| 296 | + | require.NoError(t, err, "the release paint must be displayed") | |
| 297 | + | ||
| 298 | + | writeTerminal(t, writer, "ok") | |
| 299 | + | var delivered []byte | |
| 300 | + | deadline := time.After(2 * time.Second) | |
| 301 | + | for len(delivered) < len("ok") { | |
| 302 | + | select { | |
| 303 | + | case frame := <-transport.inputCh: | |
| 304 | + | delivered = append(delivered, frame.Data...) | |
| 305 | + | case <-deadline: | |
| 306 | + | t.Fatalf("session input did not resume after the release paint: got %q", delivered) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | require.Equal(t, []byte("ok"), delivered) | |
| 310 | + | select { | |
| 311 | + | case frame := <-transport.inputCh: | |
| 312 | + | t.Fatalf("released input was replayed: %q", frame.Data) | |
| 313 | + | case <-time.After(100 * time.Millisecond): | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // writeTerminal feeds raw bytes without blocking the test when the pump is | |
| 318 | + | // momentarily inactive. | |
| 319 | + | func writeTerminal(t *testing.T, writer *io.PipeWriter, data string) { | |
| 320 | + | t.Helper() | |
| 321 | + | done := make(chan struct{}) | |
| 322 | + | go func() { | |
| 323 | + | defer close(done) | |
| 324 | + | _, _ = writer.Write([]byte(data)) | |
| 325 | + | }() | |
| 326 | + | select { | |
| 327 | + | case <-done: | |
| 328 | + | case <-time.After(2 * time.Second): | |
| 329 | + | t.Fatalf("terminal write %q never reached the pump", data) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // utilWait builds a UI wait request for one expected fragment. | |
| 334 | + | func utilWait(ui *UI, status *ports.UIPresentationStatus, text *string) ports.UIWaitRequest { | |
| 335 | + | return ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: status, TextContains: text}} | |
| 336 | + | } | |
A internal/usecase/client/picker_interaction.go +731 -0
| @@ -0,0 +1,731 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // This file owns the client side of the picker interaction: source admission, | |
| 15 | + | // local model ownership, and typed commit/cancel. It mirrors the | |
| 16 | + | // inventoryRelay admission discipline (navigation_inventory.go) but never | |
| 17 | + | // canonicalizes line order: picker order is semantically significant. The | |
| 18 | + | // loop consumes terminal-pump records, never raw reads; commit sends a typed | |
| 19 | + | // PickerSelection on the ordered control channel, never picker bytes toward | |
| 20 | + | // the PTY. | |
| 21 | + | ||
| 22 | + | // pickerInteraction tracks one picker namespace: the admitted interaction ID | |
| 23 | + | // and the newest interaction this attachment retired. A retired interaction is | |
| 24 | + | // never resurrected by a late snapshot. | |
| 25 | + | type pickerInteraction struct { | |
| 26 | + | open bool | |
| 27 | + | interaction uint64 | |
| 28 | + | retired uint64 | |
| 29 | + | // intent is the intent the serving daemon published for this interaction. | |
| 30 | + | // Move intents present destination rows instead of navigation rows. | |
| 31 | + | intent protocol.PickerIntent | |
| 32 | + | // sourceRevisions tracks the newest admitted revision per source. Every | |
| 33 | + | // source publishes its own revision, so one source's refresh never | |
| 34 | + | // invalidates a selection displayed from another. | |
| 35 | + | sourceRevisions map[string]uint64 | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // setOpen starts or stops the interaction namespace. Superseding an open | |
| 39 | + | // interaction retires it too: a late snapshot for the replaced ID must not | |
| 40 | + | // take presentation back. Closing retires the namespace for good. | |
| 41 | + | func (p *pickerInteraction) setOpen(open bool, interaction uint64) { | |
| 42 | + | if p == nil { | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | if open && interaction != p.interaction && p.open && p.interaction > p.retired { | |
| 46 | + | p.retired = p.interaction | |
| 47 | + | } | |
| 48 | + | p.open = open | |
| 49 | + | if open && interaction != p.interaction { | |
| 50 | + | p.interaction = interaction | |
| 51 | + | p.sourceRevisions = make(map[string]uint64) | |
| 52 | + | } | |
| 53 | + | if !open && interaction > p.retired { | |
| 54 | + | p.retired = interaction | |
| 55 | + | } | |
| 56 | + | if !open { | |
| 57 | + | p.intent = 0 | |
| 58 | + | p.sourceRevisions = nil | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // setIntent records the intent published with the offer that opened the | |
| 63 | + | // interaction. | |
| 64 | + | func (p *pickerInteraction) setIntent(interaction uint64, intent protocol.PickerIntent) { | |
| 65 | + | if p == nil || !p.open || p.interaction != interaction { | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | p.intent = intent | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // admitSnapshot validates one source publication against the open interaction | |
| 72 | + | // and reports whether it carries a newer revision worth displaying. Retired | |
| 73 | + | // interactions, foreign interactions, and older or duplicate source revisions | |
| 74 | + | // discard. | |
| 75 | + | func (p *pickerInteraction) admitSnapshot(snapshot protocol.PickerSnapshot, wantsEvent bool) bool { | |
| 76 | + | if p == nil { | |
| 77 | + | return false | |
| 78 | + | } | |
| 79 | + | if protocol.ValidatePickerSnapshot(snapshot) != nil { | |
| 80 | + | return false | |
| 81 | + | } | |
| 82 | + | if snapshot.InteractionID <= p.retired { | |
| 83 | + | return false | |
| 84 | + | } | |
| 85 | + | if !p.open || snapshot.InteractionID != p.interaction { | |
| 86 | + | return false | |
| 87 | + | } | |
| 88 | + | if snapshot.SourceRevision <= p.sourceRevisions[snapshot.SourceID] { | |
| 89 | + | return false | |
| 90 | + | } | |
| 91 | + | if wantsEvent && p.intent == 0 { | |
| 92 | + | // The offer carries the intent; a snapshot that arrives first is | |
| 93 | + | // still admitted so a lost offer cannot wedge the interaction. | |
| 94 | + | } | |
| 95 | + | p.sourceRevisions[snapshot.SourceID] = snapshot.SourceRevision | |
| 96 | + | return true | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // pickerLoop owns one admitted *picker.Model plus the source publication it | |
| 100 | + | // was built from. Cursor, search, and sort are local-only presentation; | |
| 101 | + | // commit and cancel cross the wire as typed messages. The daemon revalidates | |
| 102 | + | // the committed source revision and key, so the client never keeps a second | |
| 103 | + | // copy of the row set. | |
| 104 | + | type pickerLoop struct { | |
| 105 | + | model *picker.Model | |
| 106 | + | interaction uint64 | |
| 107 | + | intent protocol.PickerIntent | |
| 108 | + | sources map[string]protocol.PickerSnapshot | |
| 109 | + | order []string | |
| 110 | + | rows map[string]pickerRowIdentity | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | type pickerRowIdentity struct { | |
| 114 | + | sourceID string | |
| 115 | + | sourceRevision uint64 | |
| 116 | + | key string | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // pickerLoopFromSnapshot builds the local model from the first admitted source. | |
| 120 | + | // Source-owned opaque keys are namespaced only inside the presentation model; | |
| 121 | + | // commits translate them back to the exact source identity published on wire. | |
| 122 | + | func pickerLoopFromSnapshot(snapshot protocol.PickerSnapshot, intent protocol.PickerIntent, sort picker.SortMode) *pickerLoop { | |
| 123 | + | snapshot = protocol.NormalizePickerSnapshot(snapshot) | |
| 124 | + | loop := &pickerLoop{interaction: snapshot.InteractionID, intent: intent, sources: make(map[string]protocol.PickerSnapshot)} | |
| 125 | + | loop.sources[snapshot.SourceID] = snapshot | |
| 126 | + | loop.order = append(loop.order, snapshot.SourceID) | |
| 127 | + | projection := pickerSnapshotProjection(snapshot, sort) | |
| 128 | + | cursor := projection.Cursor | |
| 129 | + | if cursor.Key != "" { | |
| 130 | + | cursor.Key = fmt.Sprintf("%d:%s:%s", len(snapshot.SourceID), snapshot.SourceID, cursor.Key) | |
| 131 | + | } | |
| 132 | + | loop.rebuild(cursor, sort) | |
| 133 | + | return loop | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // replaceLines applies one source publication while retaining every other | |
| 137 | + | // source. Independent source revisions therefore cannot invalidate each other. | |
| 138 | + | func (l *pickerLoop) replaceLines(snapshot protocol.PickerSnapshot) { | |
| 139 | + | snapshot = protocol.NormalizePickerSnapshot(snapshot) | |
| 140 | + | if l == nil || l.model == nil || snapshot.InteractionID != l.interaction { | |
| 141 | + | return | |
| 142 | + | } | |
| 143 | + | if _, exists := l.sources[snapshot.SourceID]; !exists { | |
| 144 | + | l.order = append(l.order, snapshot.SourceID) | |
| 145 | + | } | |
| 146 | + | selectedKey := "" | |
| 147 | + | if selected, ok := l.model.Selected(); ok { | |
| 148 | + | selectedKey = selected.Key | |
| 149 | + | } | |
| 150 | + | l.sources[snapshot.SourceID] = snapshot | |
| 151 | + | l.rebuild(protocol.PickerCursor{Key: selectedKey, Index: -1}, l.model.SortMode(), true) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (l *pickerLoop) rebuild(cursor protocol.PickerCursor, sort picker.SortMode, preserveEditor ...bool) { | |
| 155 | + | lines := make([]protocol.PickerLine, 0) | |
| 156 | + | rows := make(map[string]pickerRowIdentity) | |
| 157 | + | cursorKey := "" | |
| 158 | + | for _, sourceID := range l.order { | |
| 159 | + | snapshot, ok := l.sources[sourceID] | |
| 160 | + | if !ok { | |
| 161 | + | continue | |
| 162 | + | } | |
| 163 | + | projection := pickerSnapshotProjection(snapshot, sort) | |
| 164 | + | for index, published := range projection.Lines { | |
| 165 | + | line := published | |
| 166 | + | if line.Key != "" { | |
| 167 | + | presented := fmt.Sprintf("%d:%s:%s", len(sourceID), sourceID, line.Key) | |
| 168 | + | rows[presented] = pickerRowIdentity{sourceID: sourceID, sourceRevision: snapshot.SourceRevision, key: line.Key} | |
| 169 | + | if presented == cursor.Key || cursor.Key == "" && sourceID == snapshot.SourceID && index == projection.Cursor.Index { | |
| 170 | + | cursorKey = presented | |
| 171 | + | } | |
| 172 | + | line.Key = presented | |
| 173 | + | } | |
| 174 | + | lines = append(lines, line) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | l.rows = rows | |
| 178 | + | modelCursor := protocol.PickerCursor{Key: cursorKey, Index: -1} | |
| 179 | + | if len(preserveEditor) != 0 && preserveEditor[0] && l.model != nil { | |
| 180 | + | l.model.ReplaceProjection(lines, modelCursor, sort) | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | l.model = picker.New(lines, picker.Config{Intent: l.intent, Cursor: modelCursor, Sort: sort}) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // up moves the cursor one focusable row up. | |
| 187 | + | func (l *pickerLoop) up() { | |
| 188 | + | if l == nil || l.model == nil { | |
| 189 | + | return | |
| 190 | + | } | |
| 191 | + | l.model.Up() | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // down moves the cursor one focusable row down. | |
| 195 | + | func (l *pickerLoop) down() { | |
| 196 | + | if l == nil || l.model == nil { | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | l.model.Down() | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // enterSearch begins search mode; text inserts through insert(). | |
| 203 | + | func (l *pickerLoop) enterSearch() { | |
| 204 | + | if l == nil || l.model == nil { | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | if !l.model.SearchActive() { | |
| 208 | + | l.model.EnterSearch() | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // insert types one rune into the active search, entering search first. | |
| 213 | + | func (l *pickerLoop) insert(r rune) { | |
| 214 | + | if l == nil || l.model == nil { | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | l.enterSearch() | |
| 218 | + | l.model.InsertSearch(r) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // backspace deletes one search rune; Escape handling distinguishes | |
| 222 | + | // search-exit from picker-close at the call site. | |
| 223 | + | func (l *pickerLoop) backspace() { | |
| 224 | + | if l == nil || l.model == nil { | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | l.model.BackspaceSearch() | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // escape handles one Escape press: exit search when searching, else report | |
| 231 | + | // picker-close. The boolean is true when the caller must send PickerClose. | |
| 232 | + | func (l *pickerLoop) escape() bool { | |
| 233 | + | if l == nil || l.model == nil { | |
| 234 | + | return true | |
| 235 | + | } | |
| 236 | + | if l.model.SearchActive() { | |
| 237 | + | l.model.ExitSearch() | |
| 238 | + | return false | |
| 239 | + | } | |
| 240 | + | return true | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // toggleSort flips the local ordering between recency and grouped and reports | |
| 244 | + | // whether the display changed. | |
| 245 | + | func (l *pickerLoop) toggleSort() bool { | |
| 246 | + | if l == nil || l.model == nil { | |
| 247 | + | return false | |
| 248 | + | } | |
| 249 | + | next := picker.SortGrouped | |
| 250 | + | if l.model.SortMode() == picker.SortGrouped { | |
| 251 | + | next = picker.SortRecent | |
| 252 | + | } | |
| 253 | + | selected := protocol.PickerCursor{Index: -1} | |
| 254 | + | if line, ok := l.model.Selected(); ok { | |
| 255 | + | selected.Key = line.Key | |
| 256 | + | } | |
| 257 | + | l.rebuild(selected, next, true) | |
| 258 | + | return true | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func pickerSnapshotProjection(snapshot protocol.PickerSnapshot, sort picker.SortMode) protocol.PickerProjection { | |
| 262 | + | if sort == picker.SortGrouped { | |
| 263 | + | return snapshot.Grouped | |
| 264 | + | } | |
| 265 | + | return snapshot.Recent | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // selectedAction reports the action the displayed row authorises: navigation | |
| 269 | + | // rows navigate, move rows move. The daemon publishes the bits, so the client | |
| 270 | + | // never invents authority. | |
| 271 | + | func (l *pickerLoop) selectedAction() (protocol.PickerAction, bool) { | |
| 272 | + | if l == nil || l.model == nil { | |
| 273 | + | return 0, false | |
| 274 | + | } | |
| 275 | + | line, ok := l.model.Selected() | |
| 276 | + | if !ok { | |
| 277 | + | return 0, false | |
| 278 | + | } | |
| 279 | + | switch { | |
| 280 | + | case line.Actions&protocol.PickerCanNavigate != 0: | |
| 281 | + | return protocol.PickerActionNavigate, true | |
| 282 | + | case line.Actions&protocol.PickerCanMove != 0: | |
| 283 | + | return protocol.PickerActionMove, true | |
| 284 | + | default: | |
| 285 | + | return 0, false | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // killAction reports whether the cursor row authorises destruction. | |
| 290 | + | func (l *pickerLoop) killAction() bool { | |
| 291 | + | if l == nil || l.model == nil { | |
| 292 | + | return false | |
| 293 | + | } | |
| 294 | + | line, ok := l.model.Selected() | |
| 295 | + | return ok && line.Actions&protocol.PickerCanKill != 0 | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // defaultPickerSort is the client-local initial ordering mode. | |
| 299 | + | func defaultPickerSort() picker.SortMode { return picker.SortRecent } | |
| 300 | + | ||
| 301 | + | // pickerPreviewSize is the viewport the modal can display for one terminal: the | |
| 302 | + | // preview pane inside the shared picker geometry. | |
| 303 | + | func pickerPreviewSize(terminal domain.Size) domain.Size { | |
| 304 | + | return picker.Size(picker.PreviewRect(terminal)) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // emptyPickerPreview is the zero preview used until the daemon publishes one. | |
| 308 | + | func emptyPickerPreview() picker.Preview { return picker.Preview{} } | |
| 309 | + | ||
| 310 | + | // cursorKey names the row the modal currently displays. The daemon revalidates | |
| 311 | + | // it against the interaction it published, so it stays opaque here. | |
| 312 | + | func (l *pickerLoop) cursorKey() string { | |
| 313 | + | if l == nil || l.model == nil { | |
| 314 | + | return "" | |
| 315 | + | } | |
| 316 | + | line, ok := l.model.Selected() | |
| 317 | + | if !ok { | |
| 318 | + | return "" | |
| 319 | + | } | |
| 320 | + | identity, ok := l.rows[line.Key] | |
| 321 | + | if !ok { | |
| 322 | + | return "" | |
| 323 | + | } | |
| 324 | + | return identity.key | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // pickerPreviewDebounce bounds how long the cursor may rest before the client | |
| 328 | + | // asks the serving daemon for that row's preview. The daemon keeps publishing | |
| 329 | + | // the row afterwards, so this only throttles cursor movement. | |
| 330 | + | const pickerPreviewDebounce = 80 * time.Millisecond | |
| 331 | + | ||
| 332 | + | // pickerPreviewClient owns the client half of the row preview: the row the | |
| 333 | + | // modal displays, the row whose request is on the wire, and the viewport the | |
| 334 | + | // daemon last published for it. The client never captures a viewport itself. | |
| 335 | + | type pickerPreviewClient struct { | |
| 336 | + | interaction uint64 | |
| 337 | + | sent string | |
| 338 | + | frame picker.Preview | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // resetFor drops the preview state of one retired interaction. A released | |
| 342 | + | // interaction never shows the previous row's viewport again. | |
| 343 | + | func (p *pickerPreviewClient) resetFor() { | |
| 344 | + | if p == nil { | |
| 345 | + | return | |
| 346 | + | } | |
| 347 | + | *p = pickerPreviewClient{} | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // needsRequest reports whether the displayed row still needs a request: a new | |
| 351 | + | // interaction, a moved cursor, or a row the daemon has not been asked for yet. | |
| 352 | + | func (p *pickerPreviewClient) needsRequest(interaction uint64, key string) bool { | |
| 353 | + | if p == nil || key == "" || interaction == 0 { | |
| 354 | + | return false | |
| 355 | + | } | |
| 356 | + | return p.interaction != interaction || p.sent != key | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // requestFor builds the request for one displayed row. The client asks for | |
| 360 | + | // exactly the viewport it can display, bounded by the preview protocol. | |
| 361 | + | func (p *pickerPreviewClient) requestFor(interaction uint64, key string, size domain.Size) (protocol.PickerPreviewRequest, bool) { | |
| 362 | + | request := protocol.PickerPreviewRequest{ | |
| 363 | + | Version: protocol.PickerPreviewSchemaVersion, InteractionID: interaction, | |
| 364 | + | SourceID: protocol.PickerServingSourceID, Key: key, | |
| 365 | + | Width: clampPreviewDimension(size.Cols, protocol.PickerPreviewMaxWidth), | |
| 366 | + | Height: clampPreviewDimension(size.Rows, protocol.PickerPreviewMaxHeight), | |
| 367 | + | } | |
| 368 | + | if protocol.ValidatePickerPreviewRequest(request) != nil { | |
| 369 | + | return protocol.PickerPreviewRequest{}, false | |
| 370 | + | } | |
| 371 | + | return request, true | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // markSent records the row whose request crossed the wire. The next debounce | |
| 375 | + | // fires only when the cursor moved again. | |
| 376 | + | func (p *pickerPreviewClient) markSent(interaction uint64, key string) { | |
| 377 | + | if p == nil { | |
| 378 | + | return | |
| 379 | + | } | |
| 380 | + | p.interaction, p.sent = interaction, key | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | // accept applies one published preview and reports whether it describes the | |
| 384 | + | // displayed row. A late answer for a previous row leaves the current viewport | |
| 385 | + | // untouched, and a status-only answer clears it. | |
| 386 | + | func (p *pickerPreviewClient) accept(preview protocol.PickerPreview, interaction uint64, key string) bool { | |
| 387 | + | if p == nil || key == "" || preview.InteractionID != interaction || preview.Key != key { | |
| 388 | + | return false | |
| 389 | + | } | |
| 390 | + | if preview.Status != protocol.PickerPreviewOK { | |
| 391 | + | p.frame = emptyPickerPreview() | |
| 392 | + | return true | |
| 393 | + | } | |
| 394 | + | rows := preview.FrameRows() | |
| 395 | + | if rows == nil { | |
| 396 | + | return false | |
| 397 | + | } | |
| 398 | + | p.frame = picker.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)} | |
| 399 | + | return true | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // clampPreviewDimension bounds one requested viewport dimension. | |
| 403 | + | func clampPreviewDimension(value int, maxValue uint16) uint16 { | |
| 404 | + | if value <= 0 { | |
| 405 | + | return 1 | |
| 406 | + | } | |
| 407 | + | if value > int(maxValue) { | |
| 408 | + | return maxValue | |
| 409 | + | } | |
| 410 | + | return uint16(value) | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | // pickerRenderer encodes one picker frame to terminal bytes. It owns a | |
| 414 | + | // dedicated ANSI renderer shadow (never the daemon output shadow): the | |
| 415 | + | // client-picker frame is a local presentation overlay, not daemon output, | |
| 416 | + | // so its bytes never enter the output ACK window. | |
| 417 | + | type pickerRenderer struct { | |
| 418 | + | renderer *ansirenderer.Renderer | |
| 419 | + | size domain.Size | |
| 420 | + | // prevBounds is the box drawn last: when it moves or resizes, its previous | |
| 421 | + | // cells are blanked together with the new ones, and the rest of the screen | |
| 422 | + | // stays untouched. | |
| 423 | + | prevBounds *domain.Rect | |
| 424 | + | // primed records that the renderer shadow was seeded with the screen as it | |
| 425 | + | // is, so the first box is diffed against it instead of replacing the screen. | |
| 426 | + | primed bool | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | func newPickerRenderer() *pickerRenderer { | |
| 430 | + | return &pickerRenderer{} | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // render composes the loop model into terminal bytes for one display | |
| 434 | + | // refresh. Previews arrive as source data (picker_preview.go) and are attached | |
| 435 | + | // to the model by the caller; a full redraw every refresh keeps the shadow | |
| 436 | + | // trivially consistent: the frame is small and modal, never a PTY stream. | |
| 437 | + | // render composes one floating modal over the session: the picker never takes | |
| 438 | + | // the whole screen. The box is resolved from the shared picker geometry, its | |
| 439 | + | // inner content is rendered into that rectangle, and only the box's own cells | |
| 440 | + | // are written, so the session stays visible around it until the daemon's | |
| 441 | + | // authoritative repaint after the close. | |
| 442 | + | func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview picker.Preview) []byte { | |
| 443 | + | if r == nil || loop == nil || loop.model == nil { | |
| 444 | + | return nil | |
| 445 | + | } | |
| 446 | + | if size.Cols <= 0 || size.Rows <= 0 { | |
| 447 | + | return nil | |
| 448 | + | } | |
| 449 | + | if r.renderer == nil || r.size != size { | |
| 450 | + | r.renderer = ansirenderer.New(ansirenderer.Capabilities{}) | |
| 451 | + | r.size = size | |
| 452 | + | r.prevBounds = nil | |
| 453 | + | r.primed = false | |
| 454 | + | } | |
| 455 | + | // The picker is a floating box over a screen the client does not own: a | |
| 456 | + | // fresh renderer would repaint the whole terminal from its empty shadow and | |
| 457 | + | // wipe the session. Seed the shadow with the empty screen instead, without | |
| 458 | + | // emitting anything, so only the box's own cells are ever written. | |
| 459 | + | if !r.primed { | |
| 460 | + | if prepared, err := r.renderer.Prepare(renderer.NewFrame(size.Cols, size.Rows), nil, false); err == nil { | |
| 461 | + | prepared.Commit() | |
| 462 | + | r.primed = true | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | presentation := picker.Modal.Resolve(size) | |
| 466 | + | base := renderer.NewFrame(size.Cols, size.Rows) | |
| 467 | + | border := renderer.DefaultStyle() | |
| 468 | + | border.Attrs |= renderer.AttrDim | |
| 469 | + | picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle()) | |
| 470 | + | inner := loop.model.Render(picker.Size(presentation.Inner), preview) | |
| 471 | + | copyFrameRect(base, presentation.Inner, inner) | |
| 472 | + | ||
| 473 | + | // Only the box is written: the session stays on screen around it. A box | |
| 474 | + | // that moved (a resize, or a wider title) blanks the cells it used to own. | |
| 475 | + | var damage []ansirenderer.Damage | |
| 476 | + | if r.prevBounds != nil && *r.prevBounds != presentation.Bounds { | |
| 477 | + | damage = append(damage, damageRect(*r.prevBounds)) | |
| 478 | + | } | |
| 479 | + | damage = append(damage, damageRect(presentation.Bounds)) | |
| 480 | + | bounds := presentation.Bounds | |
| 481 | + | r.prevBounds = &bounds | |
| 482 | + | ||
| 483 | + | data, err := r.renderer.Draw(base, damage) | |
| 484 | + | if err != nil { | |
| 485 | + | return nil | |
| 486 | + | } | |
| 487 | + | return data | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | // damageRect is the damage one rectangle of a composed frame produces. | |
| 491 | + | func damageRect(rect domain.Rect) ansirenderer.Damage { | |
| 492 | + | return ansirenderer.Damage{ | |
| 493 | + | Kind: renderer.DamageText, | |
| 494 | + | X: rect.X, Y: rect.Y, | |
| 495 | + | Width: rect.Width, Height: rect.Height, Count: 1, | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | // copyFrameRect blits src into dst at the target rectangle, clipped to both. | |
| 500 | + | func copyFrameRect(dst renderer.Frame, target domain.Rect, src renderer.Frame) { | |
| 501 | + | left := max(target.X, 0) | |
| 502 | + | top := max(target.Y, 0) | |
| 503 | + | right := min(target.X+target.Width, dst.Width) | |
| 504 | + | bottom := min(target.Y+target.Height, dst.Height) | |
| 505 | + | if left >= right || top >= bottom { | |
| 506 | + | return | |
| 507 | + | } | |
| 508 | + | sourceX := left - target.X | |
| 509 | + | sourceY := top - target.Y | |
| 510 | + | width := min(right-left, src.Width-sourceX) | |
| 511 | + | height := min(bottom-top, src.Height-sourceY) | |
| 512 | + | if width <= 0 || height <= 0 { | |
| 513 | + | return | |
| 514 | + | } | |
| 515 | + | for y := range height { | |
| 516 | + | for x := range width { | |
| 517 | + | dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y)) | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | // commitSelection builds the typed commit for the row under the cursor at the | |
| 523 | + | // displayed source revision. Zero picker bytes travel toward the PTY: the | |
| 524 | + | // opaque key plus revision ride a PickerSelection on the ordered control | |
| 525 | + | // channel and the owning source resolves them. | |
| 526 | + | func commitSelection(loop *pickerLoop, action protocol.PickerAction, causeActionID uint64) (protocol.PickerSelection, bool) { | |
| 527 | + | if loop == nil || loop.model == nil { | |
| 528 | + | return protocol.PickerSelection{}, false | |
| 529 | + | } | |
| 530 | + | line, ok := loop.model.Selected() | |
| 531 | + | if !ok { | |
| 532 | + | return protocol.PickerSelection{}, false | |
| 533 | + | } | |
| 534 | + | identity, ok := loop.rows[line.Key] | |
| 535 | + | if !ok { | |
| 536 | + | return protocol.PickerSelection{}, false | |
| 537 | + | } | |
| 538 | + | selection := protocol.PickerSelection{ | |
| 539 | + | CauseActionID: causeActionID, InteractionID: loop.interaction, | |
| 540 | + | SourceID: identity.sourceID, SourceRevision: identity.sourceRevision, | |
| 541 | + | Key: identity.key, Action: action, | |
| 542 | + | } | |
| 543 | + | if protocol.ValidatePickerSelection(selection) != nil { | |
| 544 | + | return protocol.PickerSelection{}, false | |
| 545 | + | } | |
| 546 | + | return selection, true | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | // killSelection builds the typed kill for the row under the cursor. It is a | |
| 550 | + | // distinct commit so the source can verify the row still authorises | |
| 551 | + | // destruction. | |
| 552 | + | func killSelection(loop *pickerLoop, causeActionID uint64) (protocol.PickerSelection, bool) { | |
| 553 | + | if loop == nil || loop.model == nil { | |
| 554 | + | return protocol.PickerSelection{}, false | |
| 555 | + | } | |
| 556 | + | line, ok := loop.model.Selected() | |
| 557 | + | if !ok || line.Actions&protocol.PickerCanKill == 0 { | |
| 558 | + | return protocol.PickerSelection{}, false | |
| 559 | + | } | |
| 560 | + | identity, ok := loop.rows[line.Key] | |
| 561 | + | if !ok { | |
| 562 | + | return protocol.PickerSelection{}, false | |
| 563 | + | } | |
| 564 | + | selection := protocol.PickerSelection{ | |
| 565 | + | CauseActionID: causeActionID, InteractionID: loop.interaction, | |
| 566 | + | SourceID: identity.sourceID, SourceRevision: identity.sourceRevision, | |
| 567 | + | Key: identity.key, Action: protocol.PickerActionKill, | |
| 568 | + | } | |
| 569 | + | if protocol.ValidatePickerSelection(selection) != nil { | |
| 570 | + | return protocol.PickerSelection{}, false | |
| 571 | + | } | |
| 572 | + | return selection, true | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | // pickerOp is the presentation decision one driver operation asks the attach | |
| 576 | + | // loop to carry out. Close wins over commit when both land in one op, | |
| 577 | + | // mirroring the overlay exit short-circuit. | |
| 578 | + | type pickerOp struct { | |
| 579 | + | commit bool | |
| 580 | + | kill bool | |
| 581 | + | close bool | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | // pickerDriverOp is one ui-driver operation applied to an open loop. Keys | |
| 585 | + | // drive cursor/search/exit: arrows and j/k move in normal mode (j/k are | |
| 586 | + | // literal in search), `/` enters search, `s` reorders locally, `x` asks to | |
| 587 | + | // destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape | |
| 588 | + | // exits search first), Backspace edits the query. Text runes insert only | |
| 589 | + | // while search is active; normal-mode typing is ignored exactly like the | |
| 590 | + | // overlay. | |
| 591 | + | func pickerDriverOp(loop *pickerLoop, keys []string, text string) (op pickerOp, changed bool) { | |
| 592 | + | if loop == nil || loop.model == nil { | |
| 593 | + | return pickerOp{}, false | |
| 594 | + | } | |
| 595 | + | insertLit := func(r rune) { | |
| 596 | + | if loop.model.SearchActive() { | |
| 597 | + | loop.insert(r) | |
| 598 | + | } | |
| 599 | + | } | |
| 600 | + | for _, key := range keys { | |
| 601 | + | active := loop.model.SearchActive() | |
| 602 | + | beforeIndex, beforeSearch := loop.model.SelectedIndex(), active | |
| 603 | + | beforeQuery := loop.model.Query() | |
| 604 | + | switch key { | |
| 605 | + | case "Up": | |
| 606 | + | loop.up() | |
| 607 | + | case "Down": | |
| 608 | + | loop.down() | |
| 609 | + | case "j": | |
| 610 | + | if active { | |
| 611 | + | loop.insert('j') | |
| 612 | + | } else { | |
| 613 | + | loop.down() | |
| 614 | + | } | |
| 615 | + | case "k": | |
| 616 | + | if active { | |
| 617 | + | loop.insert('k') | |
| 618 | + | } else { | |
| 619 | + | loop.up() | |
| 620 | + | } | |
| 621 | + | case "Enter": | |
| 622 | + | // Commit is typed at the call site via commitSelection; the | |
| 623 | + | // key itself sends zero bytes toward the PTY. | |
| 624 | + | op.commit = true | |
| 625 | + | case "Escape": | |
| 626 | + | if loop.escape() { | |
| 627 | + | return pickerOp{close: true}, changed | |
| 628 | + | } | |
| 629 | + | case "Backspace": | |
| 630 | + | if active { | |
| 631 | + | loop.backspace() | |
| 632 | + | } | |
| 633 | + | case "/": | |
| 634 | + | if active { | |
| 635 | + | loop.insert('/') | |
| 636 | + | } else { | |
| 637 | + | loop.enterSearch() | |
| 638 | + | } | |
| 639 | + | case "s": | |
| 640 | + | if active { | |
| 641 | + | loop.insert('s') | |
| 642 | + | } else { | |
| 643 | + | changed = loop.toggleSort() || changed | |
| 644 | + | } | |
| 645 | + | case "x": | |
| 646 | + | if active { | |
| 647 | + | loop.insert('x') | |
| 648 | + | } else { | |
| 649 | + | op.kill = true | |
| 650 | + | } | |
| 651 | + | case "q": | |
| 652 | + | if active { | |
| 653 | + | loop.insert('q') | |
| 654 | + | } else { | |
| 655 | + | return pickerOp{close: true}, changed | |
| 656 | + | } | |
| 657 | + | case "Ctrl+C": | |
| 658 | + | // The overlay exits on Ctrl-C in both modes; the byte | |
| 659 | + | // itself never reaches the query. | |
| 660 | + | return pickerOp{close: true}, changed | |
| 661 | + | default: | |
| 662 | + | if len(key) == 1 { | |
| 663 | + | insertLit(rune(key[0])) | |
| 664 | + | } | |
| 665 | + | } | |
| 666 | + | if loop.model.SelectedIndex() != beforeIndex || loop.model.SearchActive() != beforeSearch || loop.model.Query() != beforeQuery { | |
| 667 | + | changed = true | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | for _, r := range text { | |
| 671 | + | if loop.model.SearchActive() { | |
| 672 | + | loop.insert(r) | |
| 673 | + | changed = true | |
| 674 | + | } | |
| 675 | + | } | |
| 676 | + | return op, changed | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | // pickerInputBatch is one decoded batch: ordered input events. | |
| 680 | + | type pickerInputBatch struct { | |
| 681 | + | events []pickerEvent | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | // pickerConsumeOutcome is one decoded operation reported by the stdin | |
| 685 | + | // pump to the attach loop. The pump decodes and identifies; the attach | |
| 686 | + | // loop is the only writer of the picker model, so the outcome carries the | |
| 687 | + | // ordered events and their interaction identity, never a mutation or a | |
| 688 | + | // message. actionID echoes the admitted record so the loop completes the | |
| 689 | + | // right ui-driver action. | |
| 690 | + | type pickerConsumeOutcome struct { | |
| 691 | + | consumed bool | |
| 692 | + | actionID uint64 | |
| 693 | + | generation uint64 | |
| 694 | + | interaction uint64 | |
| 695 | + | events []pickerEvent | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | // acceptOutcome reports whether this operation still belongs to the | |
| 699 | + | // interaction the attachment currently presents. Retired generations and | |
| 700 | + | // interactions drop. | |
| 701 | + | func (o pickerConsumeOutcome) acceptOutcome(interaction, generation uint64) bool { | |
| 702 | + | return o.consumed && o.interaction != 0 && o.interaction == interaction && o.generation == generation | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | // applyPickerBatch applies one decoded batch in arrival order. Every event | |
| 706 | + | // is applied: a terminal read can legitimately carry several keystrokes, so | |
| 707 | + | // batch size never decides whether input is a command. Paste protection is | |
| 708 | + | // the decoder's paste state, not a heuristic here: a bracketed paste's | |
| 709 | + | // content never reaches this function. | |
| 710 | + | func applyPickerBatch(loop *pickerLoop, events []pickerEvent) (op pickerOp, changed bool) { | |
| 711 | + | if loop == nil || loop.model == nil || len(events) == 0 { | |
| 712 | + | return pickerOp{}, false | |
| 713 | + | } | |
| 714 | + | for _, event := range events { | |
| 715 | + | if event.kind == pickerEventRune { | |
| 716 | + | if loop.model.SearchActive() { | |
| 717 | + | loop.insert(event.r) | |
| 718 | + | changed = true | |
| 719 | + | } | |
| 720 | + | continue | |
| 721 | + | } | |
| 722 | + | eventOp, eventChanged := pickerDriverOp(loop, []string{event.key}, "") | |
| 723 | + | changed = changed || eventChanged | |
| 724 | + | if eventOp.close { | |
| 725 | + | return pickerOp{close: true}, changed | |
| 726 | + | } | |
| 727 | + | op.commit = op.commit || eventOp.commit | |
| 728 | + | op.kill = op.kill || eventOp.kill | |
| 729 | + | } | |
| 730 | + | return op, changed | |
| 731 | + | } | |
A internal/usecase/client/picker_interaction_test.go +298 -0
| @@ -0,0 +1,298 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func pickerSnapshotFixture() protocol.PickerSnapshot { | |
| 14 | + | return protocol.PickerSnapshot{ | |
| 15 | + | InteractionID: 11, SourceID: "serving", SourceRevision: 3, Status: protocol.PickerSourceOK, | |
| 16 | + | Lines: []protocol.PickerLine{ | |
| 17 | + | {Key: "aa/work", Kind: protocol.PickerLineSession, Label: "work", Detail: "2 tabs", Focusable: true, Actions: protocol.PickerCanNavigate | protocol.PickerCanKill}, | |
| 18 | + | {Key: "bb/perso", Kind: protocol.PickerLineSession, Label: "perso", Detail: "stopped", Stopped: true, Focusable: true, Actions: protocol.PickerCanNavigate}, | |
| 19 | + | {Key: "cc/remote", Kind: protocol.PickerLineSession, Label: "remote@host", Focusable: true, Actions: protocol.PickerCanNavigate}, | |
| 20 | + | }, | |
| 21 | + | Cursor: protocol.PickerCursor{Key: "aa/work", Index: 0}, | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func pickerLoopFixture(t *testing.T) *pickerLoop { | |
| 26 | + | t.Helper() | |
| 27 | + | return pickerLoopFromSnapshot(pickerSnapshotFixture(), protocol.PickerIntentNavigation, picker.SortRecent) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func pickedKey(t *testing.T, loop *pickerLoop) string { | |
| 31 | + | t.Helper() | |
| 32 | + | selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 0) | |
| 33 | + | require.True(t, ok) | |
| 34 | + | return selection.Key | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestPickerInteractionAdmitsLatestSourceRevision(t *testing.T) { | |
| 38 | + | var rel *pickerInteraction | |
| 39 | + | require.False(t, rel.admitSnapshot(pickerSnapshotFixture(), false)) | |
| 40 | + | ||
| 41 | + | rel = &pickerInteraction{} | |
| 42 | + | snapshot := pickerSnapshotFixture() | |
| 43 | + | require.False(t, rel.admitSnapshot(snapshot, false), "closed interaction must not admit") | |
| 44 | + | ||
| 45 | + | rel.setOpen(true, snapshot.InteractionID) | |
| 46 | + | require.True(t, rel.admitSnapshot(snapshot, false)) | |
| 47 | + | require.Equal(t, snapshot.SourceRevision, rel.sourceRevisions[snapshot.SourceID]) | |
| 48 | + | ||
| 49 | + | older := snapshot | |
| 50 | + | older.SourceRevision = snapshot.SourceRevision - 1 | |
| 51 | + | require.False(t, rel.admitSnapshot(older, false), "stale source revision must not step backward") | |
| 52 | + | ||
| 53 | + | foreign := snapshot | |
| 54 | + | foreign.InteractionID = snapshot.InteractionID + 1 | |
| 55 | + | require.False(t, rel.admitSnapshot(foreign, false), "foreign interaction must drop") | |
| 56 | + | ||
| 57 | + | rel.setOpen(false, snapshot.InteractionID) | |
| 58 | + | require.False(t, rel.admitSnapshot(snapshot, false), "closed interaction drops late snapshots") | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestPickerInteractionKeepsPerSourceRevisions(t *testing.T) { | |
| 62 | + | rel := &pickerInteraction{} | |
| 63 | + | snapshot := pickerSnapshotFixture() | |
| 64 | + | rel.setOpen(true, snapshot.InteractionID) | |
| 65 | + | require.True(t, rel.admitSnapshot(snapshot, false)) | |
| 66 | + | ||
| 67 | + | // Another source publishes its own first revision: it must be admitted | |
| 68 | + | // even though the serving source is far ahead. | |
| 69 | + | other := snapshot | |
| 70 | + | other.SourceID = "host-2" | |
| 71 | + | other.SourceRevision = 1 | |
| 72 | + | require.True(t, rel.admitSnapshot(other, false)) | |
| 73 | + | require.Equal(t, uint64(3), rel.sourceRevisions["serving"]) | |
| 74 | + | require.Equal(t, uint64(1), rel.sourceRevisions["host-2"]) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestPickerLoopMergesSourcesAndCommitsOwningIdentity(t *testing.T) { | |
| 78 | + | local := pickerSnapshotFixture() | |
| 79 | + | local.SourceID = "authority/local" | |
| 80 | + | local.Lines = local.Lines[:1] | |
| 81 | + | local.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0} | |
| 82 | + | loop := pickerLoopFromSnapshot(local, protocol.PickerIntentNavigation, picker.SortRecent) | |
| 83 | + | ||
| 84 | + | remote := local | |
| 85 | + | remote.SourceID = "authority/remote-a" | |
| 86 | + | remote.SourceRevision = 1 | |
| 87 | + | remote.Lines = []protocol.PickerLine{{Key: "aa/work", Kind: protocol.PickerLineSession, Label: "work@remote-a", Focusable: true, Actions: protocol.PickerCanNavigate}} | |
| 88 | + | remote.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0} | |
| 89 | + | loop.replaceLines(remote) | |
| 90 | + | ||
| 91 | + | loop.down() | |
| 92 | + | selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 9) | |
| 93 | + | require.True(t, ok) | |
| 94 | + | require.Equal(t, "authority/remote-a", selection.SourceID) | |
| 95 | + | require.Equal(t, uint64(1), selection.SourceRevision) | |
| 96 | + | require.Equal(t, "aa/work", selection.Key) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestPickerLoopRefreshesOneSourceWithoutInvalidatingAnother(t *testing.T) { | |
| 100 | + | local := pickerSnapshotFixture() | |
| 101 | + | local.SourceID = "authority/local" | |
| 102 | + | local.Lines = local.Lines[:1] | |
| 103 | + | local.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0} | |
| 104 | + | loop := pickerLoopFromSnapshot(local, protocol.PickerIntentNavigation, picker.SortRecent) | |
| 105 | + | ||
| 106 | + | remote := local | |
| 107 | + | remote.SourceID = "authority/remote-a" | |
| 108 | + | remote.SourceRevision = 1 | |
| 109 | + | remote.Lines = []protocol.PickerLine{{Key: "remote/session", Kind: protocol.PickerLineSession, Label: "remote", Focusable: true, Actions: protocol.PickerCanNavigate}} | |
| 110 | + | remote.Cursor = protocol.PickerCursor{Key: "remote/session", Index: 0} | |
| 111 | + | loop.replaceLines(remote) | |
| 112 | + | loop.down() | |
| 113 | + | ||
| 114 | + | local.SourceRevision++ | |
| 115 | + | local.Lines[0].Detail = "refreshed" | |
| 116 | + | loop.replaceLines(local) | |
| 117 | + | selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 0) | |
| 118 | + | require.True(t, ok) | |
| 119 | + | require.Equal(t, "authority/remote-a", selection.SourceID) | |
| 120 | + | require.Equal(t, uint64(1), selection.SourceRevision) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestPickerInteractionRetirementIsPermanent(t *testing.T) { | |
| 124 | + | snapshot := pickerSnapshotFixture() | |
| 125 | + | var rel pickerInteraction | |
| 126 | + | rel.setOpen(true, snapshot.InteractionID) | |
| 127 | + | require.True(t, rel.admitSnapshot(snapshot, false)) | |
| 128 | + | ||
| 129 | + | // Closing retires the namespace: an in-flight snapshot for the same | |
| 130 | + | // interaction can never reopen it. | |
| 131 | + | rel.setOpen(false, snapshot.InteractionID) | |
| 132 | + | require.Equal(t, snapshot.InteractionID, rel.retired) | |
| 133 | + | require.False(t, rel.admitSnapshot(snapshot, false)) | |
| 134 | + | rel.setOpen(true, snapshot.InteractionID) | |
| 135 | + | require.False(t, rel.admitSnapshot(snapshot, false), "a retired interaction is never reopened") | |
| 136 | + | ||
| 137 | + | // A newer interaction is a new namespace and admits normally. | |
| 138 | + | next := snapshot | |
| 139 | + | next.InteractionID = snapshot.InteractionID + 1 | |
| 140 | + | next.SourceRevision = 1 | |
| 141 | + | rel.setOpen(true, next.InteractionID) | |
| 142 | + | require.True(t, rel.admitSnapshot(next, false)) | |
| 143 | + | require.False(t, rel.admitSnapshot(snapshot, false), "the retired namespace stays closed") | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestPickerLoopCursorSearchCommit(t *testing.T) { | |
| 147 | + | loop := pickerLoopFixture(t) | |
| 148 | + | require.NotNil(t, loop.model) | |
| 149 | + | ||
| 150 | + | // Cursor starts at the published cursor key (first row). | |
| 151 | + | require.Equal(t, "aa/work", pickedKey(t, loop)) | |
| 152 | + | ||
| 153 | + | // Cursor movement selects the non-initial row: the committed key follows | |
| 154 | + | // the client cursor, never the daemon cursor. | |
| 155 | + | loop.down() | |
| 156 | + | require.Equal(t, "bb/perso", pickedKey(t, loop)) | |
| 157 | + | loop.down() | |
| 158 | + | require.Equal(t, "cc/remote", pickedKey(t, loop)) | |
| 159 | + | loop.up() | |
| 160 | + | require.Equal(t, "bb/perso", pickedKey(t, loop)) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestPickerLoopSearchExitVsClose(t *testing.T) { | |
| 164 | + | loop := pickerLoopFixture(t) | |
| 165 | + | ||
| 166 | + | // Text enters search; Escape exits search without closing. | |
| 167 | + | loop.insert('w') | |
| 168 | + | require.True(t, loop.model.SearchActive()) | |
| 169 | + | require.False(t, loop.escape(), "search-exit Escape must not close") | |
| 170 | + | require.False(t, loop.model.SearchActive()) | |
| 171 | + | ||
| 172 | + | // Escape with no search reports picker-close. | |
| 173 | + | require.True(t, loop.escape(), "idle Escape must close") | |
| 174 | + | ||
| 175 | + | // Literal s/x in search are text; backspace deletes. | |
| 176 | + | loop.insert('s') | |
| 177 | + | loop.insert('x') | |
| 178 | + | require.True(t, loop.model.SearchActive()) | |
| 179 | + | require.Equal(t, "sx", loop.model.Query()) | |
| 180 | + | loop.backspace() | |
| 181 | + | require.Equal(t, "s", loop.model.Query()) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestPickerLoopKillRequiresAuthorisedRow(t *testing.T) { | |
| 185 | + | loop := pickerLoopFixture(t) | |
| 186 | + | ||
| 187 | + | // The first row authorises destruction; its typed kill carries the | |
| 188 | + | // displayed source revision and the fatal action. | |
| 189 | + | selection, ok := killSelection(loop, 0) | |
| 190 | + | require.True(t, ok) | |
| 191 | + | require.Equal(t, protocol.PickerActionKill, selection.Action) | |
| 192 | + | require.Equal(t, "aa/work", selection.Key) | |
| 193 | + | require.Equal(t, uint64(3), selection.SourceRevision) | |
| 194 | + | ||
| 195 | + | // The stopped row is navigable but not destructible from here. | |
| 196 | + | loop.down() | |
| 197 | + | require.Equal(t, "bb/perso", pickedKey(t, loop)) | |
| 198 | + | _, ok = killSelection(loop, 0) | |
| 199 | + | require.False(t, ok, "a row without the kill action must not be destroyed") | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestPickerLoopKeepsPublishedLineOrderAndKeys(t *testing.T) { | |
| 203 | + | snapshot := pickerSnapshotFixture() | |
| 204 | + | loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent) | |
| 205 | + | ||
| 206 | + | // Every published line commits its own opaque key without parsing display | |
| 207 | + | // text, in published order. | |
| 208 | + | loop.model.SelectNearestRow(0) | |
| 209 | + | keys := []string{pickedKey(t, loop)} | |
| 210 | + | for range len(snapshot.Lines) - 1 { | |
| 211 | + | loop.down() | |
| 212 | + | keys = append(keys, pickedKey(t, loop)) | |
| 213 | + | } | |
| 214 | + | require.Equal(t, []string{"aa/work", "bb/perso", "cc/remote"}, keys) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestPickerDriverOpReportsActionsAndLocalSort(t *testing.T) { | |
| 218 | + | loop := pickerLoopFixture(t) | |
| 219 | + | ||
| 220 | + | // Normal mode: arrows and j/k move; Enter asks to commit; x asks to kill. | |
| 221 | + | op, changed := pickerDriverOp(loop, []string{"Down"}, "") | |
| 222 | + | require.False(t, op.commit) | |
| 223 | + | require.False(t, op.close) | |
| 224 | + | require.True(t, changed) | |
| 225 | + | require.Equal(t, "bb/perso", pickedKey(t, loop)) | |
| 226 | + | op, _ = pickerDriverOp(loop, []string{"k"}, "") | |
| 227 | + | require.False(t, op.commit) | |
| 228 | + | require.Equal(t, "aa/work", pickedKey(t, loop)) | |
| 229 | + | ||
| 230 | + | op, _ = pickerDriverOp(loop, []string{"Enter"}, "") | |
| 231 | + | require.True(t, op.commit) | |
| 232 | + | op, _ = pickerDriverOp(loop, []string{"x"}, "") | |
| 233 | + | require.True(t, op.kill) | |
| 234 | + | require.False(t, op.close) | |
| 235 | + | ||
| 236 | + | // q and Ctrl+C close in normal mode. | |
| 237 | + | for _, key := range []string{"q", "Ctrl+C"} { | |
| 238 | + | op, _ = pickerDriverOp(loop, []string{key}, "") | |
| 239 | + | require.True(t, op.close, "%s must close the picker", key) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // s reorders locally without asking the daemon, and keeps the selection. | |
| 243 | + | op, changed = pickerDriverOp(loop, []string{"s"}, "") | |
| 244 | + | require.False(t, op.commit) | |
| 245 | + | require.False(t, op.kill) | |
| 246 | + | require.False(t, op.close) | |
| 247 | + | require.True(t, changed) | |
| 248 | + | require.Equal(t, picker.SortGrouped, loop.model.SortMode()) | |
| 249 | + | require.Equal(t, "aa/work", pickedKey(t, loop)) | |
| 250 | + | ||
| 251 | + | // While searching, Enter still commits and printable keys are text. | |
| 252 | + | loop.insert('w') | |
| 253 | + | _, changed = pickerDriverOp(loop, []string{"o", "r"}, "") | |
| 254 | + | require.True(t, changed) | |
| 255 | + | require.Equal(t, "wor", loop.model.Query()) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // TestPickerPreviewClientTracksTheDisplayedRow pins the client half of the | |
| 259 | + | // preview: one request per settled row, the daemon's own source identity, a | |
| 260 | + | // bounded viewport ask, and a late answer that never replaces the displayed one. | |
| 261 | + | func TestPickerPreviewClientTracksTheDisplayedRow(t *testing.T) { | |
| 262 | + | var preview pickerPreviewClient | |
| 263 | + | require.False(t, preview.needsRequest(7, ""), "a row without a key needs no request") | |
| 264 | + | require.True(t, preview.needsRequest(7, "aa/first")) | |
| 265 | + | ||
| 266 | + | request, ok := preview.requestFor(7, "aa/first", domain.Size{Cols: 500, Rows: 0}) | |
| 267 | + | require.True(t, ok) | |
| 268 | + | require.Equal(t, protocol.PickerPreviewSchemaVersion, request.Version) | |
| 269 | + | require.Equal(t, uint64(7), request.InteractionID) | |
| 270 | + | require.Equal(t, protocol.PickerServingSourceID, request.SourceID) | |
| 271 | + | require.Equal(t, uint16(protocol.PickerPreviewMaxWidth), request.Width, "the client asks for what it can display") | |
| 272 | + | require.Equal(t, uint16(1), request.Height, "a degenerate size still asks for one row") | |
| 273 | + | ||
| 274 | + | preview.markSent(7, "aa/first") | |
| 275 | + | require.False(t, preview.needsRequest(7, "aa/first"), "a row already requested is not asked twice") | |
| 276 | + | require.True(t, preview.needsRequest(7, "bb/second")) | |
| 277 | + | require.True(t, preview.needsRequest(8, "aa/first"), "a new interaction asks again") | |
| 278 | + | ||
| 279 | + | viewport := protocol.PickerPreview{ | |
| 280 | + | Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: protocol.PickerServingSourceID, | |
| 281 | + | Key: "aa/first", Status: protocol.PickerPreviewOK, Width: 2, Height: 1, | |
| 282 | + | Cells: []renderer.Cell{{Rune: 'o'}, {Rune: 'k'}}, | |
| 283 | + | } | |
| 284 | + | require.False(t, preview.accept(viewport, 7, "bb/second"), "a late answer must not replace the displayed row") | |
| 285 | + | require.True(t, preview.accept(viewport, 7, "aa/first")) | |
| 286 | + | require.Equal(t, 2, preview.frame.Width) | |
| 287 | + | require.Equal(t, 'o', preview.frame.Rows[0][0].Rune) | |
| 288 | + | ||
| 289 | + | stale := viewport | |
| 290 | + | stale.Status = protocol.PickerPreviewNoSuchTarget | |
| 291 | + | stale.Width, stale.Height, stale.Cells = 0, 0, nil | |
| 292 | + | require.True(t, preview.accept(stale, 7, "aa/first")) | |
| 293 | + | require.Empty(t, preview.frame.Rows, "a rejected row clears the displayed viewport") | |
| 294 | + | ||
| 295 | + | preview.resetFor() | |
| 296 | + | require.True(t, preview.needsRequest(7, "aa/first")) | |
| 297 | + | require.Empty(t, preview.frame.Rows) | |
| 298 | + | } | |
A internal/usecase/client/picker_lease.go +277 -0
| @@ -0,0 +1,277 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/protocol" | |
| 4 | + | ||
| 5 | + | // This file owns the client-side presentation lease for one client-picker | |
| 6 | + | // interaction. The daemon keeps mutation authority (it resolves opaque | |
| 7 | + | // row keys and performs the handoff); the lease decides who writes the | |
| 8 | + | // terminal: while it is acquiring, owned, or releasing the client | |
| 9 | + | // consumes picker input and never lets a daemon paint overwrite the modal, | |
| 10 | + | // yet every accepted daemon frame is still applied to the output shadow and | |
| 11 | + | // acknowledged inside the bounded window. | |
| 12 | + | // | |
| 13 | + | // The lease is value-oriented and owns no locks: the attach loop is its | |
| 14 | + | // only caller and already owns the terminal writer. | |
| 15 | + | ||
| 16 | + | // pickerLeaseState is the client-side presentation state of one | |
| 17 | + | // client-picker interaction. | |
| 18 | + | type pickerLeaseState uint8 | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | // pickerLeaseClosed displays daemon output normally. | |
| 22 | + | pickerLeaseClosed pickerLeaseState = iota | |
| 23 | + | // pickerLeaseAcquiring validated a snapshot whose acquisition barrier | |
| 24 | + | // (epoch, state) is not applied yet. Daemon frames up to that barrier | |
| 25 | + | // are still displayed; the picker frame is not. | |
| 26 | + | pickerLeaseAcquiring | |
| 27 | + | // pickerLeaseOwned displays the picker frame and consumes picker | |
| 28 | + | // input. Daemon frames are applied and acknowledged but never written. | |
| 29 | + | pickerLeaseOwned | |
| 30 | + | // pickerLeaseReleasing retired the interaction on PickerClose. The | |
| 31 | + | // picker frame stays until one authoritative full paint arrives; then | |
| 32 | + | // the client releases the terminal and resumes normal input. | |
| 33 | + | pickerLeaseReleasing | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | // pickerLeaseEvent reports the presentation transition one accepted daemon | |
| 37 | + | // frame triggers. | |
| 38 | + | type pickerLeaseEvent uint8 | |
| 39 | + | ||
| 40 | + | const ( | |
| 41 | + | // pickerLeaseIdle displays the frame normally and leaves presentation | |
| 42 | + | // unchanged. | |
| 43 | + | pickerLeaseIdle pickerLeaseEvent = iota | |
| 44 | + | // pickerLeaseSuppress applies and acknowledges the frame without | |
| 45 | + | // writing it: the client owns the terminal. | |
| 46 | + | pickerLeaseSuppress | |
| 47 | + | // pickerLeaseAcquire displays the frame normally, then displays the | |
| 48 | + | // pending picker frame: the barrier is now applied. | |
| 49 | + | pickerLeaseAcquire | |
| 50 | + | // pickerLeaseRelease displays the authoritative full paint normally, | |
| 51 | + | // then clears the lease. | |
| 52 | + | pickerLeaseRelease | |
| 53 | + | ) | |
| 54 | + | ||
| 55 | + | // pickerLease is the client's presentation identity for one interaction: | |
| 56 | + | // attachment UI generation, interaction ID, displayed revision, and the | |
| 57 | + | // acquisition barrier the snapshot was built against. A generation change | |
| 58 | + | // (reconnect) aborts the lease instead of carrying it into a new | |
| 59 | + | // attachment. | |
| 60 | + | type pickerLease struct { | |
| 61 | + | state pickerLeaseState | |
| 62 | + | generation uint64 | |
| 63 | + | interaction uint64 | |
| 64 | + | revision uint64 | |
| 65 | + | barrierEpoch uint64 | |
| 66 | + | barrierState uint64 | |
| 67 | + | // pending holds the validated model until the barrier is applied; it | |
| 68 | + | // is nil in every other state. | |
| 69 | + | pending *pickerLoop | |
| 70 | + | // releaseAction is the ui-driver action waiting on the release paint | |
| 71 | + | // and releaseLocal reports whether it completes locally (a cancel | |
| 72 | + | // consumed by the client) instead of through the daemon handoff. | |
| 73 | + | releaseAction uint64 | |
| 74 | + | releaseLocal bool | |
| 75 | + | // releaseEpoch/releaseState name the applied output boundary that was | |
| 76 | + | // current when the daemon's close for this interaction was observed. | |
| 77 | + | // Only a full paint accepted after that boundary releases the terminal: | |
| 78 | + | // a Full already in flight before the close is a suppressed artifact, | |
| 79 | + | // not the authoritative restore the release waits for. | |
| 80 | + | releaseEpoch uint64 | |
| 81 | + | releaseState uint64 | |
| 82 | + | // daemonClosed reports whether the serving daemon confirmed the close. | |
| 83 | + | // A client-side retire alone never releases: the daemon sends the close | |
| 84 | + | // before its restore paint, so waiting for it orders the release after | |
| 85 | + | // the close instead of after an in-flight paint. | |
| 86 | + | daemonClosed bool | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // active reports whether the lease owns picker presentation. | |
| 90 | + | func (l *pickerLease) active() bool { | |
| 91 | + | return l != nil && l.state != pickerLeaseClosed | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // owns reports whether the client has taken the terminal over: only then is | |
| 95 | + | // picker input applied to the displayed model. | |
| 96 | + | func (l *pickerLease) owns() bool { | |
| 97 | + | return l != nil && l.state == pickerLeaseOwned | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // releasingFor reports whether this lease is retiring the named | |
| 101 | + | // interaction, which is the only case where a close may extend its drain. | |
| 102 | + | func (l *pickerLease) releasingFor(interaction uint64) bool { | |
| 103 | + | return l != nil && l.state == pickerLeaseReleasing && l.interaction == interaction | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // reset returns the lease to closed presentation. | |
| 107 | + | func (l *pickerLease) reset() { | |
| 108 | + | *l = pickerLease{} | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // abort ends the lease without a release paint (disconnect, generation | |
| 112 | + | // change, or a superseding interaction). It reports the pending local | |
| 113 | + | // action that can no longer complete. | |
| 114 | + | func (l *pickerLease) abort() (actionID uint64, local bool) { | |
| 115 | + | if l == nil { | |
| 116 | + | return 0, false | |
| 117 | + | } | |
| 118 | + | actionID, local = l.releaseAction, l.releaseLocal | |
| 119 | + | if actionID != 0 && !local { | |
| 120 | + | // A handoff-bound action stays unresolved: the daemon owns its | |
| 121 | + | // outcome and the caller reports the unknown outcome. | |
| 122 | + | actionID, local = 0, false | |
| 123 | + | } | |
| 124 | + | l.reset() | |
| 125 | + | return actionID, local | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // pickerBarrier is the daemon output boundary one offer names: the client | |
| 129 | + | // must display admitted output through it before it takes the terminal. | |
| 130 | + | type pickerBarrier struct { | |
| 131 | + | epoch uint64 | |
| 132 | + | state uint64 | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // admitSnapshot admits one validated snapshot against the client's applied | |
| 136 | + | // output state. It reports ready=true when the barrier named by the offer is | |
| 137 | + | // already applied and the caller must display the picker frame now. | |
| 138 | + | // superseded is a copy of the previous lease when a new interaction replaces | |
| 139 | + | // it, so the caller can resolve the old pending action without touching the | |
| 140 | + | // newly installed lease. | |
| 141 | + | // | |
| 142 | + | // A snapshot for the current interaction only replaces the pending model | |
| 143 | + | // with a newer source revision, and never after the release started: a | |
| 144 | + | // superseding interaction is a new namespace whose close/full pair cannot | |
| 145 | + | // release the previous one. | |
| 146 | + | func (l *pickerLease) admitSnapshot(snapshot protocol.PickerSnapshot, barrier pickerBarrier, loop *pickerLoop, applied outputApplyState, generation uint64) (ready bool, superseded *pickerLease) { | |
| 147 | + | if l == nil || snapshot.InteractionID == 0 { | |
| 148 | + | return false, nil | |
| 149 | + | } | |
| 150 | + | if l.active() { | |
| 151 | + | if l.interaction == snapshot.InteractionID { | |
| 152 | + | if l.state == pickerLeaseReleasing || snapshot.SourceRevision <= l.revision { | |
| 153 | + | return false, nil | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | if l.active() && l.interaction != snapshot.InteractionID { | |
| 158 | + | previous := *l | |
| 159 | + | superseded = &previous | |
| 160 | + | } | |
| 161 | + | *l = pickerLease{ | |
| 162 | + | state: pickerLeaseAcquiring, generation: generation, interaction: snapshot.InteractionID, | |
| 163 | + | revision: snapshot.SourceRevision, barrierEpoch: barrier.epoch, barrierState: barrier.state, | |
| 164 | + | pending: loop, | |
| 165 | + | } | |
| 166 | + | if barrierReached(applied, barrier.epoch, barrier.state) { | |
| 167 | + | // The barrier is already displayed: the client owns the terminal | |
| 168 | + | // from this moment, before any later daemon frame can be written. | |
| 169 | + | l.state = pickerLeaseOwned | |
| 170 | + | return true, superseded | |
| 171 | + | } | |
| 172 | + | return false, superseded | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // observe reports the presentation decision for one accepted daemon frame. | |
| 176 | + | // Only accepted frames reach it, so the output dependency chain, reset | |
| 177 | + | // handling, and bounded ACK window keep advancing in every state. | |
| 178 | + | func (l *pickerLease) observe(output protocol.Output, applied outputApplyState) pickerLeaseEvent { | |
| 179 | + | if l == nil { | |
| 180 | + | return pickerLeaseIdle | |
| 181 | + | } | |
| 182 | + | switch l.state { | |
| 183 | + | case pickerLeaseAcquiring: | |
| 184 | + | if barrierReached(applied, l.barrierEpoch, l.barrierState) { | |
| 185 | + | l.state = pickerLeaseOwned | |
| 186 | + | return pickerLeaseAcquire | |
| 187 | + | } | |
| 188 | + | return pickerLeaseIdle | |
| 189 | + | case pickerLeaseOwned: | |
| 190 | + | return pickerLeaseSuppress | |
| 191 | + | case pickerLeaseReleasing: | |
| 192 | + | // Only an authoritative full paint accepted after the serving | |
| 193 | + | // daemon's close releases the terminal: an incremental delta, a Full | |
| 194 | + | // already in flight before the close, or any paint seen before the | |
| 195 | + | // close was confirmed may itself be a suppressed artifact. | |
| 196 | + | if l.daemonClosed && output.Full && stateAfter(applied, l.releaseEpoch, l.releaseState) { | |
| 197 | + | return pickerLeaseRelease | |
| 198 | + | } | |
| 199 | + | return pickerLeaseSuppress | |
| 200 | + | } | |
| 201 | + | return pickerLeaseIdle | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // stateAfter reports whether the applied output state is strictly newer | |
| 205 | + | // than the named (epoch, state) boundary. An uninitialized state has | |
| 206 | + | // applied only the implicit empty stream: epoch 1, state 0. | |
| 207 | + | func stateAfter(applied outputApplyState, epoch, state uint64) bool { | |
| 208 | + | appliedEpoch, appliedState := applied.epoch, applied.state | |
| 209 | + | if !applied.initialized { | |
| 210 | + | appliedEpoch, appliedState = 1, 0 | |
| 211 | + | } | |
| 212 | + | if appliedEpoch != epoch { | |
| 213 | + | return appliedEpoch > epoch | |
| 214 | + | } | |
| 215 | + | return appliedState > state | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // beginRelease retires the interaction named by one close. It reports | |
| 219 | + | // false for a duplicate or foreign close, which must not restart or extend | |
| 220 | + | // the release. fromDaemon marks the serving daemon's own close, which is | |
| 221 | + | // the ordering barrier for the release: a client-side retire waits for it | |
| 222 | + | // before any paint can release the terminal. The pending action completes | |
| 223 | + | // after the release paint: local for a cancel the client consumed, through | |
| 224 | + | // the daemon handoff otherwise. | |
| 225 | + | func (l *pickerLease) beginRelease(interaction, actionID uint64, local, fromDaemon bool, applied outputApplyState) bool { | |
| 226 | + | if l == nil || !l.active() || l.state == pickerLeaseReleasing || l.interaction != interaction { | |
| 227 | + | return false | |
| 228 | + | } | |
| 229 | + | l.state = pickerLeaseReleasing | |
| 230 | + | l.pending = nil | |
| 231 | + | l.releaseAction, l.releaseLocal = actionID, local | |
| 232 | + | l.releaseEpoch, l.releaseState = applied.epoch, applied.state | |
| 233 | + | if !applied.initialized { | |
| 234 | + | l.releaseEpoch, l.releaseState = 1, 0 | |
| 235 | + | } | |
| 236 | + | l.daemonClosed = fromDaemon | |
| 237 | + | return true | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // confirmDaemonClose records the serving daemon's close for the releasing | |
| 241 | + | // interaction and re-captures the release boundary at that point. Only | |
| 242 | + | // paints accepted after it can release the terminal. | |
| 243 | + | func (l *pickerLease) confirmDaemonClose(interaction uint64, applied outputApplyState) bool { | |
| 244 | + | if l == nil || l.state != pickerLeaseReleasing || l.interaction != interaction { | |
| 245 | + | return false | |
| 246 | + | } | |
| 247 | + | l.daemonClosed = true | |
| 248 | + | l.releaseEpoch, l.releaseState = applied.epoch, applied.state | |
| 249 | + | if !applied.initialized { | |
| 250 | + | l.releaseEpoch, l.releaseState = 1, 0 | |
| 251 | + | } | |
| 252 | + | return true | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // finishRelease clears the lease after the release paint was displayed and | |
| 256 | + | // published. It reports the pending local action to complete. | |
| 257 | + | func (l *pickerLease) finishRelease() (actionID uint64, local bool) { | |
| 258 | + | if l == nil { | |
| 259 | + | return 0, false | |
| 260 | + | } | |
| 261 | + | actionID, local = l.releaseAction, l.releaseLocal | |
| 262 | + | l.reset() | |
| 263 | + | return actionID, local | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // barrierReached reports whether the applied output state covers the | |
| 267 | + | // acquisition barrier (epoch, state). An uninitialized state has applied | |
| 268 | + | // only the implicit empty stream: epoch 1, state 0. | |
| 269 | + | func barrierReached(applied outputApplyState, epoch, state uint64) bool { | |
| 270 | + | if !applied.initialized { | |
| 271 | + | return epoch <= 1 && state == 0 | |
| 272 | + | } | |
| 273 | + | if applied.epoch != epoch { | |
| 274 | + | return applied.epoch > epoch | |
| 275 | + | } | |
| 276 | + | return applied.state >= state | |
| 277 | + | } | |
A internal/usecase/client/picker_lease_test.go +222 -0
| @@ -0,0 +1,222 @@ | |||
| 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 | + | // pickerLeaseTestState builds an applied output state for barrier checks. | |
| 11 | + | func pickerLeaseTestState(epoch, state uint64) outputApplyState { | |
| 12 | + | return outputApplyState{epoch: epoch, state: state, initialized: true} | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // pickerLeaseTestSnapshot builds one validated source publication for the | |
| 16 | + | // lease. The acquisition barrier travels in the offer, so it is passed | |
| 17 | + | // separately to admitSnapshot. | |
| 18 | + | func pickerLeaseTestSnapshot(interaction, revision uint64) protocol.PickerSnapshot { | |
| 19 | + | return protocol.PickerSnapshot{ | |
| 20 | + | InteractionID: interaction, SourceID: "serving", SourceRevision: revision, | |
| 21 | + | Status: protocol.PickerSourceOK, | |
| 22 | + | Lines: []protocol.PickerLine{ | |
| 23 | + | {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanNavigate}, | |
| 24 | + | }, | |
| 25 | + | Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0}, | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func pickerLeaseTestLoop() *pickerLoop { | |
| 30 | + | return &pickerLoop{interaction: 1} | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestPickerLeaseAcquiresOnlyAtTheBarrier(t *testing.T) { | |
| 34 | + | tests := []struct { | |
| 35 | + | name string | |
| 36 | + | applied outputApplyState | |
| 37 | + | barrierEpoch uint64 | |
| 38 | + | barrierState uint64 | |
| 39 | + | ready bool | |
| 40 | + | owned bool | |
| 41 | + | }{ | |
| 42 | + | {name: "uninitialized zero barrier is already applied", applied: outputApplyState{}, barrierEpoch: 1, ready: true, owned: true}, | |
| 43 | + | {name: "applied state equals the barrier", applied: pickerLeaseTestState(4, 3), barrierEpoch: 4, barrierState: 3, ready: true, owned: true}, | |
| 44 | + | {name: "applied state passed the barrier", applied: pickerLeaseTestState(4, 5), barrierEpoch: 4, barrierState: 3, ready: true, owned: true}, | |
| 45 | + | {name: "newer epoch passed the barrier", applied: pickerLeaseTestState(6, 1), barrierEpoch: 4, barrierState: 3, ready: true, owned: true}, | |
| 46 | + | {name: "barrier still ahead", applied: pickerLeaseTestState(4, 1), barrierEpoch: 4, barrierState: 3, ready: false, owned: false}, | |
| 47 | + | {name: "barrier in a future epoch", applied: pickerLeaseTestState(4, 9), barrierEpoch: 5, barrierState: 1, ready: false, owned: false}, | |
| 48 | + | } | |
| 49 | + | for _, test := range tests { | |
| 50 | + | t.Run(test.name, func(t *testing.T) { | |
| 51 | + | var lease pickerLease | |
| 52 | + | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: test.barrierEpoch, state: test.barrierState}, pickerLeaseTestLoop(), test.applied, 3) | |
| 53 | + | require.Nil(t, superseded) | |
| 54 | + | require.Equal(t, test.ready, ready) | |
| 55 | + | require.Equal(t, test.owned, lease.state == pickerLeaseOwned) | |
| 56 | + | require.Equal(t, uint64(7), lease.interaction) | |
| 57 | + | require.Equal(t, uint64(3), lease.generation) | |
| 58 | + | }) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestPickerLeaseAcquiresWhenAHeldBarrierArrives(t *testing.T) { | |
| 63 | + | var lease pickerLease | |
| 64 | + | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 65 | + | require.False(t, ready) | |
| 66 | + | require.Equal(t, pickerLeaseAcquiring, lease.state) | |
| 67 | + | ||
| 68 | + | // A still-short frame keeps the lease acquiring and displays the frame. | |
| 69 | + | event := lease.observe(protocol.Output{Epoch: 4, New: 2, Full: true}, pickerLeaseTestState(4, 1)) | |
| 70 | + | require.Equal(t, pickerLeaseIdle, event) | |
| 71 | + | require.Equal(t, pickerLeaseAcquiring, lease.state) | |
| 72 | + | ||
| 73 | + | // The exact barrier is applied: the frame is displayed first and the | |
| 74 | + | // picker follows it. The pending model stays for the caller to install. | |
| 75 | + | event = lease.observe(protocol.Output{Epoch: 4, New: 3, Base: 2}, pickerLeaseTestState(4, 3)) | |
| 76 | + | require.Equal(t, pickerLeaseAcquire, event) | |
| 77 | + | require.Equal(t, pickerLeaseOwned, lease.state) | |
| 78 | + | require.NotNil(t, lease.pending) | |
| 79 | + | require.Equal(t, uint64(1), lease.pending.interaction) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestPickerLeaseSuppressesOwnedOutputAndPublishesBoundary(t *testing.T) { | |
| 83 | + | var lease pickerLease | |
| 84 | + | require.True(t, func() bool { | |
| 85 | + | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 86 | + | return ready | |
| 87 | + | }()) | |
| 88 | + | ||
| 89 | + | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2))) | |
| 90 | + | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1))) | |
| 91 | + | require.Equal(t, pickerLeaseOwned, lease.state) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestPickerLeaseReleasesOnlyAfterTheDaemonCloseAndANewerFullPaint(t *testing.T) { | |
| 95 | + | var lease pickerLease | |
| 96 | + | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 2) | |
| 97 | + | require.True(t, ready) | |
| 98 | + | ||
| 99 | + | // The client cancels: the client-side retire alone never releases, because | |
| 100 | + | // it cannot tell a post-close restore from a paint already in flight. | |
| 101 | + | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |
| 102 | + | require.Equal(t, pickerLeaseReleasing, lease.state) | |
| 103 | + | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2))) | |
| 104 | + | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)), | |
| 105 | + | "a Full in flight before the daemon close must not release") | |
| 106 | + | ||
| 107 | + | // The daemon confirms the close at the applied boundary it was rendering: | |
| 108 | + | // only paints accepted after that boundary release the terminal. | |
| 109 | + | require.True(t, lease.confirmDaemonClose(7, pickerLeaseTestState(5, 1))) | |
| 110 | + | require.False(t, lease.confirmDaemonClose(8, pickerLeaseTestState(5, 1)), "a foreign confirmation must not move the boundary") | |
| 111 | + | require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)), | |
| 112 | + | "a paint at the close boundary is not newer than it") | |
| 113 | + | require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 2, Full: true}, pickerLeaseTestState(5, 2))) | |
| 114 | + | ||
| 115 | + | actionID, local := lease.finishRelease() | |
| 116 | + | require.Equal(t, uint64(42), actionID) | |
| 117 | + | require.True(t, local) | |
| 118 | + | require.False(t, lease.active()) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestPickerLeaseDaemonCloseReleasesWithoutAClientRetire(t *testing.T) { | |
| 122 | + | var lease pickerLease | |
| 123 | + | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 124 | + | require.True(t, ready) | |
| 125 | + | ||
| 126 | + | // A daemon-initiated close (a resolved selection) carries its own barrier. | |
| 127 | + | require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 1))) | |
| 128 | + | require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1))) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestPickerLeaseForeignAndDuplicateCloseNeverRelease(t *testing.T) { | |
| 132 | + | var lease pickerLease | |
| 133 | + | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 134 | + | require.True(t, ready) | |
| 135 | + | ||
| 136 | + | require.False(t, lease.beginRelease(6, 42, true, false, pickerLeaseTestState(4, 1)), "foreign interaction must not release") | |
| 137 | + | require.False(t, lease.beginRelease(0, 42, true, false, pickerLeaseTestState(4, 1)), "zero interaction must not release") | |
| 138 | + | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |
| 139 | + | require.False(t, lease.beginRelease(7, 43, true, false, pickerLeaseTestState(4, 1)), "duplicate close must not restart the release") | |
| 140 | + | require.Equal(t, uint64(42), lease.releaseAction) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestPickerLeaseSupersedingSnapshotStartsANewNamespace(t *testing.T) { | |
| 144 | + | var lease pickerLease | |
| 145 | + | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 146 | + | require.True(t, ready) | |
| 147 | + | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |
| 148 | + | ||
| 149 | + | // A superseding interaction cannot be released by the prior close. | |
| 150 | + | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(8, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 151 | + | require.True(t, ready) | |
| 152 | + | require.NotNil(t, superseded) | |
| 153 | + | require.Equal(t, uint64(7), superseded.interaction) | |
| 154 | + | require.Equal(t, uint64(8), lease.interaction) | |
| 155 | + | require.Equal(t, pickerLeaseOwned, lease.state) | |
| 156 | + | require.False(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestPickerLeaseSameInteractionRevisionRules(t *testing.T) { | |
| 160 | + | var lease pickerLease | |
| 161 | + | ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 162 | + | require.True(t, ready) | |
| 163 | + | ||
| 164 | + | // Duplicate and older revisions never replace the displayed model. | |
| 165 | + | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 166 | + | require.False(t, ready) | |
| 167 | + | require.Nil(t, superseded) | |
| 168 | + | ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 2), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 169 | + | require.False(t, ready) | |
| 170 | + | require.Nil(t, superseded) | |
| 171 | + | ||
| 172 | + | // A newer revision refreshes in place. | |
| 173 | + | ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 174 | + | require.False(t, ready) | |
| 175 | + | require.Nil(t, superseded) | |
| 176 | + | require.Equal(t, uint64(4), lease.revision) | |
| 177 | + | ||
| 178 | + | // A newer revision that also moved the barrier to an applied state | |
| 179 | + | // acquires immediately. | |
| 180 | + | ready, _ = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 5), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1) | |
| 181 | + | require.True(t, ready) | |
| 182 | + | ||
| 183 | + | // A release in progress refuses revisions: the close/full pair cannot be | |
| 184 | + | // replaced by a late refresh. | |
| 185 | + | require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 2))) | |
| 186 | + | ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 6), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1) | |
| 187 | + | require.False(t, ready) | |
| 188 | + | require.Nil(t, superseded) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestPickerLeaseAbortReportsPendingLocalAction(t *testing.T) { | |
| 192 | + | var lease pickerLease | |
| 193 | + | lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 194 | + | require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1))) | |
| 195 | + | ||
| 196 | + | actionID, local := lease.abort() | |
| 197 | + | require.Equal(t, uint64(42), actionID) | |
| 198 | + | require.True(t, local) | |
| 199 | + | require.False(t, lease.active()) | |
| 200 | + | ||
| 201 | + | // A handoff-bound action stays unresolved: the daemon owns its outcome. | |
| 202 | + | lease.admitSnapshot(pickerLeaseTestSnapshot(9, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1) | |
| 203 | + | lease.beginRelease(9, 77, false, false, pickerLeaseTestState(4, 1)) | |
| 204 | + | actionID, local = lease.abort() | |
| 205 | + | require.Zero(t, actionID) | |
| 206 | + | require.False(t, local) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestPickerLeaseRejectsUnusableSnapshots(t *testing.T) { | |
| 210 | + | var lease pickerLease | |
| 211 | + | ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(0, 1), pickerBarrier{epoch: 1, state: 0}, pickerLeaseTestLoop(), outputApplyState{}, 1) | |
| 212 | + | require.False(t, ready) | |
| 213 | + | require.Nil(t, superseded) | |
| 214 | + | require.False(t, lease.active()) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestPickerLeaseClosedPresentationNeverSuppresses(t *testing.T) { | |
| 218 | + | var lease pickerLease | |
| 219 | + | require.False(t, lease.active()) | |
| 220 | + | require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 1, Full: true}, pickerLeaseTestState(1, 1))) | |
| 221 | + | require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 2, Base: 1}, pickerLeaseTestState(1, 2))) | |
| 222 | + | } | |
A internal/usecase/client/picker_retire_test.go +155 -0
| @@ -0,0 +1,155 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // pickerReleasePaint sends the daemon's authoritative repaint that releases | |
| 17 | + | // the client's presentation lease. | |
| 18 | + | func pickerReleasePaint(transport *attachPaletteTransport, text string) []byte { | |
| 19 | + | view := protocol.ViewContext{ | |
| 20 | + | Publication: 2, | |
| 21 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{ | |
| 22 | + | LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture", | |
| 23 | + | }}, | |
| 24 | + | TabID: "t_abc123", FocusedPaneID: "p_def456", | |
| 25 | + | } | |
| 26 | + | payload, err := wire.MarshalOutput(protocol.Output{ | |
| 27 | + | Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, | |
| 28 | + | Context: &view, Data: []byte("\x1b[2J\x1b[H" + text), | |
| 29 | + | }) | |
| 30 | + | if err != nil { | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | transport.detached <- wire.Frame{Type: wire.MsgOutput, Payload: payload} | |
| 34 | + | return payload | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // TestPickerRetireRejectsLateSnapshot pins that a closed interaction is | |
| 38 | + | // never reopened by an in-flight snapshot for the same ID. | |
| 39 | + | func TestPickerRetireRejectsLateSnapshot(t *testing.T) { | |
| 40 | + | ctx := context.Background() | |
| 41 | + | harness := startPickerE2E(t) | |
| 42 | + | ui, transport := harness.ui, harness.transport | |
| 43 | + | ||
| 44 | + | snapshot := openPickerOnHarness(t, transport) | |
| 45 | + | attached := ports.UIStatusAttached | |
| 46 | + | first := "first" | |
| 47 | + | _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}}) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | ||
| 50 | + | // The daemon retires the interaction and repaints authoritatively. | |
| 51 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: snapshot.InteractionID, BarrierEpoch: 2, BarrierState: 1})} | |
| 52 | + | require.NotNil(t, pickerReleasePaint(transport, "released")) | |
| 53 | + | released := "released" | |
| 54 | + | _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &released}}) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | ||
| 57 | + | // A late snapshot for the retired interaction must not restore the modal. | |
| 58 | + | late := snapshot | |
| 59 | + | late.SourceRevision = snapshot.SourceRevision + 1 | |
| 60 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(late)} | |
| 61 | + | time.Sleep(100 * time.Millisecond) | |
| 62 | + | captured, err := ui.Capture(ui.Handle()) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | text := uiSnapshotText(captured) | |
| 65 | + | require.Contains(t, text, "released") | |
| 66 | + | require.NotContains(t, text, "second", "a retired interaction must not be reopened") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // TestPickerReplaceAbortsSupersededLease pins that a superseding | |
| 70 | + | // interaction takes presentation over and commits under its own ID, and | |
| 71 | + | // that the replaced lease is resolved rather than left installed. | |
| 72 | + | func TestPickerReplaceAbortsSupersededLease(t *testing.T) { | |
| 73 | + | ctx := context.Background() | |
| 74 | + | harness := startPickerE2E(t) | |
| 75 | + | ui, transport := harness.ui, harness.transport | |
| 76 | + | ||
| 77 | + | first := openPickerOnHarness(t, transport) | |
| 78 | + | attached := ports.UIStatusAttached | |
| 79 | + | firstText := "first" | |
| 80 | + | _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &firstText}}) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | ||
| 83 | + | // A new interaction arrives without a close for the previous one. | |
| 84 | + | superseding := protocol.PickerSnapshot{ | |
| 85 | + | InteractionID: first.InteractionID + 1, SourceID: "serving", SourceRevision: 1, Status: protocol.PickerSourceOK, | |
| 86 | + | Lines: []protocol.PickerLine{ | |
| 87 | + | {Key: "cc/third", Kind: protocol.PickerLineSession, Label: "third", Focusable: true, Actions: protocol.PickerCanNavigate}, | |
| 88 | + | }, | |
| 89 | + | Cursor: protocol.PickerCursor{Key: "cc/third", Index: 0}, | |
| 90 | + | } | |
| 91 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(superseding)} | |
| 92 | + | third := "third" | |
| 93 | + | _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &third}}) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | ||
| 96 | + | // A commit crosses under the superseding interaction, never the retired one. | |
| 97 | + | committed := make(chan ports.UIActionResult, 1) | |
| 98 | + | go func() { | |
| 99 | + | action, actionErr := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Enter"}}) | |
| 100 | + | if actionErr == nil { | |
| 101 | + | committed <- action | |
| 102 | + | } | |
| 103 | + | }() | |
| 104 | + | selection := awaitPickerSelection(t, transport) | |
| 105 | + | require.Equal(t, superseding.InteractionID, selection.InteractionID) | |
| 106 | + | require.Equal(t, "cc/third", selection.Key) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // TestPickerStaleRevisionClosesInteraction pins that a terminal rejection | |
| 110 | + | // closes the daemon-side interaction instead of leaving it open, and that | |
| 111 | + | // input routing resumes only after the authoritative repaint. | |
| 112 | + | func TestPickerStaleRevisionClosesInteraction(t *testing.T) { | |
| 113 | + | ctx := context.Background() | |
| 114 | + | reader, writer := io.Pipe() | |
| 115 | + | t.Cleanup(func() { _ = writer.Close() }) | |
| 116 | + | harness := startPickerE2EWithInput(t, reader) | |
| 117 | + | ui, transport := harness.ui, harness.transport | |
| 118 | + | ||
| 119 | + | snapshot := openPickerOnHarness(t, transport) | |
| 120 | + | attached := ports.UIStatusAttached | |
| 121 | + | first := "first" | |
| 122 | + | _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}}) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | ||
| 125 | + | // The daemon refuses the committed revision: the client must close the | |
| 126 | + | // interaction so it stops owning input on the daemon side. | |
| 127 | + | failure := wire.MarshalPickerFailure(protocol.PickerFailure{ | |
| 128 | + | CauseActionID: 3, InteractionID: snapshot.InteractionID, SourceID: "serving", | |
| 129 | + | Key: "aa/first", Action: protocol.PickerActionNavigate, Code: protocol.PickerStaleRevision, | |
| 130 | + | }) | |
| 131 | + | require.NotNil(t, failure) | |
| 132 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerFailure, Payload: failure} | |
| 133 | + | ||
| 134 | + | closePayload := awaitWireFrame(t, transport, wire.MsgPickerCloseClient) | |
| 135 | + | closed, err := wire.UnmarshalPickerClose(closePayload) | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | require.Equal(t, snapshot.InteractionID, closed.InteractionID) | |
| 138 | + | ||
| 139 | + | // Input during the release window is dropped, then routing resumes once | |
| 140 | + | // the daemon confirms the close and its authoritative repaint is shown. | |
| 141 | + | writeTerminal(t, writer, "leaked") | |
| 142 | + | requireNoPickerInput(t, transport) | |
| 143 | + | transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: snapshot.InteractionID, BarrierEpoch: 2, BarrierState: 1})} | |
| 144 | + | require.NotNil(t, pickerReleasePaint(transport, "released")) | |
| 145 | + | released := "released" | |
| 146 | + | _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &released}}) | |
| 147 | + | require.NoError(t, err, "the release paint must be displayed") | |
| 148 | + | ||
| 149 | + | writeTerminal(t, writer, "ok") | |
| 150 | + | select { | |
| 151 | + | case <-transport.inputCh: | |
| 152 | + | case <-time.After(2 * time.Second): | |
| 153 | + | t.Fatal("session input did not resume after the release paint") | |
| 154 | + | } | |
| 155 | + | } | |
M internal/usecase/client/remote_attach_validation_test.go +1 -1
| @@ -18,7 +18,7 @@ func TestMalformedAttachRequestsNeverDial(t *testing.T) { | |||
| 18 | 18 | {name: "missing session name", request: AttachRequest{Intent: protocol.IntentAttach}}, | |
| 19 | 19 | {name: "unsafe session name", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "bad name"}}, | |
| 20 | 20 | {name: "daemon-owned without remote", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}}, | |
| 21 | - | {name: "ephemeral with navigation capability", request: AttachRequest{Intent: protocol.IntentEphemeral, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}}, | |
| 21 | + | {name: "ephemeral with navigation capability", request: AttachRequest{Intent: protocol.IntentEphemeral, NavigationCapabilities: protocol.NavigationCapabilityInventory}}, | |
| 22 | 22 | } | |
| 23 | 23 | for _, tt := range tests { | |
| 24 | 24 | t.Run(tt.name, func(t *testing.T) { | |
A internal/usecase/client/route_observers.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type routeObserverKey struct { | |
| 11 | + | origin protocol.RouteOrigin | |
| 12 | + | originKey string | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | type routeObserverAuthority struct { | |
| 16 | + | key routeObserverKey | |
| 17 | + | epoch uint64 | |
| 18 | + | dialer ports.ClientDialer | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | type routeObserverRegistry struct { | |
| 22 | + | mu sync.RWMutex | |
| 23 | + | nextEpoch uint64 | |
| 24 | + | authorities map[routeObserverKey]routeObserverAuthority | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func newRouteObserverRegistry() *routeObserverRegistry { | |
| 28 | + | return &routeObserverRegistry{authorities: make(map[routeObserverKey]routeObserverAuthority)} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (r *routeObserverRegistry) register(request AttachRequest, dialer ports.ClientDialer) { | |
| 32 | + | if r == nil || dialer == nil { | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | origin := normalizeRouteOrigin(request.Origin, request.Remote) | |
| 36 | + | key := routeObserverKey{origin: origin, originKey: normalizeRouteOriginKey(request.OriginKey, origin)} | |
| 37 | + | r.mu.Lock() | |
| 38 | + | defer r.mu.Unlock() | |
| 39 | + | if current, ok := r.authorities[key]; ok && current.dialer == dialer { | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | r.nextEpoch++ | |
| 43 | + | r.authorities[key] = routeObserverAuthority{key: key, epoch: r.nextEpoch, dialer: dialer} | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (r *routeObserverRegistry) snapshot() []routeObserverAuthority { | |
| 47 | + | if r == nil { | |
| 48 | + | return nil | |
| 49 | + | } | |
| 50 | + | r.mu.RLock() | |
| 51 | + | defer r.mu.RUnlock() | |
| 52 | + | authorities := make([]routeObserverAuthority, 0, len(r.authorities)) | |
| 53 | + | for _, authority := range r.authorities { | |
| 54 | + | authorities = append(authorities, authority) | |
| 55 | + | } | |
| 56 | + | return authorities | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (r *routeObserverRegistry) current(authority routeObserverAuthority) bool { | |
| 60 | + | if r == nil { | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | r.mu.RLock() | |
| 64 | + | defer r.mu.RUnlock() | |
| 65 | + | current, ok := r.authorities[authority.key] | |
| 66 | + | return ok && current.epoch == authority.epoch && current.dialer == authority.dialer | |
| 67 | + | } | |
M internal/usecase/client/routes.go +51 -2
| @@ -569,6 +569,57 @@ func (r routeRecord) snapshotEntry() protocol.RecentRouteEntry { | |||
| 569 | 569 | } | |
| 570 | 570 | } | |
| 571 | 571 | ||
| 572 | + | func (l *routeLedger) observationTargets(origin protocol.RouteOrigin, originKey string) []protocol.ExactSessionTarget { | |
| 573 | + | return l.observationTargetsExcept(origin, originKey, nil) | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | func (l *routeLedger) observationTargetsExcept(origin protocol.RouteOrigin, originKey string, excluded *protocol.ExactSessionTarget) []protocol.ExactSessionTarget { | |
| 577 | + | if l == nil { | |
| 578 | + | return nil | |
| 579 | + | } | |
| 580 | + | l.mu.RLock() | |
| 581 | + | defer l.mu.RUnlock() | |
| 582 | + | targets := make([]protocol.ExactSessionTarget, 0, len(l.entries)) | |
| 583 | + | for _, entry := range l.entries { | |
| 584 | + | if entry.origin == origin && entry.originKey == originKey && (excluded == nil || entry.target != *excluded) { | |
| 585 | + | targets = append(targets, entry.target) | |
| 586 | + | } | |
| 587 | + | } | |
| 588 | + | return targets | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | func (l *routeLedger) applyObservations(origin protocol.RouteOrigin, originKey string, observations []protocol.PickerRouteObservation) bool { | |
| 592 | + | if l == nil { | |
| 593 | + | return false | |
| 594 | + | } | |
| 595 | + | l.mu.Lock() | |
| 596 | + | defer l.mu.Unlock() | |
| 597 | + | byTarget := make(map[protocol.ExactSessionTarget]protocol.PickerRouteObservation, len(observations)) | |
| 598 | + | for _, observation := range observations { | |
| 599 | + | byTarget[observation.Target] = observation | |
| 600 | + | } | |
| 601 | + | changed := false | |
| 602 | + | for i := range l.entries { | |
| 603 | + | entry := &l.entries[i] | |
| 604 | + | if entry.origin != origin || entry.originKey != originKey { | |
| 605 | + | continue | |
| 606 | + | } | |
| 607 | + | observation, ok := byTarget[entry.target] | |
| 608 | + | if !ok || observation.Presence == protocol.PickerRouteUnknown { | |
| 609 | + | continue | |
| 610 | + | } | |
| 611 | + | attention := observation.Presence == protocol.PickerRoutePresent && observation.Attention | |
| 612 | + | if entry.presentation.attention != attention { | |
| 613 | + | entry.presentation.attention = attention | |
| 614 | + | changed = true | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | if changed { | |
| 618 | + | l.generation++ | |
| 619 | + | } | |
| 620 | + | return changed | |
| 621 | + | } | |
| 622 | + | ||
| 572 | 623 | func (l *routeLedger) snapshot() protocol.RecentRouteSnapshot { | |
| 573 | 624 | l.mu.RLock() | |
| 574 | 625 | defer l.mu.RUnlock() | |
| @@ -622,7 +673,6 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta | |||
| 622 | 673 | request.RemoteTarget = nil | |
| 623 | 674 | if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned { | |
| 624 | 675 | request.NavigationCapabilities = 0 | |
| 625 | - | request.StartupOverlay = protocol.StartupOverlayNone | |
| 626 | 676 | } | |
| 627 | 677 | request.EnvironmentPolicy = environmentPolicy | |
| 628 | 678 | request.ExactTarget = target.ExactTarget | |
| @@ -640,7 +690,6 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta | |||
| 640 | 690 | request.RemoteTarget = nil | |
| 641 | 691 | if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned { | |
| 642 | 692 | request.NavigationCapabilities = 0 | |
| 643 | - | request.StartupOverlay = protocol.StartupOverlayNone | |
| 644 | 693 | } | |
| 645 | 694 | request.EnvironmentPolicy = environmentPolicy | |
| 646 | 695 | return request | |
M internal/usecase/client/routes_test.go +57 -5
| @@ -218,8 +218,7 @@ func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t * | |||
| 218 | 218 | active := routeTestCandidate(2, protocol.RouteOriginLocal) | |
| 219 | 219 | active.originKey = "local" | |
| 220 | 220 | active.request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned | |
| 221 | - | active.request.NavigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 222 | - | active.request.StartupOverlay = protocol.StartupOverlaySessionPicker | |
| 221 | + | active.request.NavigationCapabilities = protocol.NavigationCapabilityInventory | |
| 223 | 222 | ||
| 224 | 223 | for _, tt := range []struct { | |
| 225 | 224 | name string | |
| @@ -235,8 +234,7 @@ func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t * | |||
| 235 | 234 | stored := routeTestCandidate(1, protocol.RouteOriginLocal) | |
| 236 | 235 | stored.originKey = "local" | |
| 237 | 236 | stored.request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned | |
| 238 | - | stored.request.NavigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 239 | - | stored.request.StartupOverlay = protocol.StartupOverlaySessionPicker | |
| 237 | + | stored.request.NavigationCapabilities = protocol.NavigationCapabilityInventory | |
| 240 | 238 | _, err := ledger.commit(stored) | |
| 241 | 239 | require.NoError(t, err) | |
| 242 | 240 | } | |
| @@ -248,7 +246,6 @@ func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t * | |||
| 248 | 246 | ||
| 249 | 247 | require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy) | |
| 250 | 248 | require.Zero(t, request.NavigationCapabilities) | |
| 251 | - | require.Equal(t, protocol.StartupOverlayNone, request.StartupOverlay) | |
| 252 | 249 | require.NoError(t, validateAttachRequest(request)) | |
| 253 | 250 | }) | |
| 254 | 251 | } | |
| @@ -287,6 +284,61 @@ func TestRouteLedgerSamePeerHandoffDropsOriginalRemoteTarget(t *testing.T) { | |||
| 287 | 284 | require.Nil(t, request.RemoteTarget) | |
| 288 | 285 | } | |
| 289 | 286 | ||
| 287 | + | // TestRouteLedgerSamePeerHandoffPreservesRemoteDaemonOwnedPolicy pins the | |
| 288 | + | // hybrid same-peer pairing: on a remote origin the handoff keeps the | |
| 289 | + | // serving daemon's policy, the explicit tab, and the request environment. | |
| 290 | + | // Local-origin downgrade rules must not leak into the remote path. | |
| 291 | + | func TestRouteLedgerSamePeerHandoffPreservesRemoteDaemonOwnedPolicy(t *testing.T) { | |
| 292 | + | ledger := newRouteLedger() | |
| 293 | + | work := routeTestCandidate(1, protocol.RouteOriginRemote) | |
| 294 | + | work.originKey = "remote" | |
| 295 | + | work.request.Remote = true | |
| 296 | + | work.request.OriginKey = "remote" | |
| 297 | + | work.request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned | |
| 298 | + | work.request.Environment = []string{"REMOTE=1"} | |
| 299 | + | work.request.PreferredTabID = "remembered-tab" | |
| 300 | + | _, err := ledger.commit(work) | |
| 301 | + | require.NoError(t, err) | |
| 302 | + | ||
| 303 | + | target := routeTestTarget(1) | |
| 304 | + | request := ledger.samePeerHandoff(work.request, protocol.AttachTarget{ | |
| 305 | + | Session: work.target.SessionName, | |
| 306 | + | Intent: protocol.IntentAttach, | |
| 307 | + | ExactTarget: &target, | |
| 308 | + | PreferredTabID: "chosen-tab", | |
| 309 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 310 | + | }) | |
| 311 | + | ||
| 312 | + | require.True(t, request.Remote) | |
| 313 | + | require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, request.EnvironmentPolicy) | |
| 314 | + | require.Equal(t, domain.TabStableID("chosen-tab"), request.PreferredTabID) | |
| 315 | + | require.Equal(t, []string{"REMOTE=1"}, request.Environment) | |
| 316 | + | require.NoError(t, validateAttachRequest(request)) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func TestRouteLedgerAppliesExactOriginObservationsWithoutReordering(t *testing.T) { | |
| 320 | + | ledger := newRouteLedger() | |
| 321 | + | local := routeCandidateForAttach(AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}, protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"}}, nil, 0) | |
| 322 | + | _, err := ledger.commitAttach(local) | |
| 323 | + | require.NoError(t, err) | |
| 324 | + | remote := routeCandidateForAttach(AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginRemote, OriginKey: "remote", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "work"}}, nil, 0) | |
| 325 | + | _, err = ledger.commitAttach(remote) | |
| 326 | + | require.NoError(t, err) | |
| 327 | + | before := ledger.snapshot() | |
| 328 | + | ||
| 329 | + | changed := ledger.applyObservations(protocol.RouteOriginLocal, normalizeRouteOriginKey("", protocol.RouteOriginLocal), []protocol.PickerRouteObservation{{ | |
| 330 | + | Target: local.target, Presence: protocol.PickerRoutePresent, Attention: true, | |
| 331 | + | }}) | |
| 332 | + | ||
| 333 | + | require.True(t, changed) | |
| 334 | + | after := ledger.snapshot() | |
| 335 | + | require.Equal(t, before.Active, after.Active) | |
| 336 | + | require.Equal(t, before.Previous, after.Previous) | |
| 337 | + | require.Equal(t, []string{"local"}, []string{after.Entries[0].Name}) | |
| 338 | + | require.True(t, after.Entries[0].Attention) | |
| 339 | + | require.False(t, after.ActiveEntry.Attention) | |
| 340 | + | } | |
| 341 | + | ||
| 290 | 342 | func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) { | |
| 291 | 343 | ledger := newRouteLedger() | |
| 292 | 344 | first := routeTestCandidate(0, protocol.RouteOriginRemote) | |
M internal/usecase/client/typed_connection_external_test.go +14 -6
| @@ -132,8 +132,6 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) { | |||
| 132 | 132 | case protocol.SamePeerSwitchRequest: | |
| 133 | 133 | p, e := wire.MarshalSamePeerSwitchRequest(x) | |
| 134 | 134 | return wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: p}, e | |
| 135 | - | case protocol.ParkedRouteRequest: | |
| 136 | - | return wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(x)}, nil | |
| 137 | 135 | case protocol.RouteNavigationFailure: | |
| 138 | 136 | p, e := wire.MarshalRouteNavigationFailure(x) | |
| 139 | 137 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e | |
| @@ -144,6 +142,10 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) { | |||
| 144 | 142 | return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: wire.MarshalNavigationInventoryPublication(x)}, nil | |
| 145 | 143 | case protocol.NavigationInventoryFailure: | |
| 146 | 144 | return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: wire.MarshalNavigationInventoryFailure(x)}, nil | |
| 145 | + | case protocol.PickerSelection: | |
| 146 | + | return wire.Frame{Type: wire.MsgPickerSelection, Payload: wire.MarshalPickerSelection(x)}, nil | |
| 147 | + | case protocol.PickerClose: | |
| 148 | + | return wire.Frame{Type: wire.MsgPickerCloseClient, Payload: wire.MarshalPickerClose(x)}, nil | |
| 147 | 149 | default: | |
| 148 | 150 | return wire.Frame{}, errors.New("test client: unsupported message") | |
| 149 | 151 | } | |
| @@ -166,10 +168,6 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) { | |||
| 166 | 168 | return wire.UnmarshalPong(f.Payload) | |
| 167 | 169 | case wire.MsgAttachTarget: | |
| 168 | 170 | return wire.UnmarshalAttachTarget(f.Payload) | |
| 169 | - | case wire.MsgNavigationAction: | |
| 170 | - | return wire.UnmarshalNavigationDirective(f.Payload) | |
| 171 | - | case wire.MsgParkedRouteResponse: | |
| 172 | - | return wire.UnmarshalParkedRouteResponse(f.Payload) | |
| 173 | 171 | case wire.MsgNavigateRecentRoute: | |
| 174 | 172 | return wire.UnmarshalRouteNavigationAction(f.Payload) | |
| 175 | 173 | case wire.MsgRouteCreateSession: | |
| @@ -184,6 +182,16 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) { | |||
| 184 | 182 | return wire.UnmarshalRoutePosition(f.Payload) | |
| 185 | 183 | case wire.MsgRouteNavigationFailure: | |
| 186 | 184 | return wire.UnmarshalRouteNavigationFailure(f.Payload) | |
| 185 | + | case wire.MsgPickerOffer: | |
| 186 | + | return wire.UnmarshalPickerOffer(f.Payload) | |
| 187 | + | case wire.MsgPickerSnapshot: | |
| 188 | + | return wire.UnmarshalPickerSnapshot(f.Payload) | |
| 189 | + | case wire.MsgPickerClosedServer: | |
| 190 | + | return wire.UnmarshalPickerClosed(f.Payload) | |
| 191 | + | case wire.MsgPickerResult: | |
| 192 | + | return wire.UnmarshalPickerResult(f.Payload) | |
| 193 | + | case wire.MsgPickerFailure: | |
| 194 | + | return wire.UnmarshalPickerFailure(f.Payload) | |
| 187 | 195 | default: | |
| 188 | 196 | return nil, errors.New("test client: unsupported server frame") | |
| 189 | 197 | } | |
M internal/usecase/client/typed_connection_test.go +30 -6
| @@ -128,8 +128,6 @@ func testClientFrame(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 128 | 128 | case protocol.SamePeerSwitchRequest: | |
| 129 | 129 | p, e := wire.MarshalSamePeerSwitchRequest(m) | |
| 130 | 130 | return wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: p}, e | |
| 131 | - | case protocol.ParkedRouteRequest: | |
| 132 | - | return wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(m)}, nil | |
| 133 | 131 | case protocol.RecentRouteSnapshot: | |
| 134 | 132 | p, e := wire.MarshalRecentRouteSnapshot(m) | |
| 135 | 133 | return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: p}, e | |
| @@ -154,6 +152,24 @@ func testClientFrame(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 154 | 152 | return wire.Frame{}, errors.New("test client connection: invalid inventory failure") | |
| 155 | 153 | } | |
| 156 | 154 | return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: p}, nil | |
| 155 | + | case protocol.PickerSelection: | |
| 156 | + | p := wire.MarshalPickerSelection(m) | |
| 157 | + | if p == nil { | |
| 158 | + | return wire.Frame{}, errors.New("test client connection: invalid picker selection") | |
| 159 | + | } | |
| 160 | + | return wire.Frame{Type: wire.MsgPickerSelection, Payload: p}, nil | |
| 161 | + | case protocol.PickerClose: | |
| 162 | + | p := wire.MarshalPickerClose(m) | |
| 163 | + | if p == nil { | |
| 164 | + | return wire.Frame{}, errors.New("test client connection: invalid picker close") | |
| 165 | + | } | |
| 166 | + | return wire.Frame{Type: wire.MsgPickerCloseClient, Payload: p}, nil | |
| 167 | + | case protocol.PickerPreviewRequest: | |
| 168 | + | p := wire.MarshalPickerPreviewRequest(m) | |
| 169 | + | if p == nil { | |
| 170 | + | return wire.Frame{}, errors.New("test client connection: invalid picker preview request") | |
| 171 | + | } | |
| 172 | + | return wire.Frame{Type: wire.MsgPickerPreviewRequest, Payload: p}, nil | |
| 157 | 173 | default: | |
| 158 | 174 | return wire.Frame{}, errors.New("test client connection: unsupported client message") | |
| 159 | 175 | } | |
| @@ -175,8 +191,6 @@ func testServerMessage(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 175 | 191 | return wire.UnmarshalSessions(frame.Payload) | |
| 176 | 192 | case wire.MsgCommandResult: | |
| 177 | 193 | return wire.UnmarshalCommandResult(frame.Payload) | |
| 178 | - | case wire.MsgNavigationAction: | |
| 179 | - | return wire.UnmarshalNavigationDirective(frame.Payload) | |
| 180 | 194 | case wire.MsgAttachTarget: | |
| 181 | 195 | return wire.UnmarshalAttachTarget(frame.Payload) | |
| 182 | 196 | case wire.MsgRemotePreviewResponse: | |
| @@ -193,14 +207,24 @@ func testServerMessage(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 193 | 207 | return wire.UnmarshalRoutePosition(frame.Payload) | |
| 194 | 208 | case wire.MsgSamePeerSwitchFailure: | |
| 195 | 209 | return wire.UnmarshalSamePeerSwitchFailure(frame.Payload) | |
| 196 | - | case wire.MsgParkedRouteResponse: | |
| 197 | - | return wire.UnmarshalParkedRouteResponse(frame.Payload) | |
| 198 | 210 | case wire.MsgNavigationInventoryResponse: | |
| 199 | 211 | return wire.UnmarshalNavigationInventoryResponse(frame.Payload) | |
| 200 | 212 | case wire.MsgNavigationInventoryDemand: | |
| 201 | 213 | return wire.UnmarshalNavigationInventoryDemand(frame.Payload) | |
| 202 | 214 | case wire.MsgNavigationInventorySelection: | |
| 203 | 215 | return wire.UnmarshalNavigationInventorySelection(frame.Payload) | |
| 216 | + | case wire.MsgPickerOffer: | |
| 217 | + | return wire.UnmarshalPickerOffer(frame.Payload) | |
| 218 | + | case wire.MsgPickerSnapshot: | |
| 219 | + | return wire.UnmarshalPickerSnapshot(frame.Payload) | |
| 220 | + | case wire.MsgPickerClosedServer: | |
| 221 | + | return wire.UnmarshalPickerClosed(frame.Payload) | |
| 222 | + | case wire.MsgPickerResult: | |
| 223 | + | return wire.UnmarshalPickerResult(frame.Payload) | |
| 224 | + | case wire.MsgPickerFailure: | |
| 225 | + | return wire.UnmarshalPickerFailure(frame.Payload) | |
| 226 | + | case wire.MsgPickerPreview: | |
| 227 | + | return wire.UnmarshalPickerPreview(frame.Payload) | |
| 204 | 228 | default: | |
| 205 | 229 | return nil, errors.New("test client connection: unsupported server frame") | |
| 206 | 230 | } | |
M internal/usecase/client/ui.go +74 -0
| @@ -17,6 +17,80 @@ const uiActionHistory = 64 | |||
| 17 | 17 | ||
| 18 | 18 | // UI owns attachment-local action and waiter bookkeeping. It never holds its | |
| 19 | 19 | // mutex across terminal access, transport I/O, or predicate evaluation. | |
| 20 | + | ||
| 21 | + | // pickerOutcomeQueue carries decoded picker operations from the stdin-pump | |
| 22 | + | // goroutine to the attach loop. The pump never touches the terminal, the | |
| 23 | + | // control channel, or the picker model: it only reports what the user | |
| 24 | + | // typed, and the attach loop applies it through its sole terminal writer | |
| 25 | + | // and flushes typed sends in controlCh order. | |
| 26 | + | // | |
| 27 | + | // The queue is bounded: physical input is now a producer, so an unbounded | |
| 28 | + | // queue would let a paste or a stuck attach loop accumulate operations | |
| 29 | + | // without limit. offer blocks until the loop drains or the attempt ends. | |
| 30 | + | type pickerOutcomeQueue struct { | |
| 31 | + | mu sync.Mutex | |
| 32 | + | outcomes []pickerConsumeOutcome | |
| 33 | + | signal chan struct{} | |
| 34 | + | slots chan struct{} | |
| 35 | + | done <-chan struct{} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // pickerOutcomeCapacity bounds queued picker operations between the pump and | |
| 39 | + | // the attach loop. | |
| 40 | + | const pickerOutcomeCapacity = 256 | |
| 41 | + | ||
| 42 | + | func newPickerOutcomeQueue(done <-chan struct{}) *pickerOutcomeQueue { | |
| 43 | + | queue := &pickerOutcomeQueue{ | |
| 44 | + | signal: make(chan struct{}, 1), | |
| 45 | + | slots: make(chan struct{}, pickerOutcomeCapacity), | |
| 46 | + | done: done, | |
| 47 | + | } | |
| 48 | + | for i := 0; i < pickerOutcomeCapacity; i++ { | |
| 49 | + | queue.slots <- struct{}{} | |
| 50 | + | } | |
| 51 | + | return queue | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // offer queues one decoded operation, waiting for room. A cancelled attempt | |
| 55 | + | // drops it: nothing can apply the operation any more. | |
| 56 | + | func (q *pickerOutcomeQueue) offer(outcome pickerConsumeOutcome) { | |
| 57 | + | if q == nil { | |
| 58 | + | return | |
| 59 | + | } | |
| 60 | + | select { | |
| 61 | + | case <-q.slots: | |
| 62 | + | case <-q.done: | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | q.mu.Lock() | |
| 66 | + | q.outcomes = append(q.outcomes, outcome) | |
| 67 | + | q.mu.Unlock() | |
| 68 | + | select { | |
| 69 | + | case q.signal <- struct{}{}: | |
| 70 | + | default: | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // take drains queued operations in order and returns their capacity. The | |
| 75 | + | // caller holds no locks; sends keep controlCh ordering because the attach | |
| 76 | + | // loop flushes them before processing new frames. | |
| 77 | + | func (q *pickerOutcomeQueue) take() []pickerConsumeOutcome { | |
| 78 | + | if q == nil { | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | q.mu.Lock() | |
| 82 | + | outcomes := q.outcomes | |
| 83 | + | q.outcomes = nil | |
| 84 | + | q.mu.Unlock() | |
| 85 | + | for range outcomes { | |
| 86 | + | select { | |
| 87 | + | case q.slots <- struct{}{}: | |
| 88 | + | default: | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | return outcomes | |
| 92 | + | } | |
| 93 | + | ||
| 20 | 94 | type UI struct { | |
| 21 | 95 | state ports.UIState | |
| 22 | 96 | clock ports.Clock | |
M internal/usecase/client/ui_actions.go +1 -1
| @@ -162,7 +162,7 @@ func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.U | |||
| 162 | 162 | defer timer.Stop() | |
| 163 | 163 | admissionCtx, cancelAdmission := context.WithCancel(ctx) | |
| 164 | 164 | defer cancelAdmission() | |
| 165 | - | batch := terminalAutomationRequest{ctx: admissionCtx, owner: u, consumer: consumer, record: terminalReadResult{data: data, source: terminalInputAutomation, generation: request.Generation, actionID: id, endBatch: true}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)} | |
| 165 | + | batch := terminalAutomationRequest{ctx: admissionCtx, owner: u, consumer: consumer, record: terminalReadResult{data: data, source: terminalInputAutomation, generation: request.Generation, actionID: id, endBatch: true, keys: request.Keys, text: request.Text}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)} | |
| 166 | 166 | reject := func(code ports.UIErrorCode) (ports.UIActionResult, error) { | |
| 167 | 167 | u.mu.Lock() | |
| 168 | 168 | delete(u.records, id) | |
M internal/usecase/client/ui_remote_test.go +8 -54
| @@ -68,7 +68,7 @@ func awaitUIClientMessage[T protocol.ClientMessage](t *testing.T, p *uiTestPeer) | |||
| 68 | 68 | } | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | - | func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) { | |
| 71 | + | func TestUIRunnerRemoteHandoffRequiresDestinationFull(t *testing.T) { | |
| 72 | 72 | ctx, cancel := context.WithCancel(context.Background()) | |
| 73 | 73 | t.Cleanup(cancel) | |
| 74 | 74 | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "") | |
| @@ -77,17 +77,17 @@ func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) { | |||
| 77 | 77 | ui := NewUI(terminal, systemClock{}) | |
| 78 | 78 | source := newUITestPeer(t, protocol.ConnectionCapabilities{}) | |
| 79 | 79 | destination := newUITestPeer(t, protocol.ConnectionCapabilities{PreferredOutputWindow: 1}) | |
| 80 | - | picker := newUITestPeer(t, protocol.ConnectionCapabilities{}) | |
| 81 | 80 | localDialer := portsmocks.NewMockClientDialer(t) | |
| 82 | 81 | localDialer.EXPECT().Dial(mock.Anything).Return(source.connection, nil).Once() | |
| 83 | - | localDialer.EXPECT().Dial(mock.Anything).Return(picker.connection, nil).Once() | |
| 84 | 82 | remoteDialer := portsmocks.NewMockClientDialer(t) | |
| 85 | 83 | remoteDialer.EXPECT().Dial(mock.Anything).Return(destination.connection, nil).Once() | |
| 86 | 84 | target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "remote"} | |
| 87 | - | runner := NewRunner(Dependencies{Dialer: localDialer, Terminal: terminal, Clock: systemClock{}, UI: ui, DisableCapabilityProbe: true, AttachHandoff: func(offer protocol.AttachTarget) (ports.ClientDialer, AttachRequest, error) { | |
| 88 | - | require.Equal(t, "isolated-fixture", offer.Endpoint) | |
| 89 | - | return remoteDialer, AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote", Remote: true, Origin: protocol.RouteOriginRemote, OriginKey: "isolated-fixture", ExactTarget: &target}, nil | |
| 90 | - | }}) | |
| 85 | + | deps := Dependencies{Dialer: localDialer, Terminal: terminal, Clock: systemClock{}, UI: ui, DisableCapabilityProbe: true} | |
| 86 | + | deps.HostRegistry = internalStubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 87 | + | require.Equal(t, "isolated-fixture", endpoint) | |
| 88 | + | return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil | |
| 89 | + | }} | |
| 90 | + | runner := NewRunner(deps) | |
| 91 | 91 | done := make(chan error, 1) | |
| 92 | 92 | go func() { done <- runner.Run(ctx, AttachRequest{Intent: protocol.IntentAttach, SessionName: "local"}) }() | |
| 93 | 93 | t.Cleanup(func() { | |
| @@ -118,7 +118,7 @@ func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) { | |||
| 118 | 118 | results <- result | |
| 119 | 119 | }() | |
| 120 | 120 | fence := awaitUIClientMessage[protocol.UIFence](t, source) | |
| 121 | - | source.incoming <- protocol.AttachTarget{Endpoint: "isolated-fixture", Session: "remote", ExactTarget: &target, CauseActionID: fence.ActionID} | |
| 121 | + | source.incoming <- protocol.AttachTarget{Endpoint: "isolated-fixture", Session: "remote", Intent: protocol.IntentAttach, ExactTarget: &target, CauseActionID: fence.ActionID} | |
| 122 | 122 | awaitUIClientMessage[protocol.Hello](t, destination) | |
| 123 | 123 | remoteView := view | |
| 124 | 124 | remoteView.Route.Target = target | |
| @@ -143,50 +143,4 @@ func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) { | |||
| 143 | 143 | t.Fatal("destination full did not complete handoff") | |
| 144 | 144 | } | |
| 145 | 145 | awaitUIClientMessage[protocol.Theme](t, destination) | |
| 146 | - | startAction := func(generation uint64) { | |
| 147 | - | go func() { | |
| 148 | - | result, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: generation, Keys: []string{"Enter"}}) | |
| 149 | - | if err != nil { | |
| 150 | - | failures <- err | |
| 151 | - | return | |
| 152 | - | } | |
| 153 | - | results <- result | |
| 154 | - | }() | |
| 155 | - | } | |
| 156 | - | awaitAction := func(generation uint64, target protocol.ExactSessionTarget) { | |
| 157 | - | t.Helper() | |
| 158 | - | select { | |
| 159 | - | case result := <-results: | |
| 160 | - | require.Equal(t, ports.UIActionProcessed, result.Status) | |
| 161 | - | require.Equal(t, generation, result.Context.Generation) | |
| 162 | - | require.Equal(t, target, result.Context.Route.Target) | |
| 163 | - | case err := <-failures: | |
| 164 | - | t.Fatal(err) | |
| 165 | - | case <-time.After(time.Second): | |
| 166 | - | t.Fatal("parked handoff did not complete") | |
| 167 | - | } | |
| 168 | - | } | |
| 169 | - | startAction(2) | |
| 170 | - | openPicker := awaitUIClientMessage[protocol.UIFence](t, destination) | |
| 171 | - | destination.incoming <- protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: protocol.ParkedRouteLeaseID{1}, CauseActionID: openPicker.ActionID} | |
| 172 | - | prepare := awaitUIClientMessage[protocol.ParkedRouteRequest](t, destination) | |
| 173 | - | require.Equal(t, protocol.ParkedRoutePrepare, prepare.Action) | |
| 174 | - | destination.incoming <- protocol.ParkedRouteResponse{RequestID: prepare.RequestID, Status: protocol.ParkedRouteReady} | |
| 175 | - | awaitUIClientMessage[protocol.Hello](t, picker) | |
| 176 | - | picker.incoming <- protocol.Welcome{SessionID: "local-session", SessionName: "local", CommittedIdentity: &view.Route} | |
| 177 | - | awaitUIClientMessage[protocol.Theme](t, picker) | |
| 178 | - | awaitUIClientMessage[protocol.Theme](t, picker) | |
| 179 | - | picker.incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &view, Data: []byte("\x1b[2J\x1b[Hpicker")} | |
| 180 | - | awaitAction(3, view.Route.Target) | |
| 181 | - | startAction(3) | |
| 182 | - | selectRemote := awaitUIClientMessage[protocol.UIFence](t, picker) | |
| 183 | - | remoteTarget := domain.RemoteSessionTarget{Endpoint: "isolated-fixture", DisplayOrigin: "isolated-fixture", LifecycleID: target.LifecycleID, SessionName: target.SessionName, LiveTabID: "tab"} | |
| 184 | - | picker.incoming <- protocol.AttachTarget{Endpoint: "isolated-fixture", Session: "remote", RemoteTarget: &remoteTarget, CauseActionID: selectRemote.ActionID} | |
| 185 | - | switchRequest := awaitUIClientMessage[protocol.ParkedRouteRequest](t, destination) | |
| 186 | - | require.Equal(t, protocol.ParkedRouteSwitch, switchRequest.Action) | |
| 187 | - | destination.incoming <- protocol.ParkedRouteResponse{RequestID: switchRequest.RequestID, Status: protocol.ParkedRouteSwitched} | |
| 188 | - | resumedView := remoteView | |
| 189 | - | resumedView.Publication = 2 | |
| 190 | - | destination.incoming <- protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &resumedView, Data: []byte("\x1b[2J\x1b[Hresumed")} | |
| 191 | - | awaitAction(4, target) | |
| 192 | 146 | } | |
M internal/usecase/daemon/activation_policy_test.go +10 -11
| @@ -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/catalogue" | |
| 12 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | 14 | // TestActivationPolicyTable drives the D5 contract through the picker | |
| @@ -26,7 +25,7 @@ func TestActivationPolicyTable(t *testing.T) { | |||
| 26 | 25 | name string | |
| 27 | 26 | host ports.RemoteHostSnapshot | |
| 28 | 27 | session catalogue.RemoteCatalogSession | |
| 29 | - | activation picker.RemoteActivation | |
| 28 | + | activation pickerRemoteActivation | |
| 30 | 29 | reason string | |
| 31 | 30 | rowPresent bool | |
| 32 | 31 | }{ | |
| @@ -34,21 +33,21 @@ func TestActivationPolicyTable(t *testing.T) { | |||
| 34 | 33 | name: "live reachable attaches", | |
| 35 | 34 | host: reachableDirectoryHost("user@arch", now), | |
| 36 | 35 | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 37 | - | activation: picker.RemoteAttach, | |
| 36 | + | activation: pickerRemoteAttach, | |
| 38 | 37 | rowPresent: true, | |
| 39 | 38 | }, | |
| 40 | 39 | { | |
| 41 | 40 | name: "stopped reachable restarts", | |
| 42 | 41 | host: reachableDirectoryHost("user@arch", now), | |
| 43 | 42 | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown, Tabs: tabs}, | |
| 44 | - | activation: picker.RemoteRestart, | |
| 43 | + | activation: pickerRemoteRestart, | |
| 45 | 44 | rowPresent: true, | |
| 46 | 45 | }, | |
| 47 | 46 | { | |
| 48 | 47 | name: "broken is diagnostic only", | |
| 49 | 48 | host: reachableDirectoryHost("user@arch", now), | |
| 50 | 49 | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionBroken, Tabs: tabs}, | |
| 51 | - | activation: picker.RemoteUnavailable, | |
| 50 | + | activation: pickerRemoteUnavailable, | |
| 52 | 51 | reason: domain.RemoteReasonSessionBroken, | |
| 53 | 52 | rowPresent: true, | |
| 54 | 53 | }, | |
| @@ -59,7 +58,7 @@ func TestActivationPolicyTable(t *testing.T) { | |||
| 59 | 58 | LastSuccess: now, InventoryKnown: true, | |
| 60 | 59 | }, | |
| 61 | 60 | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 62 | - | activation: picker.RemoteUnavailable, | |
| 61 | + | activation: pickerRemoteUnavailable, | |
| 63 | 62 | reason: domain.RemoteReasonVersionMismatch, | |
| 64 | 63 | rowPresent: true, | |
| 65 | 64 | }, | |
| @@ -70,7 +69,7 @@ func TestActivationPolicyTable(t *testing.T) { | |||
| 70 | 69 | return host | |
| 71 | 70 | }(), | |
| 72 | 71 | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 73 | - | activation: picker.RemoteAttach, | |
| 72 | + | activation: pickerRemoteAttach, | |
| 74 | 73 | rowPresent: true, | |
| 75 | 74 | }, | |
| 76 | 75 | { | |
| @@ -80,7 +79,7 @@ func TestActivationPolicyTable(t *testing.T) { | |||
| 80 | 79 | LastSuccess: now.Add(-time.Hour), InventoryKnown: true, | |
| 81 | 80 | }, | |
| 82 | 81 | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 83 | - | activation: picker.RemoteAttach, | |
| 82 | + | activation: pickerRemoteAttach, | |
| 84 | 83 | reason: domain.RemoteReasonHostUnreachable, | |
| 85 | 84 | rowPresent: true, | |
| 86 | 85 | }, | |
| @@ -91,7 +90,7 @@ func TestActivationPolicyTable(t *testing.T) { | |||
| 91 | 90 | LastSuccess: now.Add(-time.Hour), InventoryKnown: true, | |
| 92 | 91 | }, | |
| 93 | 92 | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 94 | - | activation: picker.RemoteAttach, | |
| 93 | + | activation: pickerRemoteAttach, | |
| 95 | 94 | reason: domain.RemoteReasonAuthFailure, | |
| 96 | 95 | rowPresent: true, | |
| 97 | 96 | }, | |
| @@ -102,7 +101,7 @@ func TestActivationPolicyTable(t *testing.T) { | |||
| 102 | 101 | LastSuccess: now.Add(-time.Hour), InventoryKnown: true, | |
| 103 | 102 | }, | |
| 104 | 103 | session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"}, | |
| 105 | - | activation: picker.RemoteAttach, | |
| 104 | + | activation: pickerRemoteAttach, | |
| 106 | 105 | reason: domain.RemoteReasonMalformed, | |
| 107 | 106 | rowPresent: true, | |
| 108 | 107 | }, | |
| @@ -117,7 +116,7 @@ func TestActivationPolicyTable(t *testing.T) { | |||
| 117 | 116 | seedRemoteDirectory(t, d, host) | |
| 118 | 117 | ||
| 119 | 118 | views, _ := d.pickerViews(current, nil) | |
| 120 | - | var found *picker.SessionView | |
| 119 | + | var found *pickerSessionView | |
| 121 | 120 | for i, view := range views { | |
| 122 | 121 | if view.RemoteHost == "user@arch" && view.Name == "work@arch" { | |
| 123 | 122 | found = &views[i] | |
M internal/usecase/daemon/attachment_connection.go +27 -0
| @@ -58,6 +58,33 @@ func (s *session) captureAttachmentCapability(ac *attachedClient, tr ports.Serve | |||
| 58 | 58 | return captureAttachmentCapability(s, ac, tr) | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | // admitCurrentAttachmentCapabilityEffect re-admits the attachment's current | |
| 62 | + | // capability after an exact capability stopped being admissible. It re-reads the | |
| 63 | + | // attachment's current session and transport, so it never sends through a stale | |
| 64 | + | // capability or resurrects a detached transport. A detached attachment, or one | |
| 65 | + | // whose current session no longer registers this link, admits nothing. | |
| 66 | + | func admitCurrentAttachmentCapabilityEffect(capability attachmentCapability) (attachmentCapability, *attachmentEffect, bool) { | |
| 67 | + | ac := capability.ac | |
| 68 | + | if ac == nil { | |
| 69 | + | return attachmentCapability{}, nil, false | |
| 70 | + | } | |
| 71 | + | sess := ac.currentAttachmentSession() | |
| 72 | + | if sess == nil { | |
| 73 | + | return attachmentCapability{}, nil, false | |
| 74 | + | } | |
| 75 | + | token, effect, admitted := ac.beginCurrentAttachmentEffect(sess, ac.transport()) | |
| 76 | + | if !admitted { | |
| 77 | + | return attachmentCapability{}, nil, false | |
| 78 | + | } | |
| 79 | + | // The re-admitted link replaces the superseded capability's identity but | |
| 80 | + | // inherits its post-publication paint semantics, so a recovered cross-session | |
| 81 | + | // first paint still rebases the output chain on the current link. | |
| 82 | + | if capability.rebase { | |
| 83 | + | token.rebase = true | |
| 84 | + | } | |
| 85 | + | return token, effect, true | |
| 86 | + | } | |
| 87 | + | ||
| 61 | 88 | func (t attachmentCapability) sameIdentity(other attachmentCapability) bool { | |
| 62 | 89 | return t.sess != nil && t.ac != nil && t.sess == other.sess && t.ac == other.ac && | |
| 63 | 90 | t.generation == other.generation && t.transport.transport == other.transport.transport && | |
M internal/usecase/daemon/attachment_effect_gate.go +0 -3
| @@ -208,9 +208,6 @@ func (t *attachmentEffect) sendControl(message protocol.ServerMessage) error { | |||
| 208 | 208 | case protocol.AttachTarget: | |
| 209 | 209 | navigation.CauseActionID = t.uiActionID | |
| 210 | 210 | message = navigation | |
| 211 | - | case protocol.NavigationDirective: | |
| 212 | - | navigation.CauseActionID = t.uiActionID | |
| 213 | - | message = navigation | |
| 214 | 211 | case protocol.RouteNavigationAction: | |
| 215 | 212 | navigation.CauseActionID = t.uiActionID | |
| 216 | 213 | message = navigation | |
M internal/usecase/daemon/attachment_effect_gate_test.go +36 -4
| @@ -784,17 +784,47 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |||
| 784 | 784 | require.Equal(t, 1, testAttachmentTabIndex(target)) | |
| 785 | 785 | } | |
| 786 | 786 | ||
| 787 | + | // openPickerForSession opens a navigation interaction on the attachment and | |
| 788 | + | // returns a kill selection for the named session. The open runs on a | |
| 789 | + | // short-lived effect: the interaction namespace lives on the attachment, not | |
| 790 | + | // on the effect that carried its offer. | |
| 791 | + | func openPickerForSession(t *testing.T, d *Daemon, sess *session, ac *attachedClient, killSession domain.SessionID) protocol.PickerSelection { | |
| 792 | + | t.Helper() | |
| 793 | + | effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 794 | + | require.True(t, admitted) | |
| 795 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 796 | + | effect.End() | |
| 797 | + | ac.overlays.pickerMu.Lock() | |
| 798 | + | interaction := ac.overlays.pickerInteraction | |
| 799 | + | revision := ac.overlays.pickerRevisions[servingPickerSourceID] | |
| 800 | + | key := "" | |
| 801 | + | for candidate, target := range ac.overlays.pickerKeys { | |
| 802 | + | if target.Session == killSession { | |
| 803 | + | key = candidate | |
| 804 | + | break | |
| 805 | + | } | |
| 806 | + | } | |
| 807 | + | ac.overlays.pickerMu.Unlock() | |
| 808 | + | require.NotEmpty(t, key, "no picker key resolves to session %s", killSession) | |
| 809 | + | return protocol.PickerSelection{ | |
| 810 | + | InteractionID: interaction, SourceID: servingPickerSourceID, | |
| 811 | + | SourceRevision: revision, Key: key, Action: protocol.PickerActionKill, | |
| 812 | + | } | |
| 813 | + | } | |
| 814 | + | ||
| 787 | 815 | func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) { | |
| 788 | 816 | p, releasePTY := newBlockingPTY(t) | |
| 789 | 817 | defer releasePTY() | |
| 790 | 818 | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |
| 791 | - | d.enterPicker(sess, old) | |
| 819 | + | selection := openPickerForSession(t, d, sess, old, sess.id) | |
| 792 | 820 | ||
| 793 | 821 | oldTransport := old.transport() | |
| 794 | 822 | rc := d.attachCoordinator(sess, nil, old, true) | |
| 795 | 823 | token := sess.captureAttachmentCapability(old, oldTransport) | |
| 796 | 824 | token.lease = rc.attachmentLease(old) | |
| 797 | 825 | old.installTestAttachmentCapability(token) | |
| 826 | + | actionEffect, admitted := old.beginAttachmentEffect(token) | |
| 827 | + | require.True(t, admitted) | |
| 798 | 828 | ||
| 799 | 829 | admissionEnded := make(chan struct{}) | |
| 800 | 830 | releaseAction := make(chan struct{}) | |
| @@ -809,7 +839,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) | |||
| 809 | 839 | } | |
| 810 | 840 | actionDone := make(chan struct{}) | |
| 811 | 841 | go func() { | |
| 812 | - | d.handleAttachmentClientFrame(token, frameInput([]byte("x"))) | |
| 842 | + | d.resolvePickerSelection(actionEffect, selection) | |
| 813 | 843 | close(actionDone) | |
| 814 | 844 | }() | |
| 815 | 845 | <-admissionEnded | |
| @@ -834,13 +864,15 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |||
| 834 | 864 | p, release := newBlockingPTY(t) | |
| 835 | 865 | defer release() | |
| 836 | 866 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 837 | - | d.enterPicker(sess, ac) | |
| 867 | + | selection := openPickerForSession(t, d, sess, ac, sess.id) | |
| 838 | 868 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 839 | 869 | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 840 | 870 | token.lease = rc.attachmentLease(ac) | |
| 841 | 871 | ac.installTestAttachmentCapability(token) | |
| 872 | + | actionEffect, admitted := ac.beginAttachmentEffect(token) | |
| 873 | + | require.True(t, admitted) | |
| 842 | 874 | ||
| 843 | - | d.handleAttachmentClientFrame(token, frameInput([]byte("x"))) | |
| 875 | + | d.resolvePickerSelection(actionEffect, selection) | |
| 844 | 876 | ||
| 845 | 877 | d.mu.Lock() | |
| 846 | 878 | _, registered := d.sessions[sess.id] | |
M internal/usecase/daemon/attachment_output.go +12 -0
| @@ -578,6 +578,18 @@ func (s *attachmentOutput) atCapacity() bool { | |||
| 578 | 578 | return s.outstandingAtomic.Load() >= maxOutstanding | |
| 579 | 579 | } | |
| 580 | 580 | ||
| 581 | + | // committedState reports the latest committed output state number. It takes | |
| 582 | + | // only the view lock, so barrier snapshots can read it without joining the | |
| 583 | + | // sendMu-ordered paint path. | |
| 584 | + | func (s *attachmentOutput) committedState() uint64 { | |
| 585 | + | if s == nil { | |
| 586 | + | return 0 | |
| 587 | + | } | |
| 588 | + | s.lockView() | |
| 589 | + | defer s.unlockView() | |
| 590 | + | return s.next | |
| 591 | + | } | |
| 592 | + | ||
| 581 | 593 | func (s *attachmentOutput) outstanding() uint64 { | |
| 582 | 594 | if s == nil { | |
| 583 | 595 | return 0 | |
M internal/usecase/daemon/attachment_transition.go +21 -0
| @@ -153,14 +153,35 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||
| 153 | 153 | if err != nil { | |
| 154 | 154 | return result, err | |
| 155 | 155 | } | |
| 156 | + | return d.finishAttachmentTransition(req, result) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // finishAttachmentTransition shares committed identity and geometry effects with | |
| 160 | + | // composite topology transactions. It runs after publication locks are released. | |
| 161 | + | func (d *Daemon) finishAttachmentTransition(req attachmentTransitionRequest, result attachmentTransitionResult) (attachmentTransitionResult, error) { | |
| 156 | 162 | // A not-ready publication is still inside the Hello handshake. Welcome | |
| 157 | 163 | // carries the committed identity for those attachments and must remain the | |
| 158 | 164 | // first server frame. Ready transitions notify an already-running client. | |
| 159 | 165 | if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 { | |
| 166 | + | if d.beforeAttachmentTransitionIdentityAdmission != nil { | |
| 167 | + | d.beforeAttachmentTransitionIdentityAdmission(result.published) | |
| 168 | + | } | |
| 160 | 169 | identityErr := errAttachmentTransition | |
| 161 | 170 | if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted { | |
| 162 | 171 | identityErr = d.sendCommittedRouteIdentityForAttachment(effect) | |
| 163 | 172 | effect.End() | |
| 173 | + | } else if token, effect, admitted := admitCurrentAttachmentCapabilityEffect(result.published); admitted { | |
| 174 | + | // The exact published capability stopped being admissible while its | |
| 175 | + | // attachment stayed live: a concurrent transition or link replacement | |
| 176 | + | // superseded it after publication released the architecture locks. The | |
| 177 | + | // committed identity is still owed to that live attachment, so re-admit | |
| 178 | + | // its current capability and report it back so the caller's first paint | |
| 179 | + | // and correlated completion use the same authority. A detached attachment | |
| 180 | + | // or one whose current session no longer registers this link admits | |
| 181 | + | // nothing and fails closed below; no stale transport is retried. | |
| 182 | + | result.published = token | |
| 183 | + | identityErr = d.sendCommittedRouteIdentityForAttachment(effect) | |
| 184 | + | effect.End() | |
| 164 | 185 | } | |
| 165 | 186 | if identityErr != nil { | |
| 166 | 187 | d.abortPublishedAttachmentTransition(result) | |
M internal/usecase/daemon/attention.go +29 -0
| @@ -139,6 +139,26 @@ func (d *Daemon) repaintAttachedClients(sess *session) { | |||
| 139 | 139 | } | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | + | // refreshClientPickers republishes the picker source of every attachment whose | |
| 143 | + | // interaction the client presents. A bell raised or cleared while that modal is | |
| 144 | + | // open must reach it, and client-owned presentation suppresses the daemon's own | |
| 145 | + | // paints: only a newer source revision updates the client's rows. Unchanged | |
| 146 | + | // line sets publish nothing, so the attention pulse stays cheap. | |
| 147 | + | func (d *Daemon) refreshClientPickers() { | |
| 148 | + | d.mu.Lock() | |
| 149 | + | sessions := sessionsSnapshot(d.sessions) | |
| 150 | + | d.mu.Unlock() | |
| 151 | + | ||
| 152 | + | for _, sess := range sessions { | |
| 153 | + | for _, ac := range sess.snapshotAttachments() { | |
| 154 | + | if ac.overlays == nil || !ac.overlays.pickerClientActive() { | |
| 155 | + | continue | |
| 156 | + | } | |
| 157 | + | d.refreshPickerSnapshot(ac) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 142 | 162 | func (d *Daemon) repaintAllAttachedClients() { | |
| 143 | 163 | d.mu.Lock() | |
| 144 | 164 | sessions := sessionsSnapshot(d.sessions) | |
| @@ -236,6 +256,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) { | |||
| 236 | 256 | } | |
| 237 | 257 | case <-timer.C(): | |
| 238 | 258 | d.advanceAttentionFrame() | |
| 259 | + | d.refreshClientPickers() | |
| 239 | 260 | d.repaintAllAttachedClients() | |
| 240 | 261 | } | |
| 241 | 262 | ||
| @@ -256,6 +277,14 @@ func (d *Daemon) anyAttention() bool { | |||
| 256 | 277 | if sess.anyAttention() { | |
| 257 | 278 | return true | |
| 258 | 279 | } | |
| 280 | + | for _, ac := range sess.snapshotAttachments() { | |
| 281 | + | snapshot := ac.routeSnapshotCopy() | |
| 282 | + | for _, entry := range snapshot.Entries { | |
| 283 | + | if entry.Attention { | |
| 284 | + | return true | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | } | |
| 259 | 288 | } | |
| 260 | 289 | return false | |
| 261 | 290 | } | |
M internal/usecase/daemon/client.go +10 -24
| @@ -43,7 +43,6 @@ type attachedClient struct { | |||
| 43 | 43 | clientID [16]byte | |
| 44 | 44 | terminalCapabilities terminalcap.Capabilities | |
| 45 | 45 | navigationCapabilities protocol.NavigationCapabilities | |
| 46 | - | startupOverlay protocol.StartupOverlay | |
| 47 | 46 | // lifecycle is the sole authority for attachment capability publication, | |
| 48 | 47 | // effect admission, transition freeze/drain, and connection generation. | |
| 49 | 48 | lifecycle attachmentLifecycle | |
| @@ -92,10 +91,6 @@ type attachedClient struct { | |||
| 92 | 91 | pendingRouteIdentity bool | |
| 93 | 92 | samePeerOfferMu sync.Mutex | |
| 94 | 93 | samePeerOffer *protocol.ExactSessionTarget | |
| 95 | - | parkedRouteMu sync.Mutex | |
| 96 | - | parkedRoute *parkedRouteLease | |
| 97 | - | parkedRouteOutput atomic.Bool | |
| 98 | - | parkedRouteFullPending atomic.Bool | |
| 99 | 94 | routeAttentionSubscription protocol.RouteAttentionSubscription | |
| 100 | 95 | routeSubscriptionTransport transportSnapshot | |
| 101 | 96 | routeObservationAfter map[protocol.RouteAttentionTarget]time.Time | |
| @@ -392,15 +387,15 @@ func (ac *attachedClient) setRouteAttentionSubscription(subscription protocol.Ro | |||
| 392 | 387 | ac.routeAttentionSubscription = subscription | |
| 393 | 388 | } | |
| 394 | 389 | ||
| 395 | - | func (ac *attachedClient) routeAttentionTarget(ref protocol.RouteRef) (protocol.ExactSessionTarget, bool) { | |
| 390 | + | func (ac *attachedClient) routeAttentionTarget(ref protocol.RouteRef) (protocol.RouteAttentionTarget, bool) { | |
| 396 | 391 | ac.routeMu.RLock() | |
| 397 | 392 | defer ac.routeMu.RUnlock() | |
| 398 | 393 | for _, target := range ac.routeAttentionSubscription.Targets { | |
| 399 | - | if target.Ref == ref && target.SourceKey == "" { | |
| 400 | - | return target.Target, true | |
| 394 | + | if target.Ref == ref { | |
| 395 | + | return target, true | |
| 401 | 396 | } | |
| 402 | 397 | } | |
| 403 | - | return protocol.ExactSessionTarget{}, false | |
| 398 | + | return protocol.RouteAttentionTarget{}, false | |
| 404 | 399 | } | |
| 405 | 400 | ||
| 406 | 401 | func (ac *attachedClient) ackOutputState(epoch, state uint64) { | |
| @@ -508,9 +503,6 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po | |||
| 508 | 503 | if !ac.transportSnapshotCurrent(expected) { | |
| 509 | 504 | return expected.transport, errTransportReplaced | |
| 510 | 505 | } | |
| 511 | - | if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() { | |
| 512 | - | return expected.transport, nil | |
| 513 | - | } | |
| 514 | 506 | ac.output.lockView() | |
| 515 | 507 | defer ac.output.unlockView() | |
| 516 | 508 | output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load()) | |
| @@ -525,9 +517,6 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po | |||
| 525 | 517 | if !ac.transportSnapshotCurrent(expected) { | |
| 526 | 518 | return errTransportReplaced | |
| 527 | 519 | } | |
| 528 | - | if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() { | |
| 529 | - | return nil | |
| 530 | - | } | |
| 531 | 520 | ac.output.lockView() | |
| 532 | 521 | defer ac.output.unlockView() | |
| 533 | 522 | output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load()) | |
| @@ -630,7 +619,6 @@ type attachClientOptions struct { | |||
| 630 | 619 | terminalCapabilities terminalcap.Capabilities | |
| 631 | 620 | capabilitiesSet bool | |
| 632 | 621 | navigationCapabilities protocol.NavigationCapabilities | |
| 633 | - | startupOverlay protocol.StartupOverlay | |
| 634 | 622 | } | |
| 635 | 623 | ||
| 636 | 624 | func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) { | |
| @@ -709,7 +697,6 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne | |||
| 709 | 697 | clientID: opts.clientID, | |
| 710 | 698 | terminalCapabilities: opts.terminalCapabilities, | |
| 711 | 699 | navigationCapabilities: opts.navigationCapabilities, | |
| 712 | - | startupOverlay: opts.startupOverlay, | |
| 713 | 700 | resumeCapable: opts.resumeCapable, | |
| 714 | 701 | resumeToken: resumeToken, | |
| 715 | 702 | } | |
| @@ -802,8 +789,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *ren | |||
| 802 | 789 | // attachmentOutput publishes capacity atomically. Do not take | |
| 803 | 790 | // attached.sendMu here: a slow transport may be holding it for an | |
| 804 | 791 | // in-flight Send, and that peer must not gate healthy attachments. | |
| 805 | - | return attached == nil || | |
| 806 | - | (!attached.parkedRouteOutput.Load() && (attached.output == nil || !attached.output.atCapacity())) | |
| 792 | + | return attached == nil || attached.output == nil || !attached.output.atCapacity() | |
| 807 | 793 | }, | |
| 808 | 794 | }) | |
| 809 | 795 | installAttachmentRenderCoordinator(entry, rc) | |
| @@ -994,11 +980,11 @@ func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geom | |||
| 994 | 980 | } | |
| 995 | 981 | ac.sendMu.Unlock() | |
| 996 | 982 | ||
| 997 | - | if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() { | |
| 998 | - | // The picker preview geometry is attachment-local. A resize must | |
| 999 | - | // invalidate any in-flight remote request and fetch the selected row | |
| 1000 | - | // at the new bounded preview dimensions. | |
| 1001 | - | d.registerPreviewForSelection(ac) | |
| 983 | + | if !sameSize && ac.overlays != nil && ac.overlays.pickerClientActive() { | |
| 984 | + | // A client-owned interaction renders from its own size: a resize | |
| 985 | + | // bumps the snapshot revision so the client never repaints against a | |
| 986 | + | // stale size epoch. | |
| 987 | + | d.refreshPickerSnapshot(ac) | |
| 1002 | 988 | } | |
| 1003 | 989 | ||
| 1004 | 990 | sess := effect.sess | |
M internal/usecase/daemon/client_frame_routing.go +25 -2
| @@ -137,10 +137,9 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability, | |||
| 137 | 137 | d.resetOutput(effect) | |
| 138 | 138 | case protocol.SamePeerSwitchRequest: | |
| 139 | 139 | d.switchSamePeerForAttachment(effect, message) | |
| 140 | - | case protocol.ParkedRouteRequest: | |
| 141 | - | d.handleParkedRouteRequest(effect, message) | |
| 142 | 140 | case protocol.RecentRouteSnapshot: | |
| 143 | 141 | replayIdentity := effect.ac.setRouteSnapshot(message) | |
| 142 | + | d.pokeAttentionTicker() | |
| 144 | 143 | d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-snapshot") | |
| 145 | 144 | if !replayIdentity { | |
| 146 | 145 | break | |
| @@ -206,6 +205,30 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability, | |||
| 206 | 205 | d.refreshPalette(effect.ac) | |
| 207 | 206 | d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication") | |
| 208 | 207 | } | |
| 208 | + | case protocol.PickerBegin: | |
| 209 | + | if effect.ac == nil || effect.ac.overlays == nil { | |
| 210 | + | break | |
| 211 | + | } | |
| 212 | + | if err := d.openPickerForAttachment(effect.ac, effect, message.Intent, moveSourceLocator{}, message.RequestID); err != nil { | |
| 213 | + | d.reportAttachmentError(effect.sess, err) | |
| 214 | + | } | |
| 215 | + | case protocol.PickerClose: | |
| 216 | + | if effect.ac == nil || effect.ac.overlays == nil { | |
| 217 | + | break | |
| 218 | + | } | |
| 219 | + | if d.closePickerForAttachment(effect.ac, effect, message.InteractionID) { | |
| 220 | + | // The client retired the interaction: force the authoritative | |
| 221 | + | // full paint its release path waits for before normal input and | |
| 222 | + | // action completion resume. | |
| 223 | + | if sess := effect.ac.currentAttachmentSession(); sess != nil { | |
| 224 | + | d.invalidateRender(sess, effect.ac, true, "client_frame_routing.go:picker-close") | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | case protocol.PickerSelection: | |
| 228 | + | effect.uiActionID = message.CauseActionID | |
| 229 | + | d.resolvePickerSelection(effect, message) | |
| 230 | + | case protocol.PickerPreviewRequest: | |
| 231 | + | d.handlePickerPreviewForAttachment(effect, message) | |
| 209 | 232 | case protocol.NavigationInventoryFailure: | |
| 210 | 233 | overlays := effect.ac.overlays | |
| 211 | 234 | if overlays == nil { | |
M internal/usecase/daemon/client_transport_errors.go +2 -4
| @@ -70,7 +70,6 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port | |||
| 70 | 70 | return | |
| 71 | 71 | } | |
| 72 | 72 | ac.clearSamePeerOffer() | |
| 73 | - | ac.clearParkedRoute() | |
| 74 | 73 | if d.afterClientGoneDetach != nil { | |
| 75 | 74 | d.afterClientGoneDetach() | |
| 76 | 75 | } | |
| @@ -99,7 +98,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port | |||
| 99 | 98 | // attachment parking marker left by a raced non-explicit teardown so | |
| 100 | 99 | // IntentResume waiters are not stranded on a never-published park. | |
| 101 | 100 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | |
| 102 | - | d.closePicker(ac) | |
| 101 | + | d.retirePicker(ac) | |
| 103 | 102 | d.closePalette(ac) | |
| 104 | 103 | if err := d.cleanupAttachmentOutput(ac); err != nil { | |
| 105 | 104 | d.log.Warn("output cleanup failed during detach", "err", err, "session", name) | |
| @@ -180,7 +179,6 @@ func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentCapability, f | |||
| 180 | 179 | ||
| 181 | 180 | func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.ServerConnection) { | |
| 182 | 181 | ac.clearSamePeerOffer() | |
| 183 | - | ac.clearParkedRoute() | |
| 184 | 182 | name := sess.nameSnapshot() | |
| 185 | 183 | if rc := sess.renderCoordinator(); rc != nil { | |
| 186 | 184 | rc.noteDetach(ac) | |
| @@ -192,7 +190,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed | |||
| 192 | 190 | return | |
| 193 | 191 | } | |
| 194 | 192 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | |
| 195 | - | d.closePicker(ac) | |
| 193 | + | d.retirePicker(ac) | |
| 196 | 194 | d.closePalette(ac) | |
| 197 | 195 | if err := d.cleanupAttachmentOutput(ac); err != nil { | |
| 198 | 196 | d.log.Warn("output cleanup failed after send error", "err", err, "session", name) | |
M internal/usecase/daemon/clipboard.go +11 -3
| @@ -38,6 +38,12 @@ func (d *Daemon) handleImagePushForAttachment(effect *attachmentEffect, ip proto | |||
| 38 | 38 | if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !effect.current() { | |
| 39 | 39 | return | |
| 40 | 40 | } | |
| 41 | + | // A client-owned picker owns the user input for this attachment: a | |
| 42 | + | // clipboard path must not reach the session while it is open, exactly | |
| 43 | + | // like a keystroke. The client re-sends after the interaction closes. | |
| 44 | + | if effect.ac != nil && effect.ac.overlays != nil && effect.ac.overlays.pickerClientActive() { | |
| 45 | + | return | |
| 46 | + | } | |
| 41 | 47 | path, err := d.writeClipboardImageForAttachment(effect, ip) | |
| 42 | 48 | if err != nil { | |
| 43 | 49 | d.log.Error("writing clipboard image failed", "err", err) | |
| @@ -179,6 +185,11 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, effect | |||
| 179 | 185 | if effect != nil && !effect.current() { | |
| 180 | 186 | return | |
| 181 | 187 | } | |
| 188 | + | // The injection point is the last gate: the interaction may have opened | |
| 189 | + | // between admission and here. | |
| 190 | + | if effect != nil && effect.ac != nil && effect.ac.overlays != nil && effect.ac.overlays.pickerClientActive() { | |
| 191 | + | return | |
| 192 | + | } | |
| 182 | 193 | d.writeToPane(sess, p, data) | |
| 183 | 194 | } | |
| 184 | 195 | ||
| @@ -269,9 +280,6 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta | |||
| 269 | 280 | if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) { | |
| 270 | 281 | return errAttachmentTransition | |
| 271 | 282 | } | |
| 272 | - | if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() { | |
| 273 | - | return nil | |
| 274 | - | } | |
| 275 | 283 | if !beginClipboardOwnerSend(item.owner, ticket, expected) { | |
| 276 | 284 | return errAttachmentTransition | |
| 277 | 285 | } | |
M internal/usecase/daemon/copy_scroll_animation.go +1 -1
| @@ -96,7 +96,7 @@ func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (boo | |||
| 96 | 96 | // Check presentation ownership before entering the copy lock. A | |
| 97 | 97 | // callback never locks another overlay or a session under copyMu. | |
| 98 | 98 | visible := ac.currentSession() == sess && !rt.promptActive() && !rt.paletteActive() && | |
| 99 | - | !rt.pickerActive() && !rt.noticesActive() && !rt.resizeModeActive() | |
| 99 | + | !rt.pickerClientActive() && !rt.noticesActive() && !rt.resizeModeActive() | |
| 100 | 100 | rt.copyMu.Lock() | |
| 101 | 101 | if rt.copyScroll.timer.timer != timer { | |
| 102 | 102 | rt.copyMu.Unlock() | |
M internal/usecase/daemon/daemon.go +13 -7
| @@ -84,9 +84,6 @@ type Daemon struct { | |||
| 84 | 84 | lastAllocatedCreatedAt int64 | |
| 85 | 85 | mruSeq atomic.Uint64 | |
| 86 | 86 | creationRequestSeq atomic.Uint64 | |
| 87 | - | // pickerSort is the picker ordering mode for this daemon's lifetime | |
| 88 | - | // (pickerSortMode); not persisted across restarts. | |
| 89 | - | pickerSort atomic.Uint32 | |
| 90 | 87 | // closing marks that shutdown has irreversibly begun. It is set under mu, | |
| 91 | 88 | // atomically with the event that makes shutdown inevitable (the registry | |
| 92 | 89 | // emptying in killSession, or shutdownAll starting), and checked by route | |
| @@ -170,6 +167,11 @@ type Daemon struct { | |||
| 170 | 167 | // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected | |
| 171 | 168 | // attachment gates are frozen and drained, before architecture publication. | |
| 172 | 169 | afterAttachmentEffectsFrozen func() | |
| 170 | + | // beforeAttachmentTransitionIdentityAdmission is a deterministic test seam | |
| 171 | + | // after a ready transition published its capability and before the committed | |
| 172 | + | // route identity is admitted, so tests can supersede that exact capability in | |
| 173 | + | // the window before the transition's first paint and completion. | |
| 174 | + | beforeAttachmentTransitionIdentityAdmission func(attachmentCapability) | |
| 173 | 175 | // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order | |
| 174 | 176 | // seam used by transition validation tests. | |
| 175 | 177 | afterAttachmentTransitionCoordinatorsLocked func() | |
| @@ -186,6 +188,10 @@ type Daemon struct { | |||
| 186 | 188 | afterMoveTabSourceSnapshot func() | |
| 187 | 189 | beforeMovePaneCommit func() | |
| 188 | 190 | beforeMoveTabCommit func() | |
| 191 | + | // beforeMoveFollowCompletion is a deterministic test seam after the composite | |
| 192 | + | // follow's first paint and before the postcommit admits the capability that | |
| 193 | + | // reports completion, so tests can supersede the published capability. | |
| 194 | + | beforeMoveFollowCompletion func(attachmentCapability) | |
| 189 | 195 | // afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the | |
| 190 | 196 | // attachment gate but before it checks session ownership. | |
| 191 | 197 | afterDetachAttachmentEffectsFrozen func() | |
| @@ -1145,6 +1151,10 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) { | |||
| 1145 | 1151 | stopTransport() | |
| 1146 | 1152 | finishHandshake() | |
| 1147 | 1153 | d.handleNavigationInventory(tr, message) | |
| 1154 | + | case protocol.PickerControlRequest: | |
| 1155 | + | stopTransport() | |
| 1156 | + | finishHandshake() | |
| 1157 | + | d.handlePickerControl(tr, message) | |
| 1148 | 1158 | case protocol.Kill: | |
| 1149 | 1159 | stopTransport() | |
| 1150 | 1160 | finishHandshake() | |
| @@ -1414,9 +1424,6 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut < | |||
| 1414 | 1424 | failAttachment() | |
| 1415 | 1425 | return | |
| 1416 | 1426 | } | |
| 1417 | - | if ac.startupOverlay == protocol.StartupOverlaySessionPicker { | |
| 1418 | - | d.enterPicker(sess, ac) | |
| 1419 | - | } | |
| 1420 | 1427 | postWelcomeTicket.End() | |
| 1421 | 1428 | stopHandshakeTransport() | |
| 1422 | 1429 | finishHandshake() | |
| @@ -1479,7 +1486,6 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai | |||
| 1479 | 1486 | resumeCapable: true, | |
| 1480 | 1487 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 1481 | 1488 | navigationCapabilities: h.NavigationCapabilities, | |
| 1482 | - | startupOverlay: h.StartupOverlay, | |
| 1483 | 1489 | terminalCapabilities: terminalCapabilities, | |
| 1484 | 1490 | capabilitiesSet: true, | |
| 1485 | 1491 | } | |
D internal/usecase/daemon/home_picker_navigation_test.go +0 -86
| @@ -1,86 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/stretchr/testify/require" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/protocol" | |
| 10 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | func TestHomePickerTabSelectionHandsOffToClient(t *testing.T) { | |
| 14 | - | for _, tc := range []struct { | |
| 15 | - | name string | |
| 16 | - | sameSession bool | |
| 17 | - | homePicker bool | |
| 18 | - | }{ | |
| 19 | - | {name: "different local session", homePicker: true}, | |
| 20 | - | {name: "backing local session", homePicker: true, sameSession: true}, | |
| 21 | - | {name: "ordinary local session switch"}, | |
| 22 | - | {name: "ordinary local tab switch", sameSession: true}, | |
| 23 | - | } { | |
| 24 | - | t.Run(tc.name, func(t *testing.T) { | |
| 25 | - | d, source, ac, sends, releases := newManualTabSession(t, 2) | |
| 26 | - | defer releaseAll(releases) | |
| 27 | - | source.name = "home" | |
| 28 | - | source.incarnation = domain.SessionLifecycleID{1} | |
| 29 | - | target := source | |
| 30 | - | if !tc.sameSession { | |
| 31 | - | target = &session{ | |
| 32 | - | sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.SessionLifecycleID{2}, attachments: make(map[*attachedClient]struct{})}, | |
| 33 | - | ctx: source.ctx, cancel: func() {}, | |
| 34 | - | tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23}), newTab(nil, domain.Size{Cols: 80, Rows: 23})}, | |
| 35 | - | } | |
| 36 | - | for _, tab := range target.tabs { | |
| 37 | - | publishTiledPaneOwners(target, tab) | |
| 38 | - | } | |
| 39 | - | d.sessions[target.id] = target | |
| 40 | - | } | |
| 41 | - | target.tabs[1].name = "chosen-tab" | |
| 42 | - | if tc.homePicker { | |
| 43 | - | ac.startupOverlay = protocol.StartupOverlaySessionPicker | |
| 44 | - | ac.navigationCapabilities = protocol.NavigationCapabilityBack | |
| 45 | - | } | |
| 46 | - | ref := protocol.RouteRef{Key: 7, Generation: 1} | |
| 47 | - | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{ | |
| 48 | - | Generation: 1, Active: ref, | |
| 49 | - | ActiveEntry: protocol.RecentRouteEntry{Key: ref.Key, Generation: ref.Generation, | |
| 50 | - | Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "remote"}, | |
| 51 | - | Name: "remote", HostLabel: "remote-host", Kind: protocol.RouteKindRemote}, | |
| 52 | - | }) | |
| 53 | - | effect := beginRecentRoutePaletteEffect(t, d, source, ac) | |
| 54 | - | d.enterPicker(source, ac) | |
| 55 | - | // Exercise the real picker model and input dispatch, not a fabricated | |
| 56 | - | // AttachTarget: navigation selects tab rows, including search hits. | |
| 57 | - | d.handleInputForAttachment(effect, []byte("/chosen-tab")) | |
| 58 | - | ac.overlays.pickerMu.Lock() | |
| 59 | - | selected, ok := ac.overlays.picker.Selected() | |
| 60 | - | ac.overlays.pickerMu.Unlock() | |
| 61 | - | require.True(t, ok) | |
| 62 | - | require.Equal(t, domain.TabStableID(target.tabs[1].stableID), selected.TabID) | |
| 63 | - | d.handleInputForAttachment(effect, []byte("\r")) | |
| 64 | - | ||
| 65 | - | if !tc.homePicker { | |
| 66 | - | require.Same(t, target, ac.currentAttachmentSession()) | |
| 67 | - | require.Equal(t, selected.TabID, ac.viewSnapshot().tabID) | |
| 68 | - | return | |
| 69 | - | } | |
| 70 | - | require.True(t, source == ac.currentAttachmentSession(), "a temporary picker must not become the destination attachment") | |
| 71 | - | var handoff *protocol.AttachTarget | |
| 72 | - | for len(sends) != 0 { | |
| 73 | - | frame := <-sends | |
| 74 | - | if frame.Type == wire.MsgAttachTarget { | |
| 75 | - | decoded, err := wire.UnmarshalAttachTarget(frame.Payload) | |
| 76 | - | require.NoError(t, err) | |
| 77 | - | handoff = &decoded | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | require.NotNil(t, handoff, "selection must return to the client that owns the parked remote route") | |
| 81 | - | require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, handoff.ExactTarget) | |
| 82 | - | require.Equal(t, selected.TabID, handoff.PreferredTabID) | |
| 83 | - | require.False(t, handoff.SamePeer, "the temporary connection must end, not be promoted in place") | |
| 84 | - | }) | |
| 85 | - | } | |
| 86 | - | } | |
D internal/usecase/daemon/home_picker_status_test.go +0 -75
| @@ -1,75 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/stretchr/testify/require" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/protocol" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | func TestHomePickerStatusKeepsActiveRemoteRoute(t *testing.T) { | |
| 13 | - | for _, tc := range []struct { | |
| 14 | - | name string | |
| 15 | - | homePicker bool | |
| 16 | - | noSnapshot bool | |
| 17 | - | staleRef bool | |
| 18 | - | ephemeral bool | |
| 19 | - | wantSession string | |
| 20 | - | }{ | |
| 21 | - | {name: "ordinary local picker ignores unrelated route", wantSession: "vev"}, | |
| 22 | - | {name: "remote home picker", homePicker: true, wantSession: "misc@igor"}, | |
| 23 | - | {name: "ephemeral remote route", homePicker: true, ephemeral: true, wantSession: "misc*@igor"}, | |
| 24 | - | {name: "before route publication", homePicker: true, noSnapshot: true}, | |
| 25 | - | {name: "inconsistent active reference", homePicker: true, staleRef: true}, | |
| 26 | - | } { | |
| 27 | - | t.Run(tc.name, func(t *testing.T) { | |
| 28 | - | d, sess, ac, _, releases := newManualTabSession(t, 1) | |
| 29 | - | defer releases[0]() | |
| 30 | - | sess.name = "vev" | |
| 31 | - | sess.incarnation = domain.SessionLifecycleID{1} | |
| 32 | - | d.barScripts = &barScriptState{outputs: map[domain.SessionID]barScriptOutputs{ | |
| 33 | - | sess.id: {topRight: "local top script", bottomRight: "local bottom script"}, | |
| 34 | - | }} | |
| 35 | - | if tc.homePicker { | |
| 36 | - | ac.startupOverlay = protocol.StartupOverlaySessionPicker | |
| 37 | - | ac.navigationCapabilities = protocol.NavigationCapabilityBack | |
| 38 | - | } | |
| 39 | - | ref := protocol.RouteRef{Key: 2, Generation: 1} | |
| 40 | - | snapshot := protocol.RecentRouteSnapshot{ | |
| 41 | - | Generation: 2, Active: ref, | |
| 42 | - | ActiveEntry: protocol.RecentRouteEntry{ | |
| 43 | - | Key: ref.Key, Generation: ref.Generation, | |
| 44 | - | Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "misc"}, | |
| 45 | - | Name: "misc", HostLabel: "dev@igor", Kind: protocol.RouteKindRemote, Ephemeral: tc.ephemeral, | |
| 46 | - | }, | |
| 47 | - | } | |
| 48 | - | if tc.staleRef { | |
| 49 | - | snapshot.Active.Generation++ | |
| 50 | - | } | |
| 51 | - | if !tc.noSnapshot { | |
| 52 | - | ac.setRouteSnapshot(snapshot) | |
| 53 | - | } | |
| 54 | - | d.enterPicker(sess, ac) | |
| 55 | - | require.True(t, ac.overlays.pickerActive()) | |
| 56 | - | bars := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{}) | |
| 57 | - | frame := composeFrame(capturedRenderState{window: domain.Size{Cols: 80, Rows: 24}, bars: bars}, composeCacheInput{}).frame | |
| 58 | - | require.Equal(t, tc.wantSession, bars.status.session) | |
| 59 | - | if tc.wantSession != "" { | |
| 60 | - | require.Contains(t, rowText(frame.Row(frame.Height-1)), tc.wantSession) | |
| 61 | - | } | |
| 62 | - | if tc.homePicker { | |
| 63 | - | require.Empty(t, bars.status.tabs, "local tabs must not masquerade as remote tabs") | |
| 64 | - | require.Empty(t, bars.topRight) | |
| 65 | - | require.Empty(t, bars.bottomRight) | |
| 66 | - | require.NotContains(t, rowText(frame.Row(frame.Height-1)), "vev") | |
| 67 | - | } else { | |
| 68 | - | require.Len(t, bars.status.tabs, 1) | |
| 69 | - | require.Equal(t, "local top script", bars.topRight) | |
| 70 | - | require.Equal(t, "local bottom script", bars.bottomRight) | |
| 71 | - | } | |
| 72 | - | require.Equal(t, "vev", sess.statusSegments(true).session, "picker presentation must not rename its local backing session") | |
| 73 | - | }) | |
| 74 | - | } | |
| 75 | - | } | |
M internal/usecase/daemon/input.go +12 -1
| @@ -29,6 +29,12 @@ func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, _ u | |||
| 29 | 29 | ||
| 30 | 30 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |
| 31 | 31 | ac.initOverlays() | |
| 32 | + | if ac.overlays.pickerClientActive() { | |
| 33 | + | // The client-owned picker owns the input: raw keys and mouse events | |
| 34 | + | // must not reach the session while it is open. Only its typed | |
| 35 | + | // selection and close messages act, on the control path. | |
| 36 | + | return | |
| 37 | + | } | |
| 32 | 38 | ac.mouseScan.Scan(data, | |
| 33 | 39 | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | |
| 34 | 40 | func(b []byte) { | |
| @@ -43,6 +49,11 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 43 | 49 | func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte) { | |
| 44 | 50 | ac := effect.ac | |
| 45 | 51 | ac.initOverlays() | |
| 52 | + | if ac.overlays.pickerClientActive() { | |
| 53 | + | // Same rule as handleInput: the client-owned picker consumes user | |
| 54 | + | // input, mouse included, until it closes. | |
| 55 | + | return | |
| 56 | + | } | |
| 46 | 57 | ac.mouseScan.Scan(data, | |
| 47 | 58 | func(ev mouse.Event) { | |
| 48 | 59 | if effect.current() { | |
| @@ -72,7 +83,7 @@ func (d *Daemon) handleMouseMutation(ac *attachedClient, ev mouse.Event) { | |||
| 72 | 83 | frameEvent := ev | |
| 73 | 84 | ac.initOverlays() | |
| 74 | 85 | rt := ac.overlays | |
| 75 | - | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() { | |
| 86 | + | if rt.promptActive() || rt.paletteActive() || rt.pickerClientActive() || rt.noticesActive() || rt.resizeModeActive() { | |
| 76 | 87 | return | |
| 77 | 88 | } | |
| 78 | 89 | sess := ac.currentSession() | |
M internal/usecase/daemon/input_test.go +2 -23
| @@ -635,11 +635,10 @@ func TestOverlayInputPrecedence(t *testing.T) { | |||
| 635 | 635 | check func(t *testing.T, ac *attachedClient, writes chan []byte) | |
| 636 | 636 | }{ | |
| 637 | 637 | { | |
| 638 | - | name: "prompt before palette picker copy and normal", | |
| 638 | + | name: "prompt before palette copy and normal", | |
| 639 | 639 | setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Frame) { | |
| 640 | 640 | t.Helper() | |
| 641 | 641 | d.enterCopyMode(sess, ac) | |
| 642 | - | d.enterPicker(sess, ac) | |
| 643 | 642 | d.enterPalette(sess, ac) | |
| 644 | 643 | d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil }) | |
| 645 | 644 | }, | |
| @@ -648,41 +647,21 @@ func TestOverlayInputPrecedence(t *testing.T) { | |||
| 648 | 647 | t.Helper() | |
| 649 | 648 | require.Equal(t, "x", ac.overlays.prompt.Value()) | |
| 650 | 649 | require.Equal(t, "", ac.overlays.palette.Query()) | |
| 651 | - | require.True(t, ac.overlays.pickerActive()) | |
| 652 | 650 | require.True(t, ac.overlays.copyActive()) | |
| 653 | 651 | requireNoPTYWrite(t, writes) | |
| 654 | 652 | }, | |
| 655 | 653 | }, | |
| 656 | 654 | { | |
| 657 | - | name: "palette before picker copy and normal", | |
| 655 | + | name: "palette before copy and normal", | |
| 658 | 656 | setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Frame) { | |
| 659 | 657 | t.Helper() | |
| 660 | 658 | d.enterCopyMode(sess, ac) | |
| 661 | - | d.enterPicker(sess, ac) | |
| 662 | 659 | d.enterPalette(sess, ac) | |
| 663 | 660 | }, | |
| 664 | 661 | input: []byte("x"), | |
| 665 | 662 | check: func(t *testing.T, ac *attachedClient, writes chan []byte) { | |
| 666 | 663 | t.Helper() | |
| 667 | 664 | require.Equal(t, "x", ac.overlays.palette.Query()) | |
| 668 | - | require.True(t, ac.overlays.pickerActive()) | |
| 669 | - | require.True(t, ac.overlays.copyActive()) | |
| 670 | - | requireNoPTYWrite(t, writes) | |
| 671 | - | }, | |
| 672 | - | }, | |
| 673 | - | { | |
| 674 | - | name: "picker before copy and normal", | |
| 675 | - | setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Frame) { | |
| 676 | - | t.Helper() | |
| 677 | - | d.enterCopyMode(sess, ac) | |
| 678 | - | d.enterPicker(sess, ac) | |
| 679 | - | }, | |
| 680 | - | input: []byte("j"), | |
| 681 | - | check: func(t *testing.T, ac *attachedClient, writes chan []byte) { | |
| 682 | - | t.Helper() | |
| 683 | - | target, ok := ac.overlays.picker.Selected() | |
| 684 | - | require.True(t, ok) | |
| 685 | - | require.Equal(t, 1, target.TabIndex) | |
| 686 | 665 | require.True(t, ac.overlays.copyActive()) | |
| 687 | 666 | requireNoPTYWrite(t, writes) | |
| 688 | 667 | }, | |
M internal/usecase/daemon/move.go +6 -0
| @@ -10,6 +10,9 @@ import ( | |||
| 10 | 10 | // immutable identity. Session names are advisory; the ID and incarnation are | |
| 11 | 11 | // the commit-time authority. | |
| 12 | 12 | type movePaneRequest struct { | |
| 13 | + | Follow bool | |
| 14 | + | BeforeFollow func() error | |
| 15 | + | AfterFollow func(*attachmentEffect) | |
| 13 | 16 | Attachment *attachedClient | |
| 14 | 17 | AttachmentCapability attachmentCapability | |
| 15 | 18 | Source moveSessionLocator | |
| @@ -20,6 +23,9 @@ type movePaneRequest struct { | |||
| 20 | 23 | } | |
| 21 | 24 | ||
| 22 | 25 | type moveTabRequest struct { | |
| 26 | + | Follow bool | |
| 27 | + | BeforeFollow func() error | |
| 28 | + | AfterFollow func(*attachmentEffect) | |
| 23 | 29 | Attachment *attachedClient | |
| 24 | 30 | AttachmentCapability attachmentCapability | |
| 25 | 31 | Source moveSessionLocator | |
M internal/usecase/daemon/move_pane_admission.go +3 -0
| @@ -26,6 +26,9 @@ type movePaneAdmission struct { | |||
| 26 | 26 | func (p *movePaneTopology) transactionRequest() moveTransactionRequest { | |
| 27 | 27 | req := p.req | |
| 28 | 28 | return moveTransactionRequest{ | |
| 29 | + | follow: req.Follow, | |
| 30 | + | beforeFollow: req.BeforeFollow, | |
| 31 | + | afterFollow: req.AfterFollow, | |
| 29 | 32 | operation: "pane", | |
| 30 | 33 | attachment: req.Attachment, | |
| 31 | 34 | attachmentCapability: req.AttachmentCapability, | |
M internal/usecase/daemon/move_picker.go +34 -126
| @@ -3,9 +3,27 @@ package daemon | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "github.com/bnema/vev/internal/domain" | |
| 6 | + | "github.com/bnema/vev/internal/protocol" | |
| 6 | 7 | "github.com/bnema/vev/internal/usecase/picker" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 10 | + | // moveSourceLocator captures the exact source of one move at command time. The | |
| 11 | + | // presenting client never supplies it: only the daemon resolves it, and only | |
| 12 | + | // for the interaction that captured it. | |
| 13 | + | type moveSessionLocator struct { | |
| 14 | + | ID domain.SessionID | |
| 15 | + | Incarnation domain.IncarnationID | |
| 16 | + | Name string | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type moveSourceLocator struct { | |
| 20 | + | Session moveSessionLocator | |
| 21 | + | TabID domain.TabStableID | |
| 22 | + | PaneID domain.PaneStableID | |
| 23 | + | Attachment *attachedClient | |
| 24 | + | AttachmentCapability attachmentCapability | |
| 25 | + | } | |
| 26 | + | ||
| 9 | 27 | func (d *Daemon) movePickerSourceError(source moveSourceLocator) error { | |
| 10 | 28 | if d == nil || source.Session.ID == "" || source.TabID == "" { | |
| 11 | 29 | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | |
| @@ -37,66 +55,22 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error { | |||
| 37 | 55 | return nil | |
| 38 | 56 | } | |
| 39 | 57 | ||
| 40 | - | type pickerIntent uint8 | |
| 41 | - | ||
| 42 | - | const ( | |
| 43 | - | pickerNavigate pickerIntent = iota | |
| 44 | - | pickerMovePane | |
| 45 | - | pickerMoveTab | |
| 46 | - | ) | |
| 47 | - | ||
| 48 | - | type moveSessionLocator struct { | |
| 49 | - | ID domain.SessionID | |
| 50 | - | Incarnation domain.IncarnationID | |
| 51 | - | Name string | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | type moveSourceLocator struct { | |
| 55 | - | Session moveSessionLocator | |
| 56 | - | TabID domain.TabStableID | |
| 57 | - | PaneID domain.PaneStableID | |
| 58 | - | Attachment *attachedClient | |
| 59 | - | AttachmentCapability attachmentCapability | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // enterPickerForIntent returns errNoMoveDestination only for move intents. | |
| 63 | - | func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error { | |
| 64 | - | if intent != pickerNavigate && !sess.capabilities().yieldsMoves() { | |
| 65 | - | return errSessionCannotYieldMoves | |
| 66 | - | } | |
| 67 | - | if source.Attachment != nil && source.AttachmentCapability.ac == nil { | |
| 68 | - | source.AttachmentCapability = sess.captureAttachmentCapability(source.Attachment, source.Attachment.transport()) | |
| 69 | - | } | |
| 70 | - | model := d.newPickerModel(sess, ac, intent, source, picker.SourceFilter{}) | |
| 71 | - | if intent != pickerNavigate { | |
| 72 | - | if _, ok := model.Selected(); !ok { | |
| 73 | - | return errNoMoveDestination | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | d.publishPicker(sess, ac, model, intent, source) | |
| 78 | - | return nil | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | func pickerSelectionMode(intent pickerIntent) picker.SelectionMode { | |
| 82 | - | switch intent { | |
| 83 | - | case pickerMovePane: | |
| 84 | - | return picker.SelectMovePaneTab | |
| 85 | - | case pickerMoveTab: | |
| 86 | - | return picker.SelectMoveTabSession | |
| 87 | - | default: | |
| 88 | - | return picker.SelectNavigationTab | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error { | |
| 58 | + | // commitMovePickerSelection performs the move the owning source authorised. | |
| 59 | + | // beforeFollow runs once admission proves the move will follow; afterFollow | |
| 60 | + | // receives the fresh effect on the published destination capability that the | |
| 61 | + | // move postcommit admits, so a composite follow can report its completion | |
| 62 | + | // after the committing effect ended. | |
| 63 | + | func (d *Daemon) commitMovePickerSelection(intent protocol.PickerIntent, source moveSourceLocator, target picker.Target, beforeFollow func() error, afterFollow func(*attachmentEffect)) error { | |
| 93 | 64 | destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name} | |
| 94 | 65 | switch intent { | |
| 95 | - | case pickerMovePane: | |
| 66 | + | case protocol.PickerIntentMovePane: | |
| 96 | 67 | if target.TabID == "" { | |
| 97 | 68 | return errMovePaneInvalid | |
| 98 | 69 | } | |
| 99 | 70 | return d.movePane(movePaneRequest{ | |
| 71 | + | Follow: true, | |
| 72 | + | BeforeFollow: beforeFollow, | |
| 73 | + | AfterFollow: afterFollow, | |
| 100 | 74 | Attachment: source.Attachment, | |
| 101 | 75 | AttachmentCapability: source.AttachmentCapability, | |
| 102 | 76 | Source: source.Session, | |
| @@ -105,8 +79,11 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc | |||
| 105 | 79 | Destination: destination, | |
| 106 | 80 | DestinationTabID: target.TabID, | |
| 107 | 81 | }) | |
| 108 | - | case pickerMoveTab: | |
| 82 | + | case protocol.PickerIntentMoveTab: | |
| 109 | 83 | return d.moveTab(moveTabRequest{ | |
| 84 | + | Follow: true, | |
| 85 | + | BeforeFollow: beforeFollow, | |
| 86 | + | AfterFollow: afterFollow, | |
| 110 | 87 | Attachment: source.Attachment, | |
| 111 | 88 | AttachmentCapability: source.AttachmentCapability, | |
| 112 | 89 | Source: source.Session, | |
| @@ -118,7 +95,7 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc | |||
| 118 | 95 | } | |
| 119 | 96 | } | |
| 120 | 97 | ||
| 121 | - | func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) { | |
| 98 | + | func (d *Daemon) previewTarget(target picker.Target, intent protocol.PickerIntent) (*session, *tab) { | |
| 122 | 99 | d.mu.Lock() | |
| 123 | 100 | sess := d.sessions[target.Session] | |
| 124 | 101 | if sess == nil { | |
| @@ -131,7 +108,7 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess | |||
| 131 | 108 | if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) { | |
| 132 | 109 | return nil, nil | |
| 133 | 110 | } | |
| 134 | - | if intent == pickerMoveTab { | |
| 111 | + | if intent == protocol.PickerIntentMoveTab { | |
| 135 | 112 | if len(sess.tabs) == 0 { | |
| 136 | 113 | return nil, nil | |
| 137 | 114 | } | |
| @@ -150,72 +127,3 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess | |||
| 150 | 127 | } | |
| 151 | 128 | return sess, sess.tabs[target.TabIndex] | |
| 152 | 129 | } | |
| 153 | - | ||
| 154 | - | type pickerRefreshOptions struct { | |
| 155 | - | // preserveSelection keeps the currently selected target selected across the | |
| 156 | - | // rebuild even for navigate intent (used by the sort toggle). | |
| 157 | - | preserveSelection bool | |
| 158 | - | // nearestRow, when >= 0, selects the row occupying this index after the | |
| 159 | - | // rebuild (used after deletes). -1 disables it. | |
| 160 | - | nearestRow int | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) { | |
| 164 | - | sess := ac.currentSession() | |
| 165 | - | if sess == nil { | |
| 166 | - | return | |
| 167 | - | } | |
| 168 | - | rt := ac.overlays | |
| 169 | - | rt.pickerMu.Lock() | |
| 170 | - | if rt.picker == nil { | |
| 171 | - | rt.pickerMu.Unlock() | |
| 172 | - | return | |
| 173 | - | } | |
| 174 | - | intent, source := rt.pickerIntent, rt.pickerSource | |
| 175 | - | observedModel, observedGeneration := rt.picker, rt.pickerGeneration | |
| 176 | - | rt.pickerRefreshSequence++ | |
| 177 | - | refreshSequence := rt.pickerRefreshSequence | |
| 178 | - | current := picker.SourceFilter{} | |
| 179 | - | if intent != pickerNavigate || opts.preserveSelection { | |
| 180 | - | cursor, _ := rt.picker.Cursor() | |
| 181 | - | current = picker.SourceFilter{ | |
| 182 | - | Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey, | |
| 183 | - | } | |
| 184 | - | } | |
| 185 | - | rt.pickerMu.Unlock() | |
| 186 | - | model := d.newPickerModel(sess, ac, intent, source, current) | |
| 187 | - | if rt.afterPickerRefreshBuild != nil { | |
| 188 | - | rt.afterPickerRefreshBuild(model) | |
| 189 | - | } | |
| 190 | - | if intent != pickerNavigate { | |
| 191 | - | if _, ok := model.Selected(); !ok { | |
| 192 | - | d.closePickerIfCurrentRefresh(ac, observedModel, observedGeneration, refreshSequence) | |
| 193 | - | return | |
| 194 | - | } | |
| 195 | - | } | |
| 196 | - | rt.pickerMu.Lock() | |
| 197 | - | updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerRefreshSequence == refreshSequence && rt.pickerIntent == intent && rt.pickerSource == source | |
| 198 | - | var before, after picker.Target | |
| 199 | - | var haveBefore, haveAfter bool | |
| 200 | - | if updated { | |
| 201 | - | before, haveBefore = rt.picker.Selected() | |
| 202 | - | rt.picker.ReplaceFrom(model) | |
| 203 | - | if intent == pickerNavigate && opts.nearestRow >= 0 { | |
| 204 | - | rt.picker.SelectNearestRow(opts.nearestRow) | |
| 205 | - | } | |
| 206 | - | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | |
| 207 | - | after, haveAfter = rt.picker.Selected() | |
| 208 | - | } | |
| 209 | - | rt.pickerMu.Unlock() | |
| 210 | - | if !updated { | |
| 211 | - | return | |
| 212 | - | } | |
| 213 | - | // A directory publication rebuilds rows around the same selection: | |
| 214 | - | // keep an in-flight remote preview when the exact selected route did | |
| 215 | - | // not change instead of cancelling it on every revision. Availability | |
| 216 | - | // reasons render around the route and are ignored here. | |
| 217 | - | if haveBefore && haveAfter && pickerRouteTargetsEqual(before, after) { | |
| 218 | - | return | |
| 219 | - | } | |
| 220 | - | d.registerPreviewForSelection(ac) | |
| 221 | - | } | |
M internal/usecase/daemon/move_picker_test.go +508 -160
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "sync/atomic" | |
| 5 | 4 | "testing" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| @@ -9,7 +8,8 @@ import ( | |||
| 9 | 8 | ||
| 10 | 9 | "github.com/bnema/vev/internal/domain" | |
| 11 | 10 | "github.com/bnema/vev/internal/ports" | |
| 12 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) { | |
| @@ -18,9 +18,24 @@ func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, * | |||
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) { | |
| 21 | + | t.Helper() | |
| 22 | + | d, source, ac, destination, destinationTab, releases, sends := setupMovePickerSessionsCore(t, clock, extraDestinationTabs) | |
| 23 | + | // The move suites drive typed interactions, so the daemon publishes offers | |
| 24 | + | // and snapshots to this attachment: drain them in the background so a | |
| 25 | + | // guarded send never blocks the fixture. | |
| 26 | + | go func() { | |
| 27 | + | for range sends { | |
| 28 | + | } | |
| 29 | + | }() | |
| 30 | + | return d, source, ac, destination, destinationTab, releases | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // setupMovePickerSessionsCore builds the move fixture without draining the | |
| 34 | + | // attachment transport, so a regression can read exactly what the daemon sent. | |
| 35 | + | func setupMovePickerSessionsCore(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func(), chan wire.Frame) { | |
| 21 | 36 | t.Helper() | |
| 22 | 37 | sourcePTY, releaseSource := newBlockingPTY(t) | |
| 23 | - | d, source, ac, _ := newManualSessionWithPTYsClock(t, clock, sourcePTY) | |
| 38 | + | d, source, ac, sends := newManualSessionWithPTYsClock(t, clock, sourcePTY) | |
| 24 | 39 | source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1} | |
| 25 | 40 | d.mu.Lock() | |
| 26 | 41 | delete(d.sessions, domain.SessionID("manual")) | |
| @@ -48,7 +63,69 @@ func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDest | |||
| 48 | 63 | d.mu.Lock() | |
| 49 | 64 | d.sessions[destination.id] = destination | |
| 50 | 65 | d.mu.Unlock() | |
| 51 | - | return d, source, ac, destination, destinationTab, releases | |
| 66 | + | return d, source, ac, destination, destinationTab, releases, sends | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // moveSourceForSession names the exact source a move command captures. The | |
| 70 | + | // attachment carrying the command travels with it, exactly like the palette | |
| 71 | + | // entry: the move transaction must freeze every other effect but this one. | |
| 72 | + | func moveSourceForSession(sess *session, ac *attachedClient, tabID domain.TabStableID, paneID domain.PaneStableID) moveSourceLocator { | |
| 73 | + | return moveSourceLocator{ | |
| 74 | + | Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name}, | |
| 75 | + | TabID: tabID, | |
| 76 | + | PaneID: paneID, | |
| 77 | + | Attachment: ac, | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // openMovePickerForTest opens one move interaction, exactly like the palette | |
| 82 | + | // command does, and releases the effect that carried the offer: the | |
| 83 | + | // interaction namespace lives on the attachment, and a later move must be able | |
| 84 | + | // to freeze every other effect on the source. | |
| 85 | + | func openMovePickerForTest(t *testing.T, d *Daemon, ac *attachedClient, sess *session, intent protocol.PickerIntent, source moveSourceLocator) { | |
| 86 | + | t.Helper() | |
| 87 | + | current := ac.transportSnapshot() | |
| 88 | + | _, effect, admitted := ac.beginCurrentAttachmentEffect(sess, current.transport) | |
| 89 | + | if !admitted { | |
| 90 | + | // A test may deliberately drop the source attachment before opening; | |
| 91 | + | // the captured capability still admits the open. | |
| 92 | + | effect, admitted = ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 93 | + | } | |
| 94 | + | require.True(t, admitted) | |
| 95 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, intent, source, 0)) | |
| 96 | + | effect.End() | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // pickActionEffectForTest admits one effect for a typed action. A move drains | |
| 100 | + | // every live effect on its source, so the caller ends it right before the | |
| 101 | + | // commit, exactly like a client frame that has already been consumed. | |
| 102 | + | func pickActionEffectForTest(t *testing.T, sess *session, ac *attachedClient) *attachmentEffect { | |
| 103 | + | t.Helper() | |
| 104 | + | effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 105 | + | require.True(t, admitted) | |
| 106 | + | return effect | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // moveSelectionForDestination builds the typed move the client would send for | |
| 110 | + | // the destination session's first selectable line. | |
| 111 | + | func moveSelectionForDestination(t *testing.T, ac *attachedClient, destination domain.SessionID) protocol.PickerSelection { | |
| 112 | + | t.Helper() | |
| 113 | + | ac.overlays.pickerMu.Lock() | |
| 114 | + | interaction := ac.overlays.pickerInteraction | |
| 115 | + | revision := ac.overlays.pickerRevisions[servingPickerSourceID] | |
| 116 | + | key := "" | |
| 117 | + | for candidate, target := range ac.overlays.pickerKeys { | |
| 118 | + | if target.Session == destination { | |
| 119 | + | key = candidate | |
| 120 | + | break | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ac.overlays.pickerMu.Unlock() | |
| 124 | + | require.NotEmpty(t, key, "no move destination resolves to session %s", destination) | |
| 125 | + | return protocol.PickerSelection{ | |
| 126 | + | InteractionID: interaction, SourceID: servingPickerSourceID, | |
| 127 | + | SourceRevision: revision, Key: key, Action: protocol.PickerActionMove, | |
| 128 | + | } | |
| 52 | 129 | } | |
| 53 | 130 | ||
| 54 | 131 | func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) { | |
| @@ -59,12 +136,13 @@ func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) { | |||
| 59 | 136 | require.True(t, ac.overlays.paletteActive()) | |
| 60 | 137 | d.handleInput(source, ac, []byte("MFP\r")) | |
| 61 | 138 | require.False(t, ac.overlays.paletteActive()) | |
| 62 | - | require.True(t, ac.overlays.pickerActive()) | |
| 63 | 139 | ||
| 64 | 140 | ac.overlays.pickerMu.Lock() | |
| 65 | - | intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource | |
| 141 | + | open := ac.overlays.pickerOpen | |
| 142 | + | intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerMoveSource | |
| 66 | 143 | ac.overlays.pickerMu.Unlock() | |
| 67 | - | require.Equal(t, pickerMovePane, intent) | |
| 144 | + | require.True(t, open) | |
| 145 | + | require.Equal(t, protocol.PickerIntentMovePane, intent) | |
| 68 | 146 | require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session) | |
| 69 | 147 | require.Equal(t, domain.TabStableID("source-tab"), captured.TabID) | |
| 70 | 148 | require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID) | |
| @@ -79,12 +157,13 @@ func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) { | |||
| 79 | 157 | ||
| 80 | 158 | d.handleInput(source, ac, []byte("\x1b ")) | |
| 81 | 159 | d.handleInput(source, ac, []byte("MAT\r")) | |
| 82 | - | require.True(t, ac.overlays.pickerActive()) | |
| 83 | 160 | ||
| 84 | 161 | ac.overlays.pickerMu.Lock() | |
| 85 | - | intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource | |
| 162 | + | open := ac.overlays.pickerOpen | |
| 163 | + | intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerMoveSource | |
| 86 | 164 | ac.overlays.pickerMu.Unlock() | |
| 87 | - | require.Equal(t, pickerMoveTab, intent) | |
| 165 | + | require.True(t, open) | |
| 166 | + | require.Equal(t, protocol.PickerIntentMoveTab, intent) | |
| 88 | 167 | require.Equal(t, domain.TabStableID("active-tab"), captured.TabID) | |
| 89 | 168 | require.Equal(t, domain.PaneStableID(""), captured.PaneID) | |
| 90 | 169 | require.Same(t, ac, captured.Attachment) | |
| @@ -100,116 +179,99 @@ func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) { | |||
| 100 | 179 | d.handleInput(source, ac, []byte("\x1b ")) | |
| 101 | 180 | d.handleInput(source, ac, []byte("MFP\r")) | |
| 102 | 181 | ||
| 103 | - | require.False(t, ac.overlays.pickerActive()) | |
| 182 | + | require.False(t, ac.overlays.pickerClientActive()) | |
| 104 | 183 | history := d.notices.history() | |
| 105 | 184 | require.NotEmpty(t, history) | |
| 106 | 185 | require.Equal(t, "No destination available.", history[0].Message) | |
| 107 | 186 | } | |
| 108 | 187 | ||
| 109 | - | func TestMovePickerEnterCommitsMovePane(t *testing.T) { | |
| 188 | + | func TestMovePickerMoveCommitsPane(t *testing.T) { | |
| 110 | 189 | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 111 | 190 | defer releaseAll(releases) | |
| 112 | - | source.mu.Lock() | |
| 113 | - | clearAttachmentsForTestLocked(source) | |
| 114 | - | source.mu.Unlock() | |
| 115 | 191 | sourceTab := source.tabs[0] | |
| 116 | 192 | moved := sourceTab.focusedPane() | |
| 117 | 193 | ||
| 118 | - | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 119 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 120 | - | TabID: "source-tab", PaneID: "source-pane", | |
| 121 | - | })) | |
| 122 | - | d.handlePickerInput(ac, []byte("\r")) | |
| 194 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane")) | |
| 195 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 196 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 197 | + | effect.End() | |
| 198 | + | d.resolvePickerSelection(effect, selection) | |
| 123 | 199 | ||
| 124 | - | require.False(t, ac.overlays.pickerActive()) | |
| 125 | 200 | require.Nil(t, source.tabs) | |
| 126 | 201 | require.Same(t, moved, destinationTab.panes[moved.id]) | |
| 127 | 202 | require.Same(t, destination, moved.ownerSnapshot().session) | |
| 128 | 203 | } | |
| 129 | 204 | ||
| 130 | - | func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) { | |
| 205 | + | func TestMovePickerCommitMovePaneViaSharedAPI(t *testing.T) { | |
| 131 | 206 | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 132 | 207 | defer releaseAll(releases) | |
| 133 | - | source.mu.Lock() | |
| 134 | - | clearAttachmentsForTestLocked(source) | |
| 135 | - | source.mu.Unlock() | |
| 136 | 208 | sourceTab := source.tabs[0] | |
| 137 | 209 | moved := sourceTab.focusedPane() | |
| 138 | 210 | ||
| 139 | - | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 140 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 141 | - | TabID: "source-tab", PaneID: "source-pane", | |
| 142 | - | })) | |
| 211 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane")) | |
| 212 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 143 | 213 | ac.overlays.pickerMu.Lock() | |
| 144 | - | target, ok := ac.overlays.picker.Selected() | |
| 214 | + | target, ok := ac.overlays.pickerKeys[selection.Key] | |
| 145 | 215 | ac.overlays.pickerMu.Unlock() | |
| 146 | 216 | require.True(t, ok) | |
| 147 | - | require.NoError(t, d.commitMovePickerSelection(pickerMovePane, moveSourceLocator{ | |
| 148 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 149 | - | TabID: "source-tab", PaneID: "source-pane", | |
| 150 | - | }, target)) | |
| 217 | + | require.NoError(t, d.commitMovePickerSelection(protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"), target, nil, nil)) | |
| 151 | 218 | ||
| 152 | 219 | require.Nil(t, source.tabs) | |
| 153 | 220 | require.Same(t, moved, destinationTab.panes[moved.id]) | |
| 154 | 221 | require.Same(t, destination, moved.ownerSnapshot().session) | |
| 155 | 222 | } | |
| 156 | 223 | ||
| 157 | - | func TestMovePickerEnterCommitsMoveTab(t *testing.T) { | |
| 224 | + | func TestMovePickerMoveCommitsTab(t *testing.T) { | |
| 158 | 225 | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 159 | 226 | defer releaseAll(releases) | |
| 160 | - | source.mu.Lock() | |
| 161 | - | clearAttachmentsForTestLocked(source) | |
| 162 | - | source.mu.Unlock() | |
| 163 | 227 | movedTab := source.tabs[0] | |
| 164 | 228 | movedTab.stableID = "moved-tab" | |
| 165 | 229 | ||
| 166 | - | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{ | |
| 167 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 168 | - | TabID: "moved-tab", | |
| 169 | - | })) | |
| 170 | - | d.handlePickerInput(ac, []byte("\r")) | |
| 230 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "moved-tab", "")) | |
| 231 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 232 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 233 | + | completed := make(chan struct{}) | |
| 234 | + | go func() { | |
| 235 | + | d.resolvePickerSelection(effect, selection) | |
| 236 | + | close(completed) | |
| 237 | + | }() | |
| 238 | + | awaitTestCompletion(t, completed, "move-tab picker selection deadlocked on its own attachment effect") | |
| 171 | 239 | ||
| 172 | - | require.False(t, ac.overlays.pickerActive()) | |
| 173 | 240 | require.Nil(t, source.tabs) | |
| 174 | 241 | require.Len(t, destination.tabs, 2) | |
| 175 | 242 | require.Same(t, movedTab, destination.tabs[1]) | |
| 176 | 243 | } | |
| 177 | 244 | ||
| 178 | - | func TestMovePickerEscapePerformsNoMutation(t *testing.T) { | |
| 179 | - | clk := &signalClock{timers: make(chan *signalTimer, 16)} | |
| 180 | - | d, source, ac, _, _, releases := setupMovePickerSessionsWithClock(t, clk, 0) | |
| 245 | + | func TestMovePickerCancelPerformsNoMutation(t *testing.T) { | |
| 246 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 181 | 247 | defer releaseAll(releases) | |
| 182 | 248 | before := len(source.tabs) | |
| 183 | 249 | ||
| 184 | - | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 185 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 186 | - | TabID: "source-tab", PaneID: "source-pane", | |
| 187 | - | })) | |
| 188 | - | for len(clk.timers) > 0 { | |
| 189 | - | <-clk.timers | |
| 190 | - | } | |
| 191 | - | d.handlePickerInput(ac, []byte("\x1b")) | |
| 192 | - | timer := <-clk.timers | |
| 193 | - | timer.ch <- time.Now() | |
| 194 | - | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | |
| 250 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane")) | |
| 251 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 252 | + | ac.overlays.pickerMu.Lock() | |
| 253 | + | interaction := ac.overlays.pickerInteraction | |
| 254 | + | ac.overlays.pickerMu.Unlock() | |
| 255 | + | require.True(t, d.closePickerForAttachment(ac, effect, interaction)) | |
| 256 | + | ||
| 257 | + | require.False(t, ac.overlays.pickerClientActive()) | |
| 195 | 258 | require.Len(t, source.tabs, before) | |
| 196 | 259 | } | |
| 197 | 260 | ||
| 198 | - | func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) { | |
| 261 | + | func TestMovePickerStaleDestinationReportsNotice(t *testing.T) { | |
| 199 | 262 | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 200 | 263 | defer releaseAll(releases) | |
| 201 | 264 | ||
| 202 | - | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 203 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 204 | - | TabID: "source-tab", PaneID: "source-pane", | |
| 205 | - | })) | |
| 265 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane")) | |
| 266 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 267 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 206 | 268 | d.mu.Lock() | |
| 207 | 269 | delete(d.sessions, destination.id) | |
| 208 | 270 | d.mu.Unlock() | |
| 209 | 271 | ||
| 210 | - | d.handlePickerInput(ac, []byte("\r")) | |
| 272 | + | effect.End() | |
| 273 | + | d.resolvePickerSelection(effect, selection) | |
| 211 | 274 | ||
| 212 | - | require.False(t, ac.overlays.pickerActive()) | |
| 213 | 275 | history := d.notices.history() | |
| 214 | 276 | require.NotEmpty(t, history) | |
| 215 | 277 | require.Equal(t, "Destination is no longer available.", history[0].Message) | |
| @@ -217,147 +279,433 @@ func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) { | |||
| 217 | 279 | } | |
| 218 | 280 | ||
| 219 | 281 | func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) { | |
| 220 | - | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 282 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 221 | 283 | defer releaseAll(releases) | |
| 222 | 284 | sourceTab := source.tabs[0] | |
| 223 | 285 | ||
| 224 | - | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 225 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 226 | - | TabID: "source-tab", PaneID: "source-pane", | |
| 227 | - | })) | |
| 286 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane")) | |
| 287 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 288 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 228 | 289 | sourceTab.mu.Lock() | |
| 229 | 290 | delete(sourceTab.panes, sourceTab.tree.Focus) | |
| 230 | 291 | sourceTab.mu.Unlock() | |
| 231 | 292 | ||
| 232 | - | d.handlePickerInput(ac, []byte("\r")) | |
| 293 | + | effect.End() | |
| 294 | + | d.resolvePickerSelection(effect, selection) | |
| 233 | 295 | ||
| 234 | - | require.True(t, ac.overlays.pickerActive()) | |
| 296 | + | require.True(t, ac.overlays.pickerClientActive()) | |
| 235 | 297 | history := d.notices.history() | |
| 236 | 298 | require.NotEmpty(t, history) | |
| 237 | 299 | require.Equal(t, "Pane no longer exists.", history[0].Message) | |
| 238 | 300 | } | |
| 239 | 301 | ||
| 240 | 302 | func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) { | |
| 241 | - | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 303 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 242 | 304 | defer releaseAll(releases) | |
| 243 | 305 | ||
| 244 | - | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{ | |
| 245 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 246 | - | TabID: "source-tab", | |
| 247 | - | })) | |
| 306 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "source-tab", "")) | |
| 307 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 308 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 248 | 309 | source.mu.Lock() | |
| 249 | 310 | source.tabs = nil | |
| 250 | 311 | source.mu.Unlock() | |
| 251 | 312 | ||
| 252 | - | d.handlePickerInput(ac, []byte("\r")) | |
| 313 | + | effect.End() | |
| 314 | + | d.resolvePickerSelection(effect, selection) | |
| 253 | 315 | ||
| 254 | - | require.True(t, ac.overlays.pickerActive()) | |
| 316 | + | require.True(t, ac.overlays.pickerClientActive()) | |
| 255 | 317 | history := d.notices.history() | |
| 256 | 318 | require.NotEmpty(t, history) | |
| 257 | 319 | require.Equal(t, "Tab no longer exists.", history[0].Message) | |
| 258 | 320 | } | |
| 259 | 321 | ||
| 260 | - | func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) { | |
| 261 | - | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 322 | + | // TestMovePickerSupersedingOpenRetiresTheEarlierNamespace pins that a second | |
| 323 | + | // open moves the interaction on, so rows published for the retired namespace | |
| 324 | + | // can never be committed again. | |
| 325 | + | func TestMovePickerSupersedingOpenRetiresTheEarlierNamespace(t *testing.T) { | |
| 326 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 262 | 327 | defer releaseAll(releases) | |
| 263 | - | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 264 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 265 | - | TabID: "source-tab", PaneID: "source-pane", | |
| 266 | - | })) | |
| 267 | - | d.mu.Lock() | |
| 268 | - | delete(d.sessions, destination.id) | |
| 269 | - | d.mu.Unlock() | |
| 328 | + | sourceSnapshot := moveSourceForSession(source, ac, "source-tab", "source-pane") | |
| 329 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, sourceSnapshot) | |
| 330 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 331 | + | ac.overlays.pickerMu.Lock() | |
| 332 | + | firstInteraction := ac.overlays.pickerInteraction | |
| 333 | + | firstRevision := ac.overlays.pickerRevisions[servingPickerSourceID] | |
| 334 | + | firstKey := "" | |
| 335 | + | for candidate := range ac.overlays.pickerKeys { | |
| 336 | + | firstKey = candidate | |
| 337 | + | break | |
| 338 | + | } | |
| 339 | + | ac.overlays.pickerMu.Unlock() | |
| 340 | + | require.NotEmpty(t, firstKey) | |
| 270 | 341 | ||
| 271 | - | rebuildReached := make(chan struct{}) | |
| 272 | - | allowClose := make(chan struct{}) | |
| 273 | - | refreshed := make(chan struct{}) | |
| 274 | - | ac.overlays.afterPickerRefreshBuild = func(*picker.Model) { | |
| 275 | - | close(rebuildReached) | |
| 276 | - | <-allowClose | |
| 342 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, sourceSnapshot) | |
| 343 | + | ac.overlays.pickerMu.Lock() | |
| 344 | + | secondInteraction := ac.overlays.pickerInteraction | |
| 345 | + | ac.overlays.pickerMu.Unlock() | |
| 346 | + | require.Greater(t, secondInteraction, firstInteraction) | |
| 347 | + | ||
| 348 | + | // A commit from the retired namespace is rejected and leaves the open | |
| 349 | + | // interaction untouched. | |
| 350 | + | d.resolvePickerSelection(effect, protocol.PickerSelection{ | |
| 351 | + | InteractionID: firstInteraction, SourceID: servingPickerSourceID, | |
| 352 | + | SourceRevision: firstRevision, Key: firstKey, Action: protocol.PickerActionMove, | |
| 353 | + | }) | |
| 354 | + | ac.overlays.pickerMu.Lock() | |
| 355 | + | require.True(t, ac.overlays.pickerOpen) | |
| 356 | + | require.Equal(t, secondInteraction, ac.overlays.pickerInteraction) | |
| 357 | + | ac.overlays.pickerMu.Unlock() | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // Exercise the real selection admission (not a pre-ended test effect), and | |
| 361 | + | // observe the publication boundary without sleeps or concurrent state reads. | |
| 362 | + | func TestMovePickerCompositeFollow(t *testing.T) { | |
| 363 | + | for _, tt := range []struct { | |
| 364 | + | name string | |
| 365 | + | intent protocol.PickerIntent | |
| 366 | + | retainSource bool | |
| 367 | + | }{ | |
| 368 | + | {"final-tab", protocol.PickerIntentMoveTab, false}, | |
| 369 | + | {"retained-source-tab", protocol.PickerIntentMoveTab, true}, | |
| 370 | + | {"final-pane", protocol.PickerIntentMovePane, false}, | |
| 371 | + | } { | |
| 372 | + | t.Run(tt.name, func(t *testing.T) { | |
| 373 | + | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 374 | + | defer releaseAll(releases) | |
| 375 | + | movedTab := source.tabs[0] | |
| 376 | + | if tt.retainSource { | |
| 377 | + | pty, release := newBlockingPTY(t) | |
| 378 | + | defer release() | |
| 379 | + | source.tabs = append(source.tabs, newTabWithStableID("remaining", "remaining-pane", pty, movedTab.size)) | |
| 380 | + | publishTiledPaneOwners(source, source.tabs[1]) | |
| 381 | + | } | |
| 382 | + | peer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size} | |
| 383 | + | peer.setSession(source) | |
| 384 | + | source.registerAttachment(peer) | |
| 385 | + | destinationPeer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size} | |
| 386 | + | destinationPeer.setSession(destination) | |
| 387 | + | destination.registerAttachment(destinationPeer) | |
| 388 | + | destination.selectAttachmentTabLocked(destinationPeer, domain.TabStableID(destinationTab.stableID)) | |
| 389 | + | old := captureAttachmentCapability(source, ac, ac.transport()) | |
| 390 | + | locator := moveSourceForSession(source, ac, "source-tab", "") | |
| 391 | + | locator.AttachmentCapability = old | |
| 392 | + | if tt.intent == protocol.PickerIntentMovePane { | |
| 393 | + | locator.PaneID = "source-pane" | |
| 394 | + | } | |
| 395 | + | openMovePickerForTest(t, d, ac, source, tt.intent, locator) | |
| 396 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 397 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 398 | + | closedAtCommit := false | |
| 399 | + | rebased := make(chan struct{}, 1) | |
| 400 | + | ac.renderStages.handoffRebase = func() { rebased <- struct{}{} } | |
| 401 | + | hook := func() { closedAtCommit = !ac.overlays.pickerClientActive() } | |
| 402 | + | d.beforeMoveTabCommit = hook | |
| 403 | + | d.beforeMovePaneCommit = hook | |
| 404 | + | done := make(chan struct{}) | |
| 405 | + | go func() { | |
| 406 | + | d.resolvePickerSelection(effect, selection) | |
| 407 | + | close(done) | |
| 408 | + | }() | |
| 409 | + | awaitTestCompletion(t, done, "composite move waited on its own effect") | |
| 410 | + | require.True(t, closedAtCommit) | |
| 411 | + | select { | |
| 412 | + | case <-rebased: | |
| 413 | + | default: | |
| 414 | + | t.Fatal("composite move skipped transition first-paint rebase") | |
| 415 | + | } | |
| 416 | + | require.True(t, destination.attachmentRegistered(destinationPeer)) | |
| 417 | + | require.Same(t, destinationTab, destination.tabForAttachment(destinationPeer)) | |
| 418 | + | require.False(t, ac.overlays.pickerClientActive()) | |
| 419 | + | require.Same(t, destination, ac.currentSession()) | |
| 420 | + | require.True(t, destination.attachmentRegistered(ac)) | |
| 421 | + | require.False(t, source.attachmentRegistered(ac)) | |
| 422 | + | require.False(t, old.current()) | |
| 423 | + | if tt.intent == protocol.PickerIntentMoveTab { | |
| 424 | + | require.Same(t, movedTab, destination.tabForAttachment(ac)) | |
| 425 | + | } else { | |
| 426 | + | require.Same(t, destinationTab, destination.tabForAttachment(ac)) | |
| 427 | + | } | |
| 428 | + | if tt.retainSource { | |
| 429 | + | require.True(t, source.attachmentRegistered(peer)) | |
| 430 | + | require.Same(t, source, d.sessionByID(source.id)) | |
| 431 | + | } else { | |
| 432 | + | require.False(t, source.attachmentRegistered(peer)) | |
| 433 | + | require.Nil(t, d.sessionByID(source.id)) | |
| 434 | + | } | |
| 435 | + | }) | |
| 277 | 436 | } | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // A composite follow ends the committing effect so the move can drain its | |
| 440 | + | // source. Its completion must still reach the client: the postcommit admits a | |
| 441 | + | // fresh effect on the published destination capability and reports the | |
| 442 | + | // correlated PickerResult the ui-driver action waits for. | |
| 443 | + | func TestMovePickerCompositeFollowReportsCorrelatedResult(t *testing.T) { | |
| 444 | + | d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0) | |
| 445 | + | defer releaseAll(releases) | |
| 446 | + | old := captureAttachmentCapability(source, ac, ac.transport()) | |
| 447 | + | locator := moveSourceForSession(source, ac, "source-tab", "") | |
| 448 | + | locator.AttachmentCapability = old | |
| 449 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator) | |
| 450 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 451 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 452 | + | selection.CauseActionID = 4242 | |
| 453 | + | done := make(chan struct{}) | |
| 278 | 454 | go func() { | |
| 279 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 280 | - | close(refreshed) | |
| 455 | + | d.resolvePickerSelection(effect, selection) | |
| 456 | + | close(done) | |
| 281 | 457 | }() | |
| 282 | - | select { | |
| 283 | - | case <-rebuildReached: | |
| 284 | - | case <-time.After(time.Second): | |
| 285 | - | t.Fatal("timed out waiting for move-picker refresh rebuild") | |
| 286 | - | } | |
| 458 | + | awaitTestCompletion(t, done, "composite follow never reported completion") | |
| 459 | + | ||
| 460 | + | result := awaitSentPickerResult(t, sends) | |
| 461 | + | require.Equal(t, selection.CauseActionID, result.CauseActionID) | |
| 462 | + | require.Equal(t, selection.InteractionID, result.InteractionID) | |
| 463 | + | require.Equal(t, selection.Key, result.Key) | |
| 464 | + | require.Equal(t, protocol.PickerActionMove, result.Action) | |
| 465 | + | assertNoFurtherFrame(t, sends, wire.MsgPickerResult, "composite follow reported its result more than once") | |
| 466 | + | } | |
| 287 | 467 | ||
| 288 | - | replacement := d.newPickerModel(source, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 289 | - | d.publishPicker(source, ac, replacement, pickerNavigate, moveSourceLocator{}) | |
| 290 | - | close(allowClose) | |
| 291 | - | select { | |
| 292 | - | case <-refreshed: | |
| 293 | - | case <-time.After(time.Second): | |
| 294 | - | t.Fatal("timed out waiting for move-picker refresh") | |
| 468 | + | // TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability pins the | |
| 469 | + | // non-admission window: when the exact capability the follow published stops | |
| 470 | + | // being admissible between its identity send and the postcommit completion (a | |
| 471 | + | // concurrent link replacement while the attachment stays live), the completion | |
| 472 | + | // must be re-admitted on the attachment's current capability and reported | |
| 473 | + | // exactly once, instead of only logging while the ui-driver action runs to its | |
| 474 | + | // deadline. | |
| 475 | + | func TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability(t *testing.T) { | |
| 476 | + | d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0) | |
| 477 | + | defer releaseAll(releases) | |
| 478 | + | base, ok := ac.transport().(*mockServerConnection) | |
| 479 | + | require.True(t, ok, "fixture transport is not the capturing mock") | |
| 480 | + | old := captureAttachmentCapability(source, ac, ac.transport()) | |
| 481 | + | locator := moveSourceForSession(source, ac, "source-tab", "") | |
| 482 | + | locator.AttachmentCapability = old | |
| 483 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator) | |
| 484 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 485 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 486 | + | selection.CauseActionID = 5252 | |
| 487 | + | ||
| 488 | + | // Supersede the exact published capability after the follow's identity send | |
| 489 | + | // and first paint but before completion admission. The replacement keeps the | |
| 490 | + | // attachment live on its current session while retiring the published link. | |
| 491 | + | // A render effect may still be in flight, so this reproduces the transition's | |
| 492 | + | // end state (a newer published capability for the replacement link) rather | |
| 493 | + | // than requiring a drained gate. | |
| 494 | + | replaced := make(chan attachmentCapability, 1) | |
| 495 | + | d.beforeMoveFollowCompletion = func(published attachmentCapability) { | |
| 496 | + | supersedeAttachmentCapabilityForTest(published, base) | |
| 497 | + | replaced <- published | |
| 295 | 498 | } | |
| 296 | - | ac.overlays.pickerMu.Lock() | |
| 297 | - | require.Same(t, replacement, ac.overlays.picker) | |
| 298 | - | ac.overlays.pickerMu.Unlock() | |
| 499 | + | done := make(chan struct{}) | |
| 500 | + | go func() { | |
| 501 | + | d.resolvePickerSelection(effect, selection) | |
| 502 | + | close(done) | |
| 503 | + | }() | |
| 504 | + | awaitTestCompletion(t, done, "composite follow never reported completion") | |
| 505 | + | published := <-replaced | |
| 506 | + | require.False(t, published.current(), "the superseded published capability was still admissible") | |
| 507 | + | ||
| 508 | + | // The completion must arrive through the current capability, exactly once. | |
| 509 | + | result := awaitSentPickerResult(t, sends) | |
| 510 | + | require.Equal(t, selection.CauseActionID, result.CauseActionID) | |
| 511 | + | require.Equal(t, selection.InteractionID, result.InteractionID) | |
| 512 | + | require.Equal(t, protocol.PickerActionMove, result.Action) | |
| 513 | + | assertNoFurtherFrame(t, sends, wire.MsgPickerResult, "composite follow reported its result more than once") | |
| 299 | 514 | } | |
| 300 | 515 | ||
| 301 | - | func TestMovePickerOlderEmptyRefreshCannotCloseNewerValidRefresh(t *testing.T) { | |
| 302 | - | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 303 | - | defer releaseAll(releases) | |
| 304 | - | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 305 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 306 | - | TabID: "source-tab", PaneID: "source-pane", | |
| 307 | - | })) | |
| 308 | - | d.mu.Lock() | |
| 309 | - | delete(d.sessions, destination.id) | |
| 310 | - | d.mu.Unlock() | |
| 516 | + | // supersedeAttachmentCapabilityForTest retires an attachment's exact published | |
| 517 | + | // capability for a replacement link that keeps the attachment live, modelling a | |
| 518 | + | // concurrent reconnect or transition that won the gate after publication. It | |
| 519 | + | // republishes the current capability directly so it also works while a render | |
| 520 | + | // effect may still be in flight. | |
| 521 | + | func supersedeAttachmentCapabilityForTest(published attachmentCapability, base *mockServerConnection) { | |
| 522 | + | wrapper := &pickerSnapshotFailTransport{mockServerConnection: base} | |
| 523 | + | published.ac.replaceTransport(wrapper) | |
| 524 | + | current := captureAttachmentCapability(published.ac.currentAttachmentSession(), published.ac, wrapper) | |
| 525 | + | gate := &published.ac.lifecycle | |
| 526 | + | gate.mu.Lock() | |
| 527 | + | gate.initLocked() | |
| 528 | + | current.generation = gate.generation.Add(1) | |
| 529 | + | gate.capability = current | |
| 530 | + | gate.failedTransport = transportSnapshot{} | |
| 531 | + | gate.mu.Unlock() | |
| 532 | + | } | |
| 311 | 533 | ||
| 312 | - | rebuildReached := make(chan struct{}) | |
| 313 | - | allowOld := make(chan struct{}) | |
| 314 | - | var builds atomic.Int32 | |
| 315 | - | ac.overlays.afterPickerRefreshBuild = func(*picker.Model) { | |
| 316 | - | if builds.Add(1) == 1 { | |
| 317 | - | close(rebuildReached) | |
| 318 | - | select { | |
| 319 | - | case <-allowOld: | |
| 320 | - | case <-time.After(time.Second): | |
| 321 | - | t.Error("timed out waiting to release the older move-picker refresh") | |
| 322 | - | } | |
| 323 | - | } | |
| 534 | + | // TestMovePickerCompositeFollowReadmitsSupersededCapabilityBeforeIdentity pins | |
| 535 | + | // the earlier non-admission window: when the exact capability the follow | |
| 536 | + | // published is superseded before finishAttachmentTransition admits its | |
| 537 | + | // committed route identity (a concurrent link replacement while the attachment | |
| 538 | + | // stays live), the transition must still send the committed identity, perform | |
| 539 | + | // the authoritative first paint, and report completion exactly once on the | |
| 540 | + | // attachment's current capability. Aborting instead would leave the post-close | |
| 541 | + | // drain and the ui-driver action pending forever. | |
| 542 | + | func TestMovePickerCompositeFollowReadmitsSupersededCapabilityBeforeIdentity(t *testing.T) { | |
| 543 | + | d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0) | |
| 544 | + | defer releaseAll(releases) | |
| 545 | + | base, ok := ac.transport().(*mockServerConnection) | |
| 546 | + | require.True(t, ok, "fixture transport is not the capturing mock") | |
| 547 | + | // A published route ledger is what makes a ready transition owe its | |
| 548 | + | // committed identity to the client. | |
| 549 | + | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1}) | |
| 550 | + | old := captureAttachmentCapability(source, ac, ac.transport()) | |
| 551 | + | locator := moveSourceForSession(source, ac, "source-tab", "") | |
| 552 | + | locator.AttachmentCapability = old | |
| 553 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator) | |
| 554 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 555 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 556 | + | selection.CauseActionID = 6161 | |
| 557 | + | ||
| 558 | + | rebased := make(chan struct{}, 1) | |
| 559 | + | ac.renderStages.handoffRebase = func() { rebased <- struct{}{} } | |
| 560 | + | ||
| 561 | + | // Supersede the published capability before the transition admits its | |
| 562 | + | // committed route identity, exactly in the window final review flagged. | |
| 563 | + | superseded := make(chan attachmentCapability, 1) | |
| 564 | + | d.beforeAttachmentTransitionIdentityAdmission = func(published attachmentCapability) { | |
| 565 | + | supersedeAttachmentCapabilityForTest(published, base) | |
| 566 | + | superseded <- published | |
| 324 | 567 | } | |
| 325 | - | oldDone := make(chan struct{}) | |
| 568 | + | done := make(chan struct{}) | |
| 326 | 569 | go func() { | |
| 327 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 328 | - | close(oldDone) | |
| 570 | + | d.resolvePickerSelection(effect, selection) | |
| 571 | + | close(done) | |
| 329 | 572 | }() | |
| 573 | + | awaitTestCompletion(t, done, "superseded composite follow never completed") | |
| 574 | + | published := <-superseded | |
| 575 | + | require.False(t, published.current(), "the superseded published capability was still admissible") | |
| 576 | + | ||
| 330 | 577 | select { | |
| 331 | - | case <-rebuildReached: | |
| 332 | - | case <-time.After(time.Second): | |
| 333 | - | t.Fatal("timed out waiting for the first move-picker refresh rebuild") | |
| 578 | + | case <-rebased: | |
| 579 | + | default: | |
| 580 | + | t.Fatal("superseded composite follow skipped its authoritative first paint") | |
| 334 | 581 | } | |
| 335 | 582 | ||
| 336 | - | d.mu.Lock() | |
| 337 | - | d.sessions[destination.id] = destination | |
| 338 | - | d.mu.Unlock() | |
| 339 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 340 | - | close(allowOld) | |
| 341 | - | select { | |
| 342 | - | case <-oldDone: | |
| 343 | - | case <-time.After(time.Second): | |
| 344 | - | t.Fatal("timed out waiting for the older move-picker refresh") | |
| 583 | + | // The committed identity must reach the live replacement link for the | |
| 584 | + | // session the attachment actually holds, exactly once, and the correlated | |
| 585 | + | // completion must follow exactly once. | |
| 586 | + | identity := awaitSentCommittedIdentity(t, sends) | |
| 587 | + | require.Equal(t, destination.name, identity.Target.SessionName) | |
| 588 | + | require.Equal(t, destination.incarnation, identity.Target.LifecycleID) | |
| 589 | + | result := awaitSentPickerResult(t, sends) | |
| 590 | + | require.Equal(t, selection.CauseActionID, result.CauseActionID) | |
| 591 | + | require.Equal(t, selection.InteractionID, result.InteractionID) | |
| 592 | + | require.Equal(t, protocol.PickerActionMove, result.Action) | |
| 593 | + | assertNoFurtherFrame(t, sends, wire.MsgCommittedRouteIdentity, "composite follow published its committed identity more than once") | |
| 594 | + | assertNoFurtherFrame(t, sends, wire.MsgPickerResult, "composite follow reported its result more than once") | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | // awaitSentCommittedIdentity reads the next committed route identity the daemon | |
| 598 | + | // published, skipping unrelated frames. | |
| 599 | + | func awaitSentCommittedIdentity(t *testing.T, sends <-chan wire.Frame) protocol.CommittedRouteIdentity { | |
| 600 | + | t.Helper() | |
| 601 | + | deadline := time.After(2 * time.Second) | |
| 602 | + | for { | |
| 603 | + | select { | |
| 604 | + | case frame := <-sends: | |
| 605 | + | if frame.Type != wire.MsgCommittedRouteIdentity { | |
| 606 | + | continue | |
| 607 | + | } | |
| 608 | + | identity, err := wire.UnmarshalCommittedRouteIdentity(frame.Payload) | |
| 609 | + | require.NoError(t, err) | |
| 610 | + | return identity | |
| 611 | + | case <-deadline: | |
| 612 | + | t.Fatal("composite follow sent no committed route identity") | |
| 613 | + | return protocol.CommittedRouteIdentity{} | |
| 614 | + | } | |
| 345 | 615 | } | |
| 616 | + | } | |
| 346 | 617 | ||
| 347 | - | require.True(t, ac.overlays.pickerActive(), "the older empty refresh must not close the newer in-place publication") | |
| 348 | - | ac.overlays.pickerMu.Lock() | |
| 349 | - | _, ok := ac.overlays.picker.Selected() | |
| 350 | - | ac.overlays.pickerMu.Unlock() | |
| 351 | - | require.True(t, ok) | |
| 618 | + | // assertNoFurtherFrame proves a frame was produced exactly once: no later frame | |
| 619 | + | // of that type may follow the one the caller already observed. | |
| 620 | + | func assertNoFurtherFrame(t *testing.T, sends <-chan wire.Frame, frameType wire.MsgType, failure string) { | |
| 621 | + | t.Helper() | |
| 622 | + | deadline := time.After(200 * time.Millisecond) | |
| 623 | + | for { | |
| 624 | + | select { | |
| 625 | + | case frame := <-sends: | |
| 626 | + | require.NotEqual(t, frameType, frame.Type, failure) | |
| 627 | + | case <-deadline: | |
| 628 | + | return | |
| 629 | + | } | |
| 630 | + | } | |
| 352 | 631 | } | |
| 353 | 632 | ||
| 354 | - | func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) { | |
| 355 | - | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 633 | + | // A rejection after the picker closed must not touch the ended effect. It | |
| 634 | + | // delivers bounded feedback and a repaint through the attachment's current | |
| 635 | + | // capability so the client's post-close drain can release the terminal. | |
| 636 | + | func TestMovePickerClosedRejectionUsesFreshEffect(t *testing.T) { | |
| 637 | + | d, source, ac, destination, destinationTab, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0) | |
| 356 | 638 | defer releaseAll(releases) | |
| 639 | + | old := captureAttachmentCapability(source, ac, ac.transport()) | |
| 640 | + | locator := moveSourceForSession(source, ac, "source-tab", "") | |
| 641 | + | locator.AttachmentCapability = old | |
| 642 | + | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator) | |
| 643 | + | effect := pickActionEffectForTest(t, source, ac) | |
| 644 | + | // Publication must reject after the picker closed: changing the destination | |
| 645 | + | // tab geometry re-admits the already-validated topology as stale. | |
| 646 | + | d.afterMoveTabSourceSnapshot = func() { | |
| 647 | + | destinationTab.mu.Lock() | |
| 648 | + | destinationTab.size = domain.Size{Cols: 79, Rows: 22} | |
| 649 | + | destinationTab.mu.Unlock() | |
| 650 | + | } | |
| 651 | + | selection := moveSelectionForDestination(t, ac, destination.id) | |
| 652 | + | selection.CauseActionID = 99 | |
| 653 | + | done := make(chan struct{}) | |
| 654 | + | go func() { | |
| 655 | + | d.resolvePickerSelection(effect, selection) | |
| 656 | + | close(done) | |
| 657 | + | }() | |
| 658 | + | awaitTestCompletion(t, done, "closed rejection never returned") | |
| 357 | 659 | ||
| 358 | - | d.handleInput(source, ac, []byte("\x1b ")) | |
| 359 | - | d.handleInput(source, ac, []byte("MFP\r")) | |
| 360 | - | require.True(t, ac.overlays.pickerActive()) | |
| 361 | - | require.True(t, source.dispatchMu.TryLock(), "dispatchMu must not remain held while the move picker is open") | |
| 362 | - | source.dispatchMu.Unlock() | |
| 660 | + | failure, result := collectSentPickerOutcome(t, sends) | |
| 661 | + | require.Nil(t, result, "rejected composite move reported success") | |
| 662 | + | require.Equal(t, selection.CauseActionID, failure.CauseActionID) | |
| 663 | + | require.Equal(t, protocol.PickerActionFailed, failure.Code) | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | // awaitSentPickerResult blocks until the daemon reports a completed mutation. | |
| 667 | + | func awaitSentPickerResult(t *testing.T, sends <-chan wire.Frame) protocol.PickerResult { | |
| 668 | + | t.Helper() | |
| 669 | + | deadline := time.After(2 * time.Second) | |
| 670 | + | for { | |
| 671 | + | select { | |
| 672 | + | case frame := <-sends: | |
| 673 | + | if frame.Type != wire.MsgPickerResult { | |
| 674 | + | continue | |
| 675 | + | } | |
| 676 | + | result, err := wire.UnmarshalPickerResult(frame.Payload) | |
| 677 | + | require.NoError(t, err) | |
| 678 | + | return result | |
| 679 | + | case <-deadline: | |
| 680 | + | t.Fatal("composite follow sent no correlated PickerResult") | |
| 681 | + | return protocol.PickerResult{} | |
| 682 | + | } | |
| 683 | + | } | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | // collectSentPickerOutcome drains the bounded set of frames one rejection can | |
| 687 | + | // produce and returns the PickerFailure, plus any PickerResult that must not | |
| 688 | + | // exist on the rejection path. | |
| 689 | + | func collectSentPickerOutcome(t *testing.T, sends <-chan wire.Frame) (failure protocol.PickerFailure, result *protocol.PickerResult) { | |
| 690 | + | t.Helper() | |
| 691 | + | found := false | |
| 692 | + | deadline := time.After(500 * time.Millisecond) | |
| 693 | + | for { | |
| 694 | + | select { | |
| 695 | + | case frame := <-sends: | |
| 696 | + | switch frame.Type { | |
| 697 | + | case wire.MsgPickerFailure: | |
| 698 | + | decoded, err := wire.UnmarshalPickerFailure(frame.Payload) | |
| 699 | + | require.NoError(t, err) | |
| 700 | + | failure, found = decoded, true | |
| 701 | + | case wire.MsgPickerResult: | |
| 702 | + | decoded, err := wire.UnmarshalPickerResult(frame.Payload) | |
| 703 | + | require.NoError(t, err) | |
| 704 | + | result = &decoded | |
| 705 | + | } | |
| 706 | + | case <-deadline: | |
| 707 | + | require.True(t, found, "closed rejection sent no bounded failure") | |
| 708 | + | return failure, result | |
| 709 | + | } | |
| 710 | + | } | |
| 363 | 711 | } | |
M internal/usecase/daemon/move_postcommit.go +41 -2
| @@ -20,6 +20,8 @@ func firstMovePane(panes []*pane) *pane { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | type movePostcommitPlan struct { | |
| 23 | + | followResult attachmentTransitionResult | |
| 24 | + | afterFollow func(*attachmentEffect) | |
| 23 | 25 | source *session | |
| 24 | 26 | destination *session | |
| 25 | 27 | sourceName string | |
| @@ -82,6 +84,45 @@ func (p movePostcommitPlan) execute(d *Daemon) { | |||
| 82 | 84 | } | |
| 83 | 85 | p.unlockDispatch() | |
| 84 | 86 | p.reservation.Release() | |
| 87 | + | if p.followResult.published.ac != nil { | |
| 88 | + | result, err := d.finishAttachmentTransition(attachmentTransitionRequest{ready: true}, p.followResult) | |
| 89 | + | if err != nil { | |
| 90 | + | d.log.Warn("composite move follow route identity failed", | |
| 91 | + | "err", err, "operation", p.operation, | |
| 92 | + | "source_session", p.sourceName, "destination_session", p.destinationName) | |
| 93 | + | } else { | |
| 94 | + | d.deferAttachmentTransitionCleanups(result) | |
| 95 | + | d.firstPaintForTransition(result.published) | |
| 96 | + | if p.afterFollow != nil { | |
| 97 | + | if d.beforeMoveFollowCompletion != nil { | |
| 98 | + | d.beforeMoveFollowCompletion(result.published) | |
| 99 | + | } | |
| 100 | + | // The committing effect ended so the transaction could freeze | |
| 101 | + | // admission. Report completion from a fresh effect on the exact | |
| 102 | + | // capability the follow published on the destination. | |
| 103 | + | effect, admitted := result.published.ac.beginAttachmentEffect(result.published) | |
| 104 | + | if !admitted { | |
| 105 | + | // The exact published capability stopped being admissible | |
| 106 | + | // between the follow's identity send and this completion. That | |
| 107 | + | // is either a concurrent transition superseding the capability | |
| 108 | + | // while the attachment stays live, or the attachment being torn | |
| 109 | + | // down. Re-admit only the attachment's current capability so the | |
| 110 | + | // pending client action is completed exactly once through the | |
| 111 | + | // existing admission seam. A detached attachment admits nothing, | |
| 112 | + | // so its teardown resolves the pending action; no stale | |
| 113 | + | // capability or retired transport is reused. | |
| 114 | + | _, effect, admitted = admitCurrentAttachmentCapabilityEffect(result.published) | |
| 115 | + | } | |
| 116 | + | if admitted { | |
| 117 | + | p.afterFollow(effect) | |
| 118 | + | effect.End() | |
| 119 | + | } else { | |
| 120 | + | d.log.Warn("composite move follow completion not admitted", | |
| 121 | + | "operation", p.operation, "destination_session", p.destinationName) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | } | |
| 85 | 126 | attrs := []any{ | |
| 86 | 127 | "operation", p.operation, | |
| 87 | 128 | "source_session", p.sourceName, | |
| @@ -143,7 +184,6 @@ func (p movePostcommitPlan) execute(d *Daemon) { | |||
| 143 | 184 | ) | |
| 144 | 185 | } | |
| 145 | 186 | for _, attachment := range p.retiredAttachments { | |
| 146 | - | d.unregisterPreview(attachment.ac) | |
| 147 | 187 | attachment.ac.clearCaptureFrames() | |
| 148 | 188 | if err := d.cleanupAttachmentOutput(attachment.ac); err != nil { | |
| 149 | 189 | d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.sourceName) | |
| @@ -154,7 +194,6 @@ func (p movePostcommitPlan) execute(d *Daemon) { | |||
| 154 | 194 | if retirement.parked == nil || retirement.parked.ac == nil { | |
| 155 | 195 | continue | |
| 156 | 196 | } | |
| 157 | - | d.unregisterPreview(retirement.parked.ac) | |
| 158 | 197 | retirement.parked.ac.clearCaptureFrames() | |
| 159 | 198 | } | |
| 160 | 199 | d.finishParkedAttachmentRetirements(p.retiredParked) | |
M internal/usecase/daemon/move_tab.go +3 -0
| @@ -42,6 +42,9 @@ func (d *Daemon) moveTab(req moveTabRequest) error { | |||
| 42 | 42 | func (p *moveTabTopology) transactionRequest() moveTransactionRequest { | |
| 43 | 43 | req := p.req | |
| 44 | 44 | return moveTransactionRequest{ | |
| 45 | + | follow: req.Follow, | |
| 46 | + | beforeFollow: req.BeforeFollow, | |
| 47 | + | afterFollow: req.AfterFollow, | |
| 45 | 48 | operation: "tab", | |
| 46 | 49 | attachment: req.Attachment, | |
| 47 | 50 | attachmentCapability: req.AttachmentCapability, | |
M internal/usecase/daemon/move_transaction.go +59 -4
| @@ -21,6 +21,9 @@ type moveTopology interface { | |||
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | type moveTransactionRequest struct { | |
| 24 | + | follow bool | |
| 25 | + | beforeFollow func() error | |
| 26 | + | afterFollow func(*attachmentEffect) | |
| 24 | 27 | operation string | |
| 25 | 28 | attachment *attachedClient | |
| 26 | 29 | attachmentCapability attachmentCapability | |
| @@ -57,6 +60,8 @@ type moveTransaction struct { | |||
| 57 | 60 | frozenEffects attachmentTransitionGuard | |
| 58 | 61 | effectsFrozen bool | |
| 59 | 62 | ||
| 63 | + | followResult attachmentTransitionResult | |
| 64 | + | ||
| 60 | 65 | publication moveTopologyPublication | |
| 61 | 66 | publishErr error | |
| 62 | 67 | ||
| @@ -121,6 +126,11 @@ func (d *Daemon) executeMove(topology moveTopology) (result error) { | |||
| 121 | 126 | return err | |
| 122 | 127 | } | |
| 123 | 128 | t.topology.afterAdmission(d) | |
| 129 | + | if t.follows() && t.request.beforeFollow != nil { | |
| 130 | + | if err := t.request.beforeFollow(); err != nil { | |
| 131 | + | return err | |
| 132 | + | } | |
| 133 | + | } | |
| 124 | 134 | if err := t.freezeSourceAttachments(); err != nil { | |
| 125 | 135 | return err | |
| 126 | 136 | } | |
| @@ -210,11 +220,18 @@ func (t *moveTransaction) admit() error { | |||
| 210 | 220 | // attachment lifecycle Module. Move uses nonblocking acquisition to preserve | |
| 211 | 221 | // its ordering with final teardown. | |
| 212 | 222 | func (t *moveTransaction) freezeSourceAttachments() error { | |
| 213 | - | if !t.topology.willRetireSource() || len(t.sourceAttachments) == 0 { | |
| 223 | + | if (!t.topology.willRetireSource() && !t.follows()) || len(t.sourceAttachments) == 0 { | |
| 214 | 224 | return nil | |
| 215 | 225 | } | |
| 216 | - | interrupts := make([]attachmentTransportInterrupt, 0, len(t.sourceAttachments)) | |
| 217 | - | for _, attachment := range t.sourceAttachments { | |
| 226 | + | participants := t.sourceAttachments | |
| 227 | + | if !t.topology.willRetireSource() { | |
| 228 | + | participants = []*attachedClient{t.request.attachment} | |
| 229 | + | } | |
| 230 | + | interrupts := make([]attachmentTransportInterrupt, 0, len(participants)) | |
| 231 | + | for _, attachment := range participants { | |
| 232 | + | if t.follows() && attachment == t.request.attachment { | |
| 233 | + | continue // The following connection must remain usable for first paint. | |
| 234 | + | } | |
| 218 | 235 | if transport := t.sourceTransports[attachment]; transport.transport != nil { | |
| 219 | 236 | interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport}) | |
| 220 | 237 | } | |
| @@ -227,7 +244,7 @@ func (t *moveTransaction) freezeSourceAttachments() error { | |||
| 227 | 244 | t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment) | |
| 228 | 245 | } | |
| 229 | 246 | }, | |
| 230 | - | }, t.sourceAttachments...) | |
| 247 | + | }, participants...) | |
| 231 | 248 | if !frozen.acquired || !frozen.drained { | |
| 232 | 249 | frozen.unfreeze() | |
| 233 | 250 | return errMovePaneInvalid | |
| @@ -260,11 +277,40 @@ func (t *moveTransaction) publishLocked() error { | |||
| 260 | 277 | return errMoveStaleTarget | |
| 261 | 278 | } | |
| 262 | 279 | ||
| 280 | + | var follow *attachmentPublication | |
| 281 | + | if t.follows() { | |
| 282 | + | req := attachmentTransitionRequest{ | |
| 283 | + | source: t.source, target: t.destination, next: t.request.attachment, | |
| 284 | + | expectedTransport: t.sourceTransports[t.request.attachment], | |
| 285 | + | preflighted: true, attachmentEffectsFrozen: true, ready: true, | |
| 286 | + | } | |
| 287 | + | if t.request.attachmentCapability.ac != nil { | |
| 288 | + | req.sourceCapability = &t.request.attachmentCapability | |
| 289 | + | } | |
| 290 | + | var err error | |
| 291 | + | follow, err = d.validateAttachmentTransitionPrelocked(req) | |
| 292 | + | if err != nil { | |
| 293 | + | return errMoveStaleTarget | |
| 294 | + | } | |
| 295 | + | // Topology sync migration takes coordinator locks itself. Session locks | |
| 296 | + | // and the frozen gate keep membership/lease authority pinned meanwhile. | |
| 297 | + | follow.unlockCoordinators() | |
| 298 | + | } | |
| 263 | 299 | publication, err := t.topology.publishLocked(d, t.source, t.destination) | |
| 264 | 300 | if err != nil { | |
| 265 | 301 | return err | |
| 266 | 302 | } | |
| 267 | 303 | t.publication = publication | |
| 304 | + | if follow != nil { | |
| 305 | + | follow.releaseCoordinators = lockAttachmentCoordinators(t.source, follow.sourceCoordinator, t.destination, follow.targetCoordinator) | |
| 306 | + | follow.req.activateTargetTab = true | |
| 307 | + | follow.req.targetTabIndex = indexMoveTabLocked(t.destination, publication.destinationTab) | |
| 308 | + | t.followResult = d.publishAttachmentTransitionPrelocked(follow) | |
| 309 | + | follow.unlockCoordinators() | |
| 310 | + | if t.source != t.destination && !publication.sourceEmpty { | |
| 311 | + | t.followResult.sourceGeometrySession = t.source | |
| 312 | + | } | |
| 313 | + | } | |
| 268 | 314 | if publication.sourceEmpty { | |
| 269 | 315 | d.unregisterSessionLocked(t.source) | |
| 270 | 316 | t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports) | |
| @@ -288,6 +334,8 @@ func (t *moveTransaction) publishLocked() error { | |||
| 288 | 334 | func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan { | |
| 289 | 335 | publication := t.publication | |
| 290 | 336 | return movePostcommitPlan{ | |
| 337 | + | followResult: t.followResult, | |
| 338 | + | afterFollow: t.request.afterFollow, | |
| 291 | 339 | source: t.source, | |
| 292 | 340 | destination: t.destination, | |
| 293 | 341 | sourceName: t.sourceName, | |
| @@ -314,3 +362,10 @@ func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *mov | |||
| 314 | 362 | destinationMetadataValid: t.destMetadataOK, | |
| 315 | 363 | } | |
| 316 | 364 | } | |
| 365 | + | ||
| 366 | + | // Picker pane moves follow only when their source retires; tab moves always | |
| 367 | + | // follow. Command/API moves retain their existing topology-only semantics. | |
| 368 | + | func (t *moveTransaction) follows() bool { | |
| 369 | + | return t.request.follow && t.request.attachment != nil && | |
| 370 | + | (t.request.operation == "tab" || t.topology.willRetireSource()) | |
| 371 | + | } | |
M internal/usecase/daemon/navigation_attachment_handoff_test.go +1 -1
| @@ -23,7 +23,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 23 | 23 | { | |
| 24 | 24 | name: "picker selection", releasedAction: "detach", | |
| 25 | 25 | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error { | |
| 26 | - | d.enterPicker(source, ac) | |
| 26 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 27 | 27 | return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select") | |
| 28 | 28 | }, | |
| 29 | 29 | }, | |
M internal/usecase/daemon/overlay_runtime.go +47 -44
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "sync" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| @@ -19,28 +18,45 @@ type overlayRuntime struct { | |||
| 19 | 18 | ac *attachedClient | |
| 20 | 19 | ||
| 21 | 20 | pickerMu sync.Mutex | |
| 22 | - | picker *picker.Model | |
| 23 | - | // pickerGeneration identifies one open lifecycle. It advances only when a | |
| 24 | - | // picker is published, so delayed close and registration work can prove it | |
| 25 | - | // still owns the exact lifecycle it captured. | |
| 26 | - | pickerGeneration uint64 | |
| 27 | - | // pickerRefreshSequence advances for every rebuild attempt in one open | |
| 28 | - | // lifecycle. A zero close token deliberately disables this extra fence. | |
| 29 | - | pickerRefreshSequence uint64 | |
| 30 | - | pickerTitle string | |
| 31 | - | pickerIntent pickerIntent | |
| 32 | - | pickerSource moveSourceLocator | |
| 33 | - | pickerPreview *tab | |
| 34 | - | pickerPreviewSession *session | |
| 35 | - | pickerRemotePreview picker.Preview | |
| 36 | - | pickerRemotePreviewCancel context.CancelFunc | |
| 37 | - | pickerPreviewGeneration uint64 | |
| 38 | - | pickerPending []byte | |
| 39 | - | pickerESC pendingByteTimer | |
| 40 | - | ||
| 41 | - | // Test-only, unsynchronized lifecycle seams. Assign them before picker | |
| 42 | - | // publication or goroutine startup. | |
| 43 | - | afterPickerRefreshBuild func(*picker.Model) | |
| 21 | + | // pickerPublishMu serializes snapshot publication for this attachment. A | |
| 22 | + | // publisher reserves the sole right to publish, builds, validates, and | |
| 23 | + | // sends its snapshot, and only then commits the revision it announced. It | |
| 24 | + | // is held across the transport send so two concurrent publishers cannot | |
| 25 | + | // interleave or reorder authoritative revisions. pickerMu is still the | |
| 26 | + | // state lock and is never held across the send; the lock order is | |
| 27 | + | // pickerPublishMu -> pickerMu, and it is never taken while sendMu is held. | |
| 28 | + | pickerPublishMu sync.Mutex | |
| 29 | + | // picker* carries the serving-daemon side of the picker interaction, | |
| 30 | + | // guarded by pickerMu. Open tracks the current interaction namespace; | |
| 31 | + | // interaction is the latest admitted open ID (late selections for closed | |
| 32 | + | // interactions reject); revisions are the per-source published versions | |
| 33 | + | // (older or duplicate revisions discard); keys maps opaque row keys of the | |
| 34 | + | // serving source to resolved targets; intent and moverSource are the | |
| 35 | + | // daemon-side facts captured at open; requestID echoes a client PickerBegin. | |
| 36 | + | pickerOpen bool | |
| 37 | + | pickerInteraction uint64 | |
| 38 | + | pickerRevisions map[string]uint64 | |
| 39 | + | pickerKeys map[string]picker.Target | |
| 40 | + | // pickerSourcePublished distinguishes "never published" from "published an | |
| 41 | + | // empty set" for the current interaction. pickerRecent and pickerGrouped | |
| 42 | + | // are the exact projections last published: a refresh whose projections | |
| 43 | + | // equal them is not authority-relevant, so it must not bump the revision | |
| 44 | + | // nor send a snapshot the client never needed to see. | |
| 45 | + | pickerSourcePublished bool | |
| 46 | + | pickerRecent protocol.PickerProjection | |
| 47 | + | pickerGrouped protocol.PickerProjection | |
| 48 | + | pickerIntent protocol.PickerIntent | |
| 49 | + | pickerMoveSource moveSourceLocator | |
| 50 | + | pickerRequestID uint64 | |
| 51 | + | // pickerPreview* track the row the client asked to preview. The generation | |
| 52 | + | // supersedes an in-flight capture and names the render subscription, so a | |
| 53 | + | // delayed preview can never replace the row the user is displaying. | |
| 54 | + | // pickerPreviewSession pins the recorded subscription to the coordinator | |
| 55 | + | // that owns it (the target session, or the viewer for a remote row), so | |
| 56 | + | // teardown removes the exact target subscription. | |
| 57 | + | pickerPreviewGeneration uint64 | |
| 58 | + | pickerPreviewKey string | |
| 59 | + | pickerPreviewSession *session | |
| 44 | 60 | ||
| 45 | 61 | paletteMu sync.Mutex | |
| 46 | 62 | palette *palette.Model | |
| @@ -136,7 +152,7 @@ func (rt *overlayRuntime) Active() bool { | |||
| 136 | 152 | if rt == nil || rt.ac == nil { | |
| 137 | 153 | return false | |
| 138 | 154 | } | |
| 139 | - | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive() | |
| 155 | + | return rt.promptActive() || rt.paletteActive() || rt.pickerClientActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive() | |
| 140 | 156 | } | |
| 141 | 157 | ||
| 142 | 158 | func (rt *overlayRuntime) promptActive() bool { | |
| @@ -157,13 +173,18 @@ func (rt *overlayRuntime) paletteActive() bool { | |||
| 157 | 173 | return rt.palette != nil | |
| 158 | 174 | } | |
| 159 | 175 | ||
| 160 | - | func (rt *overlayRuntime) pickerActive() bool { | |
| 176 | + | // pickerClientActive reports whether a picker interaction is open on this | |
| 177 | + | // attachment. Such an interaction owns the user input: the daemon must not | |
| 178 | + | // route raw key or mouse events to the session while it is open. Its typed | |
| 179 | + | // PickerSelection/PickerClose messages are the only effect it may have, and | |
| 180 | + | // they arrive on the control path, not here. | |
| 181 | + | func (rt *overlayRuntime) pickerClientActive() bool { | |
| 161 | 182 | if rt == nil { | |
| 162 | 183 | return false | |
| 163 | 184 | } | |
| 164 | 185 | rt.pickerMu.Lock() | |
| 165 | 186 | defer rt.pickerMu.Unlock() | |
| 166 | - | return rt.picker != nil | |
| 187 | + | return rt.pickerOpen | |
| 167 | 188 | } | |
| 168 | 189 | ||
| 169 | 190 | func (rt *overlayRuntime) noticesActive() bool { | |
| @@ -282,10 +303,6 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attach | |||
| 282 | 303 | d.handlePaletteInput(ac, data, effect) | |
| 283 | 304 | return true | |
| 284 | 305 | } | |
| 285 | - | if rt.pickerActive() { | |
| 286 | - | d.handlePickerInput(ac, data, effect) | |
| 287 | - | return true | |
| 288 | - | } | |
| 289 | 306 | if rt.noticesActive() { | |
| 290 | 307 | d.handleNoticesInput(ac, data) | |
| 291 | 308 | return true | |
| @@ -311,12 +328,6 @@ type overlayRenderSnapshot struct { | |||
| 311 | 328 | statusFeedback string | |
| 312 | 329 | resizeActive bool | |
| 313 | 330 | ||
| 314 | - | pickerActive bool | |
| 315 | - | pickerModel *picker.Model | |
| 316 | - | pickerTitle string | |
| 317 | - | previewTab *tab | |
| 318 | - | remotePreview picker.Preview | |
| 319 | - | ||
| 320 | 331 | noticesOverlayActive bool | |
| 321 | 332 | noticesOverlayModel *notices.Model | |
| 322 | 333 | ||
| @@ -383,14 +394,6 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 383 | 394 | snap.resizeActive = rt.resizeActive | |
| 384 | 395 | rt.resizeMu.Unlock() | |
| 385 | 396 | ||
| 386 | - | rt.pickerMu.Lock() | |
| 387 | - | snap.pickerActive = rt.picker != nil | |
| 388 | - | snap.pickerModel = rt.picker.Clone() | |
| 389 | - | snap.pickerTitle = rt.pickerTitle | |
| 390 | - | snap.previewTab = rt.pickerPreview | |
| 391 | - | snap.remotePreview = clonePickerPreview(rt.pickerRemotePreview) | |
| 392 | - | rt.pickerMu.Unlock() | |
| 393 | - | ||
| 394 | 397 | rt.paletteMu.Lock() | |
| 395 | 398 | snap.paletteModel = rt.palette | |
| 396 | 399 | snap.paletteActive = snap.paletteModel != nil | |
M internal/usecase/daemon/overlay_runtime_test.go +2 -22
| @@ -31,11 +31,10 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) { | |||
| 31 | 31 | check func(t *testing.T, ac *attachedClient) | |
| 32 | 32 | }{ | |
| 33 | 33 | { | |
| 34 | - | name: "prompt before palette picker copy", | |
| 34 | + | name: "prompt before palette copy", | |
| 35 | 35 | setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |
| 36 | 36 | t.Helper() | |
| 37 | 37 | d.enterCopyMode(sess, ac) | |
| 38 | - | d.enterPicker(sess, ac) | |
| 39 | 38 | d.enterPalette(sess, ac) | |
| 40 | 39 | d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil }) | |
| 41 | 40 | }, | |
| @@ -44,39 +43,20 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) { | |||
| 44 | 43 | t.Helper() | |
| 45 | 44 | require.Equal(t, "x", ac.overlays.prompt.Value()) | |
| 46 | 45 | require.Equal(t, "", ac.overlays.palette.Query()) | |
| 47 | - | require.True(t, ac.overlays.pickerActive()) | |
| 48 | 46 | require.True(t, ac.overlays.copyActive()) | |
| 49 | 47 | }, | |
| 50 | 48 | }, | |
| 51 | 49 | { | |
| 52 | - | name: "palette before picker copy", | |
| 50 | + | name: "palette before copy", | |
| 53 | 51 | setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |
| 54 | 52 | t.Helper() | |
| 55 | 53 | d.enterCopyMode(sess, ac) | |
| 56 | - | d.enterPicker(sess, ac) | |
| 57 | 54 | d.enterPalette(sess, ac) | |
| 58 | 55 | }, | |
| 59 | 56 | input: []byte("x"), | |
| 60 | 57 | check: func(t *testing.T, ac *attachedClient) { | |
| 61 | 58 | t.Helper() | |
| 62 | 59 | require.Equal(t, "x", ac.overlays.palette.Query()) | |
| 63 | - | require.True(t, ac.overlays.pickerActive()) | |
| 64 | - | require.True(t, ac.overlays.copyActive()) | |
| 65 | - | }, | |
| 66 | - | }, | |
| 67 | - | { | |
| 68 | - | name: "picker before copy", | |
| 69 | - | setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |
| 70 | - | t.Helper() | |
| 71 | - | d.enterCopyMode(sess, ac) | |
| 72 | - | d.enterPicker(sess, ac) | |
| 73 | - | }, | |
| 74 | - | input: []byte("j"), | |
| 75 | - | check: func(t *testing.T, ac *attachedClient) { | |
| 76 | - | t.Helper() | |
| 77 | - | target, ok := ac.overlays.picker.Selected() | |
| 78 | - | require.True(t, ok) | |
| 79 | - | require.Equal(t, 1, target.TabIndex) | |
| 80 | 60 | require.True(t, ac.overlays.copyActive()) | |
| 81 | 61 | }, | |
| 82 | 62 | }, | |
M internal/usecase/daemon/palette.go +64 -6
| @@ -737,6 +737,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 737 | 737 | if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) { | |
| 738 | 738 | return | |
| 739 | 739 | } | |
| 740 | + | // A client-owned picker may acquire presentation as soon as its offer's | |
| 741 | + | // output barrier is displayed. Commit the palette-free base first so that | |
| 742 | + | // barrier can never name a frame which still contains the command palette. | |
| 743 | + | if cmd.Slug == "session-picker" { | |
| 744 | + | d.invalidateRenderNow(sess, ac, true, "palette.go:session-picker-base") | |
| 745 | + | } | |
| 740 | 746 | sess.dispatchMu.Lock() | |
| 741 | 747 | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args) | |
| 742 | 748 | sess.dispatchMu.Unlock() | |
| @@ -1046,7 +1052,7 @@ func (e paletteExec) OpenMovePanePicker() error { | |||
| 1046 | 1052 | if target.tab == nil || target.pane == nil { | |
| 1047 | 1053 | return errMovePaneInvalid | |
| 1048 | 1054 | } | |
| 1049 | - | return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{ | |
| 1055 | + | return e.openMovePicker(protocol.PickerIntentMovePane, moveSourceLocator{ | |
| 1050 | 1056 | Session: sessionMoveLocator(e.sess), | |
| 1051 | 1057 | TabID: domain.TabStableID(target.tab.stableID), | |
| 1052 | 1058 | PaneID: domain.PaneStableID(target.pane.stableID), | |
| @@ -1059,13 +1065,33 @@ func (e paletteExec) OpenMoveTabPicker() error { | |||
| 1059 | 1065 | if target.tab == nil { | |
| 1060 | 1066 | return errMovePaneInvalid | |
| 1061 | 1067 | } | |
| 1062 | - | return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{ | |
| 1068 | + | return e.openMovePicker(protocol.PickerIntentMoveTab, moveSourceLocator{ | |
| 1063 | 1069 | Session: sessionMoveLocator(e.sess), | |
| 1064 | 1070 | TabID: domain.TabStableID(target.tab.stableID), | |
| 1065 | 1071 | Attachment: e.ac, | |
| 1066 | 1072 | }) | |
| 1067 | 1073 | } | |
| 1068 | 1074 | ||
| 1075 | + | // openMovePicker opens one move picker on an admitted effect. The captured | |
| 1076 | + | // source is daemon-side state: it is bound to the interaction at open and | |
| 1077 | + | // revalidated at commit, never supplied by the presenting client. | |
| 1078 | + | func (e paletteExec) openMovePicker(intent protocol.PickerIntent, source moveSourceLocator) error { | |
| 1079 | + | if e.ac == nil || e.ac.overlays == nil { | |
| 1080 | + | return errAttachmentTransition | |
| 1081 | + | } | |
| 1082 | + | effect := e.effect | |
| 1083 | + | if effect == nil { | |
| 1084 | + | current := e.ac.transportSnapshot() | |
| 1085 | + | _, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport) | |
| 1086 | + | if !admitted { | |
| 1087 | + | return errAttachmentTransition | |
| 1088 | + | } | |
| 1089 | + | defer fresh.End() | |
| 1090 | + | effect = fresh | |
| 1091 | + | } | |
| 1092 | + | return e.d.openPickerForAttachment(e.ac, effect, intent, source, 0) | |
| 1093 | + | } | |
| 1094 | + | ||
| 1069 | 1095 | func (e paletteExec) focus(direction layout.Direction) error { | |
| 1070 | 1096 | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | |
| 1071 | 1097 | } | |
| @@ -1173,11 +1199,43 @@ func (e paletteExec) RenameTabTo(name string) error { | |||
| 1173 | 1199 | } | |
| 1174 | 1200 | ||
| 1175 | 1201 | func (e paletteExec) OpenSessionPicker() error { | |
| 1176 | - | if e.ac != nil && e.ac.navigationCapabilities&protocol.NavigationCapabilityHomePicker != 0 && e.effect != nil { | |
| 1177 | - | return e.d.sendNavigationActionForAttachment(e.effect, protocol.NavigationOpenHomePicker) | |
| 1202 | + | if e.ac == nil || e.ac.overlays == nil { | |
| 1203 | + | return errAttachmentTransition | |
| 1178 | 1204 | } | |
| 1179 | - | e.d.enterPicker(e.sess, e.ac) | |
| 1180 | - | return nil | |
| 1205 | + | // The palette already closed on execute (see handlePaletteInput): | |
| 1206 | + | // only invalidate when the close actually changed overlay state, then | |
| 1207 | + | // open the picker interaction on a freshly admitted effect so the | |
| 1208 | + | // opener's fence retires on the authoritative repaint below. | |
| 1209 | + | overlays := e.ac.overlays | |
| 1210 | + | overlays.paletteMu.Lock() | |
| 1211 | + | generation := overlays.paletteGeneration | |
| 1212 | + | query := "" | |
| 1213 | + | if overlays.palette != nil { | |
| 1214 | + | query = overlays.palette.Query() | |
| 1215 | + | } | |
| 1216 | + | overlays.paletteMu.Unlock() | |
| 1217 | + | if e.d.closeExecutedPalette(e.ac, e.effect, generation, query) { | |
| 1218 | + | e.d.invalidateRender(e.sess, e.ac, true, "palette.go:session-picker") | |
| 1219 | + | } | |
| 1220 | + | effect := e.effect | |
| 1221 | + | if effect == nil { | |
| 1222 | + | current := e.ac.transportSnapshot() | |
| 1223 | + | _, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport) | |
| 1224 | + | if !admitted { | |
| 1225 | + | return errAttachmentTransition | |
| 1226 | + | } | |
| 1227 | + | defer fresh.End() | |
| 1228 | + | effect = fresh | |
| 1229 | + | } else { | |
| 1230 | + | fresh, admitted := e.ac.beginAttachmentEffect(effect.capability()) | |
| 1231 | + | if !admitted { | |
| 1232 | + | return errAttachmentTransition | |
| 1233 | + | } | |
| 1234 | + | fresh.uiActionID = effect.uiActionID | |
| 1235 | + | defer fresh.End() | |
| 1236 | + | effect = fresh | |
| 1237 | + | } | |
| 1238 | + | return e.d.openPickerForAttachment(e.ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0) | |
| 1181 | 1239 | } | |
| 1182 | 1240 | ||
| 1183 | 1241 | func (e paletteExec) OpenNotifications() error { | |
M internal/usecase/daemon/palette_test.go +19 -7
| @@ -771,20 +771,26 @@ func TestRemoteDirectoryTracksPickerAndPaletteOnSameAttachment(t *testing.T) { | |||
| 771 | 771 | d := newRemotePickerDaemon() | |
| 772 | 772 | seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(1_100, 0), directorySessionForTest("work"))) | |
| 773 | 773 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 774 | + | effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 775 | + | require.True(t, admitted) | |
| 776 | + | t.Cleanup(effect.End) | |
| 774 | 777 | ||
| 775 | 778 | // Opening overlays builds from the latest snapshot without requesting | |
| 776 | 779 | // reconciliation; closing one overlay leaves the other intact. | |
| 777 | - | d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 780 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 778 | 781 | d.enterPalette(sess, ac) | |
| 779 | - | require.True(t, ac.overlays.pickerActive()) | |
| 782 | + | require.True(t, ac.overlays.pickerClientActive()) | |
| 780 | 783 | require.True(t, ac.overlays.paletteActive()) | |
| 781 | 784 | ||
| 782 | 785 | d.closePalette(ac) | |
| 783 | 786 | require.False(t, ac.overlays.paletteActive()) | |
| 784 | - | require.True(t, ac.overlays.pickerActive(), "closing the palette must not disturb the open picker") | |
| 787 | + | require.True(t, ac.overlays.pickerClientActive(), "closing the palette must not disturb the open picker") | |
| 785 | 788 | ||
| 786 | - | d.closePicker(ac) | |
| 787 | - | require.False(t, ac.overlays.pickerActive()) | |
| 789 | + | ac.overlays.pickerMu.Lock() | |
| 790 | + | interaction := ac.overlays.pickerInteraction | |
| 791 | + | ac.overlays.pickerMu.Unlock() | |
| 792 | + | require.True(t, d.closePickerForAttachment(ac, effect, interaction)) | |
| 793 | + | require.False(t, ac.overlays.pickerClientActive()) | |
| 788 | 794 | } | |
| 789 | 795 | ||
| 790 | 796 | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | |
| @@ -1573,8 +1579,14 @@ func TestPaletteExecMethods(t *testing.T) { | |||
| 1573 | 1579 | d.closePrompt(ac) | |
| 1574 | 1580 | require.True(t, sess.ephemeral) | |
| 1575 | 1581 | require.NoError(t, exec.OpenSessionPicker()) | |
| 1576 | - | require.True(t, ac.overlays.pickerActive()) | |
| 1577 | - | d.closePicker(ac) | |
| 1582 | + | require.True(t, ac.overlays.pickerClientActive()) | |
| 1583 | + | ac.overlays.pickerMu.Lock() | |
| 1584 | + | interaction := ac.overlays.pickerInteraction | |
| 1585 | + | ac.overlays.pickerMu.Unlock() | |
| 1586 | + | effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 1587 | + | require.True(t, admitted) | |
| 1588 | + | require.True(t, d.closePickerForAttachment(ac, effect, interaction)) | |
| 1589 | + | effect.End() | |
| 1578 | 1590 | require.NoError(t, exec.EnterVisualMode()) | |
| 1579 | 1591 | require.True(t, ac.overlays.copyActive()) | |
| 1580 | 1592 | d.exitCopyMode(ac) | |
D internal/usecase/daemon/parked_route.go +0 -494
| @@ -1,494 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "crypto/rand" | |
| 6 | - | "io" | |
| 7 | - | "time" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/internal/ports" | |
| 11 | - | "github.com/bnema/vev/internal/protocol" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | const parkedRouteLeaseTTL = 15 * time.Minute | |
| 15 | - | ||
| 16 | - | type parkedRouteLease struct { | |
| 17 | - | id protocol.ParkedRouteLeaseID | |
| 18 | - | generation uint64 | |
| 19 | - | transport transportSnapshot | |
| 20 | - | expiresAt time.Time | |
| 21 | - | timer ports.Timer | |
| 22 | - | stop chan struct{} | |
| 23 | - | suspended bool | |
| 24 | - | inFlight bool | |
| 25 | - | expired bool | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | func (lease *parkedRouteLease) matches(id protocol.ParkedRouteLeaseID, generation uint64, transport transportSnapshot) bool { | |
| 29 | - | return lease != nil && lease.id == id && lease.generation == generation && lease.transport == transport | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func newParkedRouteLeaseID() (protocol.ParkedRouteLeaseID, error) { | |
| 33 | - | var id protocol.ParkedRouteLeaseID | |
| 34 | - | if _, err := io.ReadFull(rand.Reader, id[:]); err != nil { | |
| 35 | - | return protocol.ParkedRouteLeaseID{}, err | |
| 36 | - | } | |
| 37 | - | return id, nil | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | func stopParkedRouteLeaseLocked(lease *parkedRouteLease) { | |
| 41 | - | if lease == nil { | |
| 42 | - | return | |
| 43 | - | } | |
| 44 | - | if lease.timer != nil { | |
| 45 | - | lease.timer.Stop() | |
| 46 | - | lease.timer = nil | |
| 47 | - | } | |
| 48 | - | if lease.stop != nil { | |
| 49 | - | close(lease.stop) | |
| 50 | - | lease.stop = nil | |
| 51 | - | } | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | func (d *Daemon) armParkedRoute(effect *attachmentEffect) (protocol.ParkedRouteLeaseID, error) { | |
| 55 | - | if !effect.current() { | |
| 56 | - | return protocol.ParkedRouteLeaseID{}, errAttachmentTransition | |
| 57 | - | } | |
| 58 | - | ac := effect.ac | |
| 59 | - | if ac == nil || ac.navigationCapabilities&protocol.NavigationCapabilityHomePicker == 0 { | |
| 60 | - | return protocol.ParkedRouteLeaseID{}, errAttachmentTransition | |
| 61 | - | } | |
| 62 | - | id, err := newParkedRouteLeaseID() | |
| 63 | - | if err != nil { | |
| 64 | - | return protocol.ParkedRouteLeaseID{}, err | |
| 65 | - | } | |
| 66 | - | lease := &parkedRouteLease{ | |
| 67 | - | id: id, generation: effect.generation, transport: effect.transport, | |
| 68 | - | expiresAt: d.clock.Now().Add(parkedRouteLeaseTTL), | |
| 69 | - | timer: d.clock.NewTimer(parkedRouteLeaseTTL), stop: make(chan struct{}), | |
| 70 | - | } | |
| 71 | - | ac.parkedRouteMu.Lock() | |
| 72 | - | stopParkedRouteLeaseLocked(ac.parkedRoute) | |
| 73 | - | ac.parkedRoute = lease | |
| 74 | - | ac.parkedRouteOutput.Store(false) | |
| 75 | - | ac.parkedRouteFullPending.Store(false) | |
| 76 | - | timer, stop := lease.timer, lease.stop | |
| 77 | - | ac.parkedRouteMu.Unlock() | |
| 78 | - | go d.watchParkedRouteLease(ac, lease, timer, stop) | |
| 79 | - | return id, nil | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | func (d *Daemon) watchParkedRouteLease(ac *attachedClient, lease *parkedRouteLease, timer ports.Timer, stop <-chan struct{}) { | |
| 83 | - | select { | |
| 84 | - | case <-timer.C(): | |
| 85 | - | d.expireParkedRoute(ac, lease) | |
| 86 | - | case <-stop: | |
| 87 | - | } | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | func (d *Daemon) expireParkedRoute(ac *attachedClient, lease *parkedRouteLease) { | |
| 91 | - | if ac == nil || lease == nil { | |
| 92 | - | return | |
| 93 | - | } | |
| 94 | - | ac.parkedRouteMu.Lock() | |
| 95 | - | if ac.parkedRoute != lease { | |
| 96 | - | ac.parkedRouteMu.Unlock() | |
| 97 | - | return | |
| 98 | - | } | |
| 99 | - | lease.timer = nil | |
| 100 | - | lease.stop = nil | |
| 101 | - | if lease.inFlight { | |
| 102 | - | lease.expired = true | |
| 103 | - | ac.parkedRouteMu.Unlock() | |
| 104 | - | return | |
| 105 | - | } | |
| 106 | - | ac.parkedRoute = nil | |
| 107 | - | ac.parkedRouteOutput.Store(false) | |
| 108 | - | ac.parkedRouteFullPending.Store(false) | |
| 109 | - | ac.parkedRouteMu.Unlock() | |
| 110 | - | if ac.lifecycle.generationValue() == lease.generation && ac.transportSnapshotCurrent(lease.transport) { | |
| 111 | - | _ = ac.closeCapturedTransport(lease.transport.transport) | |
| 112 | - | } | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func (ac *attachedClient) clearParkedRoute() { | |
| 116 | - | if ac == nil { | |
| 117 | - | return | |
| 118 | - | } | |
| 119 | - | ac.parkedRouteMu.Lock() | |
| 120 | - | stopParkedRouteLeaseLocked(ac.parkedRoute) | |
| 121 | - | ac.parkedRoute = nil | |
| 122 | - | ac.parkedRouteOutput.Store(false) | |
| 123 | - | ac.parkedRouteFullPending.Store(false) | |
| 124 | - | ac.parkedRouteMu.Unlock() | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | func (ac *attachedClient) parkedRouteStatus(token attachmentCapability, id protocol.ParkedRouteLeaseID, now time.Time, suspended bool) protocol.ParkedRouteStatus { | |
| 128 | - | if ac == nil || !token.current() { | |
| 129 | - | return protocol.ParkedRouteRejected | |
| 130 | - | } | |
| 131 | - | ac.parkedRouteMu.Lock() | |
| 132 | - | defer ac.parkedRouteMu.Unlock() | |
| 133 | - | lease := ac.parkedRoute | |
| 134 | - | if !lease.matches(id, token.generation, token.transport) { | |
| 135 | - | return protocol.ParkedRouteRejected | |
| 136 | - | } | |
| 137 | - | if !now.Before(lease.expiresAt) { | |
| 138 | - | stopParkedRouteLeaseLocked(lease) | |
| 139 | - | ac.parkedRoute = nil | |
| 140 | - | ac.parkedRouteOutput.Store(false) | |
| 141 | - | ac.parkedRouteFullPending.Store(false) | |
| 142 | - | return protocol.ParkedRouteExpired | |
| 143 | - | } | |
| 144 | - | if lease.suspended != suspended { | |
| 145 | - | return protocol.ParkedRouteRejected | |
| 146 | - | } | |
| 147 | - | return 0 | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | func (ac *attachedClient) prepareParkedRoute(token attachmentCapability, id protocol.ParkedRouteLeaseID, now time.Time) protocol.ParkedRouteStatus { | |
| 151 | - | if status := ac.parkedRouteStatus(token, id, now, false); status != 0 { | |
| 152 | - | return status | |
| 153 | - | } | |
| 154 | - | ac.parkedRouteMu.Lock() | |
| 155 | - | defer ac.parkedRouteMu.Unlock() | |
| 156 | - | lease := ac.parkedRoute | |
| 157 | - | if !lease.matches(id, token.generation, token.transport) || lease.suspended { | |
| 158 | - | return protocol.ParkedRouteRejected | |
| 159 | - | } | |
| 160 | - | lease.suspended = true | |
| 161 | - | ac.parkedRouteOutput.Store(true) | |
| 162 | - | return 0 | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | func (ac *attachedClient) consumeParkedRoute(token attachmentCapability, id protocol.ParkedRouteLeaseID, now time.Time) protocol.ParkedRouteStatus { | |
| 166 | - | if status := ac.parkedRouteStatus(token, id, now, true); status != 0 { | |
| 167 | - | return status | |
| 168 | - | } | |
| 169 | - | ac.parkedRouteMu.Lock() | |
| 170 | - | defer ac.parkedRouteMu.Unlock() | |
| 171 | - | lease := ac.parkedRoute | |
| 172 | - | if !lease.matches(id, token.generation, token.transport) || !lease.suspended || lease.inFlight { | |
| 173 | - | return protocol.ParkedRouteRejected | |
| 174 | - | } | |
| 175 | - | stopParkedRouteLeaseLocked(lease) | |
| 176 | - | ac.parkedRoute = nil | |
| 177 | - | return 0 | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | func (ac *attachedClient) beginParkedRouteSwitch(token attachmentCapability, id protocol.ParkedRouteLeaseID, now time.Time) protocol.ParkedRouteStatus { | |
| 181 | - | if ac == nil || !token.current() { | |
| 182 | - | return protocol.ParkedRouteRejected | |
| 183 | - | } | |
| 184 | - | ac.parkedRouteMu.Lock() | |
| 185 | - | defer ac.parkedRouteMu.Unlock() | |
| 186 | - | lease := ac.parkedRoute | |
| 187 | - | if !lease.matches(id, token.generation, token.transport) || !lease.suspended || lease.inFlight { | |
| 188 | - | return protocol.ParkedRouteRejected | |
| 189 | - | } | |
| 190 | - | if lease.expired || !now.Before(lease.expiresAt) { | |
| 191 | - | stopParkedRouteLeaseLocked(lease) | |
| 192 | - | ac.parkedRoute = nil | |
| 193 | - | ac.parkedRouteOutput.Store(false) | |
| 194 | - | ac.parkedRouteFullPending.Store(false) | |
| 195 | - | return protocol.ParkedRouteExpired | |
| 196 | - | } | |
| 197 | - | lease.inFlight = true | |
| 198 | - | return 0 | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | func (ac *attachedClient) rejectParkedRouteSwitch(token attachmentCapability, id protocol.ParkedRouteLeaseID, now time.Time) protocol.ParkedRouteStatus { | |
| 202 | - | if ac == nil { | |
| 203 | - | return protocol.ParkedRouteRejected | |
| 204 | - | } | |
| 205 | - | ac.parkedRouteMu.Lock() | |
| 206 | - | defer ac.parkedRouteMu.Unlock() | |
| 207 | - | lease := ac.parkedRoute | |
| 208 | - | if !lease.matches(id, token.generation, token.transport) || !lease.inFlight { | |
| 209 | - | return protocol.ParkedRouteRejected | |
| 210 | - | } | |
| 211 | - | lease.inFlight = false | |
| 212 | - | if lease.expired || !now.Before(lease.expiresAt) { | |
| 213 | - | stopParkedRouteLeaseLocked(lease) | |
| 214 | - | ac.parkedRoute = nil | |
| 215 | - | ac.parkedRouteOutput.Store(false) | |
| 216 | - | ac.parkedRouteFullPending.Store(false) | |
| 217 | - | return protocol.ParkedRouteExpired | |
| 218 | - | } | |
| 219 | - | return 0 | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | func (ac *attachedClient) consumePublishedParkedRoute(previous, published attachmentCapability, id protocol.ParkedRouteLeaseID) protocol.ParkedRouteStatus { | |
| 223 | - | if ac == nil || published.ac != ac || !published.current() { | |
| 224 | - | return protocol.ParkedRouteRejected | |
| 225 | - | } | |
| 226 | - | ac.parkedRouteMu.Lock() | |
| 227 | - | defer ac.parkedRouteMu.Unlock() | |
| 228 | - | lease := ac.parkedRoute | |
| 229 | - | if !lease.matches(id, previous.generation, previous.transport) || !lease.suspended || !lease.inFlight { | |
| 230 | - | return protocol.ParkedRouteRejected | |
| 231 | - | } | |
| 232 | - | // Expiry is checked when the request is admitted. Once publication starts, | |
| 233 | - | // the accepted request must commit atomically instead of turning a completed | |
| 234 | - | // identity publication into a late pre-commit rejection. | |
| 235 | - | stopParkedRouteLeaseLocked(lease) | |
| 236 | - | ac.parkedRoute = nil | |
| 237 | - | return 0 | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | func (d *Daemon) sendParkedRouteResponse(effect *attachmentEffect, response protocol.ParkedRouteResponse) error { | |
| 241 | - | return effect.sendControl(response) | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | // sendParkedRouteResponseLocked preserves response -> full-output ordering. | |
| 245 | - | // Caller holds ac.sendMu and keeps parkedRouteOutput set through this send. | |
| 246 | - | func (d *Daemon) sendParkedRouteResponseLocked(ac *attachedClient, expected transportSnapshot, response protocol.ParkedRouteResponse) error { | |
| 247 | - | if ac == nil || !ac.transportSnapshotCurrent(expected) || expected.transport == nil { | |
| 248 | - | return errAttachmentTransition | |
| 249 | - | } | |
| 250 | - | return expected.transport.SendServer(response) | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | func (ac *attachedClient) releaseParkedOutputLocked() { | |
| 254 | - | ac.rebaseOutput() | |
| 255 | - | ac.pipelineCache = composeCacheInput{} | |
| 256 | - | ac.pipelineScratch = composeCacheInput{} | |
| 257 | - | ac.parkedRouteFullPending.Store(true) | |
| 258 | - | ac.parkedRouteOutput.Store(false) | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | func (d *Daemon) parkedRouteResponseFailed(effect *attachmentEffect) { | |
| 262 | - | if effect == nil { | |
| 263 | - | return | |
| 264 | - | } | |
| 265 | - | capability := effect.capability() | |
| 266 | - | launch := d.reserveAttachmentSendErrorCleanup(capability, capability.transport.transport) | |
| 267 | - | effect.End() | |
| 268 | - | launch() | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | func (d *Daemon) respondParkedRoute(effect *attachmentEffect, response protocol.ParkedRouteResponse) bool { | |
| 272 | - | if effect == nil || effect.ac == nil { | |
| 273 | - | return false | |
| 274 | - | } | |
| 275 | - | sender := effect | |
| 276 | - | owned := false | |
| 277 | - | if !sender.current() { | |
| 278 | - | var admitted bool | |
| 279 | - | sender, admitted = effect.ac.beginAttachmentEffect(effect.capability()) | |
| 280 | - | if !admitted { | |
| 281 | - | return false | |
| 282 | - | } | |
| 283 | - | owned = true | |
| 284 | - | } | |
| 285 | - | if owned { | |
| 286 | - | defer sender.End() | |
| 287 | - | } | |
| 288 | - | if err := d.sendParkedRouteResponse(sender, response); err != nil { | |
| 289 | - | d.parkedRouteResponseFailed(sender) | |
| 290 | - | return false | |
| 291 | - | } | |
| 292 | - | return true | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | func (d *Daemon) handleParkedRouteRequest(effect *attachmentEffect, request protocol.ParkedRouteRequest) { | |
| 296 | - | if protocol.ValidateParkedRouteRequest(request) != nil || !effect.current() || effect.ac == nil { | |
| 297 | - | return | |
| 298 | - | } | |
| 299 | - | capability := effect.capability() | |
| 300 | - | switch request.Action { | |
| 301 | - | case protocol.ParkedRoutePrepare: | |
| 302 | - | status := effect.ac.prepareParkedRoute(capability, request.LeaseID, d.clock.Now()) | |
| 303 | - | if status == 0 { | |
| 304 | - | status = protocol.ParkedRouteReady | |
| 305 | - | } | |
| 306 | - | d.respondParkedRoute(effect, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) | |
| 307 | - | case protocol.ParkedRouteResume: | |
| 308 | - | d.resumeParkedRoute(effect, request) | |
| 309 | - | case protocol.ParkedRouteSwitch: | |
| 310 | - | d.switchParkedRoute(effect, request) | |
| 311 | - | } | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | func (d *Daemon) resumeParkedRoute(effect *attachmentEffect, request protocol.ParkedRouteRequest) { | |
| 315 | - | capability := effect.capability() | |
| 316 | - | status := effect.ac.consumeParkedRoute(capability, request.LeaseID, d.clock.Now()) | |
| 317 | - | if status != 0 { | |
| 318 | - | d.respondParkedRoute(effect, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) | |
| 319 | - | return | |
| 320 | - | } | |
| 321 | - | ac := effect.ac | |
| 322 | - | ac.sendMu.Lock() | |
| 323 | - | err := d.sendParkedRouteResponseLocked(ac, effect.transport, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: protocol.ParkedRouteResumed}) | |
| 324 | - | if err == nil { | |
| 325 | - | ac.releaseParkedOutputLocked() | |
| 326 | - | } | |
| 327 | - | ac.sendMu.Unlock() | |
| 328 | - | if err != nil { | |
| 329 | - | d.parkedRouteResponseFailed(effect) | |
| 330 | - | return | |
| 331 | - | } | |
| 332 | - | if effect.sess != nil { | |
| 333 | - | d.firstPaintWithLease(effect.sess, ac, effect.lease, true) | |
| 334 | - | } | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | func (d *Daemon) rejectParkedRouteSwitch(effect *attachmentEffect, request protocol.ParkedRouteRequest, fallback protocol.ParkedRouteStatus) { | |
| 338 | - | capability := effect.capability() | |
| 339 | - | status := effect.ac.rejectParkedRouteSwitch(capability, request.LeaseID, d.clock.Now()) | |
| 340 | - | if status == 0 { | |
| 341 | - | status = fallback | |
| 342 | - | } | |
| 343 | - | if d.respondParkedRoute(effect, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == protocol.ParkedRouteExpired { | |
| 344 | - | _ = effect.ac.closeCapturedTransport(effect.transport.transport) | |
| 345 | - | } | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | func (d *Daemon) switchParkedRoute(effect *attachmentEffect, request protocol.ParkedRouteRequest) { | |
| 349 | - | capability := effect.capability() | |
| 350 | - | if status := effect.ac.beginParkedRouteSwitch(capability, request.LeaseID, d.clock.Now()); status != 0 { | |
| 351 | - | if d.respondParkedRoute(effect, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == protocol.ParkedRouteExpired { | |
| 352 | - | _ = effect.ac.closeCapturedTransport(effect.transport.transport) | |
| 353 | - | } | |
| 354 | - | return | |
| 355 | - | } | |
| 356 | - | target := *request.Target | |
| 357 | - | ctx := d.serveCtx | |
| 358 | - | if ctx == nil { | |
| 359 | - | ctx = context.Background() | |
| 360 | - | } | |
| 361 | - | if err := d.waitForParkedTargetRestore(ctx, target.SessionName); err != nil { | |
| 362 | - | d.rejectParkedRouteSwitch(effect, request, protocol.ParkedRouteStaleTarget) | |
| 363 | - | return | |
| 364 | - | } | |
| 365 | - | ||
| 366 | - | transition, targetSession, err := d.transitionParkedRouteTarget(effect, target) | |
| 367 | - | if err != nil { | |
| 368 | - | d.rejectParkedRouteSwitch(effect, request, protocol.ParkedRouteStaleTarget) | |
| 369 | - | return | |
| 370 | - | } | |
| 371 | - | if status := effect.ac.consumePublishedParkedRoute(capability, transition.published, request.LeaseID); status != 0 { | |
| 372 | - | d.abortPublishedAttachmentTransition(transition) | |
| 373 | - | return | |
| 374 | - | } | |
| 375 | - | fresh, admitted := effect.ac.beginAttachmentEffect(transition.published) | |
| 376 | - | if !admitted { | |
| 377 | - | d.abortPublishedAttachmentTransition(transition) | |
| 378 | - | return | |
| 379 | - | } | |
| 380 | - | freshCapability := fresh.capability() | |
| 381 | - | ac := effect.ac | |
| 382 | - | ac.sendMu.Lock() | |
| 383 | - | responseErr := d.sendParkedRouteResponseLocked(ac, freshCapability.transport, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: protocol.ParkedRouteSwitched}) | |
| 384 | - | if responseErr == nil { | |
| 385 | - | ac.releaseParkedOutputLocked() | |
| 386 | - | } | |
| 387 | - | ac.sendMu.Unlock() | |
| 388 | - | if responseErr != nil { | |
| 389 | - | launch := d.reserveAttachmentSendErrorCleanup(freshCapability, freshCapability.transport.transport) | |
| 390 | - | fresh.End() | |
| 391 | - | launch() | |
| 392 | - | d.deferAttachmentTransitionCleanups(transition) | |
| 393 | - | return | |
| 394 | - | } | |
| 395 | - | fresh.End() | |
| 396 | - | d.touchMRU(targetSession) | |
| 397 | - | d.deferAttachmentTransitionCleanups(transition) | |
| 398 | - | transition.published.rebase = false | |
| 399 | - | d.firstPaintForTransition(transition.published) | |
| 400 | - | } | |
| 401 | - | ||
| 402 | - | func (d *Daemon) waitForParkedTargetRestore(parent context.Context, name string) error { | |
| 403 | - | ctx, cancel := context.WithCancel(parent) | |
| 404 | - | timer := d.clock.NewTimer(protocol.HandshakeTimeout) | |
| 405 | - | stop := make(chan struct{}) | |
| 406 | - | go func() { | |
| 407 | - | select { | |
| 408 | - | case <-timer.C(): | |
| 409 | - | cancel() | |
| 410 | - | case <-ctx.Done(): | |
| 411 | - | case <-stop: | |
| 412 | - | } | |
| 413 | - | }() | |
| 414 | - | err := d.waitForTargetRestore(ctx, name) | |
| 415 | - | close(stop) | |
| 416 | - | timer.Stop() | |
| 417 | - | cancel() | |
| 418 | - | return err | |
| 419 | - | } | |
| 420 | - | ||
| 421 | - | func (d *Daemon) transitionParkedRouteTarget(effect *attachmentEffect, target domain.RemoteSessionTarget) (attachmentTransitionResult, *session, error) { | |
| 422 | - | capability := effect.capability() | |
| 423 | - | d.mu.Lock() | |
| 424 | - | if d.closing { | |
| 425 | - | d.mu.Unlock() | |
| 426 | - | return attachmentTransitionResult{}, nil, errAttachmentTransition | |
| 427 | - | } | |
| 428 | - | if live := d.findByNameLocked(target.SessionName); live != nil { | |
| 429 | - | if live.incarnation != target.LifecycleID { | |
| 430 | - | d.mu.Unlock() | |
| 431 | - | return attachmentTransitionResult{}, nil, errAttachmentTransition | |
| 432 | - | } | |
| 433 | - | tabIndex, ok := remoteTargetTabIndexLocked(live, target) | |
| 434 | - | if !ok { | |
| 435 | - | d.mu.Unlock() | |
| 436 | - | return attachmentTransitionResult{}, nil, errAttachmentTransition | |
| 437 | - | } | |
| 438 | - | live.mu.Lock() | |
| 439 | - | tabID := domain.TabStableID(live.tabs[tabIndex].stableID) | |
| 440 | - | live.mu.Unlock() | |
| 441 | - | d.mu.Unlock() | |
| 442 | - | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 443 | - | source: effect.sess, target: live, next: effect.ac, | |
| 444 | - | expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: "parked-route-switch", | |
| 445 | - | expectedTargetLifecycle: &attachmentLifecycleFence{ | |
| 446 | - | name: target.SessionName, incarnation: target.LifecycleID, checkIncarnation: true, | |
| 447 | - | tabID: tabID, tabIndex: tabIndex, checkTab: true, | |
| 448 | - | }, | |
| 449 | - | activateTargetTab: true, targetTabIndex: tabIndex, ready: true, | |
| 450 | - | }) | |
| 451 | - | return transition, live, err | |
| 452 | - | } | |
| 453 | - | ||
| 454 | - | inactive, ok := d.inactive[target.SessionName] | |
| 455 | - | if !ok || !target.Stopped || inactive.incarnation != target.LifecycleID || !inactive.canResume() { | |
| 456 | - | d.mu.Unlock() | |
| 457 | - | return attachmentTransitionResult{}, nil, errAttachmentTransition | |
| 458 | - | } | |
| 459 | - | tabIndex, ok := remoteTargetTabIndexInactive(inactive, target) | |
| 460 | - | if !ok { | |
| 461 | - | d.mu.Unlock() | |
| 462 | - | return attachmentTransitionResult{}, nil, errAttachmentTransition | |
| 463 | - | } | |
| 464 | - | d.mu.Unlock() | |
| 465 | - | ||
| 466 | - | var created *session | |
| 467 | - | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 468 | - | source: effect.sess, next: effect.ac, | |
| 469 | - | expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: "parked-route-restore", | |
| 470 | - | activateTargetTab: true, targetTabIndex: tabIndex, ready: true, | |
| 471 | - | createTargetLocked: func() (*session, error) { | |
| 472 | - | current, ok := d.inactive[target.SessionName] | |
| 473 | - | if !ok || current.incarnation != target.LifecycleID || !current.canResume() { | |
| 474 | - | return nil, errAttachmentTransition | |
| 475 | - | } | |
| 476 | - | resolved, ok := remoteTargetTabIndexInactive(current, target) | |
| 477 | - | if !ok || resolved != tabIndex { | |
| 478 | - | return nil, errAttachmentTransition | |
| 479 | - | } | |
| 480 | - | env := copyEnvironment(d.baseEnv) | |
| 481 | - | cwd := d.dirOrHome(current.cwd) | |
| 482 | - | var createErr error | |
| 483 | - | created, createErr = d.resumeRemoteInactiveSessionLocked(target, cwd, effect.ac.geometrySnapshot(), env, current) | |
| 484 | - | return created, createErr | |
| 485 | - | }, | |
| 486 | - | }) | |
| 487 | - | if err != nil && created != nil { | |
| 488 | - | _ = d.killSession(created, protocol.ReasonSessionKilled, true) | |
| 489 | - | } | |
| 490 | - | if err != nil { | |
| 491 | - | return attachmentTransitionResult{}, created, err | |
| 492 | - | } | |
| 493 | - | return transition, created, nil | |
| 494 | - | } | |
D internal/usecase/daemon/parked_route_test.go +0 -391
| @@ -1,391 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "io" | |
| 6 | - | "sync" | |
| 7 | - | "testing" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/stretchr/testify/mock" | |
| 11 | - | "github.com/stretchr/testify/require" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/vev/internal/domain" | |
| 14 | - | "github.com/bnema/vev/internal/ports" | |
| 15 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | - | "github.com/bnema/vev/internal/protocol" | |
| 17 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | type parkedRouteFailTransport struct{} | |
| 21 | - | ||
| 22 | - | func (parkedRouteFailTransport) Send(wire.Frame) error { return errors.New("send failed") } | |
| 23 | - | func (parkedRouteFailTransport) Recv() (wire.Frame, error) { return wire.Frame{}, io.EOF } | |
| 24 | - | func (parkedRouteFailTransport) Close() error { return nil } | |
| 25 | - | ||
| 26 | - | type parkedRouteExpiryTransport struct { | |
| 27 | - | sends chan wire.Frame | |
| 28 | - | closed chan struct{} | |
| 29 | - | once sync.Once | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func (t *parkedRouteExpiryTransport) Send(frame wire.Frame) error { | |
| 33 | - | t.sends <- frame | |
| 34 | - | return nil | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | func (t *parkedRouteExpiryTransport) Recv() (wire.Frame, error) { | |
| 38 | - | <-t.closed | |
| 39 | - | return wire.Frame{}, io.EOF | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func (t *parkedRouteExpiryTransport) Close() error { | |
| 43 | - | t.once.Do(func() { close(t.closed) }) | |
| 44 | - | return nil | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | type parkedRouteClock struct { | |
| 48 | - | mu sync.Mutex | |
| 49 | - | now time.Time | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | func (c *parkedRouteClock) Now() time.Time { | |
| 53 | - | c.mu.Lock() | |
| 54 | - | defer c.mu.Unlock() | |
| 55 | - | return c.now | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func (c *parkedRouteClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} } | |
| 59 | - | ||
| 60 | - | func (c *parkedRouteClock) setNow(now time.Time) { | |
| 61 | - | c.mu.Lock() | |
| 62 | - | c.now = now | |
| 63 | - | c.mu.Unlock() | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func armAndPrepareParkedRoute(t *testing.T, d *Daemon, source *session, ac *attachedClient, sends chan wire.Frame) protocol.ParkedRouteLeaseID { | |
| 67 | - | t.Helper() | |
| 68 | - | ac.navigationCapabilities |= protocol.NavigationCapabilityHomePicker | |
| 69 | - | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 70 | - | effect, admitted := ac.beginAttachmentEffect(token) | |
| 71 | - | require.True(t, admitted) | |
| 72 | - | require.NoError(t, d.sendNavigationActionForAttachment(effect, protocol.NavigationOpenHomePicker)) | |
| 73 | - | effect.End() | |
| 74 | - | directiveFrame := awaitFrame(t, sends, wire.MsgNavigationAction) | |
| 75 | - | directive, err := wire.UnmarshalNavigationDirective(directiveFrame.Payload) | |
| 76 | - | require.NoError(t, err) | |
| 77 | - | ||
| 78 | - | prepare := protocol.ParkedRouteRequest{RequestID: 1, LeaseID: directive.LeaseID, Action: protocol.ParkedRoutePrepare} | |
| 79 | - | require.False(t, d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(prepare)})) | |
| 80 | - | readyFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse) | |
| 81 | - | ready, err := wire.UnmarshalParkedRouteResponse(readyFrame.Payload) | |
| 82 | - | require.NoError(t, err) | |
| 83 | - | require.Equal(t, protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady}, ready) | |
| 84 | - | return directive.LeaseID | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | func TestParkedRouteSwitchesExactLiveTargetOnRetainedTransport(t *testing.T) { | |
| 88 | - | d, source, ac, sends, releases := newManualTabSession(t, 1) | |
| 89 | - | defer releaseAll(releases) | |
| 90 | - | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 91 | - | ||
| 92 | - | targetLifecycle := domain.SessionLifecycleID{7} | |
| 93 | - | target := &session{ | |
| 94 | - | sessionCore: sessionCore{id: "target", name: "target", incarnation: targetLifecycle, attachments: make(map[*attachedClient]struct{})}, | |
| 95 | - | ctx: source.ctx, cancel: func() {}, | |
| 96 | - | tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}, | |
| 97 | - | } | |
| 98 | - | publishTiledPaneOwners(target, target.tabs[0]) | |
| 99 | - | d.mu.Lock() | |
| 100 | - | d.sessions[target.id] = target | |
| 101 | - | d.mu.Unlock() | |
| 102 | - | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1}) | |
| 103 | - | ||
| 104 | - | leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends) | |
| 105 | - | require.True(t, ac.parkedRouteOutput.Load()) | |
| 106 | - | ||
| 107 | - | targetRequest := domain.RemoteSessionTarget{ | |
| 108 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle, | |
| 109 | - | SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID), | |
| 110 | - | } | |
| 111 | - | switchRequest := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteSwitch, Target: &targetRequest} | |
| 112 | - | require.False(t, d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(switchRequest)})) | |
| 113 | - | ||
| 114 | - | require.Same(t, target, ac.currentAttachmentSession()) | |
| 115 | - | require.False(t, ac.parkedRouteOutput.Load()) | |
| 116 | - | responseFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse) | |
| 117 | - | response, err := wire.UnmarshalParkedRouteResponse(responseFrame.Payload) | |
| 118 | - | require.NoError(t, err) | |
| 119 | - | require.Equal(t, protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteSwitched}, response) | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | func TestParkedRouteSwitchAcceptedBeforeLeaseExpiryCommitsAtomically(t *testing.T) { | |
| 123 | - | d, source, ac, sends, releases := newManualTabSession(t, 1) | |
| 124 | - | defer releaseAll(releases) | |
| 125 | - | clock := &parkedRouteClock{} | |
| 126 | - | d.clock = clock | |
| 127 | - | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 128 | - | ||
| 129 | - | targetLifecycle := domain.SessionLifecycleID{7} | |
| 130 | - | target := &session{ | |
| 131 | - | sessionCore: sessionCore{id: "target", name: "target", incarnation: targetLifecycle, attachments: make(map[*attachedClient]struct{})}, | |
| 132 | - | ctx: source.ctx, cancel: func() {}, | |
| 133 | - | tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}, | |
| 134 | - | } | |
| 135 | - | publishTiledPaneOwners(target, target.tabs[0]) | |
| 136 | - | d.mu.Lock() | |
| 137 | - | d.sessions[target.id] = target | |
| 138 | - | d.mu.Unlock() | |
| 139 | - | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1}) | |
| 140 | - | ||
| 141 | - | leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends) | |
| 142 | - | d.afterAttachmentEffectsFrozen = func() { | |
| 143 | - | clock.setNow(time.Time{}.Add(parkedRouteLeaseTTL)) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | targetRequest := domain.RemoteSessionTarget{ | |
| 147 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle, | |
| 148 | - | SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID), | |
| 149 | - | } | |
| 150 | - | switchRequest := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteSwitch, Target: &targetRequest} | |
| 151 | - | d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(switchRequest)}) | |
| 152 | - | ||
| 153 | - | require.Same(t, target, ac.currentAttachmentSession()) | |
| 154 | - | responseFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse) | |
| 155 | - | response, err := wire.UnmarshalParkedRouteResponse(responseFrame.Payload) | |
| 156 | - | require.NoError(t, err) | |
| 157 | - | require.Equal(t, protocol.ParkedRouteSwitched, response.Status) | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | func TestParkedRouteRestoresExactStoppedTargetWithDaemonEnvironment(t *testing.T) { | |
| 161 | - | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 162 | - | d.baseEnv = []string{"OWNER=daemon"} | |
| 163 | - | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | |
| 164 | - | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 165 | - | source.mu.Lock() | |
| 166 | - | source.env = []string{"OWNER=source"} | |
| 167 | - | source.mu.Unlock() | |
| 168 | - | ||
| 169 | - | lifecycle := domain.SessionLifecycleID{8} | |
| 170 | - | d.mu.Lock() | |
| 171 | - | d.inactive["stopped"] = inactiveSession{ | |
| 172 | - | name: "stopped", cwd: "/remote/work", incarnation: lifecycle, state: protocol.SessionDown, | |
| 173 | - | tabNames: []string{"alpha", "beta"}, | |
| 174 | - | tabRecords: []domain.CatalogueTabRecord{ | |
| 175 | - | {StableID: "tab-a", Name: "alpha"}, | |
| 176 | - | {StableID: "tab-b", Name: "beta"}, | |
| 177 | - | }, | |
| 178 | - | } | |
| 179 | - | d.mu.Unlock() | |
| 180 | - | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1}) | |
| 181 | - | ||
| 182 | - | leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends) | |
| 183 | - | ||
| 184 | - | target := domain.RemoteSessionTarget{ | |
| 185 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: lifecycle, | |
| 186 | - | SessionName: "stopped", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-b"), | |
| 187 | - | } | |
| 188 | - | switchRequest := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteSwitch, Target: &target} | |
| 189 | - | d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(switchRequest)}) | |
| 190 | - | ||
| 191 | - | restored := ac.currentAttachmentSession() | |
| 192 | - | require.NotNil(t, restored) | |
| 193 | - | require.Equal(t, "stopped", restored.name) | |
| 194 | - | require.Equal(t, lifecycle, restored.incarnation) | |
| 195 | - | require.Equal(t, domain.TabStableID("tab-b"), ac.viewSnapshot().tabID) | |
| 196 | - | restored.mu.Lock() | |
| 197 | - | require.Equal(t, []string{"OWNER=daemon"}, restored.env) | |
| 198 | - | restored.mu.Unlock() | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | func TestParkedRouteStaleTargetLeavesSourceResumable(t *testing.T) { | |
| 202 | - | d, source, ac, sends, releases := newManualTabSession(t, 1) | |
| 203 | - | defer releaseAll(releases) | |
| 204 | - | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 205 | - | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1}) | |
| 206 | - | ||
| 207 | - | leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends) | |
| 208 | - | ||
| 209 | - | missing := domain.RemoteSessionTarget{ | |
| 210 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{9}, | |
| 211 | - | SessionName: "missing", LiveTabID: "tab-1", | |
| 212 | - | } | |
| 213 | - | switchRequest := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteSwitch, Target: &missing} | |
| 214 | - | d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(switchRequest)}) | |
| 215 | - | staleFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse) | |
| 216 | - | stale, err := wire.UnmarshalParkedRouteResponse(staleFrame.Payload) | |
| 217 | - | require.NoError(t, err) | |
| 218 | - | require.Equal(t, protocol.ParkedRouteStaleTarget, stale.Status) | |
| 219 | - | require.Same(t, source, ac.currentAttachmentSession()) | |
| 220 | - | require.True(t, ac.parkedRouteOutput.Load()) | |
| 221 | - | ||
| 222 | - | resume := protocol.ParkedRouteRequest{RequestID: 3, LeaseID: leaseID, Action: protocol.ParkedRouteResume} | |
| 223 | - | d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(resume)}) | |
| 224 | - | resumedFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse) | |
| 225 | - | resumed, err := wire.UnmarshalParkedRouteResponse(resumedFrame.Payload) | |
| 226 | - | require.NoError(t, err) | |
| 227 | - | require.Equal(t, protocol.ParkedRouteResumed, resumed.Status) | |
| 228 | - | require.False(t, ac.parkedRouteOutput.Load()) | |
| 229 | - | outputFrame := awaitFrame(t, sends, wire.MsgOutput) | |
| 230 | - | output, err := wire.UnmarshalOutput(outputFrame.Payload) | |
| 231 | - | require.NoError(t, err) | |
| 232 | - | require.True(t, output.Full) | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | func TestParkedRouteDoesNotReplayOneShotEffectsAfterResume(t *testing.T) { | |
| 236 | - | d, source, ac, sends, releases := newManualTabSession(t, 1) | |
| 237 | - | defer releaseAll(releases) | |
| 238 | - | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 239 | - | ||
| 240 | - | leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends) | |
| 241 | - | require.NoError(t, d.boundedSendOutputErr(ac, []byte("one-shot effect"))) | |
| 242 | - | select { | |
| 243 | - | case frame := <-sends: | |
| 244 | - | t.Fatalf("parked one-shot effect emitted frame type %d", frame.Type) | |
| 245 | - | default: | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | resume := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteResume} | |
| 249 | - | d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(resume)}) | |
| 250 | - | _ = awaitFrame(t, sends, wire.MsgParkedRouteResponse) | |
| 251 | - | outputFrame := awaitFrame(t, sends, wire.MsgOutput) | |
| 252 | - | output, err := wire.UnmarshalOutput(outputFrame.Payload) | |
| 253 | - | require.NoError(t, err) | |
| 254 | - | require.True(t, output.Full) | |
| 255 | - | require.NotContains(t, string(output.Data), "one-shot effect") | |
| 256 | - | position, err := wire.UnmarshalRoutePosition(awaitFrame(t, sends, wire.MsgRoutePosition).Payload) | |
| 257 | - | require.NoError(t, err) | |
| 258 | - | require.Equal(t, source.incarnation, position.Target.LifecycleID) | |
| 259 | - | select { | |
| 260 | - | case frame := <-sends: | |
| 261 | - | t.Fatalf("parked one-shot effect replayed as frame type %d", frame.Type) | |
| 262 | - | default: | |
| 263 | - | } | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | func TestParkedRouteExpiredLeaseFailsClosed(t *testing.T) { | |
| 267 | - | d, source, ac, sends, releases := newManualTabSession(t, 1) | |
| 268 | - | defer releaseAll(releases) | |
| 269 | - | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 270 | - | ||
| 271 | - | leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends) | |
| 272 | - | ||
| 273 | - | ac.parkedRouteMu.Lock() | |
| 274 | - | ac.parkedRoute.expiresAt = d.clock.Now() | |
| 275 | - | ac.parkedRouteMu.Unlock() | |
| 276 | - | resume := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteResume} | |
| 277 | - | d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(resume)}) | |
| 278 | - | responseFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse) | |
| 279 | - | response, err := wire.UnmarshalParkedRouteResponse(responseFrame.Payload) | |
| 280 | - | require.NoError(t, err) | |
| 281 | - | require.Equal(t, protocol.ParkedRouteExpired, response.Status) | |
| 282 | - | require.Same(t, source, ac.currentAttachmentSession()) | |
| 283 | - | require.False(t, ac.parkedRouteOutput.Load()) | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | func TestParkedRouteResumeRejectsSwitchInFlight(t *testing.T) { | |
| 287 | - | d, source, ac, sends, releases := newManualTabSession(t, 1) | |
| 288 | - | defer releaseAll(releases) | |
| 289 | - | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 290 | - | leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends) | |
| 291 | - | ||
| 292 | - | ac.parkedRouteMu.Lock() | |
| 293 | - | ac.parkedRoute.inFlight = true | |
| 294 | - | ac.parkedRouteMu.Unlock() | |
| 295 | - | status := ac.consumeParkedRoute(source.captureAttachmentCapability(ac, ac.transport()), leaseID, d.clock.Now()) | |
| 296 | - | ||
| 297 | - | require.Equal(t, protocol.ParkedRouteRejected, status) | |
| 298 | - | ac.parkedRouteMu.Lock() | |
| 299 | - | require.NotNil(t, ac.parkedRoute) | |
| 300 | - | ac.parkedRouteMu.Unlock() | |
| 301 | - | require.True(t, ac.parkedRouteOutput.Load()) | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | func TestParkedRouteLeaseExpiryClosesExactRetainedTransport(t *testing.T) { | |
| 305 | - | d, source, ac, _, releases := newManualTabSession(t, 1) | |
| 306 | - | defer releaseAll(releases) | |
| 307 | - | expiry := make(chan time.Time, 1) | |
| 308 | - | timer := portsmocks.NewMockTimer(t) | |
| 309 | - | timer.EXPECT().C().Return(expiry).Maybe() | |
| 310 | - | timer.EXPECT().Stop().Return(true).Maybe() | |
| 311 | - | clock := portsmocks.NewMockClock(t) | |
| 312 | - | clock.EXPECT().Now().Return(time.Time{}).Maybe() | |
| 313 | - | clock.EXPECT().NewTimer(mock.Anything).Return(timer).Once() | |
| 314 | - | d.clock = clock | |
| 315 | - | transport := &parkedRouteExpiryTransport{sends: make(chan wire.Frame, 8), closed: make(chan struct{})} | |
| 316 | - | ac.replaceTransport(transport) | |
| 317 | - | ||
| 318 | - | _ = armAndPrepareParkedRoute(t, d, source, ac, transport.sends) | |
| 319 | - | expiry <- time.Time{}.Add(parkedRouteLeaseTTL) | |
| 320 | - | ||
| 321 | - | select { | |
| 322 | - | case <-transport.closed: | |
| 323 | - | case <-time.After(time.Second): | |
| 324 | - | t.Fatal("parked transport remained open after its lease expired") | |
| 325 | - | } | |
| 326 | - | ac.parkedRouteMu.Lock() | |
| 327 | - | require.Nil(t, ac.parkedRoute) | |
| 328 | - | ac.parkedRouteMu.Unlock() | |
| 329 | - | require.False(t, ac.parkedRouteOutput.Load()) | |
| 330 | - | require.False(t, ac.parkedRouteFullPending.Load()) | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | func TestParkedRouteMalformedSwitchRequestsFailClosed(t *testing.T) { | |
| 334 | - | d, source, ac, sends, releases := newManualTabSession(t, 1) | |
| 335 | - | defer releaseAll(releases) | |
| 336 | - | leaseID := protocol.ParkedRouteLeaseID{1} | |
| 337 | - | for _, request := range []protocol.ParkedRouteRequest{ | |
| 338 | - | {RequestID: 1, LeaseID: leaseID, Action: protocol.ParkedRouteSwitch}, | |
| 339 | - | {RequestID: 2, Action: protocol.ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}}, | |
| 340 | - | {RequestID: 3, LeaseID: leaseID, Action: 99}, | |
| 341 | - | } { | |
| 342 | - | capability := source.captureAttachmentCapability(ac, ac.transport()) | |
| 343 | - | effect, admitted := ac.beginAttachmentEffect(capability) | |
| 344 | - | require.True(t, admitted) | |
| 345 | - | d.handleParkedRouteRequest(effect, request) | |
| 346 | - | effect.End() | |
| 347 | - | } | |
| 348 | - | require.Same(t, source, ac.currentAttachmentSession()) | |
| 349 | - | select { | |
| 350 | - | case frame := <-sends: | |
| 351 | - | t.Fatalf("malformed parked route emitted frame type %d", frame.Type) | |
| 352 | - | default: | |
| 353 | - | } | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | func TestParkedRouteResponseFailureDoesNotDrainItsOwnFrameEffect(t *testing.T) { | |
| 357 | - | d, source, ac, _, releases := newManualTabSession(t, 1) | |
| 358 | - | defer releaseAll(releases) | |
| 359 | - | ac.replaceTransport(parkedRouteFailTransport{}) | |
| 360 | - | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 361 | - | effect, admitted := ac.beginAttachmentEffect(token) | |
| 362 | - | require.True(t, admitted) | |
| 363 | - | ||
| 364 | - | result := make(chan bool, 1) | |
| 365 | - | go func() { | |
| 366 | - | result <- d.respondParkedRoute(effect, protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady}) | |
| 367 | - | }() | |
| 368 | - | require.False(t, awaitTestValue(t, result, "parked-route send failure cleanup")) | |
| 369 | - | d.attachmentCleanupWg.Wait() | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | func TestParkedRouteDetachClearsLeaseAndSuspension(t *testing.T) { | |
| 373 | - | d, source, ac, sends, releases := newManualTabSession(t, 1) | |
| 374 | - | defer releaseAll(releases) | |
| 375 | - | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 376 | - | ||
| 377 | - | _ = armAndPrepareParkedRoute(t, d, source, ac, sends) | |
| 378 | - | require.True(t, ac.parkedRouteOutput.Load()) | |
| 379 | - | require.NoError(t, d.boundedSendOutputErr(ac, []byte("suppressed side effect"))) | |
| 380 | - | select { | |
| 381 | - | case frame := <-sends: | |
| 382 | - | t.Fatalf("parked side effect emitted frame type %d", frame.Type) | |
| 383 | - | default: | |
| 384 | - | } | |
| 385 | - | ||
| 386 | - | d.clientGone(source, ac, ac.transport(), true) | |
| 387 | - | ac.parkedRouteMu.Lock() | |
| 388 | - | require.Nil(t, ac.parkedRoute) | |
| 389 | - | ac.parkedRouteMu.Unlock() | |
| 390 | - | require.False(t, ac.parkedRouteOutput.Load()) | |
| 391 | - | } | |
M internal/usecase/daemon/performance_benchmark_test.go +24 -21
| @@ -48,7 +48,7 @@ func TestCountingSnapshotRepositoryDoesNotRewriteKnownObjects(t *testing.T) { | |||
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *testing.T) { | |
| 51 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 51 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: perfHistoryRows}) | |
| 52 | 52 | ||
| 53 | 53 | publishBenchmarkSnapshot(t, fixture, 1) | |
| 54 | 54 | fixture.snapshots.reset() | |
| @@ -64,7 +64,7 @@ func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t * | |||
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) { | |
| 67 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 67 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: perfHistoryRows}) | |
| 68 | 68 | ||
| 69 | 69 | // This contract concerns capture and publication bytes, not queue latency. | |
| 70 | 70 | // Publish synchronously so the 10k-row setup cannot race the test deadline. | |
| @@ -120,8 +120,8 @@ func TestIncrementalSnapshotMetricScenarios(t *testing.T) { | |||
| 120 | 120 | mutate func(*performanceFixture, int) | |
| 121 | 121 | wantHistory bool | |
| 122 | 122 | }{ | |
| 123 | - | {name: "tail-only", historyRows: 9_999, mutate: mutateBenchmarkTail}, | |
| 124 | - | {name: "new-sealed-chunk", historyRows: 10_000, mutate: mutateBenchmarkSealedChunk, wantHistory: true}, | |
| 123 | + | {name: "tail-only", historyRows: perfHistoryRows - 1, mutate: mutateBenchmarkTail}, | |
| 124 | + | {name: "new-sealed-chunk", historyRows: perfHistoryRows, mutate: mutateBenchmarkSealedChunk, wantHistory: true}, | |
| 125 | 125 | } { | |
| 126 | 126 | t.Run(tt.name, func(t *testing.T) { | |
| 127 | 127 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: tt.historyRows}) | |
| @@ -142,11 +142,14 @@ func TestIncrementalSnapshotMetricScenarios(t *testing.T) { | |||
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | 144 | func TestPerformanceFixtureByteLimitedHistory(t *testing.T) { | |
| 145 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 400, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 145 | + | if raceEnabled { | |
| 146 | + | t.Skip("byte eviction budget is calibrated without the race detector") | |
| 147 | + | } | |
| 148 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 400, Rows: 40}, panes: 1, historyRows: perfHistoryRows}) | |
| 146 | 149 | fixture.activePane.mu.Lock() | |
| 147 | 150 | rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap() | |
| 148 | 151 | fixture.activePane.mu.Unlock() | |
| 149 | - | require.Less(t, rows, 10_000, "byte eviction must happen before the line ceiling") | |
| 152 | + | require.Less(t, rows, perfHistoryRows, "byte eviction must happen before the line ceiling") | |
| 150 | 153 | require.Positive(t, bytes) | |
| 151 | 154 | require.LessOrEqual(t, bytes, budget) | |
| 152 | 155 | } | |
| @@ -296,25 +299,25 @@ func TestTransactionalResizeMetrics(t *testing.T) { | |||
| 296 | 299 | } | |
| 297 | 300 | ||
| 298 | 301 | func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |
| 299 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: 4, panes: 4, historyRows: 10_000}) | |
| 302 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: 4, panes: 4, historyRows: perfHistoryRows}) | |
| 300 | 303 | ||
| 301 | 304 | require.Len(t, fixture.sess.tabs, 4) | |
| 302 | 305 | for _, tb := range fixture.sess.tabs { | |
| 303 | 306 | require.Len(t, tb.panes, 4) | |
| 304 | 307 | for _, p := range tb.panes { | |
| 305 | - | require.Equal(t, 10_000, p.history.Len()) | |
| 308 | + | require.Equal(t, perfHistoryRows, p.history.Len()) | |
| 306 | 309 | } | |
| 307 | 310 | } | |
| 308 | 311 | } | |
| 309 | 312 | ||
| 310 | 313 | func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) { | |
| 311 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000}) | |
| 314 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, historyRows: perfHistoryRows}) | |
| 312 | 315 | ||
| 313 | 316 | first, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |
| 314 | 317 | require.True(t, ok) | |
| 315 | 318 | require.Len(t, first.tabs, 1) | |
| 316 | 319 | require.Len(t, first.tabs[0].panes, 2) | |
| 317 | - | require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len()) | |
| 320 | + | require.Equal(t, perfHistoryRows, first.tabs[0].panes[0].sealed.Len()) | |
| 318 | 321 | require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount()) | |
| 319 | 322 | ||
| 320 | 323 | second, ok := fixture.d.captureSnapshotState(fixture.sess, 2) | |
| @@ -434,7 +437,7 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||
| 434 | 437 | for _, topology := range daemonHistoryTopologies { | |
| 435 | 438 | b.Run(topology.name, func(b *testing.B) { | |
| 436 | 439 | fixture := newPerformanceFixture(b, performanceConfig{ | |
| 437 | - | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000, | |
| 440 | + | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: perfHistoryRows, | |
| 438 | 441 | }) | |
| 439 | 442 | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |
| 440 | 443 | b.Fatal("invalid daemon history fixture") | |
| @@ -483,7 +486,7 @@ func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run fu | |||
| 483 | 486 | func benchmarkDaemonSnapshotCapture(b *testing.B) { | |
| 484 | 487 | for _, topology := range daemonSnapshotTopologies { | |
| 485 | 488 | b.Run(topology.name, func(b *testing.B) { | |
| 486 | - | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000}) | |
| 489 | + | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: perfHistoryRows}) | |
| 487 | 490 | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |
| 488 | 491 | b.Fatal("invalid daemon snapshot capture fixture") | |
| 489 | 492 | } | |
| @@ -525,14 +528,14 @@ type snapshotBenchmarkScenario struct { | |||
| 525 | 528 | } | |
| 526 | 529 | ||
| 527 | 530 | var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ | |
| 528 | - | {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000}, | |
| 529 | - | {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true}, | |
| 530 | - | {name: "transcript-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkTranscript}, | |
| 531 | + | {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: perfHistoryRows}, | |
| 532 | + | {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: perfHistoryRows, baseline: true, unchanged: true}, | |
| 533 | + | {name: "transcript-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: perfHistoryRows, baseline: true, mutate: mutateBenchmarkTranscript}, | |
| 531 | 534 | // Leave one row below the retention cap so the mutation changes only the | |
| 532 | 535 | // mutable tail and cannot evict a sealed chunk. | |
| 533 | 536 | {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail}, | |
| 534 | - | {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk}, | |
| 535 | - | {name: "byte-limited-400-columns", size: domain.Size{Cols: 400, Rows: 40}, historyRows: 10_000, byteLimited: true}, | |
| 537 | + | {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: perfHistoryRows, baseline: true, mutate: mutateBenchmarkSealedChunk}, | |
| 538 | + | {name: "byte-limited-400-columns", size: domain.Size{Cols: 400, Rows: 40}, historyRows: perfHistoryRows, byteLimited: true}, | |
| 536 | 539 | } | |
| 537 | 540 | ||
| 538 | 541 | func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) { | |
| @@ -685,7 +688,7 @@ func benchmarkReportSnapshotRepositoryMetrics(b *testing.B, metrics countingSnap | |||
| 685 | 688 | } | |
| 686 | 689 | ||
| 687 | 690 | func benchmarkDaemonSnapshotRestore(b *testing.B) { | |
| 688 | - | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 691 | + | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: perfHistoryRows}) | |
| 689 | 692 | if !fixture.hasHistoryTopology(1, 1, 10_000) { | |
| 690 | 693 | b.Fatal("invalid daemon snapshot restore fixture") | |
| 691 | 694 | } | |
| @@ -837,7 +840,7 @@ func TestLivePaintAllocationBudget(t *testing.T) { | |||
| 837 | 840 | {name: "1tab-4panes", panes: 4}, | |
| 838 | 841 | } { | |
| 839 | 842 | t.Run(tt.name, func(t *testing.T) { | |
| 840 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000}) | |
| 843 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: perfHistoryRows}) | |
| 841 | 844 | run := func() { | |
| 842 | 845 | fixture.paintLive() | |
| 843 | 846 | fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next) | |
| @@ -866,7 +869,7 @@ func TestCopyEnterAllocationBudget(t *testing.T) { | |||
| 866 | 869 | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 106}, | |
| 867 | 870 | } { | |
| 868 | 871 | t.Run(tt.name, func(t *testing.T) { | |
| 869 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | |
| 872 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: perfHistoryRows}) | |
| 870 | 873 | run := func() { | |
| 871 | 874 | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |
| 872 | 875 | fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next) | |
| @@ -1244,7 +1247,7 @@ func (f *performanceFixture) searchMatches() int { | |||
| 1244 | 1247 | func (f *performanceFixture) copyModeActive() bool { | |
| 1245 | 1248 | f.ac.overlays.copyMu.Lock() | |
| 1246 | 1249 | defer f.ac.overlays.copyMu.Unlock() | |
| 1247 | - | return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= 10_000 | |
| 1250 | + | return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= perfHistoryRows | |
| 1248 | 1251 | } | |
| 1249 | 1252 | ||
| 1250 | 1253 | func (f *performanceFixture) resize() { | |
A internal/usecase/daemon/performance_history_race_test.go +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | //go:build race | |
| 2 | + | ||
| 3 | + | package daemon | |
| 4 | + | ||
| 5 | + | // The race detector makes history construction, sealing, and cloning dominate | |
| 6 | + | // this package's runtime, while the allocation and byte budgets these fixtures | |
| 7 | + | // feed are calibrated without it. The race build therefore keeps a smaller but | |
| 8 | + | // still multi-chunk history. | |
| 9 | + | const ( | |
| 10 | + | raceEnabled = true | |
| 11 | + | perfHistoryRows = 600 | |
| 12 | + | ) | |
A internal/usecase/daemon/performance_history_test.go +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | //go:build !race | |
| 2 | + | ||
| 3 | + | package daemon | |
| 4 | + | ||
| 5 | + | // perfHistoryRows is the history depth every performance fixture builds, and | |
| 6 | + | // the depth the compact-storage budgets are calibrated against. | |
| 7 | + | const ( | |
| 8 | + | raceEnabled = false | |
| 9 | + | perfHistoryRows = 10_000 | |
| 10 | + | ) | |
M internal/usecase/daemon/picker.go +60 -676
| @@ -3,7 +3,6 @@ package daemon | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | - | "errors" | |
| 7 | 6 | "sort" | |
| 8 | 7 | "time" | |
| 9 | 8 | ||
| @@ -11,85 +10,29 @@ import ( | |||
| 11 | 10 | "github.com/bnema/vev/internal/domain" | |
| 12 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 13 | 12 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 14 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 15 | 13 | "github.com/bnema/vev/internal/usecase/layout" | |
| 16 | 14 | "github.com/bnema/vev/internal/usecase/picker" | |
| 17 | 15 | "github.com/bnema/vev/internal/usecase/ui" | |
| 18 | 16 | ) | |
| 19 | 17 | ||
| 20 | - | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | |
| 18 | + | // The picker's floating geometry lives in the picker package: the presenting | |
| 19 | + | // client draws the same box, and this daemon sizes the preview it captures from | |
| 20 | + | // it, so both sides always agree. | |
| 21 | + | var pickerModal = picker.Modal | |
| 21 | 22 | ||
| 22 | - | // pickerSortMode orders the picker's live sessions. It lives for the daemon's | |
| 23 | - | // lifetime only and is never persisted. | |
| 24 | - | type pickerSortMode uint32 | |
| 23 | + | const remotePickerPreviewDebounce = 80 * time.Millisecond | |
| 25 | 24 | ||
| 26 | - | const ( | |
| 27 | - | pickerSortRecent pickerSortMode = iota | |
| 28 | - | pickerSortGrouped | |
| 29 | - | ||
| 30 | - | remotePickerPreviewDebounce = 80 * time.Millisecond | |
| 31 | - | ) | |
| 32 | - | ||
| 33 | - | func pickerTitle(mode pickerSortMode) string { | |
| 34 | - | if mode == pickerSortGrouped { | |
| 35 | - | return " Sessions · grouped " | |
| 36 | - | } | |
| 37 | - | return " Sessions · recent " | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // togglePickerSort flips the daemon's picker sort mode between the two known | |
| 41 | - | // modes with a CompareAndSwap loop, so a concurrent double-press can't lose | |
| 42 | - | // an update the way a bare Load-then-Store XOR can. | |
| 43 | - | func (d *Daemon) togglePickerSort() { | |
| 44 | - | for { | |
| 45 | - | cur := pickerSortMode(d.pickerSort.Load()) | |
| 46 | - | next := pickerSortRecent | |
| 47 | - | if cur == pickerSortRecent { | |
| 48 | - | next = pickerSortGrouped | |
| 49 | - | } | |
| 50 | - | if d.pickerSort.CompareAndSwap(uint32(cur), uint32(next)) { | |
| 51 | - | return | |
| 52 | - | } | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // enterPicker preserves the existing navigation entry point. Navigation always | |
| 57 | - | // publishes its model, including an empty one; only move entry can fail for a | |
| 58 | - | // missing destination. | |
| 59 | - | func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |
| 60 | - | model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 61 | - | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) { | |
| 65 | - | rt := ac.overlays | |
| 66 | - | rt.pickerMu.Lock() | |
| 67 | - | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | |
| 68 | - | rt.pickerPreviewGeneration++ | |
| 69 | - | if rt.pickerRemotePreviewCancel != nil { | |
| 70 | - | rt.pickerRemotePreviewCancel() | |
| 71 | - | rt.pickerRemotePreviewCancel = nil | |
| 72 | - | } | |
| 73 | - | rt.pickerPreviewSession = nil | |
| 74 | - | rt.pickerRemotePreview = picker.Preview{} | |
| 75 | - | rt.pickerPreview = nil | |
| 76 | - | rt.picker = model | |
| 77 | - | rt.pickerGeneration++ | |
| 78 | - | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | |
| 79 | - | rt.pickerIntent = intent | |
| 80 | - | rt.pickerSource = source | |
| 81 | - | rt.pickerPending = nil | |
| 82 | - | rt.pickerESC.stop() | |
| 83 | - | rt.pickerMu.Unlock() | |
| 84 | - | d.teardownPreviewSubscription(ac, previous, previousGeneration) | |
| 85 | - | d.registerPreviewForSelection(ac) | |
| 86 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 25 | + | // pickerViews projects the shared daemon inventory for the picker. It keeps | |
| 26 | + | // current/ephemeral rows, tabs, grouping, and move eligibility; lifecycle and | |
| 27 | + | // remote facts come from the common capture. Section lines are published | |
| 28 | + | // whenever the source has more than one origin group, so the presenting | |
| 29 | + | // client can order each run locally without inventing group labels. | |
| 30 | + | func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]pickerSessionView, pickerSourceFilter) { | |
| 31 | + | _, grouped, current := d.pickerViewProjections(cur, ac) | |
| 32 | + | return grouped, current | |
| 87 | 33 | } | |
| 88 | 34 | ||
| 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 | - | func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) { | |
| 35 | + | func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) { | |
| 93 | 36 | if cur != nil && ac != nil { | |
| 94 | 37 | cur.repairAttachmentView(ac) | |
| 95 | 38 | } | |
| @@ -106,17 +49,17 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 106 | 49 | // renameSession mid-sort. Remote-catalog locks are released before sorting | |
| 107 | 50 | // and picker-row construction. | |
| 108 | 51 | live := inv.live | |
| 109 | - | var current picker.SourceFilter | |
| 52 | + | var current pickerSourceFilter | |
| 110 | 53 | for _, item := range live { | |
| 111 | 54 | snap := item.view | |
| 112 | 55 | if item.sess == cur { | |
| 113 | 56 | if ac != nil { | |
| 114 | 57 | view := ac.viewSnapshot() | |
| 115 | 58 | if view.tabID != "" { | |
| 116 | - | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID} | |
| 59 | + | current = pickerSourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID} | |
| 117 | 60 | } | |
| 118 | 61 | } else if len(snap.tabs) > 0 { | |
| 119 | - | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id} | |
| 62 | + | current = pickerSourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id} | |
| 120 | 63 | } | |
| 121 | 64 | } | |
| 122 | 65 | } | |
| @@ -135,13 +78,6 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 135 | 78 | } | |
| 136 | 79 | return stopped[i].name < stopped[j].name | |
| 137 | 80 | }) | |
| 138 | - | if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped { | |
| 139 | - | // Stable partition: named sessions first, ephemeral after, each keeping | |
| 140 | - | // its MRU order from the sort above. | |
| 141 | - | sort.SliceStable(live, func(i, j int) bool { | |
| 142 | - | return !live[i].view.ephemeral && live[j].view.ephemeral | |
| 143 | - | }) | |
| 144 | - | } | |
| 145 | 81 | ||
| 146 | 82 | catalogRows := 0 | |
| 147 | 83 | for _, host := range hosts { | |
| @@ -150,16 +86,38 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 150 | 86 | catalogRows++ | |
| 151 | 87 | } | |
| 152 | 88 | } | |
| 153 | - | views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows) | |
| 154 | - | grouped := pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped | |
| 89 | + | checking := monitored && !initialized | |
| 90 | + | recent := make([]pickerSessionView, 0, len(live)+len(stopped)+catalogRows) | |
| 91 | + | for _, item := range live { | |
| 92 | + | recent = append(recent, item.view.pickerView()) | |
| 93 | + | } | |
| 94 | + | for _, host := range hosts { | |
| 95 | + | for _, session := range host.Sessions { | |
| 96 | + | key := domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name} | |
| 97 | + | if key.Validate() == nil { | |
| 98 | + | recent = append(recent, remotePickerView(key, session, host, now)) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable { | |
| 102 | + | recent = append(recent, remotePickerHostView(host, now)) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | if checking { | |
| 106 | + | recent = append(recent, remotePickerCheckingView()) | |
| 107 | + | } | |
| 108 | + | for _, s := range stopped { | |
| 109 | + | createdAt := s.createdAt | |
| 110 | + | recent = append(recent, pickerSessionView{ID: domain.SessionID("stopped:" + s.name), Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Stopped: true, ExpectedCreatedAt: &createdAt}) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | views := make([]pickerSessionView, 0, len(live)+len(stopped)+catalogRows) | |
| 155 | 114 | for i, item := range live { | |
| 156 | 115 | view := item.view.pickerView() | |
| 157 | - | if grouped && i == 0 { | |
| 116 | + | if i == 0 { | |
| 158 | 117 | view.Section = "LOCAL" | |
| 159 | 118 | } | |
| 160 | 119 | views = append(views, view) | |
| 161 | 120 | } | |
| 162 | - | publishedRemote := false | |
| 163 | 121 | for _, host := range hosts { | |
| 164 | 122 | publishedForHost := 0 | |
| 165 | 123 | for _, session := range host.Sessions { | |
| @@ -168,38 +126,29 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 168 | 126 | continue | |
| 169 | 127 | } | |
| 170 | 128 | view := remotePickerView(key, session, host, now) | |
| 171 | - | if grouped { | |
| 172 | - | view.HideRemoteOrigin = true | |
| 173 | - | if publishedForHost == 0 { | |
| 174 | - | view.Section = "REMOTE " + host.Endpoint | |
| 175 | - | } | |
| 129 | + | view.HideRemoteOrigin = true | |
| 130 | + | if publishedForHost == 0 { | |
| 131 | + | view.Section = "REMOTE " + host.Endpoint | |
| 176 | 132 | } | |
| 177 | 133 | views = append(views, view) | |
| 178 | 134 | publishedForHost++ | |
| 179 | - | publishedRemote = true | |
| 180 | 135 | } | |
| 181 | 136 | if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable { | |
| 182 | 137 | view := remotePickerHostView(host, now) | |
| 183 | - | if grouped { | |
| 184 | - | view.Section = "REMOTE " + host.Endpoint | |
| 185 | - | } | |
| 138 | + | view.Section = "REMOTE " + host.Endpoint | |
| 186 | 139 | views = append(views, view) | |
| 187 | - | publishedRemote = true | |
| 188 | 140 | } | |
| 189 | 141 | } | |
| 190 | 142 | // A nil directory means remote monitoring is not installed at all: | |
| 191 | 143 | // only an installed-but-unpublished monitor reads as "checking". | |
| 192 | - | if monitored && !initialized { | |
| 144 | + | if checking { | |
| 193 | 145 | view := remotePickerCheckingView() | |
| 194 | - | if grouped { | |
| 195 | - | view.Section = "REMOTE" | |
| 196 | - | } | |
| 146 | + | view.Section = "REMOTE" | |
| 197 | 147 | views = append(views, view) | |
| 198 | - | publishedRemote = true | |
| 199 | 148 | } | |
| 200 | 149 | for i, s := range stopped { | |
| 201 | 150 | createdAt := s.createdAt | |
| 202 | - | view := picker.SessionView{ | |
| 151 | + | view := pickerSessionView{ | |
| 203 | 152 | ID: domain.SessionID("stopped:" + s.name), | |
| 204 | 153 | Incarnation: s.incarnation, | |
| 205 | 154 | Name: s.name, | |
| @@ -207,26 +156,12 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 207 | 156 | Stopped: true, | |
| 208 | 157 | ExpectedCreatedAt: &createdAt, | |
| 209 | 158 | } | |
| 210 | - | if grouped && i == 0 && (len(live) == 0 || publishedRemote) { | |
| 159 | + | if i == 0 && len(live) == 0 && catalogRows == 0 && !checking { | |
| 211 | 160 | view.Section = "LOCAL" | |
| 212 | 161 | } | |
| 213 | 162 | views = append(views, view) | |
| 214 | 163 | } | |
| 215 | - | return views, current | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | func (d *Daemon) newPickerModel(cur *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | |
| 219 | - | views, attachedCurrent := d.pickerViews(cur, ac) | |
| 220 | - | if intent == pickerNavigate && current == (picker.SourceFilter{}) { | |
| 221 | - | current = attachedCurrent | |
| 222 | - | } | |
| 223 | - | return picker.New(views, picker.SelectionConfig{ | |
| 224 | - | Mode: pickerSelectionMode(intent), | |
| 225 | - | Current: current, | |
| 226 | - | Source: picker.SourceFilter{ | |
| 227 | - | Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID, | |
| 228 | - | }, | |
| 229 | - | }) | |
| 164 | + | return recent, views, current | |
| 230 | 165 | } | |
| 231 | 166 | ||
| 232 | 167 | func attentionSuffix(label string) string { | |
| @@ -273,391 +208,9 @@ func remotePickerReasonText(reason string) string { | |||
| 273 | 208 | } | |
| 274 | 209 | } | |
| 275 | 210 | ||
| 276 | - | func pickerSearchSlashIndex(data, pending []byte) int { | |
| 277 | - | if len(pending) != 0 { | |
| 278 | - | return -1 | |
| 279 | - | } | |
| 280 | - | for offset := 0; offset < len(data); { | |
| 281 | - | if data[offset] == '/' { | |
| 282 | - | return offset | |
| 283 | - | } | |
| 284 | - | if data[offset] != keys.ESC { | |
| 285 | - | offset++ | |
| 286 | - | continue | |
| 287 | - | } | |
| 288 | - | if consumed, _ := routeListEscape(data[offset:]); consumed > 0 { | |
| 289 | - | offset += consumed | |
| 290 | - | continue | |
| 291 | - | } | |
| 292 | - | // An incomplete or unknown escape owns the rest of this input chunk; | |
| 293 | - | // slash bytes inside it cannot transition the picker into search mode. | |
| 294 | - | return -1 | |
| 295 | - | } | |
| 296 | - | return -1 | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |
| 300 | - | rt := ac.overlays | |
| 301 | - | var previewGeneration uint64 | |
| 302 | - | return listInputState{ | |
| 303 | - | pending: &rt.pickerPending, | |
| 304 | - | esc: &rt.pickerESC, | |
| 305 | - | moveUp: rt.picker.Up, | |
| 306 | - | moveDown: rt.picker.Down, | |
| 307 | - | lock: rt.pickerMu.Lock, | |
| 308 | - | unlock: rt.pickerMu.Unlock, | |
| 309 | - | active: func() bool { return rt.picker != nil }, | |
| 310 | - | closeLocked: func() { | |
| 311 | - | rt.picker = nil | |
| 312 | - | rt.pickerIntent = pickerNavigate | |
| 313 | - | rt.pickerSource = moveSourceLocator{} | |
| 314 | - | previewGeneration = rt.pickerPreviewGeneration | |
| 315 | - | }, | |
| 316 | - | afterClose: func() { | |
| 317 | - | d.clearPreviewGeneration(ac, previewGeneration) | |
| 318 | - | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 319 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 320 | - | } | |
| 321 | - | }, | |
| 322 | - | } | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) { | |
| 326 | - | sess := ac.currentAttachmentSession() | |
| 327 | - | if sess == nil { | |
| 328 | - | return | |
| 329 | - | } | |
| 330 | - | rt := ac.overlays | |
| 331 | - | rt.pickerMu.Lock() | |
| 332 | - | if rt.picker == nil { | |
| 333 | - | rt.pickerPending = nil | |
| 334 | - | rt.pickerESC.stop() | |
| 335 | - | rt.pickerMu.Unlock() | |
| 336 | - | return | |
| 337 | - | } | |
| 338 | - | normalAction := func(b byte) listInputResult { | |
| 339 | - | switch b { | |
| 340 | - | case 'x', 's': | |
| 341 | - | return listInputResult{action: b, stop: true} | |
| 342 | - | case '\r', '\n': | |
| 343 | - | return listInputResult{action: b, exit: true} | |
| 344 | - | default: | |
| 345 | - | return listInputResult{} | |
| 346 | - | } | |
| 347 | - | } | |
| 348 | - | var result listInputResult | |
| 349 | - | if rt.picker.SearchActive() { | |
| 350 | - | result = d.handlePickerSearchInputLocked(ac, data) | |
| 351 | - | } else if slash := pickerSearchSlashIndex(data, rt.pickerPending); slash >= 0 { | |
| 352 | - | result = handleListInputLocked(d.clock, data[:slash], d.pickerListInputState(ac), normalAction) | |
| 353 | - | if result.action == 0 && !result.exit && len(rt.pickerPending) == 0 { | |
| 354 | - | rt.picker.EnterSearch() | |
| 355 | - | result.changed = true | |
| 356 | - | if slash+1 < len(data) { | |
| 357 | - | searchResult := d.handlePickerSearchInputLocked(ac, data[slash+1:]) | |
| 358 | - | searchResult.changed = searchResult.changed || result.changed | |
| 359 | - | result = searchResult | |
| 360 | - | } | |
| 361 | - | } | |
| 362 | - | } else { | |
| 363 | - | result = handleListInputLocked(d.clock, data, d.pickerListInputState(ac), normalAction) | |
| 364 | - | } | |
| 365 | - | var target picker.Target | |
| 366 | - | var cursor picker.Target | |
| 367 | - | var ok bool | |
| 368 | - | var cursorOK bool | |
| 369 | - | var rejectedBySearch bool | |
| 370 | - | observedPicker := rt.picker | |
| 371 | - | observedGeneration := rt.pickerGeneration | |
| 372 | - | var intent pickerIntent | |
| 373 | - | var source moveSourceLocator | |
| 374 | - | // prevIdx is the row the victim occupied; -1 means "no post-delete hint". | |
| 375 | - | prevIdx := -1 | |
| 376 | - | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | |
| 377 | - | target, ok = rt.picker.Selected() | |
| 378 | - | cursor, cursorOK = rt.picker.Cursor() | |
| 379 | - | rejectedBySearch = rt.picker.SelectionRejectedBySearch() | |
| 380 | - | prevIdx = rt.picker.SelectedIndex() | |
| 381 | - | } | |
| 382 | - | intent, source = rt.pickerIntent, rt.pickerSource | |
| 383 | - | rt.pickerMu.Unlock() | |
| 384 | - | ||
| 385 | - | if !d.pickerInstanceCurrent(ac, observedPicker, observedGeneration) { | |
| 386 | - | return | |
| 387 | - | } | |
| 388 | - | if result.action == 's' { | |
| 389 | - | d.togglePickerSort() | |
| 390 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 391 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 392 | - | return | |
| 393 | - | } | |
| 394 | - | if (result.action == '\r' || result.action == '\n') && !ok && rejectedBySearch { | |
| 395 | - | d.invalidateRender(sess, ac, true, "picker search no match") | |
| 396 | - | return | |
| 397 | - | } | |
| 398 | - | if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") { | |
| 399 | - | d.notifyRemotePickerUnavailable(sess, cursor) | |
| 400 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 401 | - | return | |
| 402 | - | } | |
| 403 | - | if result.action == 'x' { | |
| 404 | - | var effect *attachmentEffect | |
| 405 | - | if len(effects) != 0 { | |
| 406 | - | effect = effects[0] | |
| 407 | - | } | |
| 408 | - | if ok { | |
| 409 | - | var err error | |
| 410 | - | if effect != nil { | |
| 411 | - | effect.bindActionEnd(d, "picker-delete") | |
| 412 | - | err = d.killPickerTargetForAttachment(target, effect) | |
| 413 | - | } else { | |
| 414 | - | err = d.killPickerTarget(target) | |
| 415 | - | } | |
| 416 | - | if err != nil { | |
| 417 | - | d.reportAttachmentError(sess, err) | |
| 418 | - | } | |
| 419 | - | } | |
| 420 | - | if effect != nil && !effect.current() { | |
| 421 | - | fresh, admitted := ac.beginAttachmentEffect(effect.capability()) | |
| 422 | - | if !admitted { | |
| 423 | - | return | |
| 424 | - | } | |
| 425 | - | defer fresh.End() | |
| 426 | - | } | |
| 427 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx}) | |
| 428 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 429 | - | return | |
| 430 | - | } | |
| 431 | - | if result.changed { | |
| 432 | - | d.registerPreviewForSelection(ac) | |
| 433 | - | } | |
| 434 | - | committing := (result.action == '\r' || result.action == '\n') && ok | |
| 435 | - | backNavigationSent := false | |
| 436 | - | if result.exit && !committing { | |
| 437 | - | back := ac.startupOverlay == protocol.StartupOverlaySessionPicker && ac.navigationCapabilities&protocol.NavigationCapabilityBack != 0 | |
| 438 | - | if back { | |
| 439 | - | if len(effects) == 0 || effects[0] == nil { | |
| 440 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 441 | - | return | |
| 442 | - | } | |
| 443 | - | effect := effects[0] | |
| 444 | - | if err := d.sendNavigationActionForAttachment(effect, protocol.NavigationBack); err != nil { | |
| 445 | - | d.reportAttachmentError(sess, err) | |
| 446 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 447 | - | return | |
| 448 | - | } | |
| 449 | - | backNavigationSent = true | |
| 450 | - | } | |
| 451 | - | d.closePickerIfCurrent(ac, observedPicker, observedGeneration) | |
| 452 | - | } | |
| 453 | - | if committing { | |
| 454 | - | if intent == pickerMovePane || intent == pickerMoveTab { | |
| 455 | - | if err := d.movePickerSourceError(source); err != nil { | |
| 456 | - | d.reportAttachmentError(sess, err) | |
| 457 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 458 | - | return | |
| 459 | - | } | |
| 460 | - | if len(effects) != 0 && effects[0] != nil { | |
| 461 | - | effects[0].End() | |
| 462 | - | } | |
| 463 | - | if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) { | |
| 464 | - | return | |
| 465 | - | } | |
| 466 | - | err := d.commitMovePickerSelection(intent, source, target) | |
| 467 | - | if err != nil { | |
| 468 | - | d.reportAttachmentError(sess, movePickerUserError(err)) | |
| 469 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 470 | - | return | |
| 471 | - | } | |
| 472 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 473 | - | return | |
| 474 | - | } | |
| 475 | - | var err error | |
| 476 | - | if len(effects) != 0 && effects[0] != nil { | |
| 477 | - | err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true, pickerModel: observedPicker, pickerGeneration: observedGeneration}, "picker-select") | |
| 478 | - | } else { | |
| 479 | - | if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) { | |
| 480 | - | return | |
| 481 | - | } | |
| 482 | - | err = d.switchToTarget(sess, ac, target) | |
| 483 | - | } | |
| 484 | - | if errors.Is(err, errAttachmentTransition) { | |
| 485 | - | return | |
| 486 | - | } | |
| 487 | - | if err != nil { | |
| 488 | - | d.reportAttachmentError(sess, err) | |
| 489 | - | return | |
| 490 | - | } | |
| 491 | - | return | |
| 492 | - | } | |
| 493 | - | if (result.exit || result.changed) && !backNavigationSent { | |
| 494 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 495 | - | } | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |
| 499 | - | if ac == nil || ac.overlays == nil { | |
| 500 | - | return | |
| 501 | - | } | |
| 502 | - | ac.overlays.pickerMu.Lock() | |
| 503 | - | var target picker.Target | |
| 504 | - | var ok bool | |
| 505 | - | intent := ac.overlays.pickerIntent | |
| 506 | - | if ac.overlays.picker != nil { | |
| 507 | - | target, ok = ac.overlays.picker.Selected() | |
| 508 | - | } | |
| 509 | - | previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration | |
| 510 | - | ac.overlays.pickerPreviewGeneration++ | |
| 511 | - | generation := ac.overlays.pickerPreviewGeneration | |
| 512 | - | if ac.overlays.pickerRemotePreviewCancel != nil { | |
| 513 | - | ac.overlays.pickerRemotePreviewCancel() | |
| 514 | - | ac.overlays.pickerRemotePreviewCancel = nil | |
| 515 | - | } | |
| 516 | - | ac.overlays.pickerPreviewSession = nil | |
| 517 | - | ac.overlays.pickerRemotePreview = picker.Preview{} | |
| 518 | - | ac.overlays.pickerPreview = nil | |
| 519 | - | ac.overlays.pickerMu.Unlock() | |
| 520 | - | d.teardownPreviewSubscription(ac, previous, previousGeneration) | |
| 521 | - | if !ok { | |
| 522 | - | return | |
| 523 | - | } | |
| 524 | - | if target.RemoteTarget != nil { | |
| 525 | - | d.startRemotePickerPreview(ac, target, generation) | |
| 526 | - | return | |
| 527 | - | } | |
| 528 | - | targetSess, next := d.previewTarget(target, intent) | |
| 529 | - | if next == nil || targetSess == nil { | |
| 530 | - | return | |
| 531 | - | } | |
| 532 | - | ac.overlays.pickerMu.Lock() | |
| 533 | - | // Selection may have changed while the target was resolved. | |
| 534 | - | selected, stillSelected := ac.overlays.picker.Selected() | |
| 535 | - | valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerRouteTargetsEqual(selected, target) | |
| 536 | - | if valid { | |
| 537 | - | ac.overlays.pickerPreview = next | |
| 538 | - | ac.overlays.pickerPreviewSession = targetSess | |
| 539 | - | } | |
| 540 | - | ac.overlays.pickerMu.Unlock() | |
| 541 | - | if !valid { | |
| 542 | - | return | |
| 543 | - | } | |
| 544 | - | // A same-session preview is already invalidated by its own coordinator. | |
| 545 | - | // Cross-session previews subscribe to the target's producer wake instead | |
| 546 | - | // of starting a direct paint/timer path. | |
| 547 | - | if targetSess == ac.currentAttachmentSession() { | |
| 548 | - | return | |
| 549 | - | } | |
| 550 | - | rc := d.attachCoordinator(targetSess, nil, nil, false) | |
| 551 | - | rc.subscribePreviewFor(ac, generation, func(renderWake) { | |
| 552 | - | if pickerPreviewCurrent(ac, targetSess, next, generation) { | |
| 553 | - | if owner := ac.currentAttachmentSession(); owner != nil { | |
| 554 | - | d.invalidateRender(owner, ac, false, "picker preview") | |
| 555 | - | } | |
| 556 | - | } | |
| 557 | - | }) | |
| 558 | - | // subscribePreviewFor is deliberately outside pickerMu. Revalidate after it | |
| 559 | - | // returns and remove only this generation if selection changed meanwhile. | |
| 560 | - | d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation) | |
| 561 | - | } | |
| 562 | - | ||
| 563 | - | func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Target, generation uint64) { | |
| 564 | - | if d == nil || ac == nil || target.RemoteTarget == nil || (d.remotePreviewClient == nil && !target.RemoteTarget.Stopped) { | |
| 565 | - | return | |
| 566 | - | } | |
| 567 | - | width, height := remotePickerPreviewSize(ac.sizeSnapshot()) | |
| 568 | - | if width == 0 || height == 0 { | |
| 569 | - | return | |
| 570 | - | } | |
| 571 | - | ctx := d.serveCtx | |
| 572 | - | if ctx == nil { | |
| 573 | - | ctx = context.Background() | |
| 574 | - | } | |
| 575 | - | previewCtx, cancel := context.WithCancel(ctx) | |
| 576 | - | ac.overlays.pickerMu.Lock() | |
| 577 | - | if ac.overlays.picker == nil || ac.overlays.pickerPreviewGeneration != generation { | |
| 578 | - | ac.overlays.pickerMu.Unlock() | |
| 579 | - | cancel() | |
| 580 | - | return | |
| 581 | - | } | |
| 582 | - | ac.overlays.pickerRemotePreviewCancel = cancel | |
| 583 | - | remoteTarget := *target.RemoteTarget | |
| 584 | - | message := "loading remote preview…" | |
| 585 | - | if remoteTarget.Stopped { | |
| 586 | - | message = "stopped session — preview unavailable" | |
| 587 | - | } | |
| 588 | - | ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message) | |
| 589 | - | if remoteTarget.Stopped { | |
| 590 | - | ac.overlays.pickerRemotePreviewCancel = nil | |
| 591 | - | ac.overlays.pickerMu.Unlock() | |
| 592 | - | cancel() | |
| 593 | - | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 594 | - | d.invalidateRender(sess, ac, false, "remote picker preview") | |
| 595 | - | } | |
| 596 | - | return | |
| 597 | - | } | |
| 598 | - | ac.overlays.pickerMu.Unlock() | |
| 599 | - | go func() { | |
| 600 | - | defer cancel() | |
| 601 | - | previewClock := d.clock | |
| 602 | - | if previewClock == nil { | |
| 603 | - | previewClock = systemClock{} | |
| 604 | - | } | |
| 605 | - | debounce := previewClock.NewTimer(remotePickerPreviewDebounce) | |
| 606 | - | defer debounce.Stop() | |
| 607 | - | select { | |
| 608 | - | case <-debounce.C(): | |
| 609 | - | case <-previewCtx.Done(): | |
| 610 | - | return | |
| 611 | - | } | |
| 612 | - | preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height) | |
| 613 | - | if err != nil { | |
| 614 | - | ac.overlays.pickerMu.Lock() | |
| 615 | - | matching := false | |
| 616 | - | if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil { | |
| 617 | - | selected, stillSelected := ac.overlays.picker.Selected() | |
| 618 | - | matching = stillSelected && pickerRouteTargetsEqual(selected, target) | |
| 619 | - | if matching { | |
| 620 | - | ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable") | |
| 621 | - | } | |
| 622 | - | } | |
| 623 | - | ac.overlays.pickerMu.Unlock() | |
| 624 | - | if matching { | |
| 625 | - | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 626 | - | d.invalidateRender(sess, ac, false, "remote picker preview") | |
| 627 | - | } | |
| 628 | - | } | |
| 629 | - | return | |
| 630 | - | } | |
| 631 | - | if refreshed, refreshErr := d.awaitRemotePreviewRefresh(previewCtx, remoteTarget, width, height); refreshErr == nil { | |
| 632 | - | preview = refreshed | |
| 633 | - | } else if previewCtx.Err() != nil { | |
| 634 | - | return | |
| 635 | - | } | |
| 636 | - | rows := preview.FrameRows() | |
| 637 | - | candidate := picker.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)} | |
| 638 | - | ac.overlays.pickerMu.Lock() | |
| 639 | - | if ac.overlays.pickerPreviewGeneration != generation || ac.overlays.picker == nil { | |
| 640 | - | ac.overlays.pickerMu.Unlock() | |
| 641 | - | return | |
| 642 | - | } | |
| 643 | - | selected, stillSelected := ac.overlays.picker.Selected() | |
| 644 | - | if !stillSelected || !pickerRouteTargetsEqual(selected, target) { | |
| 645 | - | ac.overlays.pickerMu.Unlock() | |
| 646 | - | return | |
| 647 | - | } | |
| 648 | - | ac.overlays.pickerRemotePreview = candidate | |
| 649 | - | ac.overlays.pickerRemotePreviewCancel = nil | |
| 650 | - | ac.overlays.pickerMu.Unlock() | |
| 651 | - | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 652 | - | d.invalidateRender(sess, ac, false, "remote picker preview") | |
| 653 | - | } | |
| 654 | - | }() | |
| 655 | - | } | |
| 656 | - | ||
| 657 | 211 | func remotePickerPreviewSize(size domain.Size) (uint16, uint16) { | |
| 658 | - | presentation := pickerModal.Resolve(size) | |
| 659 | - | geometry := picker.ChooseGeometry(rectSize(presentation.Inner)) | |
| 660 | - | width, height := geometry.Preview.Width, geometry.Preview.Height | |
| 212 | + | preview := picker.PreviewRect(size) | |
| 213 | + | width, height := preview.Width, preview.Height | |
| 661 | 214 | if width <= 0 || height <= 0 { | |
| 662 | 215 | return 0, 0 | |
| 663 | 216 | } | |
| @@ -682,137 +235,6 @@ func staticRemotePickerPreview(width, height uint16, message string) picker.Prev | |||
| 682 | 235 | return picker.Preview{Rows: rows, Width: int(width), Height: len(rows)} | |
| 683 | 236 | } | |
| 684 | 237 | ||
| 685 | - | func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool { | |
| 686 | - | ac.overlays.pickerMu.Lock() | |
| 687 | - | defer ac.overlays.pickerMu.Unlock() | |
| 688 | - | return ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewSession == targetSess && ac.overlays.pickerPreview == next | |
| 689 | - | } | |
| 690 | - | ||
| 691 | - | // revalidatePreviewSubscription removes a subscription that lost the picker | |
| 692 | - | // selection race after it was installed. | |
| 693 | - | func (d *Daemon) revalidatePreviewSubscription(ac *attachedClient, rc *renderCoordinator, targetSess *session, next *tab, generation uint64) { | |
| 694 | - | if !pickerPreviewCurrent(ac, targetSess, next, generation) { | |
| 695 | - | rc.teardownPreviewFor(ac, generation) | |
| 696 | - | } | |
| 697 | - | } | |
| 698 | - | ||
| 699 | - | func (d *Daemon) teardownPreviewSubscription(ac *attachedClient, previous *session, generation uint64) { | |
| 700 | - | if previous != nil { | |
| 701 | - | if rc := previous.renderCoordinator(); rc != nil { | |
| 702 | - | rc.teardownPreviewFor(ac, generation) | |
| 703 | - | } | |
| 704 | - | } | |
| 705 | - | } | |
| 706 | - | ||
| 707 | - | func (d *Daemon) unregisterPreview(ac *attachedClient) { | |
| 708 | - | if ac == nil || ac.overlays == nil { | |
| 709 | - | return | |
| 710 | - | } | |
| 711 | - | ac.overlays.pickerMu.Lock() | |
| 712 | - | generation := ac.overlays.pickerPreviewGeneration | |
| 713 | - | ac.overlays.pickerMu.Unlock() | |
| 714 | - | d.clearPreviewGeneration(ac, generation) | |
| 715 | - | } | |
| 716 | - | ||
| 717 | - | // clearPreviewGeneration removes the preview only if it is still the observed | |
| 718 | - | // selection. Teardown paths may race a new picker selection. | |
| 719 | - | func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) bool { | |
| 720 | - | ac.overlays.pickerMu.Lock() | |
| 721 | - | if ac.overlays.pickerPreviewGeneration != generation { | |
| 722 | - | ac.overlays.pickerMu.Unlock() | |
| 723 | - | return false | |
| 724 | - | } | |
| 725 | - | previous := ac.overlays.pickerPreviewSession | |
| 726 | - | ac.overlays.pickerPreviewGeneration++ | |
| 727 | - | if ac.overlays.pickerRemotePreviewCancel != nil { | |
| 728 | - | ac.overlays.pickerRemotePreviewCancel() | |
| 729 | - | ac.overlays.pickerRemotePreviewCancel = nil | |
| 730 | - | } | |
| 731 | - | ac.overlays.pickerPreviewSession = nil | |
| 732 | - | ac.overlays.pickerRemotePreview = picker.Preview{} | |
| 733 | - | ac.overlays.pickerPreview = nil | |
| 734 | - | ac.overlays.pickerMu.Unlock() | |
| 735 | - | d.teardownPreviewSubscription(ac, previous, generation) | |
| 736 | - | return true | |
| 737 | - | } | |
| 738 | - | ||
| 739 | - | // clearDestroyedTabPreview clears coordinator-owned picker state for clients | |
| 740 | - | // that were previewing a removed tab. Tabs do not route render ownership. | |
| 741 | - | func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |
| 742 | - | if d == nil || d.sessions == nil { | |
| 743 | - | return | |
| 744 | - | } | |
| 745 | - | d.mu.Lock() | |
| 746 | - | var clients []*attachedClient | |
| 747 | - | for _, sess := range d.sessions { | |
| 748 | - | if sess == nil { | |
| 749 | - | continue | |
| 750 | - | } | |
| 751 | - | sess.mu.Lock() | |
| 752 | - | clients = append(clients, sess.snapshotAttachmentsLocked()...) | |
| 753 | - | sess.mu.Unlock() | |
| 754 | - | } | |
| 755 | - | d.mu.Unlock() | |
| 756 | - | for _, ac := range clients { | |
| 757 | - | if ac == nil || ac.overlays == nil { | |
| 758 | - | continue | |
| 759 | - | } | |
| 760 | - | ac.overlays.pickerMu.Lock() | |
| 761 | - | generation := ac.overlays.pickerPreviewGeneration | |
| 762 | - | observed := ac.overlays.pickerPreview == tb | |
| 763 | - | ac.overlays.pickerMu.Unlock() | |
| 764 | - | if observed && d.clearPreviewGeneration(ac, generation) { | |
| 765 | - | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 766 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 767 | - | } | |
| 768 | - | } | |
| 769 | - | } | |
| 770 | - | } | |
| 771 | - | ||
| 772 | - | func (d *Daemon) closePicker(ac *attachedClient) { | |
| 773 | - | d.closePickerIfCurrent(ac, nil, 0) | |
| 774 | - | } | |
| 775 | - | ||
| 776 | - | func (d *Daemon) pickerInstanceCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool { | |
| 777 | - | if ac == nil || ac.overlays == nil { | |
| 778 | - | return false | |
| 779 | - | } | |
| 780 | - | ac.overlays.pickerMu.Lock() | |
| 781 | - | defer ac.overlays.pickerMu.Unlock() | |
| 782 | - | return ac.overlays.picker == model && ac.overlays.pickerGeneration == generation | |
| 783 | - | } | |
| 784 | - | ||
| 785 | - | // closePickerIfCurrent closes only the observed picker lifecycle. A nonzero | |
| 786 | - | // generation guards terminal parked cleanup even when a refresh replaced the | |
| 787 | - | // model within that generation. | |
| 788 | - | func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool { | |
| 789 | - | return d.closePickerIfCurrentRefresh(ac, model, generation, 0) | |
| 790 | - | } | |
| 791 | - | ||
| 792 | - | func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.Model, generation, refreshSequence uint64) bool { | |
| 793 | - | if ac == nil || ac.overlays == nil { | |
| 794 | - | return false | |
| 795 | - | } | |
| 796 | - | ac.overlays.pickerMu.Lock() | |
| 797 | - | refreshChanged := refreshSequence != 0 && ac.overlays.pickerRefreshSequence != refreshSequence | |
| 798 | - | modelChanged := model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) | |
| 799 | - | generationChanged := model == nil && generation != 0 && ac.overlays.pickerGeneration != generation | |
| 800 | - | if refreshChanged || modelChanged || generationChanged { | |
| 801 | - | ac.overlays.pickerMu.Unlock() | |
| 802 | - | return false | |
| 803 | - | } | |
| 804 | - | ac.overlays.picker = nil | |
| 805 | - | ac.overlays.pickerTitle = "" | |
| 806 | - | ac.overlays.pickerIntent = pickerNavigate | |
| 807 | - | ac.overlays.pickerSource = moveSourceLocator{} | |
| 808 | - | ac.overlays.pickerPending = nil | |
| 809 | - | ac.overlays.pickerESC.stop() | |
| 810 | - | previewGeneration := ac.overlays.pickerPreviewGeneration | |
| 811 | - | ac.overlays.pickerMu.Unlock() | |
| 812 | - | d.clearPreviewGeneration(ac, previewGeneration) | |
| 813 | - | return true | |
| 814 | - | } | |
| 815 | - | ||
| 816 | 238 | // pickerRouteTargetsEqual compares the exact route identity of two picker | |
| 817 | 239 | // targets, ignoring presentation fields such as UnavailableReason that | |
| 818 | 240 | // render around the route on every observation. | |
| @@ -853,19 +275,8 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 853 | 275 | // one active-session handoff. Its zero value keeps daemon-owned transitions on | |
| 854 | 276 | // their established path. | |
| 855 | 277 | type sessionHandoffGuard struct { | |
| 856 | - | expectedSource *tab | |
| 857 | - | closePicker bool | |
| 858 | - | allowSamePeer bool | |
| 859 | - | pickerModel *picker.Model | |
| 860 | - | pickerGeneration uint64 | |
| 861 | - | } | |
| 862 | - | ||
| 863 | - | func (d *Daemon) closePickerForGuard(ac *attachedClient, guard sessionHandoffGuard) bool { | |
| 864 | - | if guard.pickerModel != nil || guard.pickerGeneration != 0 { | |
| 865 | - | return d.closePickerIfCurrent(ac, guard.pickerModel, guard.pickerGeneration) | |
| 866 | - | } | |
| 867 | - | d.closePicker(ac) | |
| 868 | - | return true | |
| 278 | + | expectedSource *tab | |
| 279 | + | allowSamePeer bool | |
| 869 | 280 | } | |
| 870 | 281 | ||
| 871 | 282 | // switchActiveTargetForAttachment hands a frame-bound navigation request to the | |
| @@ -932,13 +343,6 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect | |||
| 932 | 343 | } | |
| 933 | 344 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | |
| 934 | 345 | } | |
| 935 | - | if guard.closePicker { | |
| 936 | - | fresh, admitted := effect.ac.beginAttachmentEffect(transition.published) | |
| 937 | - | if admitted { | |
| 938 | - | d.closePickerForGuard(effect.ac, guard) | |
| 939 | - | fresh.End() | |
| 940 | - | } | |
| 941 | - | } | |
| 942 | 346 | d.touchMRU(targetSess) | |
| 943 | 347 | d.deferAttachmentTransitionCleanups(transition) | |
| 944 | 348 | d.firstPaintForTransition(transition.published) | |
| @@ -998,17 +402,14 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta | |||
| 998 | 402 | if !matches || !effect.current() { | |
| 999 | 403 | return errAttachmentTransition | |
| 1000 | 404 | } | |
| 1001 | - | homePicker := effect.ac.startupOverlay == protocol.StartupOverlaySessionPicker | |
| 1002 | - | if !homePicker && targetSess != nil && target.TabID != "" { | |
| 1003 | - | // Ordinary local tab navigation keeps the attachment. A home picker | |
| 1004 | - | // only borrows it: selecting even its backing session must hand off | |
| 1005 | - | // to the client so the remote route and temporary attachment end. | |
| 405 | + | if targetSess != nil && target.TabID != "" { | |
| 406 | + | // Ordinary local tab navigation keeps the attachment. | |
| 1006 | 407 | return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action) | |
| 1007 | 408 | } | |
| 1008 | 409 | ||
| 1009 | 410 | // Ordinary live session rows can use the client-confirmed same-peer | |
| 1010 | - | // path. Temporary home pickers and stopped targets require a handoff. | |
| 1011 | - | samePeerEligible := !homePicker && guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |
| 411 | + | // path. Stopped targets require a handoff. | |
| 412 | + | samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |
| 1012 | 413 | if exactTarget == nil { | |
| 1013 | 414 | return errAttachmentTransition | |
| 1014 | 415 | } | |
| @@ -1041,9 +442,6 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta | |||
| 1041 | 442 | // handoff instead of losing their target-specific transition semantics. | |
| 1042 | 443 | // Keep the source link open until the client receives the ordered cleanup | |
| 1043 | 444 | // and handoff frames, then let the client's close drive ordinary parking. | |
| 1044 | - | if guard.closePicker { | |
| 1045 | - | d.closePickerForGuard(effect.ac, guard) | |
| 1046 | - | } | |
| 1047 | 445 | effect.bindActionEnd(d, "detach") | |
| 1048 | 446 | effect.End() | |
| 1049 | 447 | return nil | |
| @@ -1081,9 +479,6 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t | |||
| 1081 | 479 | if err := effect.sendControl(handoff); err != nil { | |
| 1082 | 480 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err) | |
| 1083 | 481 | } | |
| 1084 | - | if guard.closePicker { | |
| 1085 | - | d.closePickerForGuard(effect.ac, guard) | |
| 1086 | - | } | |
| 1087 | 482 | d.clientGoneForAttachment(effect, false) | |
| 1088 | 483 | return nil | |
| 1089 | 484 | } | |
| @@ -1178,17 +573,6 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC | |||
| 1178 | 573 | d.invalidateRender(from, ac, true, "picker.go") | |
| 1179 | 574 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause) | |
| 1180 | 575 | } | |
| 1181 | - | if guard.closePicker { | |
| 1182 | - | if sourceEffect != nil { | |
| 1183 | - | fresh, admitted := ac.beginAttachmentEffect(transition.published) | |
| 1184 | - | if admitted { | |
| 1185 | - | d.closePickerForGuard(ac, guard) | |
| 1186 | - | fresh.End() | |
| 1187 | - | } | |
| 1188 | - | } else { | |
| 1189 | - | d.closePickerForGuard(ac, guard) | |
| 1190 | - | } | |
| 1191 | - | } | |
| 1192 | 576 | d.deferAttachmentTransitionCleanups(transition) | |
| 1193 | 577 | if targetSess == from { | |
| 1194 | 578 | if sourceEffect != nil { | |
A internal/usecase/daemon/picker_control.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/ports" | |
| 5 | + | "github.com/bnema/vev/internal/protocol" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const controlPickerInteractionID uint64 = 1 | |
| 10 | + | ||
| 11 | + | // handlePickerControl serves the stable local authority without creating an | |
| 12 | + | // attachment. The snapshot and resolve paths rebuild from the same daemon | |
| 13 | + | // projection, so eligibility and opaque-key ownership never move client-side. | |
| 14 | + | func (d *Daemon) handlePickerControl(tr ports.ServerConnection, request protocol.PickerControlRequest) { | |
| 15 | + | defer tr.Close() | |
| 16 | + | response := d.answerPickerControl(request) | |
| 17 | + | _ = d.boundedControlSend(tr, response) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (d *Daemon) answerPickerControl(request protocol.PickerControlRequest) protocol.PickerControlResponse { | |
| 21 | + | response := protocol.PickerControlResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.PickerSourceUnavailable} | |
| 22 | + | if protocol.ValidatePickerControlRequest(request) != nil || request.Version != protocol.Version { | |
| 23 | + | return response | |
| 24 | + | } | |
| 25 | + | recentViews, groupedViews, _ := d.pickerViewProjections(nil, nil) | |
| 26 | + | recent := pickerLineSetFor(recentViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{}) | |
| 27 | + | grouped := pickerLineSetFor(groupedViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{}) | |
| 28 | + | // Resolution revalidates the opaque key against the current catalogue. | |
| 29 | + | // A constant revision avoids rejecting an otherwise current target merely | |
| 30 | + | // because unrelated presentation text changed after the client snapshot. | |
| 31 | + | revision := uint64(1) | |
| 32 | + | if request.Operation == protocol.PickerControlSnapshot { | |
| 33 | + | snapshot := protocol.PickerSnapshot{InteractionID: controlPickerInteractionID, SourceID: protocol.PickerHomeSourceID, SourceRevision: revision, Status: protocol.PickerSourceOK, Lines: recent.lines, Cursor: recent.cursor, Recent: protocol.PickerProjection{Lines: recent.lines, Cursor: recent.cursor}, Grouped: protocol.PickerProjection{Lines: grouped.lines, Cursor: grouped.cursor}} | |
| 34 | + | response.Status, response.Snapshot = protocol.PickerSourceOK, &snapshot | |
| 35 | + | return response | |
| 36 | + | } | |
| 37 | + | if request.Operation == protocol.PickerControlObserve { | |
| 38 | + | response.Status = protocol.PickerSourceOK | |
| 39 | + | response.Observations = d.observePickerRoutes(request.Targets) | |
| 40 | + | return response | |
| 41 | + | } | |
| 42 | + | if request.SourceID != protocol.PickerHomeSourceID { | |
| 43 | + | return response | |
| 44 | + | } | |
| 45 | + | target, ok := recent.keys[request.Key] | |
| 46 | + | if !ok || !d.pickerTargetCurrent(target) { | |
| 47 | + | return response | |
| 48 | + | } | |
| 49 | + | resolved, ok := d.pickerControlAttachTarget(target) | |
| 50 | + | if !ok { | |
| 51 | + | return response | |
| 52 | + | } | |
| 53 | + | response.Status, response.Resolved = protocol.PickerSourceOK, &resolved | |
| 54 | + | return response | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (d *Daemon) observePickerRoutes(targets []protocol.ExactSessionTarget) []protocol.PickerRouteObservation { | |
| 58 | + | observations := make([]protocol.PickerRouteObservation, len(targets)) | |
| 59 | + | d.mu.Lock() | |
| 60 | + | sessions := d.sessionsSnapshotLocked() | |
| 61 | + | d.mu.Unlock() | |
| 62 | + | for i, target := range targets { | |
| 63 | + | observation := protocol.PickerRouteObservation{Target: target, Presence: protocol.PickerRouteAbsent} | |
| 64 | + | for _, sess := range sessions { | |
| 65 | + | view := sess.snapshotView(viewOptions{}) | |
| 66 | + | if view.incarnation != target.LifecycleID || view.name != target.SessionName { | |
| 67 | + | continue | |
| 68 | + | } | |
| 69 | + | observation.Presence = protocol.PickerRoutePresent | |
| 70 | + | observation.Attention = view.hasAttention | |
| 71 | + | break | |
| 72 | + | } | |
| 73 | + | observations[i] = observation | |
| 74 | + | } | |
| 75 | + | return observations | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) { | |
| 79 | + | if target.RemoteTarget != nil && target.RemoteKey != nil { | |
| 80 | + | remote := *target.RemoteTarget | |
| 81 | + | if !d.remoteCatalogTargetReady(remote) { | |
| 82 | + | return protocol.AttachTarget{}, false | |
| 83 | + | } | |
| 84 | + | return protocol.AttachTarget{Endpoint: remote.Endpoint, Session: remote.SessionName, Intent: protocol.IntentAttach, RemoteTarget: &remote, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | |
| 85 | + | } | |
| 86 | + | d.mu.Lock() | |
| 87 | + | defer d.mu.Unlock() | |
| 88 | + | sess := d.sessions[target.Session] | |
| 89 | + | if sess == nil { | |
| 90 | + | return protocol.AttachTarget{}, false | |
| 91 | + | } | |
| 92 | + | sess.mu.Lock() | |
| 93 | + | exact := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 94 | + | sess.mu.Unlock() | |
| 95 | + | return protocol.AttachTarget{Session: exact.SessionName, Intent: protocol.IntentAttach, ExactTarget: &exact, PreferredTabID: target.TabID, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | |
| 96 | + | } | |
A internal/usecase/daemon/picker_control_test.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestPickerControlObserveReportsExactAttention(t *testing.T) { | |
| 12 | + | d, sess, _, _ := newManualSessionWithPTYs(t, nil) | |
| 13 | + | sess.mu.Lock() | |
| 14 | + | sess.incarnation = domain.SessionLifecycleID{9} | |
| 15 | + | sess.tabs[0].attention = true | |
| 16 | + | target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 17 | + | sess.mu.Unlock() | |
| 18 | + | missing := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{8}, SessionName: sess.name} | |
| 19 | + | ||
| 20 | + | response := d.answerPickerControl(protocol.PickerControlRequest{ | |
| 21 | + | Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlObserve, | |
| 22 | + | Targets: []protocol.ExactSessionTarget{target, missing}, | |
| 23 | + | }) | |
| 24 | + | ||
| 25 | + | require.Equal(t, protocol.PickerSourceOK, response.Status) | |
| 26 | + | require.Equal(t, []protocol.PickerRouteObservation{ | |
| 27 | + | {Target: target, Presence: protocol.PickerRoutePresent, Attention: true}, | |
| 28 | + | {Target: missing, Presence: protocol.PickerRouteAbsent}, | |
| 29 | + | }, response.Observations) | |
| 30 | + | require.NoError(t, protocol.ValidatePickerControlResponse(response)) | |
| 31 | + | } | |
A internal/usecase/daemon/picker_interaction.go +557 -0
| @@ -0,0 +1,557 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "slices" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // This file owns the serving-daemon side of the picker interaction. The | |
| 13 | + | // daemon is the data and action authority: it publishes one structured line | |
| 14 | + | // set per source, resolves opaque keys, and performs the navigation, move, or | |
| 15 | + | // kill the client asked for. It never owns a presentation model. | |
| 16 | + | ||
| 17 | + | // servingPickerSourceID names the source the serving daemon itself owns. | |
| 18 | + | const servingPickerSourceID = protocol.PickerServingSourceID | |
| 19 | + | ||
| 20 | + | // pickerDuplicateRows reports the first repeated row key with a readable | |
| 21 | + | // identity for both rows. A refused snapshot otherwise says only that a key | |
| 22 | + | // repeated, which is not enough to find the two rows that collided. | |
| 23 | + | func pickerDuplicateRows(lines []protocol.PickerLine) (first, second string, duplicated bool) { | |
| 24 | + | seen := make(map[string]string, len(lines)) | |
| 25 | + | for _, line := range lines { | |
| 26 | + | if line.Key == "" { | |
| 27 | + | continue | |
| 28 | + | } | |
| 29 | + | identity := fmt.Sprintf("%s %q (%s)", line.Label, line.Detail, line.StatusDetail) | |
| 30 | + | if previous, ok := seen[line.Key]; ok { | |
| 31 | + | return previous, identity, true | |
| 32 | + | } | |
| 33 | + | seen[line.Key] = identity | |
| 34 | + | } | |
| 35 | + | return "", "", false | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // pickerClientKey derives a stable opaque key for an unchanged navigation | |
| 39 | + | // target (lifecycle, name). Presentation-only updates keep the key; a changed | |
| 40 | + | // or removed target retires it and a reappearing target earns a new key | |
| 41 | + | // through its new lifecycle. Same role as navigationInventoryEntryKey. | |
| 42 | + | func pickerClientKey(lifecycle domain.IncarnationID, name string) string { | |
| 43 | + | return navigationInventoryEntryKey(lifecycle, name) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // retirePicker closes the picker interaction of an attachment that is going | |
| 47 | + | // away. It never sends: the transport is already failing or parked, and the | |
| 48 | + | // client's own lease retires on its generation change. | |
| 49 | + | func (d *Daemon) retirePicker(ac *attachedClient) { | |
| 50 | + | if ac == nil || ac.overlays == nil { | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | ac.overlays.pickerMu.Lock() | |
| 54 | + | open := ac.overlays.pickerOpen | |
| 55 | + | interaction := ac.overlays.pickerInteraction | |
| 56 | + | ac.overlays.pickerMu.Unlock() | |
| 57 | + | if !open { | |
| 58 | + | return | |
| 59 | + | } | |
| 60 | + | d.closePickerForAttachment(ac, nil, interaction) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // pickerState reports the open interaction under the picker mutex. | |
| 64 | + | func (ac *attachedClient) pickerState() (open bool, interaction uint64, intent protocol.PickerIntent, source moveSourceLocator, requestID uint64) { | |
| 65 | + | if ac == nil || ac.overlays == nil { | |
| 66 | + | return false, 0, 0, moveSourceLocator{}, 0 | |
| 67 | + | } | |
| 68 | + | ac.overlays.pickerMu.Lock() | |
| 69 | + | defer ac.overlays.pickerMu.Unlock() | |
| 70 | + | return ac.overlays.pickerOpen, ac.overlays.pickerInteraction, ac.overlays.pickerIntent, ac.overlays.pickerMoveSource, ac.overlays.pickerRequestID | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // openPickerForAttachment admits one picker interaction on a guarded serving | |
| 74 | + | // connection, sends the offer that names the intent and the acquisition | |
| 75 | + | // barrier, and publishes the first serving snapshot. A failed publication | |
| 76 | + | // fails closed: the interaction is retired again so no namespace survives | |
| 77 | + | // without its client-side acquisition. | |
| 78 | + | func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentEffect, intent protocol.PickerIntent, source moveSourceLocator, requestID uint64) error { | |
| 79 | + | if ac == nil || ac.overlays == nil || effect == nil || !effect.current() { | |
| 80 | + | return errAttachmentTransition | |
| 81 | + | } | |
| 82 | + | sess := effect.sess | |
| 83 | + | if intent != protocol.PickerIntentNavigation { | |
| 84 | + | if sess == nil || !sess.capabilities().yieldsMoves() { | |
| 85 | + | return errSessionCannotYieldMoves | |
| 86 | + | } | |
| 87 | + | if source.Attachment != nil && source.AttachmentCapability.ac == nil { | |
| 88 | + | source.AttachmentCapability = sess.captureAttachmentCapability(source.Attachment, source.Attachment.transport()) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ac.overlays.pickerMu.Lock() | |
| 92 | + | previousSession, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration | |
| 93 | + | interaction := ac.overlays.pickerInteraction + 1 | |
| 94 | + | if interaction == 0 { | |
| 95 | + | interaction = 1 | |
| 96 | + | } | |
| 97 | + | ac.overlays.pickerOpen = true | |
| 98 | + | ac.overlays.pickerInteraction = interaction | |
| 99 | + | ac.overlays.pickerIntent = intent | |
| 100 | + | ac.overlays.pickerMoveSource = source | |
| 101 | + | ac.overlays.pickerRequestID = requestID | |
| 102 | + | ac.overlays.pickerRevisions = make(map[string]uint64) | |
| 103 | + | ac.overlays.pickerKeys = nil | |
| 104 | + | ac.overlays.pickerSourcePublished = false | |
| 105 | + | ac.overlays.pickerRecent = protocol.PickerProjection{} | |
| 106 | + | ac.overlays.pickerGrouped = protocol.PickerProjection{} | |
| 107 | + | ac.overlays.pickerPreviewGeneration = 0 | |
| 108 | + | ac.overlays.pickerPreviewKey = "" | |
| 109 | + | ac.overlays.pickerPreviewSession = nil | |
| 110 | + | ac.overlays.pickerMu.Unlock() | |
| 111 | + | // A previous interaction's row must stop observing before this one owns the | |
| 112 | + | // namespace, exactly as retiring the interaction would. | |
| 113 | + | d.teardownPickerPreviewSubscription(ac, previousSession, previousGeneration) | |
| 114 | + | ||
| 115 | + | barrierEpoch, barrierState, sizeEpoch := pickerClientBarrier(ac) | |
| 116 | + | offer := protocol.PickerOffer{ | |
| 117 | + | InteractionID: interaction, RequestID: requestID, Intent: intent, | |
| 118 | + | MoveSourceKey: pickerMoveSourceKey(source), Title: picker.SortRecent.Title(), | |
| 119 | + | BarrierEpoch: barrierEpoch, BarrierState: barrierState, SizeEpoch: sizeEpoch, | |
| 120 | + | } | |
| 121 | + | if err := protocol.ValidatePickerOffer(offer); err != nil { | |
| 122 | + | d.log.Error("picker offer rejected", "err", err, "interaction", interaction, "intent", intent) | |
| 123 | + | d.closePickerForAttachment(ac, effect, interaction) | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | if err := effect.sendControl(offer); err != nil { | |
| 127 | + | d.closePickerForAttachment(ac, effect, interaction) | |
| 128 | + | return err | |
| 129 | + | } | |
| 130 | + | if err := d.publishPickerSourceForAttachment(ac, effect, interaction); err != nil { | |
| 131 | + | d.closePickerForAttachment(ac, effect, interaction) | |
| 132 | + | return err | |
| 133 | + | } | |
| 134 | + | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 135 | + | d.invalidateRender(sess, ac, true, "picker.go:open") | |
| 136 | + | } | |
| 137 | + | return nil | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // pickerMoveSourceKey names the captured move source in the offer. It is | |
| 141 | + | // opaque and never a route: only the daemon resolves it, and only for the | |
| 142 | + | // interaction that captured it. | |
| 143 | + | func pickerMoveSourceKey(source moveSourceLocator) string { | |
| 144 | + | if source.Session.ID == "" || source.TabID == "" { | |
| 145 | + | return "" | |
| 146 | + | } | |
| 147 | + | return pickerClientKey(source.Session.Incarnation, string(source.Session.ID)+"#"+string(source.TabID)) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // publishPickerSourceForAttachment builds the serving projection pair and | |
| 151 | + | // publishes it only when the authoritative presentation changed. Recent is the | |
| 152 | + | // flat recency order the default client sort consumes; Grouped is the sectioned | |
| 153 | + | // order the grouped sort consumes. They always carry the same destination keys | |
| 154 | + | // and actions, but their lines differ, so each client sort runs on the input | |
| 155 | + | // its mode expects instead of re-deriving one projection from the other. A | |
| 156 | + | // refresh whose projections equal the last published pair is not | |
| 157 | + | // authority-relevant — an attention pulse whose rows already reached the client | |
| 158 | + | // is the common case — and must neither bump the revision nor send a snapshot, | |
| 159 | + | // or a revision the client never observed would stale its next selection. The | |
| 160 | + | // revision and recorded projections commit only after the snapshot reached the | |
| 161 | + | // client, so a rejected validation or failed send leaves the client's last | |
| 162 | + | // observed authority intact and an identical retry republishes instead of | |
| 163 | + | // being suppressed. | |
| 164 | + | // Callers must not hold pickerMu. | |
| 165 | + | func (d *Daemon) publishPickerSourceForAttachment(ac *attachedClient, effect *attachmentEffect, interaction uint64) error { | |
| 166 | + | if ac == nil || ac.overlays == nil || effect == nil { | |
| 167 | + | return errAttachmentTransition | |
| 168 | + | } | |
| 169 | + | open, currentInteraction, intent, source, _ := ac.pickerState() | |
| 170 | + | if !open || currentInteraction != interaction { | |
| 171 | + | return nil | |
| 172 | + | } | |
| 173 | + | sess := effect.sess | |
| 174 | + | if sess == nil { | |
| 175 | + | sess = ac.currentAttachmentSession() | |
| 176 | + | } | |
| 177 | + | if sess == nil { | |
| 178 | + | return errAttachmentTransition | |
| 179 | + | } | |
| 180 | + | recentViews, groupedViews, current := d.pickerViewProjections(sess, ac) | |
| 181 | + | filter := pickerSourceFilter{ | |
| 182 | + | Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID, | |
| 183 | + | } | |
| 184 | + | groupedSet := pickerLineSetFor(groupedViews, intent, filter, current) | |
| 185 | + | if intent != protocol.PickerIntentNavigation && !pickerLineSetHasMove(groupedSet) { | |
| 186 | + | return errNoMoveDestination | |
| 187 | + | } | |
| 188 | + | recent := setProjection(pickerLineSetFor(recentViews, intent, filter, current)) | |
| 189 | + | grouped := setProjection(groupedSet) | |
| 190 | + | ac.overlays.pickerPublishMu.Lock() | |
| 191 | + | defer ac.overlays.pickerPublishMu.Unlock() | |
| 192 | + | ac.overlays.pickerMu.Lock() | |
| 193 | + | if !ac.overlays.pickerOpen || ac.overlays.pickerInteraction != interaction { | |
| 194 | + | ac.overlays.pickerMu.Unlock() | |
| 195 | + | return nil | |
| 196 | + | } | |
| 197 | + | if ac.overlays.pickerSourcePublished && | |
| 198 | + | pickerProjectionEqual(ac.overlays.pickerRecent, recent) && | |
| 199 | + | pickerProjectionEqual(ac.overlays.pickerGrouped, grouped) { | |
| 200 | + | ac.overlays.pickerMu.Unlock() | |
| 201 | + | return nil | |
| 202 | + | } | |
| 203 | + | // Reserve the next revision without committing it: a revision the client | |
| 204 | + | // never observed must not become the recorded authority. pickerPublishMu | |
| 205 | + | // keeps this reservation exclusive, so the send below cannot be reordered | |
| 206 | + | // behind another publisher's. | |
| 207 | + | revision := ac.overlays.pickerRevisions[servingPickerSourceID] + 1 | |
| 208 | + | ac.overlays.pickerMu.Unlock() | |
| 209 | + | snapshot := protocol.PickerSnapshot{ | |
| 210 | + | InteractionID: interaction, SourceID: servingPickerSourceID, SourceRevision: revision, | |
| 211 | + | Status: protocol.PickerSourceOK, Lines: groupedSet.lines, Cursor: groupedSet.cursor, | |
| 212 | + | Recent: recent, Grouped: grouped, | |
| 213 | + | } | |
| 214 | + | if err := protocol.ValidatePickerSnapshot(snapshot); err != nil { | |
| 215 | + | attrs := []any{"err", err, "interaction", interaction, "lines", len(snapshot.Lines), "revision", revision} | |
| 216 | + | if first, second, dup := pickerDuplicateRows(snapshot.Lines); dup { | |
| 217 | + | attrs = append(attrs, "first_row", first, "second_row", second) | |
| 218 | + | } | |
| 219 | + | d.log.Error("picker snapshot rejected", attrs...) | |
| 220 | + | return err | |
| 221 | + | } | |
| 222 | + | if err := effect.sendControl(snapshot); err != nil { | |
| 223 | + | return err | |
| 224 | + | } | |
| 225 | + | // The client now observes this revision: commit the keys and projections | |
| 226 | + | // its selection will validate against. A concurrent close or replacement | |
| 227 | + | // interaction owns a newer namespace, so a superseded commit is dropped | |
| 228 | + | // instead of reviving the retired source. | |
| 229 | + | ac.overlays.pickerMu.Lock() | |
| 230 | + | if ac.overlays.pickerOpen && ac.overlays.pickerInteraction == interaction { | |
| 231 | + | ac.overlays.pickerRevisions[servingPickerSourceID] = revision | |
| 232 | + | ac.overlays.pickerKeys = groupedSet.keys | |
| 233 | + | ac.overlays.pickerSourcePublished = true | |
| 234 | + | ac.overlays.pickerRecent = recent | |
| 235 | + | ac.overlays.pickerGrouped = grouped | |
| 236 | + | } | |
| 237 | + | ac.overlays.pickerMu.Unlock() | |
| 238 | + | return nil | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // setProjection presents one resolved line set as a publishable projection. | |
| 242 | + | func setProjection(set pickerLineSet) protocol.PickerProjection { | |
| 243 | + | return protocol.PickerProjection{Lines: set.lines, Cursor: set.cursor} | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // pickerProjectionEqual reports whether two published projections present the | |
| 247 | + | // same authoritative facts. Order stays significant: a reordering changes the | |
| 248 | + | // row a cursor index names, which is authority the client must observe. | |
| 249 | + | func pickerProjectionEqual(left, right protocol.PickerProjection) bool { | |
| 250 | + | return left.Cursor == right.Cursor && slices.Equal(left.Lines, right.Lines) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // pickerLineSetHasMove reports whether a move line set offers any destination, | |
| 254 | + | // which is what failed move entry must report instead of opening an empty | |
| 255 | + | // picker. | |
| 256 | + | func pickerLineSetHasMove(set pickerLineSet) bool { | |
| 257 | + | for _, line := range set.lines { | |
| 258 | + | if line.Actions&protocol.PickerCanMove != 0 { | |
| 259 | + | return true | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | return false | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // refreshPickerSnapshot republishes the serving line set after its rows, the | |
| 266 | + | // attachment view, or the size changed. It derives a fresh effect from the | |
| 267 | + | // current attachment capability: the effect that opened the interaction is | |
| 268 | + | // bounded to its own operation and may already be retired. A closed | |
| 269 | + | // interaction is a no-op. | |
| 270 | + | func (d *Daemon) refreshPickerSnapshot(ac *attachedClient) { | |
| 271 | + | open, interaction, _, _, _ := ac.pickerState() | |
| 272 | + | if !open || interaction == 0 { | |
| 273 | + | return | |
| 274 | + | } | |
| 275 | + | sess := ac.currentAttachmentSession() | |
| 276 | + | if sess == nil { | |
| 277 | + | return | |
| 278 | + | } | |
| 279 | + | current := ac.transportSnapshot() | |
| 280 | + | _, effect, admitted := ac.beginCurrentAttachmentEffect(sess, current.transport) | |
| 281 | + | if !admitted { | |
| 282 | + | return | |
| 283 | + | } | |
| 284 | + | defer effect.End() | |
| 285 | + | _ = d.publishPickerSourceForAttachment(ac, effect, interaction) | |
| 286 | + | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 287 | + | d.invalidateRender(sess, ac, false, "picker.go:refresh") | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // closePickerForAttachment retires the interaction and confirms it to the | |
| 292 | + | // client with the daemon output boundary the client must display before it | |
| 293 | + | // releases the terminal. Stale closes skip so generations stay ordered. The | |
| 294 | + | // boolean reports whether this call retired the interaction. | |
| 295 | + | func (d *Daemon) closePickerForAttachment(ac *attachedClient, effect *attachmentEffect, interaction uint64) bool { | |
| 296 | + | if ac == nil || ac.overlays == nil || interaction == 0 { | |
| 297 | + | return false | |
| 298 | + | } | |
| 299 | + | ac.overlays.pickerMu.Lock() | |
| 300 | + | if !ac.overlays.pickerOpen || ac.overlays.pickerInteraction != interaction { | |
| 301 | + | ac.overlays.pickerMu.Unlock() | |
| 302 | + | return false | |
| 303 | + | } | |
| 304 | + | previewSession := ac.overlays.pickerPreviewSession | |
| 305 | + | previewGeneration := ac.overlays.pickerPreviewGeneration | |
| 306 | + | ac.overlays.pickerOpen = false | |
| 307 | + | ac.overlays.pickerKeys = nil | |
| 308 | + | ac.overlays.pickerRevisions = nil | |
| 309 | + | ac.overlays.pickerSourcePublished = false | |
| 310 | + | ac.overlays.pickerRecent = protocol.PickerProjection{} | |
| 311 | + | ac.overlays.pickerGrouped = protocol.PickerProjection{} | |
| 312 | + | ac.overlays.pickerPreviewGeneration = 0 | |
| 313 | + | ac.overlays.pickerPreviewKey = "" | |
| 314 | + | ac.overlays.pickerPreviewSession = nil | |
| 315 | + | ac.overlays.pickerMu.Unlock() | |
| 316 | + | // A retired interaction stops observing: its row is no longer displayed, | |
| 317 | + | // and a later preview must not resurrect it. The recorded session pins the | |
| 318 | + | // teardown to the exact coordinator that owns the subscription. | |
| 319 | + | d.teardownPickerPreviewSubscription(ac, previewSession, previewGeneration) | |
| 320 | + | if effect == nil { | |
| 321 | + | return true | |
| 322 | + | } | |
| 323 | + | barrierEpoch, barrierState, _ := pickerClientBarrier(ac) | |
| 324 | + | closed := protocol.PickerClosed{InteractionID: interaction, BarrierEpoch: barrierEpoch, BarrierState: barrierState} | |
| 325 | + | if protocol.ValidatePickerClosed(closed) != nil { | |
| 326 | + | return true | |
| 327 | + | } | |
| 328 | + | _ = effect.sendControl(closed) | |
| 329 | + | return true | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // resolvePickerSelection validates one typed selection against the open | |
| 333 | + | // interaction and performs the action the owning source authorised. Unknown | |
| 334 | + | // keys, foreign sources, and stale revisions reject with a bounded failure. | |
| 335 | + | func (d *Daemon) resolvePickerSelection(effect *attachmentEffect, selection protocol.PickerSelection) { | |
| 336 | + | if protocol.ValidatePickerSelection(selection) != nil || effect == nil { | |
| 337 | + | return | |
| 338 | + | } | |
| 339 | + | ac := effect.ac | |
| 340 | + | if ac == nil || ac.overlays == nil { | |
| 341 | + | return | |
| 342 | + | } | |
| 343 | + | ac.overlays.pickerMu.Lock() | |
| 344 | + | open := ac.overlays.pickerOpen | |
| 345 | + | interaction := ac.overlays.pickerInteraction | |
| 346 | + | intent := ac.overlays.pickerIntent | |
| 347 | + | source := ac.overlays.pickerMoveSource | |
| 348 | + | revision := ac.overlays.pickerRevisions[selection.SourceID] | |
| 349 | + | target, ok := ac.overlays.pickerKeys[selection.Key] | |
| 350 | + | ac.overlays.pickerMu.Unlock() | |
| 351 | + | if !open || interaction != selection.InteractionID { | |
| 352 | + | d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision) | |
| 353 | + | return | |
| 354 | + | } | |
| 355 | + | if selection.SourceID != servingPickerSourceID { | |
| 356 | + | d.sendPickerFailure(effect, selection, protocol.PickerUnknownSource) | |
| 357 | + | return | |
| 358 | + | } | |
| 359 | + | if selection.SourceRevision != revision { | |
| 360 | + | d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision) | |
| 361 | + | return | |
| 362 | + | } | |
| 363 | + | if !ok { | |
| 364 | + | d.sendPickerFailure(effect, selection, protocol.PickerUnknownKey) | |
| 365 | + | return | |
| 366 | + | } | |
| 367 | + | // Navigation commits through the handoff, which must not accept a target | |
| 368 | + | // whose lifecycle already moved on. Move and kill revalidate source and | |
| 369 | + | // destination themselves (move_lifecycle.go / killPickerTarget), so they | |
| 370 | + | // report their own precise rejection instead of a generic retire. | |
| 371 | + | if selection.Action == protocol.PickerActionNavigate && !d.pickerTargetCurrent(target) { | |
| 372 | + | d.sendPickerFailure(effect, selection, protocol.PickerRetiredTarget) | |
| 373 | + | return | |
| 374 | + | } | |
| 375 | + | switch selection.Action { | |
| 376 | + | case protocol.PickerActionKill: | |
| 377 | + | d.resolvePickerKill(effect, ac, interaction, target, selection) | |
| 378 | + | case protocol.PickerActionMove: | |
| 379 | + | d.resolvePickerMove(effect, ac, interaction, intent, source, target, selection) | |
| 380 | + | default: | |
| 381 | + | d.resolvePickerNavigate(effect, ac, interaction, target, selection) | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // resolvePickerNavigate retires the interaction before the handoff. The | |
| 386 | + | // client's release waits for the daemon's own close, and the handoff's | |
| 387 | + | // destination paint has to be that paint: closing afterwards would let it be | |
| 388 | + | // suppressed while the client still owned the terminal. | |
| 389 | + | func (d *Daemon) resolvePickerNavigate(effect *attachmentEffect, ac *attachedClient, interaction uint64, target picker.Target, selection protocol.PickerSelection) { | |
| 390 | + | if !d.closePickerForAttachment(ac, effect, interaction) { | |
| 391 | + | d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision) | |
| 392 | + | return | |
| 393 | + | } | |
| 394 | + | if err := d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "picker-select"); err != nil { | |
| 395 | + | d.sendPickerFailure(effect, selection, protocol.PickerNavigationFailed) | |
| 396 | + | // The interaction is already retired: the client drains until an | |
| 397 | + | // authoritative paint, so publish one even though the handoff failed. | |
| 398 | + | d.invalidateRender(effect.sess, ac, true, "picker-select-failed") | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // resolvePickerMove closes before committing a composite move+follow. The | |
| 403 | + | // calling effect must end before the transaction freezes attachment admission. | |
| 404 | + | func (d *Daemon) resolvePickerMove(effect *attachmentEffect, ac *attachedClient, interaction uint64, intent protocol.PickerIntent, source moveSourceLocator, target picker.Target, selection protocol.PickerSelection) { | |
| 405 | + | sess := effect.sess | |
| 406 | + | if err := d.movePickerSourceError(source); err != nil { | |
| 407 | + | d.sendPickerFailure(effect, selection, protocol.PickerRetiredTarget) | |
| 408 | + | d.reportAttachmentError(sess, movePickerUserError(err)) | |
| 409 | + | d.refreshPickerSnapshot(ac) | |
| 410 | + | return | |
| 411 | + | } | |
| 412 | + | closed := false | |
| 413 | + | beforeFollow := func() error { | |
| 414 | + | if !d.closePickerForAttachment(ac, effect, interaction) { | |
| 415 | + | return errMoveStaleTarget | |
| 416 | + | } | |
| 417 | + | closed = true | |
| 418 | + | effect.End() | |
| 419 | + | return nil | |
| 420 | + | } | |
| 421 | + | // A composite follow reports completion from the move postcommit, which | |
| 422 | + | // admits a fresh effect on the published destination capability once the | |
| 423 | + | // committing effect has ended and the destination paint is authoritative. | |
| 424 | + | afterFollow := func(published *attachmentEffect) { | |
| 425 | + | d.sendPickerResult(published, selection, interaction) | |
| 426 | + | } | |
| 427 | + | // Non-following pane moves retain the open interaction. The transaction | |
| 428 | + | // calls beforeFollow only after admission proves that it will follow. | |
| 429 | + | defer effect.End() | |
| 430 | + | if err := d.commitMovePickerSelection(intent, source, target, beforeFollow, afterFollow); err != nil { | |
| 431 | + | if closed { | |
| 432 | + | // The committing effect already ended so the transaction could drain | |
| 433 | + | // it. Reject and repaint through the attachment's current capability | |
| 434 | + | // instead of reusing an effect that is no longer admitted. | |
| 435 | + | d.reportAttachmentError(sess, movePickerUserError(err)) | |
| 436 | + | d.rejectClosedPickerMove(ac, selection) | |
| 437 | + | } else { | |
| 438 | + | d.sendPickerFailure(effect, selection, protocol.PickerActionFailed) | |
| 439 | + | d.reportAttachmentError(sess, movePickerUserError(err)) | |
| 440 | + | d.refreshPickerSnapshot(ac) | |
| 441 | + | } | |
| 442 | + | return | |
| 443 | + | } | |
| 444 | + | if !closed { | |
| 445 | + | d.sendPickerResult(effect, selection, interaction) | |
| 446 | + | d.refreshPickerSnapshot(ac) | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // rejectClosedPickerMove delivers bounded rejection feedback and an | |
| 451 | + | // authoritative repaint after a composite move closed the picker and ended its | |
| 452 | + | // committing effect. It never sends through that ended effect: a fresh effect | |
| 453 | + | // on the attachment's current capability carries the failure, and the paint | |
| 454 | + | // releases the client's post-close drain. | |
| 455 | + | func (d *Daemon) rejectClosedPickerMove(ac *attachedClient, selection protocol.PickerSelection) { | |
| 456 | + | sess := ac.currentAttachmentSession() | |
| 457 | + | if sess == nil { | |
| 458 | + | return | |
| 459 | + | } | |
| 460 | + | d.invalidateRender(sess, ac, true, "picker-move-failed") | |
| 461 | + | _, effect, admitted := ac.beginCurrentAttachmentEffect(sess, ac.transport()) | |
| 462 | + | if !admitted { | |
| 463 | + | return | |
| 464 | + | } | |
| 465 | + | d.sendPickerFailure(effect, selection, protocol.PickerActionFailed) | |
| 466 | + | effect.End() | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | // resolvePickerKill destroys the target and keeps the picker open. | |
| 470 | + | func (d *Daemon) resolvePickerKill(effect *attachmentEffect, ac *attachedClient, interaction uint64, target picker.Target, selection protocol.PickerSelection) { | |
| 471 | + | // The kill holds this admission until it finishes, so a replacement | |
| 472 | + | // initiator cannot make the destructive action look unattributed. | |
| 473 | + | effect.bindActionEnd(d, "picker-delete") | |
| 474 | + | if err := d.killPickerTargetForAttachment(target, effect); err != nil { | |
| 475 | + | d.sendPickerFailure(effect, selection, protocol.PickerActionFailed) | |
| 476 | + | d.refreshPickerSnapshot(ac) | |
| 477 | + | return | |
| 478 | + | } | |
| 479 | + | d.sendPickerResult(effect, selection, interaction) | |
| 480 | + | d.refreshPickerSnapshot(ac) | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | // pickerTargetCurrent revalidates a resolved target for lifecycle freshness: | |
| 484 | + | // incarnation pin plus expected-created-at, mirroring previewTarget and | |
| 485 | + | // targetMatchesLifecycle. A renamed-or-recreated session between snapshot and | |
| 486 | + | // commit rejects as retired, never force-commits. | |
| 487 | + | func (d *Daemon) pickerTargetCurrent(target picker.Target) bool { | |
| 488 | + | // A remote target names an endpoint-qualified remote session, not a local | |
| 489 | + | // one: resolving it against the local session table would always miss and | |
| 490 | + | // report a retired target. Its exact route is revalidated against the remote | |
| 491 | + | // catalogue by the handoff itself (remoteCatalogTargetReady), which reports | |
| 492 | + | // its own precise rejection. | |
| 493 | + | if target.RemoteTarget != nil || target.RemoteKey != nil { | |
| 494 | + | return true | |
| 495 | + | } | |
| 496 | + | if target.Name == "" { | |
| 497 | + | d.mu.Lock() | |
| 498 | + | sess := d.sessions[target.Session] | |
| 499 | + | d.mu.Unlock() | |
| 500 | + | if sess == nil { | |
| 501 | + | return false | |
| 502 | + | } | |
| 503 | + | sess.mu.Lock() | |
| 504 | + | defer sess.mu.Unlock() | |
| 505 | + | return targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) | |
| 506 | + | } | |
| 507 | + | d.mu.Lock() | |
| 508 | + | defer d.mu.Unlock() | |
| 509 | + | _, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | |
| 510 | + | if !ok { | |
| 511 | + | return false | |
| 512 | + | } | |
| 513 | + | if isStopped { | |
| 514 | + | return targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) | |
| 515 | + | } | |
| 516 | + | return true | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // pickerClientBarrier snapshots the acquisition boundary for one interaction | |
| 520 | + | // offer or close: the output fence epoch, the latest committed state, and the | |
| 521 | + | // view revision that doubles as the size epoch. It reads viewMu-protected | |
| 522 | + | // state only (never pane/session locks, never sendMu — see the lock-order | |
| 523 | + | // notes at the top of client.go); callers must not hold pickerMu. | |
| 524 | + | func pickerClientBarrier(ac *attachedClient) (epoch, state, sizeEpoch uint64) { | |
| 525 | + | if ac == nil { | |
| 526 | + | return 0, 0, 0 | |
| 527 | + | } | |
| 528 | + | view := ac.viewSnapshot() | |
| 529 | + | if ac.output != nil { | |
| 530 | + | epoch = ac.output.currentEpoch() | |
| 531 | + | state = ac.output.committedState() | |
| 532 | + | } | |
| 533 | + | return epoch, state, view.revision | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | func (d *Daemon) sendPickerResult(effect *attachmentEffect, selection protocol.PickerSelection, interaction uint64) { | |
| 537 | + | result := protocol.PickerResult{ | |
| 538 | + | CauseActionID: selection.CauseActionID, RequestID: selection.RequestID, | |
| 539 | + | InteractionID: interaction, SourceID: selection.SourceID, Key: selection.Key, Action: selection.Action, | |
| 540 | + | } | |
| 541 | + | if protocol.ValidatePickerResult(result) != nil { | |
| 542 | + | return | |
| 543 | + | } | |
| 544 | + | _ = effect.sendControl(result) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func (d *Daemon) sendPickerFailure(effect *attachmentEffect, selection protocol.PickerSelection, code protocol.PickerFailureCode) { | |
| 548 | + | failure := protocol.PickerFailure{ | |
| 549 | + | CauseActionID: selection.CauseActionID, RequestID: selection.RequestID, | |
| 550 | + | InteractionID: selection.InteractionID, SourceID: selection.SourceID, | |
| 551 | + | Key: selection.Key, Action: selection.Action, Code: code, | |
| 552 | + | } | |
| 553 | + | if protocol.ValidatePickerFailure(failure) != nil { | |
| 554 | + | return | |
| 555 | + | } | |
| 556 | + | _ = effect.sendControl(failure) | |
| 557 | + | } | |
A internal/usecase/daemon/picker_interaction_test.go +860 -0
| @@ -0,0 +1,860 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/protocol" | |
| 15 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // pickerClientTestUnit builds a fixture attachment and one extra session so | |
| 21 | + | // snapshots are non-empty. It returns an admitted effect for the guarded | |
| 22 | + | // sends. | |
| 23 | + | func pickerClientTestUnit(t *testing.T) (*Daemon, *session, *attachedClient, chan wire.Frame, *attachmentEffect) { | |
| 24 | + | t.Helper() | |
| 25 | + | p, releasePTY := newBlockingPTY(t) | |
| 26 | + | return pickerClientTestUnitWithPTY(t, p, releasePTY) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // pickerClientTestUnitWithPTY builds the same fixture over a caller-owned | |
| 30 | + | // PTY, so tests can observe whether session input reached the child. | |
| 31 | + | func pickerClientTestUnitWithPTY(t *testing.T, p ports.PTY, releasePTY func()) (*Daemon, *session, *attachedClient, chan wire.Frame, *attachmentEffect) { | |
| 32 | + | t.Helper() | |
| 33 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 34 | + | t.Cleanup(releasePTY) | |
| 35 | + | d.ptys = newFactorySeq(t, newQuietPTY()) | |
| 36 | + | _, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 39 | + | require.True(t, ok) | |
| 40 | + | t.Cleanup(effect.End) | |
| 41 | + | return d, sess, ac, sends, effect | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func openTestPicker(t *testing.T, d *Daemon, ac *attachedClient, effect *attachmentEffect, sends chan wire.Frame, intent protocol.PickerIntent) protocol.PickerOffer { | |
| 45 | + | t.Helper() | |
| 46 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, intent, moveSourceLocator{}, 0)) | |
| 47 | + | offer := awaitPickerOffer(t, sends) | |
| 48 | + | // The interaction namespace lives on the attachment, so the effect that | |
| 49 | + | // carried the offer is released here: a later transition or move must be | |
| 50 | + | // able to drain every other effect on this attachment. | |
| 51 | + | effect.End() | |
| 52 | + | return offer | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func awaitPickerOffer(t *testing.T, sends chan wire.Frame) protocol.PickerOffer { | |
| 56 | + | t.Helper() | |
| 57 | + | for { | |
| 58 | + | frame := awaitTestValue(t, sends, "picker offer was not published") | |
| 59 | + | if frame.Type != wire.MsgPickerOffer { | |
| 60 | + | continue | |
| 61 | + | } | |
| 62 | + | offer, err := wire.UnmarshalPickerOffer(frame.Payload) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | return offer | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func awaitPickerSnapshot(t *testing.T, sends chan wire.Frame) protocol.PickerSnapshot { | |
| 69 | + | t.Helper() | |
| 70 | + | for { | |
| 71 | + | frame := awaitTestValue(t, sends, "picker snapshot was not published") | |
| 72 | + | if frame.Type != wire.MsgPickerSnapshot { | |
| 73 | + | continue | |
| 74 | + | } | |
| 75 | + | snapshot, err := wire.UnmarshalPickerSnapshot(frame.Payload) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | return snapshot | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func awaitPickerFailure(t *testing.T, sends chan wire.Frame) protocol.PickerFailure { | |
| 82 | + | t.Helper() | |
| 83 | + | for { | |
| 84 | + | frame := awaitTestValue(t, sends, "picker failure was not published") | |
| 85 | + | if frame.Type != wire.MsgPickerFailure { | |
| 86 | + | continue | |
| 87 | + | } | |
| 88 | + | failure, err := wire.UnmarshalPickerFailure(frame.Payload) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | return failure | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func pickerClientCapability(t *testing.T, ac *attachedClient, sess *session) attachmentCapability { | |
| 95 | + | t.Helper() | |
| 96 | + | return captureAttachmentCapability(sess, ac, ac.transport()) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // navigateSelection builds one typed commit for the displayed snapshot. | |
| 100 | + | func navigateSelection(snapshot protocol.PickerSnapshot, key string) protocol.PickerSelection { | |
| 101 | + | return protocol.PickerSelection{ | |
| 102 | + | InteractionID: snapshot.InteractionID, SourceID: servingPickerSourceID, | |
| 103 | + | SourceRevision: snapshot.SourceRevision, Key: key, Action: protocol.PickerActionNavigate, | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // firstSelectableKey returns the first line the source authorised for the | |
| 108 | + | // requested action. | |
| 109 | + | func firstSelectableKey(t *testing.T, snapshot protocol.PickerSnapshot, action protocol.PickerLineActions) string { | |
| 110 | + | t.Helper() | |
| 111 | + | for _, line := range snapshot.Lines { | |
| 112 | + | if line.Actions&action != 0 { | |
| 113 | + | return line.Key | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | t.Fatalf("snapshot published no line admitting %d", action) | |
| 117 | + | return "" | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestPickerOfferAndSnapshotPublishResolvableLines(t *testing.T) { | |
| 121 | + | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 122 | + | ||
| 123 | + | offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 124 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 125 | + | ||
| 126 | + | require.NotZero(t, offer.InteractionID) | |
| 127 | + | require.Equal(t, protocol.PickerIntentNavigation, offer.Intent) | |
| 128 | + | require.Equal(t, offer.InteractionID, snapshot.InteractionID) | |
| 129 | + | require.Equal(t, servingPickerSourceID, snapshot.SourceID) | |
| 130 | + | require.Equal(t, uint64(1), snapshot.SourceRevision) | |
| 131 | + | require.NotEmpty(t, snapshot.Lines) | |
| 132 | + | ||
| 133 | + | // Every selectable line resolves through the published keys, and the keys | |
| 134 | + | // carry session names for the unchanged handoff. | |
| 135 | + | ac.overlays.pickerMu.Lock() | |
| 136 | + | keys := ac.overlays.pickerKeys | |
| 137 | + | ac.overlays.pickerMu.Unlock() | |
| 138 | + | selectable := 0 | |
| 139 | + | for _, line := range snapshot.Lines { | |
| 140 | + | if line.Actions == 0 { | |
| 141 | + | continue | |
| 142 | + | } | |
| 143 | + | selectable++ | |
| 144 | + | target, ok := keys[line.Key] | |
| 145 | + | require.True(t, ok, "line %q has no resolved target", line.Key) | |
| 146 | + | require.NotEmpty(t, target.Name, "resolved target carries no session name") | |
| 147 | + | } | |
| 148 | + | require.NotZero(t, selectable) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestPickerSnapshotPublishesFocusEligibility(t *testing.T) { | |
| 152 | + | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 153 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 154 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 155 | + | ||
| 156 | + | headers, tabs := 0, 0 | |
| 157 | + | for _, line := range snapshot.Lines { | |
| 158 | + | switch line.Kind { | |
| 159 | + | case protocol.PickerLineSection: | |
| 160 | + | require.False(t, line.Focusable, "section %q is not a cursor destination", line.Label) | |
| 161 | + | require.Zero(t, line.Actions, "section %q admits no action", line.Label) | |
| 162 | + | case protocol.PickerLineSession: | |
| 163 | + | headers++ | |
| 164 | + | // A live session header names a group of tabs: navigation belongs | |
| 165 | + | // to its tab rows, so the header is rendered and skipped. | |
| 166 | + | require.False(t, line.Focusable, "session header %q is not a destination", line.Label) | |
| 167 | + | require.Zero(t, line.Actions) | |
| 168 | + | case protocol.PickerLineTab: | |
| 169 | + | tabs++ | |
| 170 | + | require.True(t, line.Focusable, "tab %q must be reachable", line.Label) | |
| 171 | + | require.NotZero(t, line.Actions, "a reachable tab admits the action that made it reachable") | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | require.NotZero(t, headers) | |
| 175 | + | require.NotZero(t, tabs) | |
| 176 | + | ||
| 177 | + | // The published cursor hint never names a row the client may not rest on. | |
| 178 | + | require.NotEmpty(t, snapshot.Cursor.Key) | |
| 179 | + | for _, line := range snapshot.Lines { | |
| 180 | + | if line.Key == snapshot.Cursor.Key { | |
| 181 | + | require.True(t, line.Focusable) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func TestRemotePickerHostRowStaysReachableWithoutCommitting(t *testing.T) { | |
| 187 | + | d := newRemotePickerDaemon() | |
| 188 | + | seedRemoteDirectory(t, d, ports.RemoteHostSnapshot{ | |
| 189 | + | Endpoint: "arch", Availability: domain.RemoteAvailabilityUnreachable, | |
| 190 | + | InventoryKnown: true, LastSuccess: time.Unix(10, 0), | |
| 191 | + | }) | |
| 192 | + | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 193 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 194 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 195 | + | require.True(t, admitted) | |
| 196 | + | defer effect.End() | |
| 197 | + | ||
| 198 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 199 | + | _ = awaitPickerOffer(t, sends) | |
| 200 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 201 | + | ||
| 202 | + | hostRows := 0 | |
| 203 | + | for _, line := range snapshot.Lines { | |
| 204 | + | if line.Kind != protocol.PickerLineHost { | |
| 205 | + | continue | |
| 206 | + | } | |
| 207 | + | hostRows++ | |
| 208 | + | require.True(t, line.Focusable, "a host status row stays reachable for inspection") | |
| 209 | + | require.Zero(t, line.Actions, "a host status row never commits") | |
| 210 | + | } | |
| 211 | + | require.NotZero(t, hostRows, "an unreachable host publishes a status row") | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestPickerOpenInstallsNoPresentationModel(t *testing.T) { | |
| 215 | + | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 216 | + | ||
| 217 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 218 | + | _ = awaitPickerSnapshot(t, sends) | |
| 219 | + | ||
| 220 | + | // The daemon publishes data and actions only: no model, no cursor, no | |
| 221 | + | // search state of its own. | |
| 222 | + | ac.overlays.pickerMu.Lock() | |
| 223 | + | require.True(t, ac.overlays.pickerOpen) | |
| 224 | + | require.NotZero(t, ac.overlays.pickerInteraction) | |
| 225 | + | require.NotEmpty(t, ac.overlays.pickerKeys) | |
| 226 | + | ac.overlays.pickerMu.Unlock() | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestPickerSelectionRejectsStaleAndUnknown(t *testing.T) { | |
| 230 | + | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 231 | + | ||
| 232 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 233 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 234 | + | ||
| 235 | + | capability := pickerClientCapability(t, ac, sess) | |
| 236 | + | // Unknown key on the open interaction reports UnknownKey without | |
| 237 | + | // closing the interaction. | |
| 238 | + | require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, "ff00/ghost"))) | |
| 239 | + | failure := awaitPickerFailure(t, sends) | |
| 240 | + | require.Equal(t, protocol.PickerUnknownKey, failure.Code) | |
| 241 | + | require.Equal(t, snapshot.InteractionID, failure.InteractionID) | |
| 242 | + | ||
| 243 | + | // A foreign source is rejected before any key lookup. | |
| 244 | + | foreign := navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)) | |
| 245 | + | foreign.SourceID = "elsewhere" | |
| 246 | + | require.False(t, d.handleAttachmentClientMessage(capability, foreign)) | |
| 247 | + | require.Equal(t, protocol.PickerUnknownSource, awaitPickerFailure(t, sends).Code) | |
| 248 | + | ||
| 249 | + | // A retired interaction reports StaleRevision. | |
| 250 | + | d.closePickerForAttachment(ac, effect, snapshot.InteractionID) | |
| 251 | + | require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)))) | |
| 252 | + | require.Equal(t, protocol.PickerStaleRevision, awaitPickerFailure(t, sends).Code) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestPickerMoveIntentPublishesMoveLinesOnly(t *testing.T) { | |
| 256 | + | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 257 | + | source := moveSourceLocator{ | |
| 258 | + | Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name}, | |
| 259 | + | TabID: domain.TabStableID(testAttachmentTab(sess).stableID), | |
| 260 | + | } | |
| 261 | + | ac.overlays.pickerMu.Lock() | |
| 262 | + | require.False(t, ac.overlays.pickerOpen) | |
| 263 | + | ac.overlays.pickerMu.Unlock() | |
| 264 | + | ||
| 265 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMoveTab, source, 0)) | |
| 266 | + | offer := awaitPickerOffer(t, sends) | |
| 267 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 268 | + | require.Equal(t, protocol.PickerIntentMoveTab, offer.Intent) | |
| 269 | + | ||
| 270 | + | // The source session never appears as its own destination, and rows that | |
| 271 | + | // cannot accept a move carry no action. | |
| 272 | + | for _, line := range snapshot.Lines { | |
| 273 | + | require.NotEqual(t, sourceMoveKey(source), line.Key) | |
| 274 | + | if line.Actions != 0 { | |
| 275 | + | require.Equal(t, protocol.PickerCanMove, line.Actions) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func sourceMoveKey(source moveSourceLocator) string { | |
| 281 | + | return pickerMoveSourceKey(source) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestPickerOpenRepaintsWithoutSessionChange(t *testing.T) { | |
| 285 | + | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 286 | + | ||
| 287 | + | // The open invalidates the session even when nothing else changed: | |
| 288 | + | // without this repaint the opener's fence would never retire and | |
| 289 | + | // the palette Enter would hang until its deadline. | |
| 290 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 291 | + | _ = awaitPickerSnapshot(t, sends) | |
| 292 | + | select { | |
| 293 | + | case frame := <-sends: | |
| 294 | + | require.Equal(t, wire.MsgOutput, frame.Type, "open must trigger an authoritative repaint") | |
| 295 | + | default: | |
| 296 | + | t.Fatal("open produced no repaint after the snapshot") | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func TestPickerSessionPickerCommandOpensInteraction(t *testing.T) { | |
| 301 | + | p, release := newBlockingPTY(t) | |
| 302 | + | defer release() | |
| 303 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |
| 304 | + | effect := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 305 | + | effect.uiActionID = 77 | |
| 306 | + | ||
| 307 | + | // Full path through overlay dispatch: palette query, then Enter on | |
| 308 | + | // the same effect. The Enter routes through handlePaletteInput via | |
| 309 | + | // the overlay runtime, exactly like a real client frame. | |
| 310 | + | d.enterPalette(current, ac) | |
| 311 | + | d.handlePaletteInput(ac, []byte("sessionpicker"), effect) | |
| 312 | + | require.NotNil(t, ac.overlays.palette) | |
| 313 | + | d.handlePaletteInput(ac, []byte("\r"), effect) | |
| 314 | + | ||
| 315 | + | offer := awaitPickerOffer(t, sends) | |
| 316 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 317 | + | require.NotZero(t, offer.InteractionID) | |
| 318 | + | require.Equal(t, protocol.PickerIntentNavigation, offer.Intent) | |
| 319 | + | require.NotEmpty(t, snapshot.Lines) | |
| 320 | + | ac.overlays.pickerMu.Lock() | |
| 321 | + | require.True(t, ac.overlays.pickerOpen) | |
| 322 | + | ac.overlays.pickerMu.Unlock() | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func TestPickerCancelPublishesClosedAndAuthoritativeFullPaint(t *testing.T) { | |
| 326 | + | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 327 | + | ||
| 328 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 329 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 330 | + | // The open publishes its own restore paint after the snapshot; the release | |
| 331 | + | // assertion below is about the paint that follows the close. | |
| 332 | + | drainAllFrames(sends) | |
| 333 | + | ||
| 334 | + | // The client cancels: the daemon retires the interaction, confirms the | |
| 335 | + | // close with its restore barrier, and publishes the authoritative full | |
| 336 | + | // paint the client's release path waits for. | |
| 337 | + | capability := pickerClientCapability(t, ac, sess) | |
| 338 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID})) | |
| 339 | + | ac.overlays.pickerMu.Lock() | |
| 340 | + | require.False(t, ac.overlays.pickerOpen) | |
| 341 | + | ac.overlays.pickerMu.Unlock() | |
| 342 | + | ||
| 343 | + | var sawClosed bool | |
| 344 | + | for settled := false; !settled; { | |
| 345 | + | select { | |
| 346 | + | case frame := <-sends: | |
| 347 | + | if frame.Type == wire.MsgPickerClosedServer { | |
| 348 | + | closed, err := wire.UnmarshalPickerClosed(frame.Payload) | |
| 349 | + | require.NoError(t, err) | |
| 350 | + | require.Equal(t, snapshot.InteractionID, closed.InteractionID) | |
| 351 | + | sawClosed = true | |
| 352 | + | continue | |
| 353 | + | } | |
| 354 | + | if frame.Type == wire.MsgOutput { | |
| 355 | + | output, err := wire.UnmarshalOutput(frame.Payload) | |
| 356 | + | require.NoError(t, err) | |
| 357 | + | require.True(t, sawClosed, "the restore paint must follow the close") | |
| 358 | + | require.True(t, output.Full, "cancel release paint must be authoritative") | |
| 359 | + | settled = true | |
| 360 | + | } | |
| 361 | + | case <-time.After(200 * time.Millisecond): | |
| 362 | + | settled = true | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | require.True(t, sawClosed, "cancel published no close confirmation") | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func TestPickerPaletteFramesOpenInteraction(t *testing.T) { | |
| 369 | + | p, release := newBlockingPTY(t) | |
| 370 | + | defer release() | |
| 371 | + | d, _, ac, sends := newManualSessionWithPTYs(t, p) | |
| 372 | + | capability := pickerClientCapability(t, ac, testAttachmentSession(t, ac)) | |
| 373 | + | ||
| 374 | + | // Drive the real attached-client frame path instead of calling the | |
| 375 | + | // palette handler: Alt+Space opens the palette, the query selects the | |
| 376 | + | // session-picker command, and Enter executes it. | |
| 377 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 4, Data: []byte("\x1b ")})) | |
| 378 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 5, Data: []byte("SSP")})) | |
| 379 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 6, Data: []byte("\r")})) | |
| 380 | + | ||
| 381 | + | _ = awaitPickerOffer(t, sends) | |
| 382 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 383 | + | require.NotZero(t, snapshot.InteractionID) | |
| 384 | + | require.NotEmpty(t, snapshot.Lines) | |
| 385 | + | ac.overlays.pickerMu.Lock() | |
| 386 | + | require.True(t, ac.overlays.pickerOpen) | |
| 387 | + | ac.overlays.pickerMu.Unlock() | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func testAttachmentSession(t *testing.T, ac *attachedClient) *session { | |
| 391 | + | t.Helper() | |
| 392 | + | sess := ac.currentAttachmentSession() | |
| 393 | + | require.NotNil(t, sess) | |
| 394 | + | return sess | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | // secondSessionForTest returns the fixture's extra session, the one the attach | |
| 398 | + | // itself does not display. | |
| 399 | + | func secondSessionForTest(t *testing.T, d *Daemon) *session { | |
| 400 | + | t.Helper() | |
| 401 | + | d.mu.Lock() | |
| 402 | + | defer d.mu.Unlock() | |
| 403 | + | for _, sess := range d.sessions { | |
| 404 | + | if sess == nil { | |
| 405 | + | continue | |
| 406 | + | } | |
| 407 | + | sess.mu.Lock() | |
| 408 | + | name := sess.name | |
| 409 | + | sess.mu.Unlock() | |
| 410 | + | if name == "second" { | |
| 411 | + | return sess | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | t.Fatal("fixture second session missing") | |
| 415 | + | return nil | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // pickerBellLines reports the session headers carrying the bell glyph and the | |
| 419 | + | // tab rows carrying the structured attention marker. | |
| 420 | + | func pickerBellLines(snapshot protocol.PickerSnapshot) (headers int, tabRows int) { | |
| 421 | + | bell := string(ui.AttentionGlyph) | |
| 422 | + | for _, line := range snapshot.Lines { | |
| 423 | + | switch line.Kind { | |
| 424 | + | case protocol.PickerLineSession: | |
| 425 | + | if strings.HasSuffix(line.Label, " "+bell) { | |
| 426 | + | headers++ | |
| 427 | + | } | |
| 428 | + | case protocol.PickerLineTab: | |
| 429 | + | if line.Attention { | |
| 430 | + | tabRows++ | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | return headers, tabRows | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // TestPickerSnapshotCarriesAttentionBellsToTheClient pins the notification path | |
| 438 | + | // into the client-owned modal: the daemon publishes the bell on the ringing | |
| 439 | + | // session's header and the structured marker on its tab row, and the client | |
| 440 | + | // renders both. | |
| 441 | + | func TestPickerSnapshotCarriesAttentionBellsToTheClient(t *testing.T) { | |
| 442 | + | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 443 | + | ringing := secondSessionForTest(t, d) | |
| 444 | + | ringing.mu.Lock() | |
| 445 | + | ringing.tabs[0].attention = true | |
| 446 | + | ringing.tabs[0].attentionAt = time.Unix(1, 0) | |
| 447 | + | ringing.mu.Unlock() | |
| 448 | + | ||
| 449 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 450 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 451 | + | ||
| 452 | + | headers, tabRows := pickerBellLines(snapshot) | |
| 453 | + | require.Equal(t, 1, headers, "the ringing session header must carry the bell glyph") | |
| 454 | + | require.Equal(t, 1, tabRows, "the ringing tab row must carry the structured attention marker") | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | // TestAttentionPulseRepublishesTheOpenPickerSource pins that a bell raised | |
| 458 | + | // while the client presents the modal reaches it: client-owned presentation | |
| 459 | + | // suppresses the daemon's paints, so the pulse must advance the picker source | |
| 460 | + | // the client presents from. A closed interaction republishes nothing. | |
| 461 | + | func TestAttentionPulseRepublishesTheOpenPickerSource(t *testing.T) { | |
| 462 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 463 | + | defer releases[0]() | |
| 464 | + | defer releases[1]() | |
| 465 | + | clk := newManualAttentionClock() | |
| 466 | + | d.clock = clk | |
| 467 | + | require.True(t, selectTestAttachmentTab(sess, 0)) | |
| 468 | + | ||
| 469 | + | effect := admitPickerEffectForTest(t, sess, ac) | |
| 470 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 471 | + | require.Equal(t, protocol.PickerIntentNavigation, awaitPickerOffer(t, sends).Intent) | |
| 472 | + | first := awaitPickerSnapshot(t, sends) | |
| 473 | + | require.Equal(t, 0, pickerBellCount(first), "a quiet session must publish no bell") | |
| 474 | + | ||
| 475 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 476 | + | defer cancel() | |
| 477 | + | done := make(chan struct{}) | |
| 478 | + | go func() { | |
| 479 | + | d.attentionAnimator(ctx) | |
| 480 | + | close(done) | |
| 481 | + | }() | |
| 482 | + | ||
| 483 | + | // A background tab rings while the modal is open. The client owns | |
| 484 | + | // presentation, so only a newer source revision can update its rows. | |
| 485 | + | sess.mu.Lock() | |
| 486 | + | sess.tabs[1].attention = true | |
| 487 | + | sess.tabs[1].attentionAt = time.Unix(1, 0) | |
| 488 | + | sess.mu.Unlock() | |
| 489 | + | d.pokeAttentionTicker() | |
| 490 | + | ||
| 491 | + | timer := clk.nextTimer(t) | |
| 492 | + | timer.fire() | |
| 493 | + | second := awaitPickerSnapshot(t, sends) | |
| 494 | + | require.Greater(t, second.SourceRevision, first.SourceRevision) | |
| 495 | + | headers, tabRows := pickerBellLines(second) | |
| 496 | + | require.Equal(t, 1, headers, "the pulse must publish the new bell on the session header") | |
| 497 | + | require.Equal(t, 1, tabRows, "the pulse must publish the new bell on the tab row") | |
| 498 | + | ||
| 499 | + | // A retired interaction must not be reopened by a later pulse. The | |
| 500 | + | // animator keeps one timer while attention exists, so the same fired timer | |
| 501 | + | // carries the next pulse. | |
| 502 | + | retireEffect := admitPickerEffectForTest(t, sess, ac) | |
| 503 | + | require.True(t, d.closePickerForAttachment(ac, retireEffect, second.InteractionID)) | |
| 504 | + | _ = drainAllFrames(sends) | |
| 505 | + | sess.mu.Lock() | |
| 506 | + | sess.tabs[0].attention = true | |
| 507 | + | sess.tabs[0].attentionAt = time.Unix(2, 0) | |
| 508 | + | sess.mu.Unlock() | |
| 509 | + | d.pokeAttentionTicker() | |
| 510 | + | timer.fire() | |
| 511 | + | for _, frame := range drainAllFrames(sends) { | |
| 512 | + | require.NotEqual(t, wire.MsgPickerSnapshot, frame.Type, "a closed interaction republished a snapshot") | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | cancel() | |
| 516 | + | require.Eventually(t, func() bool { | |
| 517 | + | select { | |
| 518 | + | case <-done: | |
| 519 | + | return true | |
| 520 | + | default: | |
| 521 | + | return false | |
| 522 | + | } | |
| 523 | + | }, time.Second, time.Millisecond) | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | func pickerBellCount(snapshot protocol.PickerSnapshot) int { | |
| 527 | + | headers, tabRows := pickerBellLines(snapshot) | |
| 528 | + | return headers + tabRows | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | func TestPickerSelectionRequiresExactSourceRevision(t *testing.T) { | |
| 532 | + | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 533 | + | ||
| 534 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 535 | + | first := awaitPickerSnapshot(t, sends) | |
| 536 | + | // A refresh that observes a real row change publishes a newer revision: the | |
| 537 | + | // client may only commit the model it is displaying, so an older revision | |
| 538 | + | // must fail even though it is not newer than the daemon's current one. | |
| 539 | + | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 540 | + | require.NoError(t, err) | |
| 541 | + | d.refreshPickerSnapshot(ac) | |
| 542 | + | second := awaitPickerSnapshot(t, sends) | |
| 543 | + | require.Greater(t, second.SourceRevision, first.SourceRevision) | |
| 544 | + | ||
| 545 | + | capability := pickerClientCapability(t, ac, sess) | |
| 546 | + | key := firstSelectableKey(t, second, protocol.PickerCanNavigate) | |
| 547 | + | for _, revision := range []uint64{second.SourceRevision - 1, second.SourceRevision + 1} { | |
| 548 | + | selection := navigateSelection(second, key) | |
| 549 | + | selection.SourceRevision = revision | |
| 550 | + | require.False(t, d.handleAttachmentClientMessage(capability, selection)) | |
| 551 | + | failure := awaitPickerFailure(t, sends) | |
| 552 | + | require.Equal(t, protocol.PickerStaleRevision, failure.Code, "revision %d must be rejected", revision) | |
| 553 | + | ac.overlays.pickerMu.Lock() | |
| 554 | + | require.True(t, ac.overlays.pickerOpen, "a rejected revision must not close the interaction") | |
| 555 | + | ac.overlays.pickerMu.Unlock() | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | func TestPickerInteractionDropsRawInput(t *testing.T) { | |
| 560 | + | writes := make(chan []byte, 8) | |
| 561 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 562 | + | d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY) | |
| 563 | + | capability := pickerClientCapability(t, ac, sess) | |
| 564 | + | ||
| 565 | + | // A normal attachment forwards keys to the session. | |
| 566 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: []byte("x")})) | |
| 567 | + | require.Equal(t, []byte("x"), awaitTestValue(t, writes, "session input never reached the pty")) | |
| 568 | + | ||
| 569 | + | // Positive control: with the pane reporting mouse mode, a mouse report | |
| 570 | + | // reaches the pane's pty. Without this control the negative assertion | |
| 571 | + | // below would pass even if mouse routing were broken outright. | |
| 572 | + | tb := testAttachmentTab(sess) | |
| 573 | + | require.NotNil(t, tb) | |
| 574 | + | pane := tb.panes["pane-1"] | |
| 575 | + | require.NotNil(t, pane) | |
| 576 | + | pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 577 | + | mouseReport := []byte(fmt.Sprintf("\x1b[<0;2;%dM", clientTopBarRows+2)) | |
| 578 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: mouseReport})) | |
| 579 | + | require.NotEmpty(t, awaitTestValue(t, writes, "positive control: mouse never reached the pane")) | |
| 580 | + | ||
| 581 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 582 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 583 | + | ||
| 584 | + | // While the picker is open it owns user input: keys and mouse reports | |
| 585 | + | // must not reach the session, and the interaction stays open. | |
| 586 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 2, Data: []byte("hidden")})) | |
| 587 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 3, Data: mouseReport})) | |
| 588 | + | select { | |
| 589 | + | case frame := <-writes: | |
| 590 | + | t.Fatalf("picker-owned input reached the pty: %q", frame) | |
| 591 | + | case <-time.After(100 * time.Millisecond): | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | // Closing the interaction restores normal routing, mouse included. | |
| 595 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID})) | |
| 596 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 4, ActionID: 4, Data: []byte("visible")})) | |
| 597 | + | require.Equal(t, []byte("visible"), awaitTestValue(t, writes, "session input did not resume after the close")) | |
| 598 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 5, ActionID: 5, Data: mouseReport})) | |
| 599 | + | require.NotEmpty(t, awaitTestValue(t, writes, "mouse routing did not resume after the close")) | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | func TestPickerSelectionClosesBeforeTheDestinationPaint(t *testing.T) { | |
| 603 | + | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 604 | + | ||
| 605 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 606 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 607 | + | capability := pickerClientCapability(t, ac, sess) | |
| 608 | + | ||
| 609 | + | // Selecting the current session is a same-peer handoff. The interaction | |
| 610 | + | // must be retired before that handoff: the client releases only on a | |
| 611 | + | // paint accepted after the daemon's close, so the destination paint has | |
| 612 | + | // to follow it on the wire. Drain the open's own repaint first. | |
| 613 | + | drainAllFrames(sends) | |
| 614 | + | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |
| 615 | + | require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, key))) | |
| 616 | + | ||
| 617 | + | // Collect the handoff frames for a short settle window, then require the | |
| 618 | + | // close to precede any paint. | |
| 619 | + | var order []wire.MsgType | |
| 620 | + | for settled := false; !settled; { | |
| 621 | + | select { | |
| 622 | + | case frame := <-sends: | |
| 623 | + | order = append(order, frame.Type) | |
| 624 | + | case <-time.After(200 * time.Millisecond): | |
| 625 | + | settled = true | |
| 626 | + | } | |
| 627 | + | } | |
| 628 | + | require.NotEmpty(t, order, "the handoff published nothing") | |
| 629 | + | require.Equal(t, wire.MsgPickerClosedServer, order[0], "the close must precede the handoff") | |
| 630 | + | for i, frameType := range order { | |
| 631 | + | if frameType == wire.MsgOutput { | |
| 632 | + | require.NotZero(t, i, "a paint must not precede the close") | |
| 633 | + | } | |
| 634 | + | } | |
| 635 | + | ac.overlays.pickerMu.Lock() | |
| 636 | + | require.False(t, ac.overlays.pickerOpen) | |
| 637 | + | ac.overlays.pickerMu.Unlock() | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | func TestPickerInteractionDropsClipboardImage(t *testing.T) { | |
| 641 | + | writes := make(chan []byte, 8) | |
| 642 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 643 | + | d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY) | |
| 644 | + | capability := pickerClientCapability(t, ac, sess) | |
| 645 | + | ||
| 646 | + | temp := t.TempDir() | |
| 647 | + | t.Setenv("TMPDIR", temp) | |
| 648 | + | push := protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte("\x89PNG\r\n\x1a\n")} | |
| 649 | + | ||
| 650 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 651 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 652 | + | ||
| 653 | + | // An image push is user input too: the clipboard path must not reach the | |
| 654 | + | // session while the picker owns input. | |
| 655 | + | require.False(t, d.handleAttachmentClientMessage(capability, push)) | |
| 656 | + | select { | |
| 657 | + | case frame := <-writes: | |
| 658 | + | t.Fatalf("clipboard path reached the pty during the interaction: %q", frame) | |
| 659 | + | case <-time.After(100 * time.Millisecond): | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | // After the close the same push is delivered again. | |
| 663 | + | require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID})) | |
| 664 | + | push.InputSeq = 2 | |
| 665 | + | require.False(t, d.handleAttachmentClientMessage(capability, push)) | |
| 666 | + | select { | |
| 667 | + | case frame := <-writes: | |
| 668 | + | require.Contains(t, string(frame), ".png") | |
| 669 | + | case <-time.After(2 * time.Second): | |
| 670 | + | t.Fatal("clipboard path did not resume after the close") | |
| 671 | + | } | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | func TestPickerSnapshotRefreshThroughDirectoryNotification(t *testing.T) { | |
| 675 | + | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 676 | + | ||
| 677 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 678 | + | first := awaitPickerSnapshot(t, sends) | |
| 679 | + | ||
| 680 | + | // The row set changes for real: a new session appears in the catalogue. | |
| 681 | + | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 682 | + | require.NoError(t, err) | |
| 683 | + | ||
| 684 | + | // The catalogue notification path must reach the picker even though the | |
| 685 | + | // daemon installs no overlay model: refreshRemoteDirectoryViews is the | |
| 686 | + | // entry the directory subscription calls. | |
| 687 | + | d.refreshRemoteDirectoryViews() | |
| 688 | + | second := awaitPickerSnapshot(t, sends) | |
| 689 | + | require.Equal(t, first.InteractionID, second.InteractionID) | |
| 690 | + | require.Greater(t, second.SourceRevision, first.SourceRevision) | |
| 691 | + | require.Greater(t, len(second.Lines), len(first.Lines), "the new session must appear as a line") | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | // TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections pins that the | |
| 695 | + | // serving daemon publishes the actual Recent and Grouped projections instead of | |
| 696 | + | // one line set copied into both. Recent carries the flat recency order the | |
| 697 | + | // default client sort consumes; Grouped carries the sectioned order the grouped | |
| 698 | + | // sort consumes. Both stay one authority: the same keys admit the same actions. | |
| 699 | + | func TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections(t *testing.T) { | |
| 700 | + | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 701 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 702 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 703 | + | ||
| 704 | + | recentSections, groupedSections := 0, 0 | |
| 705 | + | recentActions := make(map[string]protocol.PickerLineActions, len(snapshot.Recent.Lines)) | |
| 706 | + | groupedActions := make(map[string]protocol.PickerLineActions, len(snapshot.Grouped.Lines)) | |
| 707 | + | for _, line := range snapshot.Recent.Lines { | |
| 708 | + | if line.Kind == protocol.PickerLineSection { | |
| 709 | + | recentSections++ | |
| 710 | + | } | |
| 711 | + | if line.Key != "" { | |
| 712 | + | recentActions[line.Key] = line.Actions | |
| 713 | + | } | |
| 714 | + | } | |
| 715 | + | for _, line := range snapshot.Grouped.Lines { | |
| 716 | + | if line.Kind == protocol.PickerLineSection { | |
| 717 | + | groupedSections++ | |
| 718 | + | } | |
| 719 | + | if line.Key != "" { | |
| 720 | + | groupedActions[line.Key] = line.Actions | |
| 721 | + | } | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | // The sort inputs differ: the grouped order is sectioned so its run | |
| 725 | + | // boundaries reach the grouped sort, and the recent order stays flat so the | |
| 726 | + | // recency sort never inherits grouping it did not ask for. | |
| 727 | + | require.Zero(t, recentSections, "recent projection published grouped section runs") | |
| 728 | + | require.NotZero(t, groupedSections, "grouped projection published no section runs") | |
| 729 | + | require.NotEqual(t, snapshot.Recent.Lines, snapshot.Grouped.Lines, "both projections carry the same sort input") | |
| 730 | + | require.Equal(t, recentActions, groupedActions, "projections disagree on destination actions") | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | // TestPickerRefreshPublishesOnlyChangedSource pins the revision contract: a | |
| 734 | + | // repeated publish of an unchanged projection pair neither bumps the source | |
| 735 | + | // revision nor reaches the client, while a real row-set change republishes a | |
| 736 | + | // full snapshot with a newer revision. | |
| 737 | + | func TestPickerRefreshPublishesOnlyChangedSource(t *testing.T) { | |
| 738 | + | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 739 | + | ||
| 740 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 741 | + | first := awaitPickerSnapshot(t, sends) | |
| 742 | + | require.Equal(t, uint64(1), first.SourceRevision) | |
| 743 | + | _ = drainAllFrames(sends) | |
| 744 | + | ||
| 745 | + | // The rows did not change, so the revision the client is displaying stays | |
| 746 | + | // current: a refresh must publish nothing and send nothing. | |
| 747 | + | d.refreshPickerSnapshot(ac) | |
| 748 | + | d.refreshPickerSnapshot(ac) | |
| 749 | + | for _, frame := range drainAllFrames(sends) { | |
| 750 | + | require.NotEqual(t, wire.MsgPickerSnapshot, frame.Type, "unchanged source republished a snapshot") | |
| 751 | + | } | |
| 752 | + | ac.overlays.pickerMu.Lock() | |
| 753 | + | require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "unchanged source bumped the revision") | |
| 754 | + | ac.overlays.pickerMu.Unlock() | |
| 755 | + | ||
| 756 | + | // A real row-set change republishes a full snapshot with a newer revision | |
| 757 | + | // through the current attachment capability. | |
| 758 | + | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 759 | + | require.NoError(t, err) | |
| 760 | + | d.refreshPickerSnapshot(ac) | |
| 761 | + | second := awaitPickerSnapshot(t, sends) | |
| 762 | + | require.Equal(t, first.InteractionID, second.InteractionID) | |
| 763 | + | require.Greater(t, second.SourceRevision, first.SourceRevision) | |
| 764 | + | require.Greater(t, len(second.Lines), len(first.Lines)) | |
| 765 | + | ||
| 766 | + | // A closed interaction republishes nothing: only the close confirmation | |
| 767 | + | // and the authoritative repaint may follow. | |
| 768 | + | effect = admitPickerEffectForTest(t, testAttachmentSession(t, ac), ac) | |
| 769 | + | require.True(t, d.closePickerForAttachment(ac, effect, first.InteractionID)) | |
| 770 | + | for _, frame := range drainAllFrames(sends) { | |
| 771 | + | require.NotEqual(t, wire.MsgPickerSnapshot, frame.Type, "closed interaction republished a snapshot") | |
| 772 | + | } | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | // pickerSnapshotFailTransport delegates to the fixture's capturing transport | |
| 776 | + | // but rejects the next authoritative snapshot, modelling a link that drops one | |
| 777 | + | // publication while the daemon still owns it. | |
| 778 | + | type pickerSnapshotFailTransport struct { | |
| 779 | + | *mockServerConnection | |
| 780 | + | failNext atomic.Bool | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | func (t *pickerSnapshotFailTransport) SendServer(message protocol.ServerMessage) error { | |
| 784 | + | if _, ok := message.(protocol.PickerSnapshot); ok && t.failNext.CompareAndSwap(true, false) { | |
| 785 | + | return errPickerSnapshotSend | |
| 786 | + | } | |
| 787 | + | return t.mockServerConnection.SendServer(message) | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | var errPickerSnapshotSend = errors.New("picker snapshot send failed") | |
| 791 | + | ||
| 792 | + | // TestPickerSnapshotRetryAfterFailedSend pins the publication commit point: a | |
| 793 | + | // snapshot whose send fails must not advance the recorded revision or replace | |
| 794 | + | // the keys the client is still selecting against, and the identical retry must | |
| 795 | + | // republish instead of being suppressed as unchanged. | |
| 796 | + | func TestPickerSnapshotRetryAfterFailedSend(t *testing.T) { | |
| 797 | + | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 798 | + | ||
| 799 | + | offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 800 | + | first := awaitPickerSnapshot(t, sends) | |
| 801 | + | require.Equal(t, uint64(1), first.SourceRevision) | |
| 802 | + | ||
| 803 | + | // Swap in a link whose next snapshot send fails, then install it as the | |
| 804 | + | // attachment authority so publication effects are admitted against it. | |
| 805 | + | captured, ok := ac.transport().(*mockServerConnection) | |
| 806 | + | require.True(t, ok, "fixture transport is not the capturing mock") | |
| 807 | + | failTransport := &pickerSnapshotFailTransport{mockServerConnection: captured} | |
| 808 | + | failTransport.failNext.Store(true) | |
| 809 | + | ac.replaceTransport(failTransport) | |
| 810 | + | ac.installTestAttachmentCapability(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 811 | + | ||
| 812 | + | // A real row-set change is attempted while the send fails. | |
| 813 | + | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 814 | + | require.NoError(t, err) | |
| 815 | + | failedEffect := admitPickerEffectForTest(t, sess, ac) | |
| 816 | + | failErr := d.publishPickerSourceForAttachment(ac, failedEffect, offer.InteractionID) | |
| 817 | + | require.ErrorIs(t, failErr, errPickerSnapshotSend) | |
| 818 | + | failedEffect.End() | |
| 819 | + | ||
| 820 | + | // The failed publication committed nothing: the revision the client is | |
| 821 | + | // displaying still validates, and its keys still resolve. | |
| 822 | + | ac.overlays.pickerMu.Lock() | |
| 823 | + | require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "failed send advanced the revision") | |
| 824 | + | require.True(t, ac.overlays.pickerSourcePublished, "failed send dropped the last published source") | |
| 825 | + | displayedKeys := ac.overlays.pickerKeys | |
| 826 | + | ac.overlays.pickerMu.Unlock() | |
| 827 | + | for _, line := range first.Lines { | |
| 828 | + | if line.Actions != 0 { | |
| 829 | + | require.Contains(t, displayedKeys, line.Key, "failed send invalidated a displayed key") | |
| 830 | + | } | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | // The identical retry must reach the client with the next revision, and the | |
| 834 | + | // committed keys must resolve every row of that snapshot. | |
| 835 | + | failTransport.failNext.Store(false) | |
| 836 | + | retryEffect := admitPickerEffectForTest(t, sess, ac) | |
| 837 | + | require.NoError(t, d.publishPickerSourceForAttachment(ac, retryEffect, offer.InteractionID)) | |
| 838 | + | second := awaitPickerSnapshot(t, sends) | |
| 839 | + | require.Equal(t, offer.InteractionID, second.InteractionID) | |
| 840 | + | require.Equal(t, first.SourceRevision+1, second.SourceRevision) | |
| 841 | + | ||
| 842 | + | ac.overlays.pickerMu.Lock() | |
| 843 | + | require.Equal(t, second.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "retry revision and client revision diverged") | |
| 844 | + | require.Equal(t, second.Recent, ac.overlays.pickerRecent) | |
| 845 | + | require.Equal(t, second.Grouped, ac.overlays.pickerGrouped) | |
| 846 | + | retryKeys := ac.overlays.pickerKeys | |
| 847 | + | ac.overlays.pickerMu.Unlock() | |
| 848 | + | for _, line := range second.Lines { | |
| 849 | + | if line.Actions != 0 { | |
| 850 | + | require.Contains(t, retryKeys, line.Key, "retry committed no key for a displayed row") | |
| 851 | + | } | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | // A further identical publish is genuinely unchanged and must stay silent. | |
| 855 | + | quietEffect := admitPickerEffectForTest(t, sess, ac) | |
| 856 | + | require.NoError(t, d.publishPickerSourceForAttachment(ac, quietEffect, offer.InteractionID)) | |
| 857 | + | for _, frame := range drainAllFrames(sends) { | |
| 858 | + | require.NotEqual(t, wire.MsgPickerSnapshot, frame.Type, "committed retry republished an unchanged source") | |
| 859 | + | } | |
| 860 | + | } | |
A internal/usecase/daemon/picker_lines.go +495 -0
| @@ -0,0 +1,495 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "math" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // This file owns picker destination eligibility and the structured lines the | |
| 14 | + | // serving daemon publishes. The daemon knows lifecycles, tab identities, and | |
| 15 | + | // remote activation; the presenting client only renders. Keys are opaque and | |
| 16 | + | // resolve back to picker.Target values inside the daemon. | |
| 17 | + | ||
| 18 | + | type pickerRemoteAvailability uint8 | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | pickerRemoteNone pickerRemoteAvailability = iota | |
| 22 | + | pickerRemoteCached | |
| 23 | + | pickerRemoteFresh | |
| 24 | + | pickerRemoteStale | |
| 25 | + | pickerRemoteVersionMismatch | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // pickerRemoteActivation is the picker action authorized by the current remote | |
| 29 | + | // catalog snapshot. It is presentation state only; the structured remote | |
| 30 | + | // target remains the exact route and is revalidated at commit time. | |
| 31 | + | type pickerRemoteActivation uint8 | |
| 32 | + | ||
| 33 | + | const ( | |
| 34 | + | pickerRemoteUnavailable pickerRemoteActivation = iota | |
| 35 | + | pickerRemoteAttach | |
| 36 | + | pickerRemoteRestart | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // pickerSessionView is the daemon-private projection of one session or remote | |
| 40 | + | // catalogue row used to compute destination eligibility. | |
| 41 | + | type pickerSessionView struct { | |
| 42 | + | ID domain.SessionID | |
| 43 | + | Section string | |
| 44 | + | Incarnation domain.IncarnationID | |
| 45 | + | Name string | |
| 46 | + | TargetName string | |
| 47 | + | Tabs []pickerTabEntry | |
| 48 | + | Active int | |
| 49 | + | Stopped bool | |
| 50 | + | ExpectedCreatedAt *int64 | |
| 51 | + | RemoteKey *domain.RemoteSessionKey | |
| 52 | + | HideRemoteOrigin bool | |
| 53 | + | // RemoteTarget is the structured route/lifecycle identity for picker rows. | |
| 54 | + | // It is never reconstructed from Name or a rendered label. | |
| 55 | + | RemoteTarget *domain.RemoteSessionTarget | |
| 56 | + | // RemoteHost marks remote host status rows that have no session key. | |
| 57 | + | RemoteHost string | |
| 58 | + | RemoteAvailability pickerRemoteAvailability | |
| 59 | + | RemoteDetail string | |
| 60 | + | RemoteReason string | |
| 61 | + | RemoteActivation pickerRemoteActivation | |
| 62 | + | // CannotAcceptMoves reports whether this session cannot receive a moved tab | |
| 63 | + | // or pane. False for ordinary local (and stopped) sessions; true for | |
| 64 | + | // restricted remote rows. | |
| 65 | + | CannotAcceptMoves bool | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // pickerTabEntry is one tab row; Name is drawn emphasized, Detail muted. | |
| 69 | + | type pickerTabEntry struct { | |
| 70 | + | TabID domain.TabStableID // stable tab identity; independent of its current index | |
| 71 | + | Name string // tab display name | |
| 72 | + | RawName string // unformatted name used by exact remote selectors | |
| 73 | + | Detail string // " (paneTitle)" or "", drawn muted | |
| 74 | + | Attention bool // draw the attention marker right after Name, before Detail | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // pickerSourceFilter names the row the attachment currently displays. | |
| 78 | + | type pickerSourceFilter struct { | |
| 79 | + | Session domain.SessionID | |
| 80 | + | Incarnation domain.IncarnationID | |
| 81 | + | TabID domain.TabStableID | |
| 82 | + | RemoteKey *domain.RemoteSessionKey | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // pickerLine is one published line plus its source-private target. cursor | |
| 86 | + | // marks the row the attachment currently displays; defaultCursor marks the row | |
| 87 | + | // a fresh interaction starts on when nothing matches the current view. | |
| 88 | + | type pickerLine struct { | |
| 89 | + | line protocol.PickerLine | |
| 90 | + | target picker.Target | |
| 91 | + | cursor bool | |
| 92 | + | defaultCursor bool | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // pickerLineSet is one published source: ordered lines, the opaque key map, | |
| 96 | + | // and the cursor hint. | |
| 97 | + | type pickerLineSet struct { | |
| 98 | + | lines []protocol.PickerLine | |
| 99 | + | keys map[string]picker.Target | |
| 100 | + | cursor protocol.PickerCursor | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // pickerLineSetFor projects one eligibility view set into structured lines in | |
| 104 | + | // view order (never canonicalized: order is semantically significant). | |
| 105 | + | func pickerLineSetFor(views []pickerSessionView, intent protocol.PickerIntent, source, current pickerSourceFilter) pickerLineSet { | |
| 106 | + | set := pickerLineSet{keys: make(map[string]picker.Target)} | |
| 107 | + | entries := make([]pickerLine, 0) | |
| 108 | + | for _, view := range views { | |
| 109 | + | if view.Section != "" { | |
| 110 | + | entries = append(entries, pickerLine{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: view.Section, Dim: true}}) | |
| 111 | + | } | |
| 112 | + | entries = append(entries, pickerSessionLines(view, intent, source, current)...) | |
| 113 | + | } | |
| 114 | + | for i, entry := range entries { | |
| 115 | + | set.lines = append(set.lines, entry.line) | |
| 116 | + | if entry.line.Key == "" { | |
| 117 | + | continue | |
| 118 | + | } | |
| 119 | + | if _, dup := set.keys[entry.line.Key]; !dup { | |
| 120 | + | set.keys[entry.line.Key] = entry.target | |
| 121 | + | } | |
| 122 | + | if entry.cursor { | |
| 123 | + | set.cursor = protocol.PickerCursor{Key: entry.line.Key, Index: i} | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | if set.cursor.Key == "" { | |
| 127 | + | // Prefer the active tab of a session over the first published row. | |
| 128 | + | for i, entry := range entries { | |
| 129 | + | if !entry.defaultCursor { | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | set.cursor = protocol.PickerCursor{Key: entry.line.Key, Index: i} | |
| 133 | + | break | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | if set.cursor.Key == "" && len(set.lines) > 0 { | |
| 137 | + | // Last resort: the first row the source authorised as a cursor | |
| 138 | + | // destination. A row kept for inspection or skipped by the source is | |
| 139 | + | // never hinted. | |
| 140 | + | for i, line := range set.lines { | |
| 141 | + | if !line.Focusable { | |
| 142 | + | continue | |
| 143 | + | } | |
| 144 | + | if target, ok := set.keys[line.Key]; ok && !target.Stopped { | |
| 145 | + | set.cursor = protocol.PickerCursor{Key: line.Key, Index: i} | |
| 146 | + | break | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | return set | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // pickerRowKey derives the opaque key of one picker row. Local rows are keyed by | |
| 154 | + | // incarnation and name, which stays readable for the common case. A remote row | |
| 155 | + | // also carries its owning endpoint: the same machine can be registered under | |
| 156 | + | // more than one target (a pinned alias and one learned by attach), and two | |
| 157 | + | // endpoints may legitimately expose the same session lifecycle and name, so an | |
| 158 | + | // unqualified key would collide and the whole snapshot would be rejected. The | |
| 159 | + | // digest keeps the key unique per endpoint while staying inside the bounded key | |
| 160 | + | // length. | |
| 161 | + | func pickerRowKey(view pickerSessionView, name string) string { | |
| 162 | + | if view.RemoteKey == nil { | |
| 163 | + | return pickerClientKey(view.Incarnation, name) | |
| 164 | + | } | |
| 165 | + | digest := sha256.Sum256([]byte(view.RemoteKey.Host + "\x00" + string(view.Incarnation[:]) + "\x00" + name)) | |
| 166 | + | return "remote:" + base64.RawURLEncoding.EncodeToString(digest[:16]) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // pickerSessionLines is the sole owner of intent-specific destination | |
| 170 | + | // eligibility. The daemon supplies canonical lifecycle/tab snapshots without | |
| 171 | + | // prefiltering. | |
| 172 | + | func pickerSessionLines(view pickerSessionView, intent protocol.PickerIntent, source, current pickerSourceFilter) []pickerLine { | |
| 173 | + | stopped := view.Stopped | |
| 174 | + | if view.RemoteTarget != nil { | |
| 175 | + | stopped = view.RemoteTarget.Stopped | |
| 176 | + | } | |
| 177 | + | if intent != protocol.PickerIntentNavigation && stopped { | |
| 178 | + | return nil | |
| 179 | + | } | |
| 180 | + | if intent == protocol.PickerIntentMoveTab && pickerSourceMatchesSession(source, view) { | |
| 181 | + | return nil | |
| 182 | + | } | |
| 183 | + | if intent == protocol.PickerIntentMoveTab && len(view.Tabs) == 0 && !view.CannotAcceptMoves { | |
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | targetName := view.TargetName | |
| 188 | + | if intent == protocol.PickerIntentNavigation && !stopped && view.ExpectedCreatedAt == nil { | |
| 189 | + | targetName = "" | |
| 190 | + | } else if targetName == "" { | |
| 191 | + | targetName = view.Name | |
| 192 | + | } | |
| 193 | + | expectedCreatedAt, hasExpectedCreatedAt := int64Value(view.ExpectedCreatedAt) | |
| 194 | + | common := picker.Target{ | |
| 195 | + | Session: view.ID, Incarnation: view.Incarnation, Name: targetName, | |
| 196 | + | RemoteKey: view.RemoteKey, RemoteTarget: view.RemoteTarget, RemoteHost: view.RemoteHost, | |
| 197 | + | UnavailableReason: view.RemoteReason, TabIndex: -1, Stopped: stopped, | |
| 198 | + | ExpectedCreatedAt: optionalInt64(expectedCreatedAt, hasExpectedCreatedAt), | |
| 199 | + | } | |
| 200 | + | headerLine := pickerLine{ | |
| 201 | + | line: protocol.PickerLine{ | |
| 202 | + | Key: pickerRowKey(view, common.Name), Kind: pickerLineKindForView(view), | |
| 203 | + | Label: view.Name, Detail: pickerStatusDetailFor(view, stopped), Stopped: stopped, | |
| 204 | + | Status: pickerStatusFor(view, stopped), StatusDetail: view.RemoteDetail, Ephemeral: pickerEphemeral(view), | |
| 205 | + | }, | |
| 206 | + | target: common, | |
| 207 | + | } | |
| 208 | + | if view.RemoteKey != nil { | |
| 209 | + | headerLine.line.Label = view.RemoteKey.Name | |
| 210 | + | if !view.HideRemoteOrigin { | |
| 211 | + | display := view.RemoteKey.Display() | |
| 212 | + | headerLine.line.Detail = display[len(view.RemoteKey.Name):] | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | header := headerLine | |
| 216 | + | headerSelectable, headerFocusable, headerDim := pickerHeaderEligibility(view, intent, stopped) | |
| 217 | + | header.line.Actions = pickerActionsFor(view, intent, headerSelectable) | |
| 218 | + | header.line.Focusable = headerFocusable | |
| 219 | + | header.line.Dim = headerDim | |
| 220 | + | if headerFocusable && pickerSelectionMatches(header, current, intent) { | |
| 221 | + | header.cursor = true | |
| 222 | + | } | |
| 223 | + | if !headerFocusable { | |
| 224 | + | header.line.Actions = 0 | |
| 225 | + | header.target = picker.Target{} | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | lines := []pickerLine{header} | |
| 229 | + | for i, tab := range view.Tabs { | |
| 230 | + | if intent == protocol.PickerIntentMovePane && pickerSourceMatchesTab(source, view, tab) { | |
| 231 | + | continue | |
| 232 | + | } | |
| 233 | + | tabTarget := common | |
| 234 | + | tabTarget.TabID, tabTarget.TabIndex = tab.TabID, i | |
| 235 | + | // Each tab row carries its own structured remote target: a stopped row | |
| 236 | + | // restores the tab it names, a live row binds its stable tab ID. | |
| 237 | + | if resolved, ok := pickerTabRemoteTarget(view, tab, i); ok { | |
| 238 | + | tabTarget.RemoteTarget = &resolved | |
| 239 | + | } | |
| 240 | + | tabLine := pickerLine{ | |
| 241 | + | line: protocol.PickerLine{ | |
| 242 | + | Key: pickerRowKey(view, common.Name+"#"+string(tab.TabID)), | |
| 243 | + | Kind: protocol.PickerLineTab, | |
| 244 | + | Label: tab.Name, Detail: tab.Detail, Attention: tab.Attention, | |
| 245 | + | Stopped: stopped, StatusDetail: view.RemoteDetail, Ephemeral: pickerEphemeral(view), | |
| 246 | + | }, | |
| 247 | + | target: tabTarget, | |
| 248 | + | } | |
| 249 | + | selectable, focusable, dim := pickerTabEligibility(view, intent, tab, i) | |
| 250 | + | tabLine.line.Actions = pickerActionsFor(view, intent, selectable) | |
| 251 | + | tabLine.line.Focusable = focusable | |
| 252 | + | tabLine.line.Dim = dim | |
| 253 | + | if !focusable { | |
| 254 | + | tabLine.line.Actions = 0 | |
| 255 | + | tabLine.target = picker.Target{} | |
| 256 | + | } | |
| 257 | + | if focusable && pickerSelectionMatches(tabLine, current, intent) { | |
| 258 | + | tabLine.cursor = true | |
| 259 | + | } | |
| 260 | + | // A fresh navigation starts on the session's active tab, exactly like | |
| 261 | + | // the daemon-owned model did. | |
| 262 | + | if focusable && intent == protocol.PickerIntentNavigation && i == normalizedActiveTab(view) { | |
| 263 | + | tabLine.defaultCursor = true | |
| 264 | + | } | |
| 265 | + | lines = append(lines, tabLine) | |
| 266 | + | } | |
| 267 | + | if intent == protocol.PickerIntentMovePane && len(lines) == 1 && !view.CannotAcceptMoves { | |
| 268 | + | return nil | |
| 269 | + | } | |
| 270 | + | return lines | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func pickerHeaderEligibility(view pickerSessionView, intent protocol.PickerIntent, stopped bool) (selectable, focusable, dim bool) { | |
| 274 | + | if intent == protocol.PickerIntentMoveTab { | |
| 275 | + | selectable, focusable = true, true | |
| 276 | + | } else if view.ID != "" && pickerViewHasTarget(view) { | |
| 277 | + | selectable, focusable = false, false | |
| 278 | + | } | |
| 279 | + | if intent == protocol.PickerIntentNavigation && stopped && len(view.Tabs) == 0 && pickerViewHasTarget(view) { | |
| 280 | + | selectable, focusable = true, true | |
| 281 | + | } | |
| 282 | + | if pickerViewIsRemote(view) { | |
| 283 | + | if view.RemoteTarget != nil { | |
| 284 | + | selectable = intent == protocol.PickerIntentNavigation && stopped && len(view.Tabs) == 0 && pickerRemoteActivatable(view) && view.RemoteTarget.Validate() == nil | |
| 285 | + | focusable = intent == protocol.PickerIntentNavigation && len(view.Tabs) == 0 | |
| 286 | + | } else { | |
| 287 | + | selectable = false | |
| 288 | + | focusable = intent == protocol.PickerIntentNavigation && view.RemoteHost != "" | |
| 289 | + | } | |
| 290 | + | dim = view.RemoteActivation == pickerRemoteUnavailable | |
| 291 | + | } | |
| 292 | + | if intent != protocol.PickerIntentNavigation && view.CannotAcceptMoves { | |
| 293 | + | selectable, focusable, dim = false, false, true | |
| 294 | + | } | |
| 295 | + | return selectable, focusable, dim | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func pickerTabEligibility(view pickerSessionView, intent protocol.PickerIntent, tab pickerTabEntry, index int) (selectable, focusable, dim bool) { | |
| 299 | + | selectable, focusable = intent != protocol.PickerIntentMoveTab, intent != protocol.PickerIntentMoveTab | |
| 300 | + | if pickerViewIsRemote(view) { | |
| 301 | + | if view.RemoteTarget == nil { | |
| 302 | + | focusable, selectable = false, false | |
| 303 | + | } else { | |
| 304 | + | resolved, resolvable := pickerTabRemoteTarget(view, tab, index) | |
| 305 | + | focusable = true | |
| 306 | + | selectable = resolvable && resolved.Validate() == nil && intent == protocol.PickerIntentNavigation && pickerRemoteActivatable(view) | |
| 307 | + | } | |
| 308 | + | dim = view.RemoteActivation == pickerRemoteUnavailable | |
| 309 | + | } | |
| 310 | + | if intent != protocol.PickerIntentNavigation && view.CannotAcceptMoves { | |
| 311 | + | selectable, focusable, dim = false, false, true | |
| 312 | + | } | |
| 313 | + | return selectable, focusable, dim | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // pickerTabRemoteTarget resolves the per-tab copy of a remote session's exact | |
| 317 | + | // route. A stopped session targets the tab the row names (stable ID when known, | |
| 318 | + | // otherwise its ordinal and raw name); a live session binds the tab's stable | |
| 319 | + | // ID. The second result is false when no exact target can be expressed. | |
| 320 | + | func pickerTabRemoteTarget(view pickerSessionView, tab pickerTabEntry, index int) (domain.RemoteSessionTarget, bool) { | |
| 321 | + | if view.RemoteTarget == nil { | |
| 322 | + | return domain.RemoteSessionTarget{}, false | |
| 323 | + | } | |
| 324 | + | target := *view.RemoteTarget | |
| 325 | + | if target.Stopped { | |
| 326 | + | target.LiveTabID = "" | |
| 327 | + | if tab.TabID != "" { | |
| 328 | + | target.StoppedTab = domain.NewStableTabSelector(tab.TabID) | |
| 329 | + | return target, true | |
| 330 | + | } | |
| 331 | + | if target.StoppedTab == (domain.TabSelector{}) { | |
| 332 | + | return target, true | |
| 333 | + | } | |
| 334 | + | selector, ok := remoteStoppedOrdinalSelector(index, tab.RawName, len(view.Tabs)) | |
| 335 | + | if !ok { | |
| 336 | + | return domain.RemoteSessionTarget{}, false | |
| 337 | + | } | |
| 338 | + | target.StoppedTab = selector | |
| 339 | + | return target, true | |
| 340 | + | } | |
| 341 | + | target.StoppedTab = domain.TabSelector{} | |
| 342 | + | target.LiveTabID = tab.TabID | |
| 343 | + | return target, true | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // pickerActionsFor maps eligibility to the actions the source authorises. The | |
| 347 | + | // daemon revalidates key and lifecycle at commit, so these bits are a | |
| 348 | + | // presentation contract, never authority. | |
| 349 | + | // normalizedActiveTab clamps a session's active tab index into its tab range. | |
| 350 | + | func normalizedActiveTab(view pickerSessionView) int { | |
| 351 | + | if view.Active < 0 || view.Active >= len(view.Tabs) { | |
| 352 | + | return 0 | |
| 353 | + | } | |
| 354 | + | return view.Active | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | func pickerActionsFor(view pickerSessionView, intent protocol.PickerIntent, selectable bool) protocol.PickerLineActions { | |
| 358 | + | if !selectable { | |
| 359 | + | return 0 | |
| 360 | + | } | |
| 361 | + | if intent != protocol.PickerIntentNavigation { | |
| 362 | + | return protocol.PickerCanMove | |
| 363 | + | } | |
| 364 | + | actions := protocol.PickerCanNavigate | |
| 365 | + | if !pickerViewIsRemote(view) && pickerViewHasTarget(view) { | |
| 366 | + | actions |= protocol.PickerCanKill | |
| 367 | + | } | |
| 368 | + | return actions | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func pickerViewHasTarget(view pickerSessionView) bool { | |
| 372 | + | return view.ID != "" && view.Name != "" | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // pickerLineKindForView names one published header row's shape. A remote host | |
| 376 | + | // status row carries no session key and no structured route: it is published | |
| 377 | + | // for inspection, so it is a host row rather than a session row. | |
| 378 | + | func pickerLineKindForView(view pickerSessionView) protocol.PickerLineKind { | |
| 379 | + | if pickerViewHostStatus(view) { | |
| 380 | + | return protocol.PickerLineHost | |
| 381 | + | } | |
| 382 | + | return protocol.PickerLineSession | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // pickerViewHostStatus reports a catalogue host line: an endpoint with no | |
| 386 | + | // session identity behind it. | |
| 387 | + | func pickerViewHostStatus(view pickerSessionView) bool { | |
| 388 | + | return view.RemoteHost != "" && view.RemoteKey == nil && view.RemoteTarget == nil | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func pickerViewIsRemote(view pickerSessionView) bool { | |
| 392 | + | return view.RemoteKey != nil || view.RemoteTarget != nil || view.RemoteHost != "" | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func pickerEphemeral(view pickerSessionView) bool { | |
| 396 | + | return view.RemoteKey != nil || view.RemoteTarget != nil || view.RemoteHost != "" | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func pickerRemoteActivatable(view pickerSessionView) bool { | |
| 400 | + | return view.RemoteActivation == pickerRemoteAttach || view.RemoteActivation == pickerRemoteRestart | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func pickerStatusFor(view pickerSessionView, stopped bool) protocol.PickerLineStatus { | |
| 404 | + | remote := pickerViewIsRemote(view) | |
| 405 | + | if remote { | |
| 406 | + | switch view.RemoteReason { | |
| 407 | + | case domain.RemoteReasonHostUnreachable: | |
| 408 | + | return protocol.PickerLineStatusDown | |
| 409 | + | case domain.RemoteReasonVersionMismatch: | |
| 410 | + | return protocol.PickerLineStatusVersion | |
| 411 | + | case domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale: | |
| 412 | + | return protocol.PickerLineStatusStale | |
| 413 | + | case domain.RemoteReasonMalformed, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonAuthFailure: | |
| 414 | + | return protocol.PickerLineStatusError | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | if stopped { | |
| 418 | + | return protocol.PickerLineStatusStopped | |
| 419 | + | } | |
| 420 | + | if !remote { | |
| 421 | + | return protocol.PickerLineStatusNone | |
| 422 | + | } | |
| 423 | + | if view.RemoteActivation == pickerRemoteAttach { | |
| 424 | + | return protocol.PickerLineStatusUp | |
| 425 | + | } | |
| 426 | + | return protocol.PickerLineStatusError | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | // pickerStatusDetailFor fills the muted detail of a header line: a stopped | |
| 430 | + | // session reads "stopped" and a remote reason replaces it. | |
| 431 | + | func pickerStatusDetailFor(view pickerSessionView, stopped bool) string { | |
| 432 | + | if view.RemoteReason != "" { | |
| 433 | + | return view.RemoteReason | |
| 434 | + | } | |
| 435 | + | if stopped { | |
| 436 | + | return "stopped" | |
| 437 | + | } | |
| 438 | + | return "" | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func pickerSelectionMatches(line pickerLine, current pickerSourceFilter, intent protocol.PickerIntent) bool { | |
| 442 | + | target := line.target | |
| 443 | + | if target.Session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && target.Incarnation != current.Incarnation) { | |
| 444 | + | return false | |
| 445 | + | } | |
| 446 | + | if target.RemoteKey != nil && line.line.Kind == protocol.PickerLineSession { | |
| 447 | + | return line.line.Actions != 0 && (current.RemoteKey == nil || *target.RemoteKey == *current.RemoteKey) | |
| 448 | + | } | |
| 449 | + | if intent == protocol.PickerIntentMoveTab { | |
| 450 | + | return line.line.Kind == protocol.PickerLineSession | |
| 451 | + | } | |
| 452 | + | // A stopped session with no retained tab metadata uses its selectable | |
| 453 | + | // header as the default restart target and matches on exact session | |
| 454 | + | // identity. | |
| 455 | + | if current.TabID == "" && target.Stopped { | |
| 456 | + | return line.line.Kind == protocol.PickerLineSession && line.line.Actions != 0 | |
| 457 | + | } | |
| 458 | + | if line.line.Kind != protocol.PickerLineTab { | |
| 459 | + | return false | |
| 460 | + | } | |
| 461 | + | return target.TabID == current.TabID | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func pickerSourceMatchesSession(source pickerSourceFilter, view pickerSessionView) bool { | |
| 465 | + | if source.Session != view.ID { | |
| 466 | + | return false | |
| 467 | + | } | |
| 468 | + | return source.Incarnation == (domain.IncarnationID{}) || source.Incarnation == view.Incarnation | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | func pickerSourceMatchesTab(source pickerSourceFilter, view pickerSessionView, tab pickerTabEntry) bool { | |
| 472 | + | return pickerSourceMatchesSession(source, view) && source.TabID == tab.TabID | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) { | |
| 476 | + | if tabCount > math.MaxUint16 || index < 0 || index >= tabCount { | |
| 477 | + | return domain.TabSelector{}, false | |
| 478 | + | } | |
| 479 | + | return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | func int64Value(value *int64) (int64, bool) { | |
| 483 | + | if value == nil { | |
| 484 | + | return 0, false | |
| 485 | + | } | |
| 486 | + | return *value, true | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | func optionalInt64(value int64, present bool) *int64 { | |
| 490 | + | if !present { | |
| 491 | + | return nil | |
| 492 | + | } | |
| 493 | + | copyValue := value | |
| 494 | + | return ©Value | |
| 495 | + | } | |
A internal/usecase/daemon/picker_preview.go +210 -0
| @@ -0,0 +1,210 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 5 | + | "github.com/bnema/vev/internal/protocol" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // This file owns the serving-daemon side of the picker preview: the client | |
| 10 | + | // names the row it displays, the daemon captures that row's viewport and keeps | |
| 11 | + | // publishing it while the row's content changes. Capture reuses the navigation | |
| 12 | + | // preview facts (previewTarget for local sessions, the remote preview cache for | |
| 13 | + | // remote rows), so the client never asks a second time for a refresh it did not | |
| 14 | + | // cause. | |
| 15 | + | ||
| 16 | + | // handlePickerPreviewForAttachment answers one client preview request. The | |
| 17 | + | // request supersedes the viewer's previous preview: a newer generation replaces | |
| 18 | + | // the render subscription, so only the displayed row keeps publishing. | |
| 19 | + | func (d *Daemon) handlePickerPreviewForAttachment(effect *attachmentEffect, request protocol.PickerPreviewRequest) { | |
| 20 | + | if effect == nil || effect.ac == nil || effect.ac.overlays == nil { | |
| 21 | + | return | |
| 22 | + | } | |
| 23 | + | if protocol.ValidatePickerPreviewRequest(request) != nil { | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | ac := effect.ac | |
| 27 | + | open, interaction, intent, _, _ := ac.pickerState() | |
| 28 | + | if !open || interaction != request.InteractionID || request.SourceID != servingPickerSourceID { | |
| 29 | + | return | |
| 30 | + | } | |
| 31 | + | ac.overlays.pickerMu.Lock() | |
| 32 | + | target, known := ac.overlays.pickerKeys[request.Key] | |
| 33 | + | previousSession, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration | |
| 34 | + | ac.overlays.pickerPreviewGeneration++ | |
| 35 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 36 | + | ac.overlays.pickerPreviewKey = request.Key | |
| 37 | + | ac.overlays.pickerPreviewSession = nil | |
| 38 | + | ac.overlays.pickerMu.Unlock() | |
| 39 | + | // The superseded row stops observing before the new subscription is | |
| 40 | + | // installed, so moving between rows cannot leave a live observer on the | |
| 41 | + | // previous target's coordinator. | |
| 42 | + | d.teardownPickerPreviewSubscription(ac, previousSession, previousGeneration) | |
| 43 | + | ||
| 44 | + | coordinator, targetSession := d.pickerPreviewWakeCoordinator(effect, target, intent) | |
| 45 | + | if coordinator != nil && coordinator.subscribePreviewFor(ac, generation, func(renderWake) { | |
| 46 | + | d.publishPickerPreviewForAttachment(ac, request, generation, target, intent, known) | |
| 47 | + | }) { | |
| 48 | + | // subscribePreviewFor is deliberately outside pickerMu. Revalidate after | |
| 49 | + | // it returns so a concurrent close or newer request tears the | |
| 50 | + | // subscription it just installed back down before it can outlive its row. | |
| 51 | + | ac.overlays.pickerMu.Lock() | |
| 52 | + | current := ac.overlays.pickerOpen && ac.overlays.pickerInteraction == request.InteractionID && | |
| 53 | + | ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewKey == request.Key | |
| 54 | + | if current { | |
| 55 | + | ac.overlays.pickerPreviewSession = targetSession | |
| 56 | + | } | |
| 57 | + | ac.overlays.pickerMu.Unlock() | |
| 58 | + | if !current { | |
| 59 | + | coordinator.teardownPreviewFor(ac, generation) | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | d.publishPickerPreviewForAttachment(ac, request, generation, target, intent, known) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // pickerPreviewWakeCoordinator resolves the render coordinator whose wakes | |
| 67 | + | // refresh the displayed row. A local row is rendered by its target session, so | |
| 68 | + | // the subscription lives there: a headless target still gets its own | |
| 69 | + | // coordinator, which keeps the previewed tab renderable and turns target | |
| 70 | + | // output into a fresh capture. A remote row has no local renderer, so the | |
| 71 | + | // viewer's own session remains the only wake that can re-capture its cached | |
| 72 | + | // viewport. | |
| 73 | + | func (d *Daemon) pickerPreviewWakeCoordinator(effect *attachmentEffect, target picker.Target, intent protocol.PickerIntent) (*renderCoordinator, *session) { | |
| 74 | + | if target.RemoteTarget == nil { | |
| 75 | + | if targetSession, _ := d.previewTarget(target, intent); targetSession != nil { | |
| 76 | + | return d.ensureRenderCoordinator(targetSession), targetSession | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | if effect == nil { | |
| 80 | + | return nil, nil | |
| 81 | + | } | |
| 82 | + | return attachmentRenderCoordinator(effect.sess), effect.sess | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // teardownPickerPreviewSubscription removes one generation from the exact | |
| 86 | + | // coordinator that owns it. The recorded session pins the subscription to its | |
| 87 | + | // target coordinator, so a superseded or closed row cannot leave a live | |
| 88 | + | // observer, or a renderable headless target, behind. | |
| 89 | + | func (d *Daemon) teardownPickerPreviewSubscription(ac *attachedClient, targetSession *session, generation uint64) { | |
| 90 | + | if ac == nil || generation == 0 { | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | if coordinator := attachmentRenderCoordinator(targetSession); coordinator != nil { | |
| 94 | + | coordinator.teardownPreviewFor(ac, generation) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // publishPickerPreviewForAttachment captures the requested row and sends one | |
| 99 | + | // preview when the request is still the displayed one. A superseding request, a | |
| 100 | + | // closed interaction, or an attachment that moved on all publish nothing. | |
| 101 | + | func (d *Daemon) publishPickerPreviewForAttachment(ac *attachedClient, request protocol.PickerPreviewRequest, generation uint64, target picker.Target, intent protocol.PickerIntent, known bool) { | |
| 102 | + | if ac == nil || ac.overlays == nil { | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | if !pickerPreviewRequestCurrent(ac, request, generation) { | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | sess := ac.currentAttachmentSession() | |
| 109 | + | if sess == nil { | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | _, effect, admitted := ac.beginCurrentAttachmentEffect(sess, ac.transportSnapshot().transport) | |
| 113 | + | if !admitted { | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | defer effect.End() | |
| 117 | + | ||
| 118 | + | preview := protocol.PickerPreview{ | |
| 119 | + | Version: protocol.PickerPreviewSchemaVersion, InteractionID: request.InteractionID, | |
| 120 | + | SourceID: request.SourceID, Key: request.Key, | |
| 121 | + | } | |
| 122 | + | if !known { | |
| 123 | + | preview.Status = protocol.PickerPreviewNoSuchTarget | |
| 124 | + | } else { | |
| 125 | + | viewport := d.capturePickerPreview(target, intent, request.Width, request.Height) | |
| 126 | + | preview.Status, preview.Width, preview.Height, preview.Cells = viewport.Status, viewport.Width, viewport.Height, viewport.Cells | |
| 127 | + | } | |
| 128 | + | if protocol.ValidatePickerPreview(preview) != nil { | |
| 129 | + | return | |
| 130 | + | } | |
| 131 | + | _ = effect.sendControl(preview) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // pickerPreviewRequestCurrent reports whether request is still the displayed | |
| 135 | + | // preview generation. Callers must not hold pickerMu. | |
| 136 | + | func pickerPreviewRequestCurrent(ac *attachedClient, request protocol.PickerPreviewRequest, generation uint64) bool { | |
| 137 | + | if ac == nil || ac.overlays == nil { | |
| 138 | + | return false | |
| 139 | + | } | |
| 140 | + | ac.overlays.pickerMu.Lock() | |
| 141 | + | defer ac.overlays.pickerMu.Unlock() | |
| 142 | + | return ac.overlays.pickerOpen && ac.overlays.pickerInteraction == request.InteractionID && | |
| 143 | + | ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewKey == request.Key | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // capturePickerPreview resolves one picker row to its displayed viewport. A row | |
| 147 | + | // whose session or tab moved on, or whose remote fetch failed, reports | |
| 148 | + | // unavailable instead of a stale frame. | |
| 149 | + | func (d *Daemon) capturePickerPreview(target picker.Target, intent protocol.PickerIntent, width, height uint16) protocol.PickerPreview { | |
| 150 | + | unavailable := protocol.PickerPreview{ | |
| 151 | + | Version: protocol.PickerPreviewSchemaVersion, Status: protocol.PickerPreviewUnavailable, | |
| 152 | + | } | |
| 153 | + | if target.RemoteTarget != nil { | |
| 154 | + | remote, err := d.awaitRemotePreviewRefresh(d.remotePreviewContext(), *target.RemoteTarget, width, height) | |
| 155 | + | if err != nil || remote.Status != protocol.RemotePreviewOK { | |
| 156 | + | return unavailable | |
| 157 | + | } | |
| 158 | + | if remote.Width == 0 || remote.Height == 0 || len(remote.Cells) == 0 { | |
| 159 | + | return unavailable | |
| 160 | + | } | |
| 161 | + | return protocol.PickerPreview{ | |
| 162 | + | Version: protocol.PickerPreviewSchemaVersion, Status: protocol.PickerPreviewOK, | |
| 163 | + | Width: remote.Width, Height: remote.Height, Cells: remote.Cells, | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | sess, tb := d.previewTarget(target, intent) | |
| 167 | + | if sess == nil || tb == nil { | |
| 168 | + | return unavailable | |
| 169 | + | } | |
| 170 | + | return clampPickerPreview(snapshotPickerPreview(tb), width, height) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // clampPickerPreview fits a captured viewport into the requested bounds. The | |
| 174 | + | // wire rejects a cell run whose continuation columns disagree with the row, so a | |
| 175 | + | // double-width cell cut at the right edge becomes a blank cell instead. | |
| 176 | + | func clampPickerPreview(preview picker.Preview, width, height uint16) protocol.PickerPreview { | |
| 177 | + | unavailable := protocol.PickerPreview{ | |
| 178 | + | Version: protocol.PickerPreviewSchemaVersion, Status: protocol.PickerPreviewUnavailable, | |
| 179 | + | } | |
| 180 | + | if preview.Width <= 0 || preview.Height <= 0 || len(preview.Rows) == 0 || width == 0 || height == 0 { | |
| 181 | + | return unavailable | |
| 182 | + | } | |
| 183 | + | columns := int(width) | |
| 184 | + | rows := preview.Rows | |
| 185 | + | if len(rows) > int(height) { | |
| 186 | + | rows = rows[:int(height)] | |
| 187 | + | } | |
| 188 | + | cells := make([]renderer.Cell, 0, len(rows)*columns) | |
| 189 | + | for _, row := range rows { | |
| 190 | + | if len(row) > columns { | |
| 191 | + | row = row[:columns] | |
| 192 | + | // A cropped double-width cell loses its continuation column. | |
| 193 | + | if len(row) > 0 && renderer.RuneWidth(row[len(row)-1].Rune) == 2 { | |
| 194 | + | row = append(append([]renderer.Cell(nil), row[:len(row)-1]...), renderer.Cell{}) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | cells = append(cells, row...) | |
| 198 | + | for len(cells)%columns != 0 { | |
| 199 | + | cells = append(cells, renderer.Cell{}) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | fitted := protocol.PickerPreview{ | |
| 203 | + | Version: protocol.PickerPreviewSchemaVersion, Status: protocol.PickerPreviewOK, | |
| 204 | + | Width: width, Height: uint16(len(rows)), Cells: cells, | |
| 205 | + | } | |
| 206 | + | if protocol.ValidatePickerPreviewViewport(fitted.Width, fitted.Height, fitted.Cells) != nil { | |
| 207 | + | return unavailable | |
| 208 | + | } | |
| 209 | + | return fitted | |
| 210 | + | } | |
A internal/usecase/daemon/picker_preview_test.go +327 -0
| @@ -0,0 +1,327 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 14 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func awaitPickerPreview(t *testing.T, sends chan wire.Frame) protocol.PickerPreview { | |
| 19 | + | t.Helper() | |
| 20 | + | for { | |
| 21 | + | frame := awaitTestValue(t, sends, "picker preview was not published") | |
| 22 | + | if frame.Type != wire.MsgPickerPreview { | |
| 23 | + | continue | |
| 24 | + | } | |
| 25 | + | preview, err := wire.UnmarshalPickerPreview(frame.Payload) | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | return preview | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func pickerPreviewRequestFor(snapshot protocol.PickerSnapshot, key string, width, height uint16) protocol.PickerPreviewRequest { | |
| 32 | + | return protocol.PickerPreviewRequest{ | |
| 33 | + | Version: protocol.PickerPreviewSchemaVersion, InteractionID: snapshot.InteractionID, | |
| 34 | + | SourceID: servingPickerSourceID, Key: key, Width: width, Height: height, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestPickerPreviewRequestPublishesCapturedViewport(t *testing.T) { | |
| 39 | + | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 40 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 41 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 42 | + | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |
| 43 | + | ||
| 44 | + | capability := pickerClientCapability(t, ac, sess) | |
| 45 | + | request := pickerPreviewRequestFor(snapshot, key, 20, 5) | |
| 46 | + | require.NoError(t, protocol.ValidatePickerPreviewRequest(request)) | |
| 47 | + | d.handleAttachmentClientMessage(capability, request) | |
| 48 | + | ||
| 49 | + | preview := awaitPickerPreview(t, sends) | |
| 50 | + | require.Equal(t, snapshot.InteractionID, preview.InteractionID) | |
| 51 | + | require.Equal(t, servingPickerSourceID, preview.SourceID) | |
| 52 | + | require.Equal(t, key, preview.Key) | |
| 53 | + | require.Equal(t, protocol.PickerPreviewOK, preview.Status, "the fixture session must capture a viewport") | |
| 54 | + | require.Equal(t, uint16(20), preview.Width) | |
| 55 | + | require.LessOrEqual(t, preview.Height, uint16(5)) | |
| 56 | + | require.Len(t, preview.Cells, int(preview.Width)*int(preview.Height)) | |
| 57 | + | require.NotNil(t, preview.FrameRows()) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestPickerPreviewUnknownKeyReportsNoSuchTarget(t *testing.T) { | |
| 61 | + | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 62 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 63 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 64 | + | ||
| 65 | + | capability := pickerClientCapability(t, ac, sess) | |
| 66 | + | request := pickerPreviewRequestFor(snapshot, "zz/retired", 20, 5) | |
| 67 | + | d.handleAttachmentClientMessage(capability, request) | |
| 68 | + | ||
| 69 | + | preview := awaitPickerPreview(t, sends) | |
| 70 | + | require.Equal(t, protocol.PickerPreviewNoSuchTarget, preview.Status) | |
| 71 | + | require.Zero(t, preview.Width) | |
| 72 | + | require.Empty(t, preview.Cells) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestPickerPreviewStaleRequestPublishesNothing(t *testing.T) { | |
| 76 | + | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 77 | + | offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 78 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 79 | + | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |
| 80 | + | capability := pickerClientCapability(t, ac, sess) | |
| 81 | + | ||
| 82 | + | for range 3 { | |
| 83 | + | request := pickerPreviewRequestFor(snapshot, key, 20, 5) | |
| 84 | + | request.InteractionID = offer.InteractionID + 1 | |
| 85 | + | d.handleAttachmentClientMessage(capability, request) | |
| 86 | + | } | |
| 87 | + | for _, frame := range drainAllFrames(sends) { | |
| 88 | + | require.NotEqual(t, wire.MsgPickerPreview, frame.Type, "a request for another interaction must publish nothing") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // A superseded generation is dropped even though its request was valid. | |
| 92 | + | live := pickerPreviewRequestFor(snapshot, key, 20, 5) | |
| 93 | + | d.handleAttachmentClientMessage(capability, live) | |
| 94 | + | first := awaitPickerPreview(t, sends) | |
| 95 | + | ac.overlays.pickerMu.Lock() | |
| 96 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 97 | + | target, known := ac.overlays.pickerKeys[live.Key] | |
| 98 | + | ac.overlays.pickerMu.Unlock() | |
| 99 | + | require.True(t, known) | |
| 100 | + | d.publishPickerPreviewForAttachment(ac, live, generation-1, target, protocol.PickerIntentNavigation, known) | |
| 101 | + | for _, frame := range drainAllFrames(sends) { | |
| 102 | + | require.NotEqual(t, wire.MsgPickerPreview, frame.Type, "a superseded generation must not publish") | |
| 103 | + | } | |
| 104 | + | require.Equal(t, key, first.Key) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestPickerPreviewRetiredInteractionStopsPublishing(t *testing.T) { | |
| 108 | + | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 109 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 110 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 111 | + | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |
| 112 | + | capability := pickerClientCapability(t, ac, sess) | |
| 113 | + | d.handleAttachmentClientMessage(capability, pickerPreviewRequestFor(snapshot, key, 20, 5)) | |
| 114 | + | require.Equal(t, protocol.PickerPreviewOK, awaitPickerPreview(t, sends).Status) | |
| 115 | + | ||
| 116 | + | require.True(t, d.closePickerForAttachment(ac, admitPickerEffectForTest(t, sess, ac), snapshot.InteractionID)) | |
| 117 | + | _ = drainAllFrames(sends) | |
| 118 | + | ac.overlays.pickerMu.Lock() | |
| 119 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 120 | + | ac.overlays.pickerMu.Unlock() | |
| 121 | + | require.Zero(t, generation, "retiring the interaction must clear the preview generation") | |
| 122 | + | ||
| 123 | + | for _, frame := range drainAllFrames(sends) { | |
| 124 | + | require.NotEqual(t, wire.MsgPickerPreview, frame.Type) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestClampPickerPreviewFitsTheRequestedBounds(t *testing.T) { | |
| 129 | + | captured := picker.Preview{ | |
| 130 | + | Width: 4, Height: 2, | |
| 131 | + | Rows: [][]renderer.Cell{ | |
| 132 | + | {{Rune: 'a'}, {Rune: 'b'}, {Rune: 'c'}, {Rune: 'd'}}, | |
| 133 | + | {{Rune: 'e'}, {Rune: '界'}, {Continuation: true}, {Rune: 'h'}}, | |
| 134 | + | }, | |
| 135 | + | } | |
| 136 | + | fitted := clampPickerPreview(captured, 3, 1) | |
| 137 | + | require.Equal(t, protocol.PickerPreviewOK, fitted.Status) | |
| 138 | + | require.Equal(t, uint16(3), fitted.Width) | |
| 139 | + | require.Equal(t, uint16(1), fitted.Height) | |
| 140 | + | require.Len(t, fitted.Cells, 3) | |
| 141 | + | require.Equal(t, 'a', fitted.Cells[0].Rune) | |
| 142 | + | ||
| 143 | + | // A double-width cell whose continuation column is cropped out becomes | |
| 144 | + | // blank, so the run never ends on a wide cell. | |
| 145 | + | wide := clampPickerPreview(captured, 2, 2) | |
| 146 | + | require.Equal(t, protocol.PickerPreviewOK, wide.Status) | |
| 147 | + | require.Len(t, wide.Cells, 4) | |
| 148 | + | require.Equal(t, 'e', wide.Cells[2].Rune) | |
| 149 | + | require.Zero(t, wide.Cells[3].Rune) | |
| 150 | + | require.False(t, wide.Cells[3].Continuation) | |
| 151 | + | ||
| 152 | + | // Every row pads to the requested width, and the height stays the number of | |
| 153 | + | // captured rows (never more than the request). | |
| 154 | + | padded := clampPickerPreview(captured, 4, 3) | |
| 155 | + | require.Equal(t, protocol.PickerPreviewOK, padded.Status) | |
| 156 | + | require.Len(t, padded.Cells, 8) | |
| 157 | + | require.Equal(t, uint16(2), padded.Height) | |
| 158 | + | ||
| 159 | + | require.Equal(t, protocol.PickerPreviewUnavailable, clampPickerPreview(picker.Preview{}, 4, 2).Status) | |
| 160 | + | require.Equal(t, protocol.PickerPreviewUnavailable, clampPickerPreview(captured, 0, 2).Status) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // TestPickerPreviewCapturesRemoteRowsThroughThePreviewClient pins the daemon | |
| 164 | + | // half of the coexistence contract: a remote picker row is still previewed | |
| 165 | + | // through the daemon's own remote viewport client and its cache, independent of | |
| 166 | + | // whatever the launching client resolves for itself. | |
| 167 | + | func TestPickerPreviewCapturesRemoteRowsThroughThePreviewClient(t *testing.T) { | |
| 168 | + | clock := &remotePreviewTestClock{now: time.Unix(100, 0)} | |
| 169 | + | client := &remotePreviewTestClient{result: remotePreviewCacheResult(remotePreviewCacheTarget(), 1)} | |
| 170 | + | d := newTestDaemon(t, nil, clock) | |
| 171 | + | d.remotePreviewClient = client | |
| 172 | + | target := remotePreviewCacheTarget() | |
| 173 | + | ||
| 174 | + | viewport := d.capturePickerPreview(picker.Target{RemoteTarget: &target}, protocol.PickerIntentNavigation, 1, 1) | |
| 175 | + | require.Equal(t, protocol.PickerPreviewOK, viewport.Status) | |
| 176 | + | require.Equal(t, uint16(1), viewport.Width) | |
| 177 | + | require.Equal(t, 'x', viewport.Cells[0].Rune) | |
| 178 | + | require.Equal(t, target, client.lastTarget) | |
| 179 | + | require.Equal(t, uint16(1), client.lastWidth) | |
| 180 | + | ||
| 181 | + | // A failed remote fetch reports unavailable instead of a stale viewport. | |
| 182 | + | client.err = errors.New("remote host unreachable") | |
| 183 | + | client.calls = 0 | |
| 184 | + | d.remotePreview.cache = nil | |
| 185 | + | failed := d.capturePickerPreview(picker.Target{RemoteTarget: &target}, protocol.PickerIntentNavigation, 1, 1) | |
| 186 | + | require.Equal(t, protocol.PickerPreviewUnavailable, failed.Status) | |
| 187 | + | require.Zero(t, failed.Width) | |
| 188 | + | require.Empty(t, failed.Cells) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // TestPickerOpenSurvivesOneHostRegisteredTwice pins the row-key contract: the | |
| 192 | + | // same machine can be registered under more than one target (a pinned alias and | |
| 193 | + | // a target learned by attach), and both endpoints legitimately expose the same | |
| 194 | + | // session lifecycle and name. Row keys must therefore stay unique per endpoint, | |
| 195 | + | // or the snapshot is rejected and the picker never opens at all. | |
| 196 | + | func TestPickerOpenSurvivesOneHostRegisteredTwice(t *testing.T) { | |
| 197 | + | d := newRemotePickerDaemon() | |
| 198 | + | lifecycle := remoteLifecycleForTest() | |
| 199 | + | session := catalogue.RemoteCatalogSession{ | |
| 200 | + | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, | |
| 201 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "1"}}, | |
| 202 | + | } | |
| 203 | + | seedRemoteDirectory(t, d, | |
| 204 | + | reachableDirectoryHost("remote", time.Unix(10, 0), session, secondRemoteSessionForTest()), | |
| 205 | + | reachableDirectoryHost("demo@remote", time.Unix(10, 0), session, secondRemoteSessionForTest()), | |
| 206 | + | ) | |
| 207 | + | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 208 | + | effect := admitPickerEffectForTest(t, sess, ac) | |
| 209 | + | ||
| 210 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 211 | + | offer := awaitPickerOffer(t, sends) | |
| 212 | + | require.Equal(t, protocol.PickerIntentNavigation, offer.Intent) | |
| 213 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 214 | + | ||
| 215 | + | seen := make(map[string]struct{}, len(snapshot.Lines)) | |
| 216 | + | workRows, sessionRows := 0, 0 | |
| 217 | + | for _, line := range snapshot.Lines { | |
| 218 | + | if line.Key == "" { | |
| 219 | + | continue | |
| 220 | + | } | |
| 221 | + | _, duplicate := seen[line.Key] | |
| 222 | + | require.False(t, duplicate, "row key %q was published twice", line.Key) | |
| 223 | + | seen[line.Key] = struct{}{} | |
| 224 | + | if line.Kind != protocol.PickerLineSession { | |
| 225 | + | continue | |
| 226 | + | } | |
| 227 | + | sessionRows++ | |
| 228 | + | if line.Label == "work" { | |
| 229 | + | workRows++ | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | require.NotEmpty(t, snapshot.Lines) | |
| 233 | + | require.Equal(t, 2, workRows, "both endpoints of the same host must publish their rows") | |
| 234 | + | require.Equal(t, 5, sessionRows, "the local session and every remote session must publish its own row") | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // secondRemoteSessionForTest is another live session on the same host: two live | |
| 238 | + | // sessions share an endpoint, so their rows must still be distinct. | |
| 239 | + | func secondRemoteSessionForTest() catalogue.RemoteCatalogSession { | |
| 240 | + | lifecycle := remoteLifecycleForTest() | |
| 241 | + | lifecycle[0]++ | |
| 242 | + | return catalogue.RemoteCatalogSession{ | |
| 243 | + | LifecycleID: lifecycle, Name: "ticker", State: catalogue.RemoteCatalogSessionUp, | |
| 244 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-2", Index: 0, Name: "1"}}, | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // pickerPreviewKeyForSession returns the opaque row key whose target resolves | |
| 249 | + | // to sess. The preview request names a key, so a cross-session test must read | |
| 250 | + | // the same mapping the serving source published. | |
| 251 | + | func pickerPreviewKeyForSession(t *testing.T, ac *attachedClient, sess *session) string { | |
| 252 | + | t.Helper() | |
| 253 | + | ac.overlays.pickerMu.Lock() | |
| 254 | + | defer ac.overlays.pickerMu.Unlock() | |
| 255 | + | for key, target := range ac.overlays.pickerKeys { | |
| 256 | + | if target.RemoteTarget == nil && target.Session == sess.id { | |
| 257 | + | return key | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | return "" | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // TestPickerPreviewCrossSessionFollowsTheTargetsRenderCoordinator pins the | |
| 264 | + | // cross-session contract: the subscription that refreshes the displayed row | |
| 265 | + | // lives on the target session's coordinator. A target render wakes the preview | |
| 266 | + | // and keeps the headless target renderable, while a viewer-session render must | |
| 267 | + | // not drive the target's preview at all. | |
| 268 | + | func TestPickerPreviewCrossSessionFollowsTheTargetsRenderCoordinator(t *testing.T) { | |
| 269 | + | viewerPTY, releaseViewer := newBlockingPTY(t) | |
| 270 | + | t.Cleanup(releaseViewer) | |
| 271 | + | d, viewerSess, ac, sends := newManualSessionWithPTYs(t, viewerPTY) | |
| 272 | + | d.ptys = newFactorySeq(t, newQuietPTY()) | |
| 273 | + | targetSess, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 274 | + | require.NoError(t, err) | |
| 275 | + | ||
| 276 | + | effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(viewerSess, ac, ac.transport())) | |
| 277 | + | require.True(t, ok) | |
| 278 | + | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 279 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 280 | + | key := pickerPreviewKeyForSession(t, ac, targetSess) | |
| 281 | + | require.NotEmpty(t, key, "the snapshot must offer a navigable row for the target session") | |
| 282 | + | ||
| 283 | + | clock := newCoordinatorMockClock(t, 16) | |
| 284 | + | d.clock = clock.clock | |
| 285 | + | viewerCoordinator := attachmentRenderCoordinator(viewerSess) | |
| 286 | + | require.Nil(t, attachmentRenderCoordinator(targetSess), "an un-previewed target owns no coordinator") | |
| 287 | + | ||
| 288 | + | d.handleAttachmentClientMessage(pickerClientCapability(t, ac, viewerSess), pickerPreviewRequestFor(snapshot, key, 20, 5)) | |
| 289 | + | first := awaitPickerPreview(t, sends) | |
| 290 | + | require.Equal(t, protocol.PickerPreviewOK, first.Status) | |
| 291 | + | ||
| 292 | + | targetCoordinator := attachmentRenderCoordinator(targetSess) | |
| 293 | + | require.NotNil(t, targetCoordinator, "the preview must subscribe on the target session's coordinator") | |
| 294 | + | require.True(t, targetCoordinator.hasPreviewSubscribers()) | |
| 295 | + | if viewerCoordinator != nil { | |
| 296 | + | require.False(t, viewerCoordinator.hasPreviewSubscribers(), "the viewer session must not own the target's subscription") | |
| 297 | + | } | |
| 298 | + | targetTab := targetSess.tabs[0] | |
| 299 | + | require.True(t, d.paneRenderable(targetSess, targetTab, targetTab.focusedPane()), | |
| 300 | + | "a previewed headless target must stay renderable") | |
| 301 | + | ||
| 302 | + | // A target render wakes the target's subscription and republishes. | |
| 303 | + | d.processPTYData(targetSess, targetTab, targetTab.focusedPane(), []byte("target repaint"), false) | |
| 304 | + | fireCoordinatorTimer(t, targetCoordinator, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 305 | + | refreshed := awaitPickerPreview(t, sends) | |
| 306 | + | require.Equal(t, key, refreshed.Key) | |
| 307 | + | require.Equal(t, protocol.PickerPreviewOK, refreshed.Status) | |
| 308 | + | ||
| 309 | + | // A viewer-session render must not publish the target's preview. The direct | |
| 310 | + | // invalidateRender path is urgent, so it arms the urgent deadline. | |
| 311 | + | viewerCoordinator = d.attachCoordinator(viewerSess, nil, ac, true) | |
| 312 | + | d.invalidateRender(viewerSess, ac, true, "picker_preview_test.go:viewer") | |
| 313 | + | fireCoordinatorTimer(t, viewerCoordinator, drainCoordinatorTimers(clock), urgentRenderDeadline) | |
| 314 | + | for _, frame := range drainAllFrames(sends) { | |
| 315 | + | require.NotEqual(t, wire.MsgPickerPreview, frame.Type, | |
| 316 | + | "a viewer-session render must not publish the target's preview") | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // Retiring the interaction removes the subscription from the target's own | |
| 320 | + | // coordinator, so the headless target stops being renderable. A nil effect | |
| 321 | + | // retires the interaction without a close confirmation. | |
| 322 | + | require.True(t, d.closePickerForAttachment(ac, nil, snapshot.InteractionID)) | |
| 323 | + | require.False(t, targetCoordinator.hasPreviewSubscribers(), | |
| 324 | + | "teardown must remove the exact target subscription") | |
| 325 | + | require.False(t, d.paneRenderable(targetSess, targetTab, targetTab.focusedPane()), | |
| 326 | + | "a retired preview must stop keeping the target renderable") | |
| 327 | + | } | |
D internal/usecase/daemon/picker_search_input.go +0 -130
| @@ -1,130 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "unicode" | |
| 5 | - | "unicode/utf8" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/vev/internal/ports" | |
| 8 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // handlePickerSearchInputLocked decodes picker search input while pickerMu is | |
| 12 | - | // held. Printable bytes belong to the query; navigation is intentionally | |
| 13 | - | // limited to arrows and Ctrl-N/Ctrl-P so names containing j/k/q/x/s remain | |
| 14 | - | // searchable. Incomplete UTF-8 and escape sequences are retained across input | |
| 15 | - | // chunks in the attachment-owned picker pending buffer. | |
| 16 | - | func (d *Daemon) handlePickerSearchInputLocked(ac *attachedClient, data []byte) listInputResult { | |
| 17 | - | rt := ac.overlays | |
| 18 | - | if len(rt.pickerPending) > 0 { | |
| 19 | - | rt.pickerESC.stop() | |
| 20 | - | combined := make([]byte, 0, len(rt.pickerPending)+len(data)) | |
| 21 | - | combined = append(combined, rt.pickerPending...) | |
| 22 | - | combined = append(combined, data...) | |
| 23 | - | data = combined | |
| 24 | - | rt.pickerPending = nil | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | var result listInputResult | |
| 28 | - | for offset := 0; offset < len(data); { | |
| 29 | - | switch data[offset] { | |
| 30 | - | case keys.ESC: | |
| 31 | - | tail := data[offset:] | |
| 32 | - | if len(tail) > 1 && tail[1] == keys.ESC { | |
| 33 | - | d.applyPickerSearchEscapeLocked(ac) | |
| 34 | - | result.changed = true | |
| 35 | - | if !rt.picker.SearchActive() { | |
| 36 | - | result.exit = true | |
| 37 | - | return result | |
| 38 | - | } | |
| 39 | - | offset++ | |
| 40 | - | continue | |
| 41 | - | } | |
| 42 | - | if consumed, move := routeListEscape(tail); consumed > 0 { | |
| 43 | - | switch move { | |
| 44 | - | case 'A': | |
| 45 | - | rt.picker.Up() | |
| 46 | - | result.changed = true | |
| 47 | - | case 'B': | |
| 48 | - | rt.picker.Down() | |
| 49 | - | result.changed = true | |
| 50 | - | } | |
| 51 | - | offset += consumed | |
| 52 | - | continue | |
| 53 | - | } | |
| 54 | - | if len(tail) == 1 { | |
| 55 | - | d.retainPickerSearchESCLocked(ac) | |
| 56 | - | return result | |
| 57 | - | } | |
| 58 | - | if isListEscapePrefix(tail) { | |
| 59 | - | rt.pickerPending = append(rt.pickerPending[:0], tail...) | |
| 60 | - | return result | |
| 61 | - | } | |
| 62 | - | offset++ | |
| 63 | - | case 0x03: // Ctrl-C | |
| 64 | - | result.exit = true | |
| 65 | - | return result | |
| 66 | - | case 0x0e: // Ctrl-N | |
| 67 | - | rt.picker.Down() | |
| 68 | - | result.changed = true | |
| 69 | - | offset++ | |
| 70 | - | case 0x10: // Ctrl-P | |
| 71 | - | rt.picker.Up() | |
| 72 | - | result.changed = true | |
| 73 | - | offset++ | |
| 74 | - | case '\r', '\n': | |
| 75 | - | result.action = data[offset] | |
| 76 | - | result.exit = true | |
| 77 | - | return result | |
| 78 | - | case 0x08, 0x7f: | |
| 79 | - | before := rt.picker.Query() | |
| 80 | - | rt.picker.BackspaceSearch() | |
| 81 | - | result.changed = result.changed || before != rt.picker.Query() | |
| 82 | - | offset++ | |
| 83 | - | default: | |
| 84 | - | if !utf8.FullRune(data[offset:]) { | |
| 85 | - | rt.pickerPending = append(rt.pickerPending[:0], data[offset:]...) | |
| 86 | - | return result | |
| 87 | - | } | |
| 88 | - | r, width := utf8.DecodeRune(data[offset:]) | |
| 89 | - | if r == utf8.RuneError && width == 1 { | |
| 90 | - | offset++ | |
| 91 | - | continue | |
| 92 | - | } | |
| 93 | - | if unicode.IsPrint(r) { | |
| 94 | - | rt.picker.InsertSearch(r) | |
| 95 | - | result.changed = true | |
| 96 | - | } | |
| 97 | - | offset += width | |
| 98 | - | } | |
| 99 | - | } | |
| 100 | - | return result | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | func (d *Daemon) applyPickerSearchEscapeLocked(ac *attachedClient) { | |
| 104 | - | if ac.overlays.picker.Query() != "" { | |
| 105 | - | ac.overlays.picker.ClearSearch() | |
| 106 | - | } else { | |
| 107 | - | ac.overlays.picker.ExitSearch() | |
| 108 | - | } | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func (d *Daemon) retainPickerSearchESCLocked(ac *attachedClient) { | |
| 112 | - | rt := ac.overlays | |
| 113 | - | rt.pickerPending = append(rt.pickerPending[:0], keys.ESC) | |
| 114 | - | rt.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 115 | - | rt.pickerMu.Lock() | |
| 116 | - | if rt.pickerESC.timer != timer || len(rt.pickerPending) != 1 || rt.pickerPending[0] != keys.ESC || rt.picker == nil || !rt.picker.SearchActive() { | |
| 117 | - | rt.pickerMu.Unlock() | |
| 118 | - | return | |
| 119 | - | } | |
| 120 | - | rt.pickerPending = nil | |
| 121 | - | rt.pickerESC.timer = nil | |
| 122 | - | rt.pickerESC.done = nil | |
| 123 | - | d.applyPickerSearchEscapeLocked(ac) | |
| 124 | - | rt.pickerMu.Unlock() | |
| 125 | - | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 126 | - | d.registerPreviewForSelection(ac) | |
| 127 | - | d.invalidateRender(sess, ac, true, "picker search escape") | |
| 128 | - | } | |
| 129 | - | }) | |
| 130 | - | } | |
D internal/usecase/daemon/picker_test.go +0 -1883 collapsed to keep the page light
M internal/usecase/daemon/recent_route_snapshot.go +0 -37
| @@ -38,40 +38,3 @@ func recentRoutePresentationsFromSnapshot(snapshot protocol.RecentRouteSnapshot) | |||
| 38 | 38 | func formatRecentRouteSnapshot(snapshot protocol.RecentRouteSnapshot) []recentRouteDisplay { | |
| 39 | 39 | return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot)) | |
| 40 | 40 | } | |
| 41 | - | ||
| 42 | - | // formatRecentRouteSnapshotForAttachment resolves attention against this | |
| 43 | - | // daemon's live sessions. The client retains route ordering and presentation; | |
| 44 | - | // it subscribes only the exact routes this daemon serves. | |
| 45 | - | func (d *Daemon) formatRecentRouteSnapshotForAttachment(ac *attachedClient, snapshot protocol.RecentRouteSnapshot) []recentRouteDisplay { | |
| 46 | - | presentations := recentRoutePresentationsFromSnapshot(snapshot) | |
| 47 | - | if d == nil || ac == nil { | |
| 48 | - | return formatRecentRoutePresentations(presentations) | |
| 49 | - | } | |
| 50 | - | for i, entry := range snapshot.Entries { | |
| 51 | - | target, ok := ac.routeAttentionTarget(protocol.RouteRef{Key: entry.Key, Generation: entry.Generation}) | |
| 52 | - | if ok { | |
| 53 | - | presentations[i].attention = d.routeHasAttention(target) | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | return formatRecentRoutePresentations(presentations) | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | func (d *Daemon) routeHasAttention(target protocol.ExactSessionTarget) bool { | |
| 60 | - | d.mu.Lock() | |
| 61 | - | sess := d.findByNameLocked(target.SessionName) | |
| 62 | - | d.mu.Unlock() | |
| 63 | - | if sess == nil { | |
| 64 | - | return false | |
| 65 | - | } | |
| 66 | - | sess.mu.Lock() | |
| 67 | - | defer sess.mu.Unlock() | |
| 68 | - | if sess.incarnation != target.LifecycleID { | |
| 69 | - | return false | |
| 70 | - | } | |
| 71 | - | for _, tb := range sess.tabs { | |
| 72 | - | if tb.attention { | |
| 73 | - | return true | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | return false | |
| 77 | - | } | |
M internal/usecase/daemon/recent_route_snapshot_test.go +14 -36
| @@ -3,7 +3,6 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/internal/domain" | |
| 7 | 6 | "github.com/bnema/vev/internal/protocol" | |
| 8 | 7 | "github.com/bnema/vev/internal/protocol/wire" | |
| 9 | 8 | "github.com/stretchr/testify/require" | |
| @@ -26,41 +25,20 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) { | |||
| 26 | 25 | require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name}) | |
| 27 | 26 | } | |
| 28 | 27 | ||
| 29 | - | func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | |
| 30 | - | for _, tt := range []struct { | |
| 31 | - | name string | |
| 32 | - | targetID domain.SessionLifecycleID | |
| 33 | - | attention bool | |
| 34 | - | }{ | |
| 35 | - | {name: "matching lifecycle", targetID: domain.SessionLifecycleID{9}, attention: true}, | |
| 36 | - | {name: "stale lifecycle", targetID: domain.SessionLifecycleID{8}, attention: false}, | |
| 37 | - | } { | |
| 38 | - | t.Run(tt.name, func(t *testing.T) { | |
| 39 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 40 | - | sess.mu.Lock() | |
| 41 | - | sess.incarnation = domain.SessionLifecycleID{9} | |
| 42 | - | sess.tabs[0].attention = true | |
| 43 | - | sess.mu.Unlock() | |
| 44 | - | ref := protocol.RouteRef{Key: 2, Generation: 1} | |
| 45 | - | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{ | |
| 46 | - | Generation: 2, | |
| 47 | - | Active: protocol.RouteRef{Key: 3, Generation: 2}, | |
| 48 | - | Entries: []protocol.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Target: protocol.ExactSessionTarget{LifecycleID: tt.targetID, SessionName: sess.name}, Name: sess.name, Kind: protocol.RouteKindLocal}}, | |
| 49 | - | }) | |
| 50 | - | ac.setRouteAttentionSubscription(protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{{ | |
| 51 | - | Ref: ref, | |
| 52 | - | Target: protocol.ExactSessionTarget{ | |
| 53 | - | LifecycleID: tt.targetID, | |
| 54 | - | SessionName: sess.name, | |
| 55 | - | }, | |
| 56 | - | }}}, ac.transportSnapshot(), d.clock.Now()) | |
| 57 | - | ||
| 58 | - | state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{}) | |
| 59 | - | ||
| 60 | - | require.Len(t, state.mru, 1) | |
| 61 | - | require.Equal(t, tt.attention, state.mru[0].attention) | |
| 62 | - | }) | |
| 63 | - | } | |
| 28 | + | func TestAttachmentStatusUsesClientPublishedAttention(t *testing.T) { | |
| 29 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 30 | + | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{ | |
| 31 | + | Generation: 2, | |
| 32 | + | Active: protocol.RouteRef{Key: 3, Generation: 2}, | |
| 33 | + | Entries: []protocol.RecentRouteEntry{{ | |
| 34 | + | Key: 2, Generation: 1, Target: testRouteTarget("local", 9), Name: "local", Kind: protocol.RouteKindLocal, Attention: true, | |
| 35 | + | }}, | |
| 36 | + | }) | |
| 37 | + | ||
| 38 | + | state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{}) | |
| 39 | + | ||
| 40 | + | require.Len(t, state.mru, 1) | |
| 41 | + | require.True(t, state.mru[0].attention) | |
| 64 | 42 | } | |
| 65 | 43 | ||
| 66 | 44 | func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) { | |
M internal/usecase/daemon/remote_attach.go +0 -23
| @@ -103,29 +103,6 @@ func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTa | |||
| 103 | 103 | return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames) | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | - | func (d *Daemon) sendNavigationActionForAttachment(effect *attachmentEffect, action protocol.NavigationAction) error { | |
| 107 | - | directive := protocol.NavigationDirective{Action: action} | |
| 108 | - | armed := false | |
| 109 | - | if action == protocol.NavigationOpenHomePicker { | |
| 110 | - | leaseID, err := d.armParkedRoute(effect) | |
| 111 | - | if err != nil { | |
| 112 | - | return err | |
| 113 | - | } | |
| 114 | - | directive.LeaseID = leaseID | |
| 115 | - | armed = true | |
| 116 | - | } | |
| 117 | - | rollback := func() { | |
| 118 | - | if armed { | |
| 119 | - | effect.ac.clearParkedRoute() | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | - | if err := effect.sendControl(directive); err != nil { | |
| 123 | - | rollback() | |
| 124 | - | return err | |
| 125 | - | } | |
| 126 | - | return nil | |
| 127 | - | } | |
| 128 | - | ||
| 129 | 106 | func (d *Daemon) sendRecentRouteNavigationActionForAttachment(effect *attachmentEffect, action protocol.RouteNavigationAction) error { | |
| 130 | 107 | if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 { | |
| 131 | 108 | return errAttachmentTransition | |
M internal/usecase/daemon/remote_attach_test.go +2 -2
| @@ -59,7 +59,7 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) { | |||
| 59 | 59 | ||
| 60 | 60 | require.NoError(t, d.switchToTargetForAttachment(effect, picker.Target{ | |
| 61 | 61 | Name: "stopped", Incarnation: lifecycle, | |
| 62 | - | }, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select")) | |
| 62 | + | }, sessionHandoffGuard{allowSamePeer: true}, "picker-select")) | |
| 63 | 63 | ||
| 64 | 64 | cleanup := receiveRemotePicker(t, sends, "graphics cleanup") | |
| 65 | 65 | require.Equal(t, wire.MsgOutput, cleanup.Type) | |
| @@ -87,7 +87,7 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | |||
| 87 | 87 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 88 | 88 | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 89 | 89 | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"} | |
| 90 | - | require.NoError(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 90 | + | require.NoError(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{}, "picker-select")) | |
| 91 | 91 | frame := receiveRemotePicker(t, sends, "rich attach target") | |
| 92 | 92 | got, err := wire.UnmarshalAttachTarget(frame.Payload) | |
| 93 | 93 | require.NoError(t, err) | |
M internal/usecase/daemon/remote_directory_view.go +4 -2
| @@ -76,13 +76,15 @@ func (d *Daemon) refreshRemoteDirectoryViewsFor(ac *attachedClient) { | |||
| 76 | 76 | if ac == nil || ac.overlays == nil { | |
| 77 | 77 | return | |
| 78 | 78 | } | |
| 79 | - | pickerOpen := ac.overlays.pickerActive() | |
| 79 | + | pickerOpen := ac.overlays.pickerClientActive() | |
| 80 | 80 | paletteOpen := ac.overlays.paletteActive() | |
| 81 | 81 | if !pickerOpen && !paletteOpen { | |
| 82 | 82 | return | |
| 83 | 83 | } | |
| 84 | + | // The picker and the palette both read the directory: republish the | |
| 85 | + | // interaction snapshot with a new revision when the row set changed. | |
| 84 | 86 | if pickerOpen { | |
| 85 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 87 | + | d.refreshPickerSnapshot(ac) | |
| 86 | 88 | } | |
| 87 | 89 | if paletteOpen { | |
| 88 | 90 | d.refreshPalette(ac) | |
M internal/usecase/daemon/remote_picker.go +27 -24
| @@ -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/catalogue" | |
| 12 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | 14 | // directoryInventoryStaleAge is the presentation freshness threshold: past | |
| @@ -64,12 +63,12 @@ func directoryInventoryStale(host ports.RemoteHostSnapshot, now time.Time) bool | |||
| 64 | 63 | return !now.Before(host.LastSuccess.Add(directoryInventoryStaleAge)) | |
| 65 | 64 | } | |
| 66 | 65 | ||
| 67 | - | func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession, host ports.RemoteHostSnapshot, now time.Time) picker.SessionView { | |
| 66 | + | func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession, host ports.RemoteHostSnapshot, now time.Time) pickerSessionView { | |
| 68 | 67 | key, target := remoteCatalogSessionTarget(key, session) | |
| 69 | 68 | stopped := remoteSessionStateStopped(session.State) | |
| 70 | 69 | broken := session.State == catalogue.RemoteCatalogSessionBroken | |
| 71 | 70 | tabs := catalogue.CatalogTabs(session) | |
| 72 | - | viewTabs := make([]picker.TabEntry, 0, len(tabs)) | |
| 71 | + | viewTabs := make([]pickerTabEntry, 0, len(tabs)) | |
| 73 | 72 | active := 0 | |
| 74 | 73 | for i, tab := range tabs { | |
| 75 | 74 | if session.ActiveTabID != "" && tab.ID == session.ActiveTabID { | |
| @@ -79,7 +78,7 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal | |||
| 79 | 78 | if name == "" { | |
| 80 | 79 | name = fmt.Sprintf("%d", int(tab.Index)+1) | |
| 81 | 80 | } | |
| 82 | - | viewTabs = append(viewTabs, picker.TabEntry{ | |
| 81 | + | viewTabs = append(viewTabs, pickerTabEntry{ | |
| 83 | 82 | TabID: domain.TabStableID(tab.ID), | |
| 84 | 83 | Name: name, | |
| 85 | 84 | RawName: tab.Name, | |
| @@ -94,21 +93,25 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal | |||
| 94 | 93 | remoteTarget := &target | |
| 95 | 94 | reason := directorySessionReason(host, session, target) | |
| 96 | 95 | targetValid := remoteTarget.Validate() == nil | |
| 97 | - | activation := picker.RemoteUnavailable | |
| 96 | + | activation := pickerRemoteUnavailable | |
| 98 | 97 | if !broken && targetValid && host.Availability != domain.RemoteAvailabilityIncompatible { | |
| 99 | 98 | // Known inventory is attemptable at any age: freshness is | |
| 100 | 99 | // presentation information, not attach authority. Exact session, | |
| 101 | 100 | // lifecycle and tab validation still applies at submit, and the | |
| 102 | 101 | // destination rejects precisely. | |
| 103 | 102 | if stopped { | |
| 104 | - | activation = picker.RemoteRestart | |
| 103 | + | activation = pickerRemoteRestart | |
| 105 | 104 | } else { | |
| 106 | - | activation = picker.RemoteAttach | |
| 105 | + | activation = pickerRemoteAttach | |
| 107 | 106 | } | |
| 108 | 107 | } | |
| 109 | 108 | ||
| 110 | - | return picker.SessionView{ | |
| 111 | - | ID: key.ID(), | |
| 109 | + | return pickerSessionView{ | |
| 110 | + | ID: key.ID(), | |
| 111 | + | // A remote row's incarnation is its session lifecycle. Leaving it zero | |
| 112 | + | // would make every live session on one host share one row identity, so | |
| 113 | + | // two of them would collide and the whole snapshot would be rejected. | |
| 114 | + | Incarnation: session.LifecycleID, | |
| 112 | 115 | Name: key.Display(), | |
| 113 | 116 | RemoteKey: &key, | |
| 114 | 117 | RemoteTarget: remoteTarget, | |
| @@ -153,26 +156,26 @@ func directorySessionReason(host ports.RemoteHostSnapshot, session catalogue.Rem | |||
| 153 | 156 | } | |
| 154 | 157 | } | |
| 155 | 158 | ||
| 156 | - | func directoryPickerAvailability(host ports.RemoteHostSnapshot, now time.Time) picker.RemoteAvailability { | |
| 159 | + | func directoryPickerAvailability(host ports.RemoteHostSnapshot, now time.Time) pickerRemoteAvailability { | |
| 157 | 160 | switch host.Availability { | |
| 158 | 161 | case domain.RemoteAvailabilityReachable: | |
| 159 | 162 | if directoryInventoryStale(host, now) { | |
| 160 | - | return picker.RemoteStale | |
| 163 | + | return pickerRemoteStale | |
| 161 | 164 | } | |
| 162 | - | return picker.RemoteFresh | |
| 165 | + | return pickerRemoteFresh | |
| 163 | 166 | case domain.RemoteAvailabilityIncompatible: | |
| 164 | - | return picker.RemoteVersionMismatch | |
| 167 | + | return pickerRemoteVersionMismatch | |
| 165 | 168 | case domain.RemoteAvailabilityUnknown: | |
| 166 | - | return picker.RemoteCached | |
| 169 | + | return pickerRemoteCached | |
| 167 | 170 | default: | |
| 168 | 171 | if host.Checking { | |
| 169 | - | return picker.RemoteCached | |
| 172 | + | return pickerRemoteCached | |
| 170 | 173 | } | |
| 171 | - | return picker.RemoteStale | |
| 174 | + | return pickerRemoteStale | |
| 172 | 175 | } | |
| 173 | 176 | } | |
| 174 | 177 | ||
| 175 | - | func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, reason string, activation picker.RemoteActivation, now time.Time) string { | |
| 178 | + | func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, reason string, activation pickerRemoteActivation, now time.Time) string { | |
| 176 | 179 | if host.Checking { | |
| 177 | 180 | return "checking remote…" | |
| 178 | 181 | } | |
| @@ -202,7 +205,7 @@ func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.Remo | |||
| 202 | 205 | return "session broken" | |
| 203 | 206 | case reason == domain.RemoteReasonIdentityChanged: | |
| 204 | 207 | return "identity changed" | |
| 205 | - | case activation == picker.RemoteRestart: | |
| 208 | + | case activation == pickerRemoteRestart: | |
| 206 | 209 | return "stopped — Enter to restart" | |
| 207 | 210 | case session.State == catalogue.RemoteCatalogSessionUp: | |
| 208 | 211 | return "up" | |
| @@ -217,24 +220,24 @@ func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.Remo | |||
| 217 | 220 | // while the remote directory has never been initialized: it distinguishes | |
| 218 | 221 | // "still checking remotes" from "no remotes configured". Enter on the | |
| 219 | 222 | // row fails safe through the usual unavailable-target path. | |
| 220 | - | func remotePickerCheckingView() picker.SessionView { | |
| 221 | - | return picker.SessionView{ | |
| 223 | + | func remotePickerCheckingView() pickerSessionView { | |
| 224 | + | return pickerSessionView{ | |
| 222 | 225 | ID: domain.SessionID("remote:checking"), | |
| 223 | 226 | Name: "checking remotes…", | |
| 224 | - | RemoteActivation: picker.RemoteUnavailable, | |
| 227 | + | RemoteActivation: pickerRemoteUnavailable, | |
| 225 | 228 | CannotAcceptMoves: true, | |
| 226 | 229 | } | |
| 227 | 230 | } | |
| 228 | 231 | ||
| 229 | - | func remotePickerHostView(host ports.RemoteHostSnapshot, now time.Time) picker.SessionView { | |
| 230 | - | return picker.SessionView{ | |
| 232 | + | func remotePickerHostView(host ports.RemoteHostSnapshot, now time.Time) pickerSessionView { | |
| 233 | + | return pickerSessionView{ | |
| 231 | 234 | ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host.Endpoint))), | |
| 232 | 235 | Name: host.Endpoint, | |
| 233 | 236 | RemoteHost: host.Endpoint, | |
| 234 | 237 | RemoteReason: directoryHostReason(host), | |
| 235 | 238 | RemoteAvailability: directoryPickerAvailability(host, now), | |
| 236 | 239 | RemoteDetail: directoryHostDetail(host), | |
| 237 | - | RemoteActivation: picker.RemoteUnavailable, | |
| 240 | + | RemoteActivation: pickerRemoteUnavailable, | |
| 238 | 241 | CannotAcceptMoves: true, | |
| 239 | 242 | } | |
| 240 | 243 | } | |
M internal/usecase/daemon/remote_picker_test.go +227 -275
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | 9 | "math" | |
| 10 | + | "strings" | |
| 10 | 11 | "sync" | |
| 11 | 12 | "testing" | |
| 12 | 13 | "time" | |
| @@ -107,7 +108,7 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) | |||
| 107 | 108 | require.True(t, view.Stopped) | |
| 108 | 109 | require.NotNil(t, view.RemoteTarget) | |
| 109 | 110 | require.True(t, view.RemoteTarget.Stopped) | |
| 110 | - | require.Equal(t, picker.RemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target") | |
| 111 | + | require.Equal(t, pickerRemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target") | |
| 111 | 112 | require.Equal(t, "stopped — Enter to restart", view.RemoteDetail) | |
| 112 | 113 | ||
| 113 | 114 | } | |
| @@ -179,8 +180,8 @@ func TestRemoteCatalogTargetReadyIgnoresAge(t *testing.T) { | |||
| 179 | 180 | ||
| 180 | 181 | views, _ := d.pickerViews(nil, nil) | |
| 181 | 182 | require.Len(t, views, 1) | |
| 182 | - | require.Equal(t, picker.RemoteAttach, views[0].RemoteActivation) | |
| 183 | - | require.Equal(t, picker.RemoteStale, views[0].RemoteAvailability) | |
| 183 | + | require.Equal(t, pickerRemoteAttach, views[0].RemoteActivation) | |
| 184 | + | require.Equal(t, pickerRemoteStale, views[0].RemoteAvailability) | |
| 184 | 185 | } | |
| 185 | 186 | ||
| 186 | 187 | func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) { | |
| @@ -241,8 +242,8 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) { | |||
| 241 | 242 | require.NotNil(t, views[0].RemoteTarget) | |
| 242 | 243 | require.Equal(t, test.wantTabID, views[0].RemoteTarget.LiveTabID) | |
| 243 | 244 | ||
| 244 | - | model := d.newPickerModel(nil, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 245 | - | target, ok := model.Selected() | |
| 245 | + | set := pickerLineSetFor(views, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{}) | |
| 246 | + | target, ok := firstSelectableTarget(t, set) | |
| 246 | 247 | require.True(t, ok) | |
| 247 | 248 | require.NotNil(t, target.RemoteTarget) | |
| 248 | 249 | require.Equal(t, test.wantTabID, target.RemoteTarget.LiveTabID) | |
| @@ -258,7 +259,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) { | |||
| 258 | 259 | tests := []struct { | |
| 259 | 260 | name string | |
| 260 | 261 | host ports.RemoteHostSnapshot | |
| 261 | - | want picker.RemoteAvailability | |
| 262 | + | want pickerRemoteAvailability | |
| 262 | 263 | wantActivate bool | |
| 263 | 264 | }{ | |
| 264 | 265 | { | |
| @@ -267,7 +268,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) { | |||
| 267 | 268 | Endpoint: "arch", Availability: domain.RemoteAvailabilityUnknown, | |
| 268 | 269 | Checking: true, InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{session}, | |
| 269 | 270 | }, | |
| 270 | - | want: picker.RemoteCached, wantActivate: true, | |
| 271 | + | want: pickerRemoteCached, wantActivate: true, | |
| 271 | 272 | }, | |
| 272 | 273 | { | |
| 273 | 274 | name: "unreachable with inventory renders stale and stays attemptable", | |
| @@ -276,7 +277,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) { | |||
| 276 | 277 | LastSuccess: time.Unix(10, 0), InventoryKnown: true, | |
| 277 | 278 | Sessions: []catalogue.RemoteCatalogSession{session}, | |
| 278 | 279 | }, | |
| 279 | - | want: picker.RemoteStale, wantActivate: true, | |
| 280 | + | want: pickerRemoteStale, wantActivate: true, | |
| 280 | 281 | }, | |
| 281 | 282 | { | |
| 282 | 283 | name: "version mismatch gates activation", | |
| @@ -284,7 +285,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) { | |||
| 284 | 285 | Endpoint: "arch", Availability: domain.RemoteAvailabilityIncompatible, | |
| 285 | 286 | InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{session}, | |
| 286 | 287 | }, | |
| 287 | - | want: picker.RemoteVersionMismatch, wantActivate: false, | |
| 288 | + | want: pickerRemoteVersionMismatch, wantActivate: false, | |
| 288 | 289 | }, | |
| 289 | 290 | } | |
| 290 | 291 | ||
| @@ -298,9 +299,9 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) { | |||
| 298 | 299 | require.Len(t, views, 1) | |
| 299 | 300 | require.Equal(t, test.want, views[0].RemoteAvailability) | |
| 300 | 301 | if test.wantActivate { | |
| 301 | - | require.NotEqual(t, picker.RemoteUnavailable, views[0].RemoteActivation) | |
| 302 | + | require.NotEqual(t, pickerRemoteUnavailable, views[0].RemoteActivation) | |
| 302 | 303 | } else { | |
| 303 | - | require.Equal(t, picker.RemoteUnavailable, views[0].RemoteActivation) | |
| 304 | + | require.Equal(t, pickerRemoteUnavailable, views[0].RemoteActivation) | |
| 304 | 305 | } | |
| 305 | 306 | }) | |
| 306 | 307 | } | |
| @@ -326,19 +327,19 @@ func TestRemotePickerNoSessionHostFailuresRemainVisible(t *testing.T) { | |||
| 326 | 327 | for _, test := range []struct { | |
| 327 | 328 | name string | |
| 328 | 329 | host ports.RemoteHostSnapshot | |
| 329 | - | want picker.RemoteAvailability | |
| 330 | + | want pickerRemoteAvailability | |
| 330 | 331 | wantDetail string | |
| 331 | 332 | }{ | |
| 332 | 333 | { | |
| 333 | 334 | name: "unreachable", | |
| 334 | 335 | host: ports.RemoteHostSnapshot{Endpoint: "arch", Availability: domain.RemoteAvailabilityUnreachable}, | |
| 335 | - | want: picker.RemoteStale, | |
| 336 | + | want: pickerRemoteStale, | |
| 336 | 337 | wantDetail: "unreachable", | |
| 337 | 338 | }, | |
| 338 | 339 | { | |
| 339 | 340 | name: "version mismatch", | |
| 340 | 341 | host: ports.RemoteHostSnapshot{Endpoint: "arch", Availability: domain.RemoteAvailabilityIncompatible}, | |
| 341 | - | want: picker.RemoteVersionMismatch, | |
| 342 | + | want: pickerRemoteVersionMismatch, | |
| 342 | 343 | wantDetail: "version mismatch", | |
| 343 | 344 | }, | |
| 344 | 345 | } { | |
| @@ -352,7 +353,7 @@ func TestRemotePickerNoSessionHostFailuresRemainVisible(t *testing.T) { | |||
| 352 | 353 | require.Equal(t, "arch", views[0].Name) | |
| 353 | 354 | require.Equal(t, test.want, views[0].RemoteAvailability) | |
| 354 | 355 | require.Equal(t, test.wantDetail, views[0].RemoteDetail) | |
| 355 | - | model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 356 | + | model := picker.New(pickerLineSetFor(views, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{}).lines, picker.Config{Intent: protocol.PickerIntentNavigation}) | |
| 356 | 357 | _, selectable := model.Selected() | |
| 357 | 358 | require.False(t, selectable) | |
| 358 | 359 | }) | |
| @@ -387,7 +388,7 @@ func TestRemotePickerStaleAndCheckingInventoryRemainsAttemptable(t *testing.T) { | |||
| 387 | 388 | domain.RemoteSessionKey{Host: "arch", Name: "work"}, | |
| 388 | 389 | session, test.host, now, | |
| 389 | 390 | ) | |
| 390 | - | require.Equal(t, picker.RemoteAttach, view.RemoteActivation) | |
| 391 | + | require.Equal(t, pickerRemoteAttach, view.RemoteActivation) | |
| 391 | 392 | require.NotNil(t, view.RemoteTarget) | |
| 392 | 393 | require.NoError(t, view.RemoteTarget.Validate()) | |
| 393 | 394 | }) | |
| @@ -404,11 +405,13 @@ func TestRemotePickerOverlayCausesNoDirectoryIO(t *testing.T) { | |||
| 404 | 405 | } | |
| 405 | 406 | d.remoteDirectory = directory | |
| 406 | 407 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 407 | - | ||
| 408 | - | model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 409 | - | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 410 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 411 | - | d.closePicker(ac) | |
| 408 | + | effect := admitPickerEffectForTest(t, sess, ac) | |
| 409 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 410 | + | d.refreshPickerSnapshot(ac) | |
| 411 | + | ac.overlays.pickerMu.Lock() | |
| 412 | + | interaction := ac.overlays.pickerInteraction | |
| 413 | + | ac.overlays.pickerMu.Unlock() | |
| 414 | + | require.True(t, d.closePickerForAttachment(ac, effect, interaction)) | |
| 412 | 415 | ||
| 413 | 416 | require.Zero(t, directory.reconciles, "overlay lifecycle must never request reconciliation") | |
| 414 | 417 | require.GreaterOrEqual(t, directory.snapshots, 1, "presentation renders from snapshots") | |
| @@ -480,6 +483,84 @@ func seedPickerDirectoryHost(t *testing.T, d *Daemon, endpoint string, fetchedAt | |||
| 480 | 483 | seedRemoteDirectory(t, d, reachableDirectoryHost(endpoint, fetchedAt, sessions...)) | |
| 481 | 484 | } | |
| 482 | 485 | ||
| 486 | + | func TestRemotePickerNavigationCommitsARemoteRowThroughTheHandoff(t *testing.T) { | |
| 487 | + | // A committed remote row names an endpoint-qualified session, never a local | |
| 488 | + | // one. The selection gate must not resolve it as a local lifecycle, and the | |
| 489 | + | // handoff must be what reaches the client. | |
| 490 | + | lifecycle := remoteLifecycleForTest() | |
| 491 | + | remoteSession := catalogue.RemoteCatalogSession{ | |
| 492 | + | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, | |
| 493 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "main", Index: 0}}, ActiveTabID: "tab-1", | |
| 494 | + | } | |
| 495 | + | d := newRemotePickerDaemon() | |
| 496 | + | seedPickerDirectoryHost(t, d, "arch", time.Unix(10, 0), remoteSession) | |
| 497 | + | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 498 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 499 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 500 | + | require.True(t, admitted) | |
| 501 | + | defer effect.End() | |
| 502 | + | ||
| 503 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 504 | + | _ = awaitPickerOffer(t, sends) | |
| 505 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 506 | + | key := "" | |
| 507 | + | for _, line := range snapshot.Lines { | |
| 508 | + | if line.Actions&protocol.PickerCanNavigate != 0 && strings.HasPrefix(line.Key, "remote:") { | |
| 509 | + | key = line.Key | |
| 510 | + | break | |
| 511 | + | } | |
| 512 | + | } | |
| 513 | + | require.NotEmpty(t, key, "the remote session's rows must authorise navigation") | |
| 514 | + | ||
| 515 | + | d.resolvePickerSelection(effect, navigateSelection(snapshot, key)) | |
| 516 | + | ||
| 517 | + | frame := awaitFrame(t, sends, wire.MsgAttachTarget) | |
| 518 | + | handoff, err := wire.UnmarshalAttachTarget(frame.Payload) | |
| 519 | + | require.NoError(t, err) | |
| 520 | + | require.Equal(t, "arch", handoff.Endpoint) | |
| 521 | + | require.Equal(t, "work", handoff.Session) | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | func TestRemotePickerNavigationRejectsARowTheCatalogNoLongerHolds(t *testing.T) { | |
| 525 | + | // The handoff is the remote target's lifecycle gate, so a committed row | |
| 526 | + | // whose session was replaced must fail precisely instead of attaching to a | |
| 527 | + | // route the catalogue no longer publishes. | |
| 528 | + | lifecycle := remoteLifecycleForTest() | |
| 529 | + | remoteSession := catalogue.RemoteCatalogSession{ | |
| 530 | + | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, | |
| 531 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "main", Index: 0}}, ActiveTabID: "tab-1", | |
| 532 | + | } | |
| 533 | + | d := newRemotePickerDaemon() | |
| 534 | + | seedPickerDirectoryHost(t, d, "arch", time.Unix(10, 0), remoteSession) | |
| 535 | + | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 536 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 537 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 538 | + | require.True(t, admitted) | |
| 539 | + | defer effect.End() | |
| 540 | + | ||
| 541 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 542 | + | _ = awaitPickerOffer(t, sends) | |
| 543 | + | snapshot := awaitPickerSnapshot(t, sends) | |
| 544 | + | key := "" | |
| 545 | + | for _, line := range snapshot.Lines { | |
| 546 | + | if line.Actions&protocol.PickerCanNavigate != 0 && strings.HasPrefix(line.Key, "remote:") { | |
| 547 | + | key = line.Key | |
| 548 | + | break | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | require.NotEmpty(t, key) | |
| 552 | + | ||
| 553 | + | // The same session name now carries a different lifecycle. | |
| 554 | + | replaced := remoteSession | |
| 555 | + | replaced.LifecycleID = domain.SessionLifecycleID{9: 3} | |
| 556 | + | seedPickerDirectoryHost(t, d, "arch", time.Unix(20, 0), replaced) | |
| 557 | + | ||
| 558 | + | d.resolvePickerSelection(effect, navigateSelection(snapshot, key)) | |
| 559 | + | ||
| 560 | + | failure := awaitPickerFailure(t, sends) | |
| 561 | + | require.Equal(t, protocol.PickerNavigationFailed, failure.Code) | |
| 562 | + | } | |
| 563 | + | ||
| 483 | 564 | func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |
| 484 | 565 | lifecycle := remoteLifecycleForTest() | |
| 485 | 566 | remoteSession := catalogue.RemoteCatalogSession{ | |
| @@ -495,7 +576,7 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |||
| 495 | 576 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 496 | 577 | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 497 | 578 | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"} | |
| 498 | - | require.NoError(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 579 | + | require.NoError(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{}, "picker-select")) | |
| 499 | 580 | ||
| 500 | 581 | frame := receiveRemotePicker(t, sends, "attach target") | |
| 501 | 582 | require.Equal(t, wire.MsgAttachTarget, frame.Type) | |
| @@ -519,43 +600,38 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||
| 519 | 600 | {ID: "tab-b", Name: "beta", Index: 1}, | |
| 520 | 601 | }, | |
| 521 | 602 | } | |
| 522 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 523 | - | host := reachableDirectoryHost("arch", time.Unix(10, 0), remoteSession) | |
| 524 | - | remoteView := remotePickerView(key, remoteSession, host, time.Unix(10, 0)) | |
| 525 | - | model := picker.New([]picker.SessionView{ | |
| 526 | - | {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}}, | |
| 527 | - | remoteView, | |
| 528 | - | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 529 | - | var selected picker.Target | |
| 530 | - | for range 4 { | |
| 531 | - | candidate, ok := model.Selected() | |
| 532 | - | if ok && candidate.RemoteTarget != nil && candidate.RemoteTarget.StoppedTab.StableID == "tab-b" { | |
| 533 | - | selected = candidate | |
| 534 | - | break | |
| 603 | + | // The stopping tab is offered by the daemon's published keys: a stopped | |
| 604 | + | // remote session keeps a structured restore target per tab. | |
| 605 | + | local := newRemotePickerDaemon() | |
| 606 | + | seedPickerDirectoryHost(t, local, "arch", time.Unix(10, 0), remoteSession) | |
| 607 | + | localSession, localAttachment, _ := addRemoteRefreshPickerOwner(t, local, "local") | |
| 608 | + | effect := admitPickerEffectForTest(t, localSession, localAttachment) | |
| 609 | + | require.NoError(t, local.openPickerForAttachment(localAttachment, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 610 | + | localAttachment.overlays.pickerMu.Lock() | |
| 611 | + | selected, ok := selectableTargetMatching(t, pickerLineSet{keys: localAttachment.overlays.pickerKeys}, func(target picker.Target) bool { | |
| 612 | + | return target.RemoteTarget != nil && target.RemoteTarget.StoppedTab.StableID == "tab-b" | |
| 613 | + | }) | |
| 614 | + | localAttachment.overlays.pickerMu.Unlock() | |
| 615 | + | if !ok { | |
| 616 | + | localAttachment.overlays.pickerMu.Lock() | |
| 617 | + | for candidate, target := range localAttachment.overlays.pickerKeys { | |
| 618 | + | if target.RemoteTarget != nil { | |
| 619 | + | t.Logf("key=%q session=%q remote=%+v", candidate, target.Session, *target.RemoteTarget) | |
| 620 | + | } else { | |
| 621 | + | t.Logf("key=%q session=%q remote=nil", candidate, target.Session) | |
| 622 | + | } | |
| 535 | 623 | } | |
| 536 | - | model.Down() | |
| 624 | + | localAttachment.overlays.pickerMu.Unlock() | |
| 537 | 625 | } | |
| 626 | + | require.True(t, ok) | |
| 538 | 627 | require.NotNil(t, selected.RemoteTarget) | |
| 539 | 628 | require.True(t, selected.RemoteTarget.Stopped) | |
| 540 | 629 | require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab) | |
| 630 | + | require.Equal(t, selected.RemoteKey.ID(), selected.Session, "the row identity must match its remote key") | |
| 631 | + | require.True(t, local.remoteCatalogTargetReady(*selected.RemoteTarget), "the published stopped target must be ready to attach") | |
| 541 | 632 | ||
| 542 | - | local := newRemotePickerDaemon() | |
| 543 | - | seedPickerDirectoryHost(t, local, "arch", time.Unix(10, 0), remoteSession) | |
| 544 | - | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | |
| 545 | - | token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport()) | |
| 546 | - | effect, admitted := localAttachment.beginAttachmentEffect(token) | |
| 547 | - | require.True(t, admitted) | |
| 548 | - | require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 549 | - | ||
| 550 | - | frame := receiveRemotePicker(t, sends, "stopped remote target") | |
| 551 | - | handoff, err := wire.UnmarshalAttachTarget(frame.Payload) | |
| 552 | - | require.NoError(t, err) | |
| 553 | - | require.NotNil(t, handoff.RemoteTarget) | |
| 554 | - | require.Equal(t, selected.RemoteTarget, handoff.RemoteTarget) | |
| 555 | - | local.mu.Lock() | |
| 556 | - | require.NotContains(t, local.sessions, key.ID(), "picker handoff must not create a local remote shadow") | |
| 557 | - | local.mu.Unlock() | |
| 558 | - | ||
| 633 | + | // The handoff itself is covered by TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession; | |
| 634 | + | // this suite pins the per-tab restore target the stopped rows publish. | |
| 559 | 635 | remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 560 | 636 | remote.mu.Lock() | |
| 561 | 637 | remote.inactive["work"] = inactiveSession{ | |
| @@ -566,8 +642,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||
| 566 | 642 | remote.mu.Unlock() | |
| 567 | 643 | transport, _ := newCapturingTransport(t) | |
| 568 | 644 | restored, attachment, err := remote.routeWithContext(context.Background(), protocol.Hello{ | |
| 569 | - | Version: protocol.Version, Intent: protocol.IntentAttach, Name: handoff.Session, | |
| 570 | - | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: handoff.RemoteTarget, | |
| 645 | + | Version: protocol.Version, Intent: protocol.IntentAttach, Name: selected.RemoteTarget.SessionName, | |
| 646 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: selected.RemoteTarget, | |
| 571 | 647 | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 572 | 648 | }, transport) | |
| 573 | 649 | require.NoError(t, err) | |
| @@ -587,21 +663,16 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing | |||
| 587 | 663 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 588 | 664 | host := reachableDirectoryHost("arch", time.Unix(10, 0), remoteSession) | |
| 589 | 665 | remoteView := remotePickerView(key, remoteSession, host, time.Unix(10, 0)) | |
| 590 | - | require.Equal(t, picker.RemoteRestart, remoteView.RemoteActivation) | |
| 666 | + | require.Equal(t, pickerRemoteRestart, remoteView.RemoteActivation) | |
| 591 | 667 | ||
| 592 | - | model := picker.New([]picker.SessionView{ | |
| 593 | - | {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}}, | |
| 668 | + | set := pickerLineSetFor([]pickerSessionView{ | |
| 669 | + | {ID: "local", Name: "local", Tabs: []pickerTabEntry{{TabID: "local-tab", Name: "local"}}}, | |
| 594 | 670 | remoteView, | |
| 595 | - | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 596 | - | var selected picker.Target | |
| 597 | - | for range 3 { | |
| 598 | - | model.Down() | |
| 599 | - | candidate, ok := model.Selected() | |
| 600 | - | if ok && candidate.RemoteTarget != nil { | |
| 601 | - | selected = candidate | |
| 602 | - | break | |
| 603 | - | } | |
| 604 | - | } | |
| 671 | + | }, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{}) | |
| 672 | + | selected, ok := selectableTargetMatching(t, set, func(target picker.Target) bool { | |
| 673 | + | return target.RemoteTarget != nil | |
| 674 | + | }) | |
| 675 | + | require.True(t, ok) | |
| 605 | 676 | require.NotNil(t, selected.RemoteTarget) | |
| 606 | 677 | require.True(t, selected.RemoteTarget.Stopped) | |
| 607 | 678 | require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab) | |
| @@ -612,7 +683,7 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing | |||
| 612 | 683 | token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport()) | |
| 613 | 684 | effect, admitted := localAttachment.beginAttachmentEffect(token) | |
| 614 | 685 | require.True(t, admitted) | |
| 615 | - | require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 686 | + | require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{}, "picker-select")) | |
| 616 | 687 | ||
| 617 | 688 | frame := receiveRemotePicker(t, sends, "stopped remote target without tab metadata") | |
| 618 | 689 | handoff, err := wire.UnmarshalAttachTarget(frame.Payload) | |
| @@ -636,41 +707,6 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing | |||
| 636 | 707 | require.Equal(t, restored.tabs[0].stableID, string(attachment.viewSnapshot().tabID)) | |
| 637 | 708 | } | |
| 638 | 709 | ||
| 639 | - | func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) { | |
| 640 | - | tests := []struct { | |
| 641 | - | name string | |
| 642 | - | action protocol.NavigationAction | |
| 643 | - | }{ | |
| 644 | - | {name: "home picker", action: protocol.NavigationOpenHomePicker}, | |
| 645 | - | {name: "back", action: protocol.NavigationBack}, | |
| 646 | - | } | |
| 647 | - | ||
| 648 | - | for _, tt := range tests { | |
| 649 | - | t.Run(tt.name, func(t *testing.T) { | |
| 650 | - | d := newRemotePickerDaemon() | |
| 651 | - | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 652 | - | if tt.action == protocol.NavigationOpenHomePicker { | |
| 653 | - | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| 654 | - | } | |
| 655 | - | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 656 | - | effect, admitted := ac.beginAttachmentEffect(token) | |
| 657 | - | require.True(t, admitted) | |
| 658 | - | defer effect.End() | |
| 659 | - | require.NoError(t, d.sendNavigationActionForAttachment(effect, tt.action)) | |
| 660 | - | frame := receiveRemotePicker(t, sends, "navigation action") | |
| 661 | - | require.Equal(t, wire.MsgNavigationAction, frame.Type) | |
| 662 | - | directive, err := wire.UnmarshalNavigationDirective(frame.Payload) | |
| 663 | - | require.NoError(t, err) | |
| 664 | - | require.Equal(t, tt.action, directive.Action) | |
| 665 | - | if tt.action == protocol.NavigationOpenHomePicker { | |
| 666 | - | require.False(t, directive.LeaseID.IsZero()) | |
| 667 | - | } else { | |
| 668 | - | require.True(t, directive.LeaseID.IsZero()) | |
| 669 | - | } | |
| 670 | - | }) | |
| 671 | - | } | |
| 672 | - | } | |
| 673 | - | ||
| 674 | 710 | func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) { | |
| 675 | 711 | lifecycle := remoteLifecycleForTest() | |
| 676 | 712 | remoteSession := catalogue.RemoteCatalogSession{ | |
| @@ -683,26 +719,23 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) { | |||
| 683 | 719 | tr := portsmocks.NewMockServerConnection(t) | |
| 684 | 720 | tr.EXPECT().SendServer(mock.Anything).Return(cause) | |
| 685 | 721 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local", tr) | |
| 686 | - | ac.overlays.pickerMu.Lock() | |
| 687 | - | ac.overlays.picker = picker.New(nil, picker.SelectionConfig{}) | |
| 688 | - | ac.overlays.pickerGeneration++ | |
| 689 | - | ac.overlays.pickerMu.Unlock() | |
| 722 | + | openPickerStateForTest(ac) | |
| 690 | 723 | ||
| 691 | 724 | gone := make(chan struct{}) | |
| 692 | 725 | d.afterClientGoneDetach = func() { close(gone) } | |
| 693 | 726 | token := sess.captureAttachmentCapability(ac, tr) | |
| 694 | - | effect, admitted := ac.beginAttachmentEffect(token) | |
| 727 | + | sendEffect, admitted := ac.beginAttachmentEffect(token) | |
| 695 | 728 | require.True(t, admitted) | |
| 696 | - | defer effect.End() | |
| 729 | + | defer sendEffect.End() | |
| 697 | 730 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 698 | 731 | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 699 | 732 | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"} | |
| 700 | - | err := d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{closePicker: true}, "picker-select") | |
| 733 | + | err := d.sendRemoteAttachTargetForAttachment(sendEffect, target, sessionHandoffGuard{}, "picker-select") | |
| 701 | 734 | var userErr *domain.UserError | |
| 702 | 735 | require.ErrorAs(t, err, &userErr) | |
| 703 | 736 | require.Equal(t, "couldn't attach to remote session", userErr.Msg) | |
| 704 | 737 | require.ErrorIs(t, err, cause) | |
| 705 | - | require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open") | |
| 738 | + | require.True(t, ac.overlays.pickerClientActive(), "failed control send must leave the picker open") | |
| 706 | 739 | select { | |
| 707 | 740 | case <-gone: | |
| 708 | 741 | t.Fatal("failed control send reached clientGoneForAttachment") | |
| @@ -711,6 +744,81 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) { | |||
| 711 | 744 | require.Same(t, sess, ac.currentAttachmentSession()) | |
| 712 | 745 | } | |
| 713 | 746 | ||
| 747 | + | // selectableTargetMatching returns the resolved target of the first | |
| 748 | + | // selectable line whose target satisfies the predicate. A key-only line set | |
| 749 | + | // (every published key is selectable) is accepted too. | |
| 750 | + | func selectableTargetMatching(t *testing.T, set pickerLineSet, match func(picker.Target) bool) (picker.Target, bool) { | |
| 751 | + | t.Helper() | |
| 752 | + | if len(set.lines) == 0 { | |
| 753 | + | for _, target := range set.keys { | |
| 754 | + | if match(target) { | |
| 755 | + | return target, true | |
| 756 | + | } | |
| 757 | + | } | |
| 758 | + | return picker.Target{}, false | |
| 759 | + | } | |
| 760 | + | for _, line := range set.lines { | |
| 761 | + | if line.Actions == 0 { | |
| 762 | + | continue | |
| 763 | + | } | |
| 764 | + | target, ok := set.keys[line.Key] | |
| 765 | + | if !ok { | |
| 766 | + | continue | |
| 767 | + | } | |
| 768 | + | if match(target) { | |
| 769 | + | return target, true | |
| 770 | + | } | |
| 771 | + | } | |
| 772 | + | return picker.Target{}, false | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | // openPickerStateForTest marks the attachment as owning an interaction without | |
| 776 | + | // publishing frames. Suites that observe teardown or send failures on the wire | |
| 777 | + | // must not hold a live effect while they run. | |
| 778 | + | func openPickerStateForTest(ac *attachedClient) uint64 { | |
| 779 | + | ac.overlays.pickerMu.Lock() | |
| 780 | + | defer ac.overlays.pickerMu.Unlock() | |
| 781 | + | ac.overlays.pickerInteraction++ | |
| 782 | + | if ac.overlays.pickerInteraction == 0 { | |
| 783 | + | ac.overlays.pickerInteraction = 1 | |
| 784 | + | } | |
| 785 | + | ac.overlays.pickerOpen = true | |
| 786 | + | ac.overlays.pickerRevisions = map[string]uint64{servingPickerSourceID: 1} | |
| 787 | + | return ac.overlays.pickerInteraction | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | // admitPickerEffectForTest admits one effect for the attachment, so typed | |
| 791 | + | // picker opens and closes travel the same path a client frame would. | |
| 792 | + | func admitPickerEffectForTest(t *testing.T, sess *session, ac *attachedClient) *attachmentEffect { | |
| 793 | + | t.Helper() | |
| 794 | + | effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 795 | + | require.True(t, admitted) | |
| 796 | + | t.Cleanup(effect.End) | |
| 797 | + | return effect | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | // firstSelectableTarget returns the resolved target of the first line a source | |
| 801 | + | // authorised for navigation. | |
| 802 | + | func firstSelectableTarget(t *testing.T, set pickerLineSet) (picker.Target, bool) { | |
| 803 | + | t.Helper() | |
| 804 | + | // The presented selection wins: the daemon's default cursor is a | |
| 805 | + | // session's active tab, so it is the row a fresh interaction would commit. | |
| 806 | + | if set.cursor.Key != "" { | |
| 807 | + | if target, ok := set.keys[set.cursor.Key]; ok { | |
| 808 | + | return target, true | |
| 809 | + | } | |
| 810 | + | } | |
| 811 | + | for _, line := range set.lines { | |
| 812 | + | if line.Actions&protocol.PickerCanNavigate == 0 { | |
| 813 | + | continue | |
| 814 | + | } | |
| 815 | + | target, ok := set.keys[line.Key] | |
| 816 | + | require.True(t, ok, "selectable line %q has no resolved target", line.Key) | |
| 817 | + | return target, true | |
| 818 | + | } | |
| 819 | + | return picker.Target{}, false | |
| 820 | + | } | |
| 821 | + | ||
| 714 | 822 | func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, transports ...appports.ServerConnection) (*session, *attachedClient, chan wire.Frame) { | |
| 715 | 823 | t.Helper() | |
| 716 | 824 | var tr appports.ServerConnection | |
| @@ -747,24 +855,20 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) { | |||
| 747 | 855 | d := newRemotePickerDaemon() | |
| 748 | 856 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 749 | 857 | ac.resumeCapable = test.resumeCapable | |
| 750 | - | model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 751 | - | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 752 | - | ac.overlays.pickerMu.Lock() | |
| 753 | - | generation := ac.overlays.pickerGeneration | |
| 754 | - | ac.overlays.pickerMu.Unlock() | |
| 858 | + | _ = openPickerStateForTest(ac) | |
| 755 | 859 | ||
| 756 | 860 | test.teardown(d, sess, ac) | |
| 757 | 861 | ||
| 758 | 862 | ac.overlays.pickerMu.Lock() | |
| 759 | - | current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration | |
| 863 | + | open, currentInteraction := ac.overlays.pickerOpen, ac.overlays.pickerInteraction | |
| 760 | 864 | ac.overlays.pickerMu.Unlock() | |
| 761 | 865 | if !test.resumeCapable { | |
| 762 | - | require.Nil(t, current) | |
| 866 | + | require.False(t, open) | |
| 763 | 867 | return | |
| 764 | 868 | } | |
| 765 | 869 | require.True(t, ac.parked) | |
| 766 | - | require.Same(t, model, current) | |
| 767 | - | require.Equal(t, generation, currentGeneration) | |
| 870 | + | require.False(t, open, "parking retires picker state before resume") | |
| 871 | + | require.NotZero(t, currentInteraction, "interaction IDs remain monotone after retirement") | |
| 768 | 872 | d.mu.Lock() | |
| 769 | 873 | parked := d.parked[ac.resumeToken] | |
| 770 | 874 | d.mu.Unlock() | |
| @@ -785,10 +889,10 @@ func TestPickerShowsCheckingRowUntilDirectoryInitializes(t *testing.T) { | |||
| 785 | 889 | d.remoteDirectory = &stubRemoteDirectory{} | |
| 786 | 890 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 787 | 891 | ||
| 788 | - | findChecking := func(views []picker.SessionView) bool { | |
| 892 | + | findChecking := func(views []pickerSessionView) bool { | |
| 789 | 893 | for _, view := range views { | |
| 790 | 894 | if view.ID == domain.SessionID("remote:checking") { | |
| 791 | - | if view.RemoteActivation != picker.RemoteUnavailable || !view.CannotAcceptMoves { | |
| 895 | + | if view.RemoteActivation != pickerRemoteUnavailable || !view.CannotAcceptMoves { | |
| 792 | 896 | t.Fatal("checking row must be non-actionable") | |
| 793 | 897 | } | |
| 794 | 898 | return true | |
| @@ -804,155 +908,3 @@ func TestPickerShowsCheckingRowUntilDirectoryInitializes(t *testing.T) { | |||
| 804 | 908 | views, _ = d.pickerViews(sess, ac) | |
| 805 | 909 | require.False(t, findChecking(views), "initialized directory must retire the checking row") | |
| 806 | 910 | } | |
| 807 | - | ||
| 808 | - | // TestRemoteDirectoryRefreshPreservesBlockedPreview proves an unchanged | |
| 809 | - | // directory revision does not interrupt an in-flight remote preview: the | |
| 810 | - | // rebuild keeps the selected exact target, so the preview cancel func is | |
| 811 | - | // never invoked and its content survives. A changed selection still | |
| 812 | - | // re-registers (and cancels) the preview. | |
| 813 | - | func TestRemoteDirectoryRefreshPreservesBlockedPreview(t *testing.T) { | |
| 814 | - | d := newRemotePickerDaemon() | |
| 815 | - | session := catalogue.RemoteCatalogSession{ | |
| 816 | - | LifecycleID: remoteLifecycleForTest(), Name: "work", State: catalogue.RemoteCatalogSessionUp, | |
| 817 | - | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, ActiveTabID: "tab-1", | |
| 818 | - | } | |
| 819 | - | seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), session)) | |
| 820 | - | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 821 | - | d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 822 | - | // Move the cursor onto the remote row. | |
| 823 | - | ac.overlays.pickerMu.Lock() | |
| 824 | - | for range 8 { | |
| 825 | - | target, ok := ac.overlays.picker.Selected() | |
| 826 | - | if ok && target.RemoteTarget != nil { | |
| 827 | - | break | |
| 828 | - | } | |
| 829 | - | ac.overlays.picker.Down() | |
| 830 | - | } | |
| 831 | - | target, ok := ac.overlays.picker.Selected() | |
| 832 | - | ac.overlays.pickerMu.Unlock() | |
| 833 | - | require.True(t, ok && target.RemoteTarget != nil, "picker must offer the seeded remote row") | |
| 834 | - | ||
| 835 | - | // Simulate a blocked remote preview fetch. | |
| 836 | - | cancelled := make(chan struct{}) | |
| 837 | - | _, cancel := context.WithCancel(context.Background()) | |
| 838 | - | defer cancel() | |
| 839 | - | ac.overlays.pickerMu.Lock() | |
| 840 | - | ac.overlays.pickerRemotePreviewCancel = func() { | |
| 841 | - | close(cancelled) | |
| 842 | - | cancel() | |
| 843 | - | } | |
| 844 | - | ac.overlays.pickerRemotePreview = picker.Preview{Width: 8, Height: 4} | |
| 845 | - | ac.overlays.pickerMu.Unlock() | |
| 846 | - | ||
| 847 | - | // An identical directory revision rebuilds rows around the same | |
| 848 | - | // exact target: the preview survives untouched. | |
| 849 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 850 | - | select { | |
| 851 | - | case <-cancelled: | |
| 852 | - | t.Fatal("unchanged directory revision cancelled the blocked preview") | |
| 853 | - | default: | |
| 854 | - | } | |
| 855 | - | ac.overlays.pickerMu.Lock() | |
| 856 | - | kept := ac.overlays.pickerRemotePreview | |
| 857 | - | ac.overlays.pickerMu.Unlock() | |
| 858 | - | require.Equal(t, 8, kept.Width, "blocked preview content must survive an unchanged refresh") | |
| 859 | - | ||
| 860 | - | // Removing the remote session moves the selection: the preview | |
| 861 | - | // re-registers and the blocked fetch is cancelled. | |
| 862 | - | seedRemoteDirectory(t, d) | |
| 863 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 864 | - | select { | |
| 865 | - | case <-cancelled: | |
| 866 | - | case <-time.After(5 * time.Second): | |
| 867 | - | t.Fatal("changed selection did not cancel the blocked preview") | |
| 868 | - | } | |
| 869 | - | } | |
| 870 | - | ||
| 871 | - | func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) { | |
| 872 | - | d := newRemotePickerDaemon() | |
| 873 | - | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 874 | - | ac.resumeCapable = true | |
| 875 | - | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 876 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 877 | - | ac.overlays.pickerMu.Lock() | |
| 878 | - | refreshed := ac.overlays.picker | |
| 879 | - | ac.overlays.pickerMu.Unlock() | |
| 880 | - | require.NotNil(t, refreshed) | |
| 881 | - | require.True(t, d.parkAttachment(sess, ac)) | |
| 882 | - | token := ac.resumeToken | |
| 883 | - | d.mu.Lock() | |
| 884 | - | parked := d.parked[token] | |
| 885 | - | d.mu.Unlock() | |
| 886 | - | require.NotNil(t, parked) | |
| 887 | - | ||
| 888 | - | d.mu.Lock() | |
| 889 | - | retirement := d.retireParkedAttachmentLocked(token, parked) | |
| 890 | - | d.mu.Unlock() | |
| 891 | - | d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{retirement}) | |
| 892 | - | ac.overlays.pickerMu.Lock() | |
| 893 | - | require.Nil(t, ac.overlays.picker, "terminal retirement closes a refreshed model in its captured generation") | |
| 894 | - | ac.overlays.pickerMu.Unlock() | |
| 895 | - | } | |
| 896 | - | ||
| 897 | - | func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) { | |
| 898 | - | d := newRemotePickerDaemon() | |
| 899 | - | firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first") | |
| 900 | - | secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second") | |
| 901 | - | first.resumeCapable, second.resumeCapable = true, true | |
| 902 | - | d.publishPicker(firstSession, first, d.newPickerModel(firstSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 903 | - | require.True(t, d.parkAttachment(firstSession, first)) | |
| 904 | - | token := first.resumeToken | |
| 905 | - | d.publishPicker(secondSession, second, d.newPickerModel(secondSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 906 | - | second.resumeToken = token | |
| 907 | - | require.True(t, d.parkAttachment(secondSession, second)) | |
| 908 | - | ||
| 909 | - | first.overlays.pickerMu.Lock() | |
| 910 | - | require.Nil(t, first.overlays.picker) | |
| 911 | - | first.overlays.pickerMu.Unlock() | |
| 912 | - | second.overlays.pickerMu.Lock() | |
| 913 | - | require.NotNil(t, second.overlays.picker) | |
| 914 | - | second.overlays.pickerMu.Unlock() | |
| 915 | - | } | |
| 916 | - | ||
| 917 | - | func TestParkedPickerResumePreservesGeneration(t *testing.T) { | |
| 918 | - | d := newRemotePickerDaemon() | |
| 919 | - | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 920 | - | ac.resumeCapable = true | |
| 921 | - | ac.clientID = [16]byte{1, 2, 3, 4} | |
| 922 | - | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 923 | - | ac.overlays.pickerMu.Lock() | |
| 924 | - | model, generation := ac.overlays.picker, ac.overlays.pickerGeneration | |
| 925 | - | ac.overlays.pickerMu.Unlock() | |
| 926 | - | d.clientGone(sess, ac, ac.transport(), false) | |
| 927 | - | token := ac.resumeToken | |
| 928 | - | tr := &closeTrackingTransport{} | |
| 929 | - | resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(protocol.IntentResume, sess.name, token), tr, domain.Size{Cols: 80, Rows: 24}) | |
| 930 | - | require.NoError(t, err) | |
| 931 | - | require.True(t, ok) | |
| 932 | - | require.Same(t, sess, resumedSess) | |
| 933 | - | require.Same(t, ac, resumedAC) | |
| 934 | - | ac.overlays.pickerMu.Lock() | |
| 935 | - | require.Same(t, model, ac.overlays.picker) | |
| 936 | - | require.Equal(t, generation, ac.overlays.pickerGeneration) | |
| 937 | - | ac.overlays.pickerMu.Unlock() | |
| 938 | - | } | |
| 939 | - | ||
| 940 | - | func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) { | |
| 941 | - | d := newRemotePickerDaemon() | |
| 942 | - | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 943 | - | ac.resumeCapable = true | |
| 944 | - | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 945 | - | require.True(t, d.parkAttachment(sess, ac)) | |
| 946 | - | token := ac.resumeToken | |
| 947 | - | d.mu.Lock() | |
| 948 | - | parked := d.parked[token] | |
| 949 | - | d.mu.Unlock() | |
| 950 | - | require.NotNil(t, parked) | |
| 951 | - | ||
| 952 | - | newer := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 953 | - | d.publishPicker(sess, ac, newer, pickerNavigate, moveSourceLocator{}) | |
| 954 | - | d.expireParked(token, parked) | |
| 955 | - | ac.overlays.pickerMu.Lock() | |
| 956 | - | require.Same(t, newer, ac.overlays.picker) | |
| 957 | - | ac.overlays.pickerMu.Unlock() | |
| 958 | - | } | |
M internal/usecase/daemon/remote_preview_cache_test.go +0 -225
| @@ -13,7 +13,6 @@ import ( | |||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | 15 | "github.com/bnema/vev/internal/protocol" | |
| 16 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 17 | 16 | ) | |
| 18 | 17 | ||
| 19 | 18 | type remotePreviewTestClock struct { | |
| @@ -253,230 +252,6 @@ func (immediateRemotePreviewClock) NewTimer(time.Duration) ports.Timer { | |||
| 253 | 252 | return immediateRemotePreviewTimer{ch: ch} | |
| 254 | 253 | } | |
| 255 | 254 | ||
| 256 | - | func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *testing.T) { | |
| 257 | - | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | |
| 258 | - | firstTarget := remotePreviewCacheTarget() | |
| 259 | - | firstStarted := make(chan struct{}) | |
| 260 | - | firstRelease := make(chan struct{}) | |
| 261 | - | client := &remotePreviewTestClient{ | |
| 262 | - | result: remotePreviewCacheResult(firstTarget, 1), | |
| 263 | - | started: firstStarted, | |
| 264 | - | release: firstRelease, | |
| 265 | - | ignoreCancel: true, | |
| 266 | - | } | |
| 267 | - | d.remotePreviewClient = client | |
| 268 | - | _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 269 | - | ||
| 270 | - | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | |
| 271 | - | firstView := picker.SessionView{ | |
| 272 | - | ID: firstKey.ID(), Name: firstTarget.SessionName, | |
| 273 | - | Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}}, | |
| 274 | - | RemoteKey: &firstKey, RemoteTarget: &firstTarget, | |
| 275 | - | RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach, | |
| 276 | - | } | |
| 277 | - | firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 278 | - | firstSelected, ok := firstModel.Selected() | |
| 279 | - | require.True(t, ok) | |
| 280 | - | ac.overlays.pickerMu.Lock() | |
| 281 | - | ac.overlays.picker = firstModel | |
| 282 | - | ac.overlays.pickerIntent = pickerNavigate | |
| 283 | - | ac.overlays.pickerPreviewGeneration = 1 | |
| 284 | - | ac.overlays.pickerMu.Unlock() | |
| 285 | - | d.startRemotePickerPreview(ac, firstSelected, 1) | |
| 286 | - | <-firstStarted | |
| 287 | - | ||
| 288 | - | d.closePicker(ac) | |
| 289 | - | ||
| 290 | - | secondTarget := firstTarget | |
| 291 | - | secondTarget.LifecycleID[0]++ | |
| 292 | - | secondTarget.LiveTabID = "tab-2" | |
| 293 | - | client.mu.Lock() | |
| 294 | - | client.result = remotePreviewCacheResultRune(secondTarget, 2, 'y') | |
| 295 | - | client.started = make(chan struct{}) | |
| 296 | - | client.release = make(chan struct{}) | |
| 297 | - | client.startedSignal = false | |
| 298 | - | client.finishedSignal = false | |
| 299 | - | client.mu.Unlock() | |
| 300 | - | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | |
| 301 | - | secondView := picker.SessionView{ | |
| 302 | - | ID: secondKey.ID(), Name: secondTarget.SessionName, | |
| 303 | - | Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}}, | |
| 304 | - | RemoteKey: &secondKey, RemoteTarget: &secondTarget, | |
| 305 | - | RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach, | |
| 306 | - | } | |
| 307 | - | secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 308 | - | secondSelected, ok := secondModel.Selected() | |
| 309 | - | require.True(t, ok) | |
| 310 | - | ac.overlays.pickerMu.Lock() | |
| 311 | - | ac.overlays.picker = secondModel | |
| 312 | - | ac.overlays.pickerIntent = pickerNavigate | |
| 313 | - | ac.overlays.pickerPreviewGeneration++ | |
| 314 | - | secondGeneration := ac.overlays.pickerPreviewGeneration | |
| 315 | - | ac.overlays.pickerMu.Unlock() | |
| 316 | - | d.startRemotePickerPreview(ac, secondSelected, secondGeneration) | |
| 317 | - | <-client.started | |
| 318 | - | ||
| 319 | - | close(firstRelease) | |
| 320 | - | close(client.release) | |
| 321 | - | width, height := remotePickerPreviewSize(ac.sizeSnapshot()) | |
| 322 | - | for _, key := range []remotePreviewCacheKey{ | |
| 323 | - | remotePreviewKeyFor(firstTarget, width, height), | |
| 324 | - | remotePreviewKeyFor(secondTarget, width, height), | |
| 325 | - | } { | |
| 326 | - | d.remotePreview.mu.Lock() | |
| 327 | - | flight := d.remotePreview.flights[key] | |
| 328 | - | d.remotePreview.mu.Unlock() | |
| 329 | - | if flight != nil { | |
| 330 | - | <-flight.done | |
| 331 | - | } | |
| 332 | - | } | |
| 333 | - | var preview picker.Preview | |
| 334 | - | require.Eventually(t, func() bool { | |
| 335 | - | ac.overlays.pickerMu.Lock() | |
| 336 | - | preview = ac.overlays.pickerRemotePreview | |
| 337 | - | currentGeneration := ac.overlays.pickerPreviewGeneration | |
| 338 | - | ac.overlays.pickerMu.Unlock() | |
| 339 | - | return currentGeneration == secondGeneration && len(preview.Rows) != 0 && len(preview.Rows[0]) != 0 && preview.Rows[0][0].Rune == 'y' | |
| 340 | - | }, time.Second, time.Millisecond) | |
| 341 | - | require.Equal(t, rune('y'), preview.Rows[0][0].Rune) | |
| 342 | - | require.Equal(t, 2, client.Calls()) | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) { | |
| 346 | - | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | |
| 347 | - | firstTarget := remotePreviewCacheTarget() | |
| 348 | - | secondTarget := firstTarget | |
| 349 | - | secondTarget.LifecycleID[0]++ | |
| 350 | - | secondTarget.LiveTabID = "tab-2" | |
| 351 | - | client := &remotePreviewTestClient{result: remotePreviewCacheResult(firstTarget, 1), started: make(chan struct{})} | |
| 352 | - | d.remotePreviewClient = client | |
| 353 | - | _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 354 | - | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | |
| 355 | - | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | |
| 356 | - | views := []picker.SessionView{ | |
| 357 | - | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, | |
| 358 | - | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, | |
| 359 | - | } | |
| 360 | - | model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 361 | - | ac.overlays.pickerMu.Lock() | |
| 362 | - | ac.overlays.picker = model | |
| 363 | - | ac.overlays.pickerIntent = pickerNavigate | |
| 364 | - | ac.overlays.pickerPreviewGeneration = 1 | |
| 365 | - | ac.overlays.pickerMu.Unlock() | |
| 366 | - | d.registerPreviewForSelection(ac) | |
| 367 | - | <-client.started | |
| 368 | - | require.Equal(t, firstTarget, client.LastTarget()) | |
| 369 | - | ||
| 370 | - | client.mu.Lock() | |
| 371 | - | client.result = remotePreviewCacheResult(secondTarget, 2) | |
| 372 | - | client.started = make(chan struct{}) | |
| 373 | - | client.startedSignal = false | |
| 374 | - | client.mu.Unlock() | |
| 375 | - | ac.overlays.pickerMu.Lock() | |
| 376 | - | model.Down() | |
| 377 | - | ac.overlays.pickerMu.Unlock() | |
| 378 | - | d.registerPreviewForSelection(ac) | |
| 379 | - | <-client.started | |
| 380 | - | require.Equal(t, secondTarget, client.LastTarget()) | |
| 381 | - | require.Equal(t, 2, client.Calls()) | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | func TestRemotePickerPreviewErrorCannotPublishAfterSelectionChange(t *testing.T) { | |
| 385 | - | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | |
| 386 | - | firstTarget := remotePreviewCacheTarget() | |
| 387 | - | secondTarget := firstTarget | |
| 388 | - | secondTarget.LifecycleID[0]++ | |
| 389 | - | secondTarget.LiveTabID = "tab-2" | |
| 390 | - | client := &remotePreviewTestClient{ | |
| 391 | - | err: errors.New("remote unavailable"), | |
| 392 | - | started: make(chan struct{}), | |
| 393 | - | release: make(chan struct{}), | |
| 394 | - | } | |
| 395 | - | d.remotePreviewClient = client | |
| 396 | - | _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 397 | - | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | |
| 398 | - | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | |
| 399 | - | model := picker.New([]picker.SessionView{ | |
| 400 | - | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, | |
| 401 | - | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach}, | |
| 402 | - | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 403 | - | selected, ok := model.Selected() | |
| 404 | - | require.True(t, ok) | |
| 405 | - | ac.overlays.pickerMu.Lock() | |
| 406 | - | ac.overlays.picker = model | |
| 407 | - | ac.overlays.pickerIntent = pickerNavigate | |
| 408 | - | ac.overlays.pickerPreviewGeneration = 1 | |
| 409 | - | ac.overlays.pickerMu.Unlock() | |
| 410 | - | d.startRemotePickerPreview(ac, selected, 1) | |
| 411 | - | <-client.started | |
| 412 | - | ||
| 413 | - | ac.overlays.pickerMu.Lock() | |
| 414 | - | ac.overlays.pickerRemotePreview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: 'k', Style: renderer.DefaultStyle()}}}} | |
| 415 | - | model.Down() | |
| 416 | - | ac.overlays.pickerMu.Unlock() | |
| 417 | - | close(client.release) | |
| 418 | - | ||
| 419 | - | require.Eventually(t, func() bool { | |
| 420 | - | ac.overlays.pickerMu.Lock() | |
| 421 | - | defer ac.overlays.pickerMu.Unlock() | |
| 422 | - | return len(ac.overlays.pickerRemotePreview.Rows) == 1 && ac.overlays.pickerRemotePreview.Rows[0][0].Rune == 'k' | |
| 423 | - | }, time.Second, time.Millisecond) | |
| 424 | - | } | |
| 425 | - | ||
| 426 | - | func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) { | |
| 427 | - | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | |
| 428 | - | target := remotePreviewCacheTarget() | |
| 429 | - | firstStarted := make(chan struct{}) | |
| 430 | - | client := &remotePreviewTestClient{ | |
| 431 | - | result: remotePreviewCacheResult(target, 1), | |
| 432 | - | started: firstStarted, | |
| 433 | - | release: make(chan struct{}), | |
| 434 | - | } | |
| 435 | - | d.remotePreviewClient = client | |
| 436 | - | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 437 | - | key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin} | |
| 438 | - | view := picker.SessionView{ | |
| 439 | - | ID: key.ID(), Name: target.SessionName, | |
| 440 | - | Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}}, | |
| 441 | - | RemoteKey: &key, RemoteTarget: &target, | |
| 442 | - | RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach, | |
| 443 | - | } | |
| 444 | - | model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 445 | - | selected, ok := model.Selected() | |
| 446 | - | require.True(t, ok) | |
| 447 | - | ac.overlays.pickerMu.Lock() | |
| 448 | - | ac.overlays.picker = model | |
| 449 | - | ac.overlays.pickerIntent = pickerNavigate | |
| 450 | - | ac.overlays.pickerPreviewGeneration = 1 | |
| 451 | - | ac.overlays.pickerMu.Unlock() | |
| 452 | - | d.startRemotePickerPreview(ac, selected, 1) | |
| 453 | - | <-firstStarted | |
| 454 | - | ||
| 455 | - | secondStarted := make(chan struct{}) | |
| 456 | - | client.mu.Lock() | |
| 457 | - | client.started = secondStarted | |
| 458 | - | client.release = nil | |
| 459 | - | client.startedSignal = false | |
| 460 | - | client.mu.Unlock() | |
| 461 | - | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 462 | - | effect, admitted := ac.beginAttachmentEffect(token) | |
| 463 | - | require.True(t, admitted) | |
| 464 | - | defer effect.End() | |
| 465 | - | require.True(t, d.resizeAttachmentForLease(effect, domain.Size{Cols: 100, Rows: 30})) | |
| 466 | - | <-secondStarted | |
| 467 | - | wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30}) | |
| 468 | - | gotWidth, gotHeight := client.LastSize() | |
| 469 | - | require.Equal(t, wantWidth, gotWidth) | |
| 470 | - | require.Equal(t, wantHeight, gotHeight) | |
| 471 | - | ||
| 472 | - | require.Eventually(t, func() bool { | |
| 473 | - | ac.overlays.pickerMu.Lock() | |
| 474 | - | defer ac.overlays.pickerMu.Unlock() | |
| 475 | - | return ac.overlays.pickerRemotePreview.Width == 1 && ac.overlays.pickerRemotePreview.Height == 1 | |
| 476 | - | }, time.Second, time.Millisecond) | |
| 477 | - | require.Equal(t, 2, client.Calls()) | |
| 478 | - | } | |
| 479 | - | ||
| 480 | 255 | func TestFetchRemotePreviewRejectsInvalidDimensionsBeforeRemoteIO(t *testing.T) { | |
| 481 | 256 | clock := &remotePreviewTestClock{now: time.Unix(400, 0)} | |
| 482 | 257 | client := &remotePreviewTestClient{result: remotePreviewCacheResult(remotePreviewCacheTarget(), 1)} | |
M internal/usecase/daemon/render.go +11 -54
| @@ -30,10 +30,13 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool { | |||
| 30 | 30 | attached := len(sess.attachments) != 0 | |
| 31 | 31 | sess.mu.Unlock() | |
| 32 | 32 | ||
| 33 | - | // The normal attached render path needs no cross-session picker lookup. | |
| 34 | - | // Only inactive or headless tabs can be renderable as a picker preview. | |
| 35 | - | if (!active || !attached) && !d.tabIsPickerPreview(tb) { | |
| 36 | - | return false | |
| 33 | + | if !active || !attached { | |
| 34 | + | // A headless tab is still renderable while a preview subscriber | |
| 35 | + | // watches it: the preview service composes its frames from this tab. | |
| 36 | + | rc := sess.renderCoordinator() | |
| 37 | + | if rc == nil || !rc.hasPreviewSubscribers() { | |
| 38 | + | return false | |
| 39 | + | } | |
| 37 | 40 | } | |
| 38 | 41 | tb.mu.Lock() | |
| 39 | 42 | defer tb.mu.Unlock() | |
| @@ -52,32 +55,6 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool { | |||
| 52 | 55 | return false | |
| 53 | 56 | } | |
| 54 | 57 | ||
| 55 | - | // tabIsPickerPreview reports whether any attached client currently composes | |
| 56 | - | // tb as a picker preview. It snapshots daemon ownership before taking overlay | |
| 57 | - | // locks, preserving the Daemon -> session -> tab -> pane lock order. | |
| 58 | - | func (d *Daemon) tabIsPickerPreview(tb *tab) bool { | |
| 59 | - | if d == nil || tb == nil { | |
| 60 | - | return false | |
| 61 | - | } | |
| 62 | - | d.mu.Lock() | |
| 63 | - | sessions := sessionsSnapshot(d.sessions) | |
| 64 | - | d.mu.Unlock() | |
| 65 | - | for _, sess := range sessions { | |
| 66 | - | for _, ac := range sess.snapshotAttachments() { | |
| 67 | - | if ac == nil || ac.overlays == nil { | |
| 68 | - | continue | |
| 69 | - | } | |
| 70 | - | ac.overlays.pickerMu.Lock() | |
| 71 | - | preview := ac.overlays.pickerPreview == tb | |
| 72 | - | ac.overlays.pickerMu.Unlock() | |
| 73 | - | if preview { | |
| 74 | - | return true | |
| 75 | - | } | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | return false | |
| 79 | - | } | |
| 80 | - | ||
| 81 | 58 | // ptyReader is retained as a compatibility entry point for focused tests. The | |
| 82 | 59 | // production goroutine calls readPanePTY, whose closure owns only the pane. | |
| 83 | 60 | func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |
| @@ -360,7 +337,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||
| 360 | 337 | ||
| 361 | 338 | ac.sendMu.Lock() | |
| 362 | 339 | if paintEffect != nil { | |
| 363 | - | if !paintEffect.current() || ac.parkedRouteOutput.Load() { | |
| 340 | + | if !paintEffect.current() { | |
| 364 | 341 | ac.sendMu.Unlock() | |
| 365 | 342 | return paintRejected | |
| 366 | 343 | } | |
| @@ -370,14 +347,11 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||
| 370 | 347 | entry.core().mu.Lock() | |
| 371 | 348 | _, owned := entry.core().attachments[ac] | |
| 372 | 349 | entry.core().mu.Unlock() | |
| 373 | - | if !owned || ac.currentAttachmentSession() != entry || ac.parkedRouteOutput.Load() { | |
| 350 | + | if !owned || ac.currentAttachmentSession() != entry { | |
| 374 | 351 | ac.sendMu.Unlock() | |
| 375 | 352 | return paintRejected | |
| 376 | 353 | } | |
| 377 | 354 | } | |
| 378 | - | if ac.parkedRouteFullPending.Load() { | |
| 379 | - | reset = true | |
| 380 | - | } | |
| 381 | 355 | // Capacity is checked before any destructive capture. Refresh the atomic | |
| 382 | 356 | // readiness snapshot while sendMu is held so direct test/setup mutations of | |
| 383 | 357 | // the stream fields remain covered without making coordinator probes wait on | |
| @@ -416,13 +390,6 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||
| 416 | 390 | d.repaintAllAttachedClients() | |
| 417 | 391 | } | |
| 418 | 392 | }() | |
| 419 | - | preview := snapshotPickerPreview(nil) | |
| 420 | - | if local && overlays.previewTab != tb { | |
| 421 | - | preview = snapshotPickerPreview(overlays.previewTab) | |
| 422 | - | if overlays.previewTab == nil && overlays.remotePreview.Height > 0 { | |
| 423 | - | preview = overlays.remotePreview | |
| 424 | - | } | |
| 425 | - | } | |
| 426 | 393 | if local { | |
| 427 | 394 | d.refreshSessionFocusedTitles(sess) | |
| 428 | 395 | } | |
| @@ -464,14 +431,13 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||
| 464 | 431 | ||
| 465 | 432 | capturedOverlays := capturedOverlayRenderState{ | |
| 466 | 433 | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | |
| 467 | - | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | |
| 434 | + | paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | |
| 468 | 435 | resizeActive: overlays.resizeActive, statusFeedback: statusFeedback, | |
| 469 | 436 | } | |
| 470 | 437 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | |
| 471 | 438 | state, ok := entry.captureRenderState(ac, renderCaptureRequest{ | |
| 472 | 439 | bars: bars, | |
| 473 | 440 | overlays: capturedOverlays, | |
| 474 | - | preview: preview, | |
| 475 | 441 | floatingCfg: floatingCfg, | |
| 476 | 442 | styles: applied.Resolved.Styles, | |
| 477 | 443 | styleGeneration: applied.Generation, | |
| @@ -487,12 +453,6 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||
| 487 | 453 | if ac.renderStages.capture != nil { | |
| 488 | 454 | ac.renderStages.capture() | |
| 489 | 455 | } | |
| 490 | - | if local && overlays.pickerActive && overlays.previewTab == tb { | |
| 491 | - | previewState := *state | |
| 492 | - | previewState.overlays = capturedOverlayRenderState{} | |
| 493 | - | previewState.reset = true | |
| 494 | - | state.preview = pickerPreviewFromCapturedRender(previewState) | |
| 495 | - | } | |
| 496 | 456 | captureOverlayLayers(state, overlays, paletteCfg) | |
| 497 | 457 | endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0) | |
| 498 | 458 | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |
| @@ -500,10 +460,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||
| 500 | 460 | if ac.renderStages.compose != nil { | |
| 501 | 461 | ac.renderStages.compose() | |
| 502 | 462 | } | |
| 503 | - | full := state.reset | |
| 504 | - | if d.emitFrame(entry, ac, state, composed, &marks) && full { | |
| 505 | - | ac.parkedRouteFullPending.Store(false) | |
| 506 | - | } | |
| 463 | + | d.emitFrame(entry, ac, state, composed, &marks) | |
| 507 | 464 | return paintEmitted | |
| 508 | 465 | } | |
| 509 | 466 | ||
M internal/usecase/daemon/render_capture.go +13 -30
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "github.com/bnema/vev/internal/protocol" | |
| 8 | 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 11 | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 12 | 11 | "github.com/bnema/vev/internal/usecase/ui" | |
| 13 | 12 | ) | |
| @@ -65,7 +64,6 @@ type capturedRenderState struct { | |||
| 65 | 64 | // that cannot emit graphics. Capture keeps the scene out of the output but | |
| 66 | 65 | // lets the daemon issue one bounded user warning after pane locks release. | |
| 67 | 66 | suppressedGraphics bool | |
| 68 | - | preview picker.Preview | |
| 69 | 67 | cursor capturedCursorInputs | |
| 70 | 68 | tabGeneration uint64 | |
| 71 | 69 | floatingGeneration uint64 | |
| @@ -125,15 +123,15 @@ type capturedFloatingRenderState struct { | |||
| 125 | 123 | } | |
| 126 | 124 | ||
| 127 | 125 | type capturedOverlayRenderState struct { | |
| 128 | - | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | |
| 129 | - | noticesOverlayActive, resizeActive bool | |
| 130 | - | copyMode *scopy.Mode | |
| 131 | - | copyPaneID layout.PaneID | |
| 132 | - | statusFeedback string | |
| 133 | - | paletteGuidance string | |
| 134 | - | copySearch, picker, palette, prompt, noticesOverlay capturedModal | |
| 135 | - | notices []domain.Notification | |
| 136 | - | noticeOverflow int | |
| 126 | + | copyActive, copySearchActive, paletteActive, promptActive bool | |
| 127 | + | noticesOverlayActive, resizeActive bool | |
| 128 | + | copyMode *scopy.Mode | |
| 129 | + | copyPaneID layout.PaneID | |
| 130 | + | statusFeedback string | |
| 131 | + | paletteGuidance string | |
| 132 | + | copySearch, palette, prompt, noticesOverlay capturedModal | |
| 133 | + | notices []domain.Notification | |
| 134 | + | noticeOverflow int | |
| 137 | 135 | } | |
| 138 | 136 | ||
| 139 | 137 | type capturedModal struct { | |
| @@ -145,7 +143,7 @@ type capturedModal struct { | |||
| 145 | 143 | } | |
| 146 | 144 | ||
| 147 | 145 | func (o capturedOverlayRenderState) active() bool { | |
| 148 | - | return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive || o.noticesOverlayActive | |
| 146 | + | return o.copyActive || o.copySearchActive || o.paletteActive || o.promptActive || o.noticesOverlayActive | |
| 149 | 147 | } | |
| 150 | 148 | ||
| 151 | 149 | type capturedCursorInputs struct { | |
| @@ -269,7 +267,6 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool { | |||
| 269 | 267 | type renderCaptureRequest struct { | |
| 270 | 268 | bars barState | |
| 271 | 269 | overlays capturedOverlayRenderState | |
| 272 | - | preview picker.Preview | |
| 273 | 270 | floatingCfg domain.FloatingConfig | |
| 274 | 271 | styles themeui.Styles | |
| 275 | 272 | styleGeneration uint64 | |
| @@ -289,7 +286,6 @@ func captureLocalRenderState( | |||
| 289 | 286 | ) (*capturedRenderState, bool) { | |
| 290 | 287 | bars := request.bars | |
| 291 | 288 | overlays := request.overlays | |
| 292 | - | preview := request.preview | |
| 293 | 289 | floatingCfg := request.floatingCfg | |
| 294 | 290 | reset := request.reset | |
| 295 | 291 | lease := request.lease | |
| @@ -335,14 +331,9 @@ func captureLocalRenderState( | |||
| 335 | 331 | scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent) | |
| 336 | 332 | bars.rankedRecent = scratch.ranked | |
| 337 | 333 | } | |
| 338 | - | if len(preview.Rows) > 0 { | |
| 339 | - | preview = clonePickerPreview(preview) | |
| 340 | - | } else { | |
| 341 | - | preview = picker.Preview{Width: preview.Width, Height: preview.Height} | |
| 342 | - | } | |
| 343 | - | ||
| 344 | 334 | tb.mu.Lock() | |
| 345 | 335 | defer tb.mu.Unlock() | |
| 336 | + | ||
| 346 | 337 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 347 | 338 | var focus layout.PaneID | |
| 348 | 339 | var focusedPaneID domain.PaneStableID | |
| @@ -392,7 +383,7 @@ func captureLocalRenderState( | |||
| 392 | 383 | uiFence: request.uiFence, | |
| 393 | 384 | focusedPaneID: focusedPaneID, | |
| 394 | 385 | styles: request.styles, styleGeneration: request.styleGeneration, | |
| 395 | - | overlays: overlays, preview: preview, | |
| 386 | + | overlays: overlays, | |
| 396 | 387 | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| 397 | 388 | floatingGeneration: tb.floating.generation, | |
| 398 | 389 | receipts: scratch.receipts[:0], | |
| @@ -486,18 +477,10 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | |||
| 486 | 477 | ||
| 487 | 478 | func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { | |
| 488 | 479 | style, hasStyle := p.screen.CursorStyle() | |
| 489 | - | hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive | |
| 480 | + | hidden := overlays.copyActive || overlays.copySearchActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive | |
| 490 | 481 | return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content} | |
| 491 | 482 | } | |
| 492 | 483 | ||
| 493 | - | func clonePickerPreview(in picker.Preview) picker.Preview { | |
| 494 | - | out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))} | |
| 495 | - | for i := range in.Rows { | |
| 496 | - | out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...) | |
| 497 | - | } | |
| 498 | - | return out | |
| 499 | - | } | |
| 500 | - | ||
| 501 | 484 | // appendStackPaneIDs returns exactly the panes whose solved placement can own a | |
| 502 | 485 | // stack title bar. The caller holds tab.mu, and dst is attachment scratch. | |
| 503 | 486 | func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID { | |
M internal/usecase/daemon/render_coordinator.go +12 -0
| @@ -631,6 +631,18 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generatio | |||
| 631 | 631 | c.mu.Unlock() | |
| 632 | 632 | } | |
| 633 | 633 | ||
| 634 | + | // hasPreviewSubscribers reports whether any presentation is currently | |
| 635 | + | // previewing this coordinator's tab. A headless tab stays renderable while a | |
| 636 | + | // preview subscriber watches it. | |
| 637 | + | func (c *renderCoordinator) hasPreviewSubscribers() bool { | |
| 638 | + | if c == nil { | |
| 639 | + | return false | |
| 640 | + | } | |
| 641 | + | c.mu.Lock() | |
| 642 | + | defer c.mu.Unlock() | |
| 643 | + | return len(c.previewWakes) != 0 | |
| 644 | + | } | |
| 645 | + | ||
| 634 | 646 | func (c *renderCoordinator) attachmentRegisteredLocked(ac *attachedClient) bool { | |
| 635 | 647 | if ac == nil { | |
| 636 | 648 | return false | |
M internal/usecase/daemon/render_coordinator_test.go +16 -4
| @@ -1153,7 +1153,12 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1153 | 1153 | name string | |
| 1154 | 1154 | tabs int | |
| 1155 | 1155 | producer string | |
| 1156 | - | run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) | |
| 1156 | + | // interaction marks producers that publish their own control frames | |
| 1157 | + | // (the picker offer and snapshot): the coordinator-invalidation | |
| 1158 | + | // assertion below covers their repaint, and those frames are pinned | |
| 1159 | + | // by the interaction suites. | |
| 1160 | + | interaction bool | |
| 1161 | + | run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) | |
| 1157 | 1162 | }{ | |
| 1158 | 1163 | { | |
| 1159 | 1164 | file: "attention.go", | |
| @@ -1216,10 +1221,12 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1216 | 1221 | }, | |
| 1217 | 1222 | }, | |
| 1218 | 1223 | { | |
| 1219 | - | file: "picker.go", | |
| 1220 | - | name: "picker entry", | |
| 1224 | + | file: "picker.go", | |
| 1225 | + | name: "picker entry", | |
| 1226 | + | interaction: true, | |
| 1221 | 1227 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |
| 1222 | - | d.enterPicker(sess, ac) | |
| 1228 | + | effect := admitPickerEffectForTest(t, sess, ac) | |
| 1229 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 1223 | 1230 | }, | |
| 1224 | 1231 | }, | |
| 1225 | 1232 | { | |
| @@ -1287,6 +1294,11 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1287 | 1294 | require.Equal(t, tc.producer, inv.producer) | |
| 1288 | 1295 | } | |
| 1289 | 1296 | requireNoInvalidation(t, invs) | |
| 1297 | + | if tc.interaction { | |
| 1298 | + | // The picker publishes offer/snapshot/restore frames of its own; | |
| 1299 | + | // drain them so the frame assertion below covers the repaint. | |
| 1300 | + | drainAllFrames(sends) | |
| 1301 | + | } | |
| 1290 | 1302 | requireNoCoordinatorOutputFrame(t, sends) | |
| 1291 | 1303 | }) | |
| 1292 | 1304 | } | |
M internal/usecase/daemon/render_damage_receipt_test.go +0 -5
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | 9 | "github.com/bnema/vev/internal/protocol/wire" | |
| 10 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | 11 | "github.com/stretchr/testify/require" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| @@ -58,7 +57,6 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) { | |||
| 58 | 57 | state, ok := captureRenderState(sess, ac, renderCaptureRequest{ | |
| 59 | 58 | bars: barState{}, | |
| 60 | 59 | overlays: capturedOverlayRenderState{}, | |
| 61 | - | preview: pickerPreviewEmpty(), | |
| 62 | 60 | floatingCfg: domain.FloatingConfig{}, | |
| 63 | 61 | reset: false, | |
| 64 | 62 | lease: nil, | |
| @@ -254,7 +252,6 @@ func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClien | |||
| 254 | 252 | state, ok := captureRenderState(sess, ac, renderCaptureRequest{ | |
| 255 | 253 | bars: barState{}, | |
| 256 | 254 | overlays: capturedOverlayRenderState{}, | |
| 257 | - | preview: pickerPreviewEmpty(), | |
| 258 | 255 | floatingCfg: domain.FloatingConfig{}, | |
| 259 | 256 | reset: false, | |
| 260 | 257 | lease: nil, | |
| @@ -262,5 +259,3 @@ func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClien | |||
| 262 | 259 | require.True(t, ok) | |
| 263 | 260 | return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |
| 264 | 261 | } | |
| 265 | - | ||
| 266 | - | func pickerPreviewEmpty() picker.Preview { return picker.Preview{} } | |
M internal/usecase/daemon/render_pipeline.go +4 -20
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 12 | "github.com/bnema/vev/internal/usecase/notices" | |
| 13 | 13 | "github.com/bnema/vev/internal/usecase/palette" | |
| 14 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 15 | 14 | "github.com/bnema/vev/internal/usecase/prompt" | |
| 16 | 15 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 17 | 16 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -175,7 +174,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 175 | 174 | damage = floatingDamage | |
| 176 | 175 | } | |
| 177 | 176 | copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive && | |
| 178 | - | !state.overlays.copySearchActive && !state.overlays.pickerActive && | |
| 177 | + | !state.overlays.copySearchActive && | |
| 179 | 178 | !state.overlays.paletteActive && !state.overlays.promptActive && | |
| 180 | 179 | !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible | |
| 181 | 180 | var copyViewport copyViewportState | |
| @@ -269,7 +268,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 269 | 268 | return | |
| 270 | 269 | } | |
| 271 | 270 | o := &state.overlays | |
| 272 | - | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | |
| 271 | + | o.copyActive, o.copySearchActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.paletteActive, snap.promptActive | |
| 273 | 272 | o.noticesOverlayActive = snap.noticesOverlayActive | |
| 274 | 273 | o.copyMode = snap.copyMode | |
| 275 | 274 | o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow | |
| @@ -288,21 +287,6 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 288 | 287 | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} | |
| 289 | 288 | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | |
| 290 | 289 | } | |
| 291 | - | if snap.pickerActive && snap.pickerModel != nil { | |
| 292 | - | presentation := pickerModal.Resolve(size) | |
| 293 | - | title := snap.pickerTitle | |
| 294 | - | if searchTitle := snap.pickerModel.SearchTitle(presentation.Bounds.Width - 2); searchTitle != "" { | |
| 295 | - | title = searchTitle | |
| 296 | - | } else if title == "" { | |
| 297 | - | title = pickerModal.Title | |
| 298 | - | } | |
| 299 | - | o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true} | |
| 300 | - | stoppedStyle := styles.PickerName | |
| 301 | - | stoppedStyle.Attrs |= renderer.AttrDim | |
| 302 | - | stoppedStyle.Italic = true | |
| 303 | - | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle, Status: styles.PickerDescription, SearchMatch: styles.SearchSelection, SelectionMatch: styles.HintKey} | |
| 304 | - | o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles) | |
| 305 | - | } | |
| 306 | 290 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | |
| 307 | 291 | presentation := noticesModal.Resolve(size) | |
| 308 | 292 | o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true} | |
| @@ -363,8 +347,8 @@ func capturedCopyTarget(state capturedRenderState, content domain.Rect) domain.R | |||
| 363 | 347 | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) { | |
| 364 | 348 | o := state.overlays | |
| 365 | 349 | // Paint in reverse keyboard priority so the same layer that owns input is | |
| 366 | - | // visually topmost: prompt > palette > picker > notices > copy search. | |
| 367 | - | for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} { | |
| 350 | + | // visually topmost: prompt > palette > notices > copy search. | |
| 351 | + | for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.palette, o.prompt} { | |
| 368 | 352 | if !modal.active { | |
| 369 | 353 | continue | |
| 370 | 354 | } | |
M internal/usecase/daemon/render_pipeline_test.go +1 -8
| @@ -21,7 +21,6 @@ import ( | |||
| 21 | 21 | "github.com/bnema/vev/internal/usecase/layout" | |
| 22 | 22 | "github.com/bnema/vev/internal/usecase/notices" | |
| 23 | 23 | "github.com/bnema/vev/internal/usecase/palette" | |
| 24 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 25 | 24 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 26 | 25 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 27 | 26 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -592,8 +591,7 @@ func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | |||
| 592 | 591 | higher string | |
| 593 | 592 | }{ | |
| 594 | 593 | {name: "notices above copy search", lower: "copy search", higher: "notices"}, | |
| 595 | - | {name: "picker above notices", lower: "notices", higher: "picker"}, | |
| 596 | - | {name: "palette above picker", lower: "picker", higher: "palette"}, | |
| 594 | + | {name: "palette above notices", lower: "notices", higher: "palette"}, | |
| 597 | 595 | {name: "prompt above palette", lower: "palette", higher: "prompt"}, | |
| 598 | 596 | } { | |
| 599 | 597 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -604,8 +602,6 @@ func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | |||
| 604 | 602 | state.overlays.copySearch = modal | |
| 605 | 603 | case "notices": | |
| 606 | 604 | state.overlays.noticesOverlay = modal | |
| 607 | - | case "picker": | |
| 608 | - | state.overlays.picker = modal | |
| 609 | 605 | case "palette": | |
| 610 | 606 | state.overlays.palette = modal | |
| 611 | 607 | case "prompt": | |
| @@ -634,8 +630,6 @@ func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) { | |||
| 634 | 630 | snapshot := scopy.NewSnapshotFromRows(nil, 79, 38) | |
| 635 | 631 | snap := &overlayRenderSnapshot{ | |
| 636 | 632 | copySearchModel: visualsearch.New(snapshot), | |
| 637 | - | pickerActive: true, | |
| 638 | - | pickerModel: picker.New(nil, picker.SelectionConfig{}), | |
| 639 | 633 | noticesOverlayActive: true, | |
| 640 | 634 | noticesOverlayModel: notices.New(nil, time.Time{}), | |
| 641 | 635 | paletteActive: true, | |
| @@ -648,7 +642,6 @@ func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) { | |||
| 648 | 642 | ||
| 649 | 643 | for name, modal := range map[string]capturedModal{ | |
| 650 | 644 | "copy search": state.overlays.copySearch, | |
| 651 | - | "picker": state.overlays.picker, | |
| 652 | 645 | "notices": state.overlays.noticesOverlay, | |
| 653 | 646 | "palette": state.overlays.palette, | |
| 654 | 647 | "prompt": state.overlays.prompt, | |
M internal/usecase/daemon/render_test.go +2 -66
| @@ -97,29 +97,6 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |||
| 97 | 97 | } | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | - | func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) { | |
| 101 | - | p, release := newBlockingPTY(t) | |
| 102 | - | defer release() | |
| 103 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 104 | - | ac.setThemeForTest(backdropTheme()) | |
| 105 | - | client := vt.NewScreen(80, 25) | |
| 106 | - | pane := sess.tabs[0].focusedPane() | |
| 107 | - | pane.screen.Write([]byte("X")) | |
| 108 | - | ||
| 109 | - | d.enterPicker(sess, ac) | |
| 110 | - | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 111 | - | pickerTitle := client.Cell(31, 2) | |
| 112 | - | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | |
| 113 | - | undimmedPane := client.Cell(0, 1) | |
| 114 | - | ||
| 115 | - | d.enterPalette(sess, ac) | |
| 116 | - | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 117 | - | dimmedPickerTitle := pickerTitle | |
| 118 | - | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style).Canonical() | |
| 119 | - | require.Equal(t, dimmedPickerTitle, client.Cell(31, 2), "the lower-priority picker must be part of the palette backdrop") | |
| 120 | - | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style).Canonical(), client.Cell(0, 1).Style.Canonical(), "pane content outside overlays must use the theme dim style") | |
| 121 | - | } | |
| 122 | - | ||
| 123 | 100 | func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |
| 124 | 101 | p, release := newBlockingPTY(t) | |
| 125 | 102 | defer release() | |
| @@ -388,12 +365,9 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 388 | 365 | d.clock = clock.clock | |
| 389 | 366 | rc := d.attachCoordinator(target, nil, targetClient, true) | |
| 390 | 367 | ||
| 391 | - | // A viewer in a different session is previewing the headless target. | |
| 368 | + | // A viewer in a different session subscribes to the headless target. | |
| 392 | 369 | viewer := &attachedClient{} | |
| 393 | 370 | viewer.initOverlays() | |
| 394 | - | viewer.overlays.pickerMu.Lock() | |
| 395 | - | viewer.overlays.pickerPreview = target.tabs[0] | |
| 396 | - | viewer.overlays.pickerMu.Unlock() | |
| 397 | 371 | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}} | |
| 398 | 372 | previews := make(chan renderWake, 2) | |
| 399 | 373 | rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w }) | |
| @@ -495,25 +469,6 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 495 | 469 | }) | |
| 496 | 470 | } | |
| 497 | 471 | ||
| 498 | - | // S2 keeps damage pending until the owning render capture consumes it. In | |
| 499 | - | // particular, a pane becoming invisible must not let a PTY reader erase data | |
| 500 | - | // that a later attachment or picker preview needs to render. | |
| 501 | - | func TestPaneRenderableActiveAttachmentDoesNotScanPickerPreviews(t *testing.T) { | |
| 502 | - | p, release := newBlockingPTY(t) | |
| 503 | - | defer release() | |
| 504 | - | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 505 | - | tb := testAttachmentTab(sess) | |
| 506 | - | pane := tb.focusedPane() | |
| 507 | - | ||
| 508 | - | // Holding daemon ownership makes a picker scan block. An active attached | |
| 509 | - | // tab must decide renderability from its session state without that scan. | |
| 510 | - | d.mu.Lock() | |
| 511 | - | defer d.mu.Unlock() | |
| 512 | - | done := make(chan bool, 1) | |
| 513 | - | go func() { done <- d.paneRenderable(sess, tb, pane) }() | |
| 514 | - | require.True(t, awaitTestValue(t, done, "active attached renderability scanned picker previews")) | |
| 515 | - | } | |
| 516 | - | ||
| 517 | 472 | func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |
| 518 | 473 | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan wire.Frame) { | |
| 519 | 474 | t.Helper() | |
| @@ -561,23 +516,11 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |||
| 561 | 516 | } | |
| 562 | 517 | }) | |
| 563 | 518 | ||
| 564 | - | t.Run("retains active and picker-preview pane damage", func(t *testing.T) { | |
| 519 | + | t.Run("retains active pane damage", func(t *testing.T) { | |
| 565 | 520 | d, sess, tb, p, _ := newFixture(t) | |
| 566 | 521 | p.screen.Write([]byte("active")) | |
| 567 | 522 | _ = d.paneRenderable(sess, tb, p) | |
| 568 | 523 | require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition") | |
| 569 | - | p.screen.ClearDamage() | |
| 570 | - | ||
| 571 | - | clearAttachmentsForTest(sess) | |
| 572 | - | viewer := &attachedClient{} | |
| 573 | - | viewer.initOverlays() | |
| 574 | - | viewer.overlays.pickerMu.Lock() | |
| 575 | - | viewer.overlays.pickerPreview = tb | |
| 576 | - | viewer.overlays.pickerMu.Unlock() | |
| 577 | - | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}} | |
| 578 | - | p.screen.Write([]byte("preview")) | |
| 579 | - | _ = d.paneRenderable(sess, tb, p) | |
| 580 | - | require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition") | |
| 581 | 524 | }) | |
| 582 | 525 | } | |
| 583 | 526 | ||
| @@ -797,13 +740,6 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||
| 797 | 740 | visible: "Commands", | |
| 798 | 741 | notVisible: "Commands", | |
| 799 | 742 | }, | |
| 800 | - | { | |
| 801 | - | name: "picker", | |
| 802 | - | open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) }, | |
| 803 | - | close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true, nil) }, | |
| 804 | - | visible: "Sessions", | |
| 805 | - | notVisible: "Sessions", | |
| 806 | - | }, | |
| 807 | 743 | { | |
| 808 | 744 | name: "copy mode", | |
| 809 | 745 | open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) }, | |
M internal/usecase/daemon/resume.go +6 -11
| @@ -217,16 +217,17 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||
| 217 | 217 | if !d.prepareParkAttachment(sess, ac) { | |
| 218 | 218 | return false | |
| 219 | 219 | } | |
| 220 | + | // The client recreates picker state after reconnect. Retire the daemon-side | |
| 221 | + | // interaction before publishing the parked attachment so resumed input is | |
| 222 | + | // never swallowed by a stale overlay. | |
| 223 | + | d.retirePicker(ac) | |
| 220 | 224 | // A parked attachment has no session owner. Release pane snapshots before | |
| 221 | 225 | // publishing it in the daemon registry so headless pane/session teardown | |
| 222 | 226 | // cannot leave closed panes retained through its capture cache. This must | |
| 223 | 227 | // remain outside d.mu: sendMu is ordered before the daemon lock. | |
| 224 | 228 | ac.clearCaptureFrames() | |
| 225 | - | var pickerGeneration, paletteGeneration uint64 | |
| 229 | + | var paletteGeneration uint64 | |
| 226 | 230 | if ac.overlays != nil { | |
| 227 | - | ac.overlays.pickerMu.Lock() | |
| 228 | - | pickerGeneration = ac.overlays.pickerGeneration | |
| 229 | - | ac.overlays.pickerMu.Unlock() | |
| 230 | 231 | ac.overlays.paletteMu.Lock() | |
| 231 | 232 | paletteGeneration = ac.overlays.paletteGeneration | |
| 232 | 233 | ac.overlays.paletteMu.Unlock() | |
| @@ -253,7 +254,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||
| 253 | 254 | grace := d.resumeParkGrace | |
| 254 | 255 | timer := d.clock.NewTimer(grace) | |
| 255 | 256 | parked := &parkedAttachment{ | |
| 256 | - | sess: sess, ac: ac, pickerGeneration: pickerGeneration, paletteGeneration: paletteGeneration, | |
| 257 | + | sess: sess, ac: ac, paletteGeneration: paletteGeneration, | |
| 257 | 258 | timer: timer, done: make(chan struct{}), | |
| 258 | 259 | } | |
| 259 | 260 | ac.parked = true | |
| @@ -297,7 +298,6 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | |||
| 297 | 298 | } | |
| 298 | 299 | d.removeParkedLocked(token, parked) | |
| 299 | 300 | d.mu.Unlock() | |
| 300 | - | d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration) | |
| 301 | 301 | d.closePaletteIfCurrent(parked.ac, parked.paletteGeneration) | |
| 302 | 302 | d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot()) | |
| 303 | 303 | return | |
| @@ -400,7 +400,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |||
| 400 | 400 | rearmed := &parkedAttachment{ | |
| 401 | 401 | sess: parked.sess, | |
| 402 | 402 | ac: ac, | |
| 403 | - | pickerGeneration: parked.pickerGeneration, | |
| 404 | 403 | paletteGeneration: parked.paletteGeneration, | |
| 405 | 404 | timer: d.clock.NewTimer(d.resumeParkGrace), | |
| 406 | 405 | done: make(chan struct{}), | |
| @@ -420,7 +419,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |||
| 420 | 419 | ||
| 421 | 420 | type parkedAttachmentRetirement struct { | |
| 422 | 421 | parked *parkedAttachment | |
| 423 | - | pickerGeneration uint64 | |
| 424 | 422 | paletteGeneration uint64 | |
| 425 | 423 | transport transportSnapshot | |
| 426 | 424 | } | |
| @@ -434,7 +432,6 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach | |||
| 434 | 432 | parked.ac.invalidateRetiredAttachmentCapability() | |
| 435 | 433 | return parkedAttachmentRetirement{ | |
| 436 | 434 | parked: parked, | |
| 437 | - | pickerGeneration: parked.pickerGeneration, | |
| 438 | 435 | paletteGeneration: parked.paletteGeneration, | |
| 439 | 436 | transport: parked.ac.transportSnapshot(), | |
| 440 | 437 | } | |
| @@ -470,7 +467,6 @@ func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmen | |||
| 470 | 467 | } | |
| 471 | 468 | retirement.parked.closeDone() | |
| 472 | 469 | ac := retirement.parked.ac | |
| 473 | - | d.closePickerIfCurrent(ac, nil, retirement.pickerGeneration) | |
| 474 | 470 | d.closePaletteIfCurrent(ac, retirement.paletteGeneration) | |
| 475 | 471 | _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport)) | |
| 476 | 472 | } | |
| @@ -559,7 +555,6 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio | |||
| 559 | 555 | rc.noteDetach(ac) | |
| 560 | 556 | } | |
| 561 | 557 | sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go") | |
| 562 | - | d.unregisterPreview(ac) | |
| 563 | 558 | if !d.parkAttachment(sess, ac) { | |
| 564 | 559 | // Detach already published; retire the captured link exactly once so | |
| 565 | 560 | // shutdown cannot leave an orphaned open transport with no owner. | |
M internal/usecase/daemon/route_retirement_test.go +1 -1
| @@ -45,7 +45,7 @@ func TestRemoteDirectoryDeletionRetiresHistoryWithPickerClosed(t *testing.T) { | |||
| 45 | 45 | ref := protocol.RouteRef{Key: 3, Generation: 2} | |
| 46 | 46 | ac.setRouteAttentionSubscription(protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{{Ref: ref, Target: target, SourceKey: protocol.RemoteInventorySourceKey("remote")}}}, ac.transportSnapshot(), d.clock.Now()) | |
| 47 | 47 | seedRemoteDirectory(t, d, ports.RemoteHostSnapshot{Endpoint: "remote", InventoryKnown: true, Availability: domain.RemoteAvailabilityReachable, LastAttempt: d.clock.Now().Add(time.Second), LastSuccess: d.clock.Now().Add(2 * time.Second)}) | |
| 48 | - | require.False(t, ac.overlays.pickerActive()) | |
| 48 | + | require.False(t, ac.overlays.pickerClientActive()) | |
| 49 | 49 | d.refreshRemoteDirectoryViews() | |
| 50 | 50 | frame := awaitFrame(t, sends, wire.MsgRouteRetired) | |
| 51 | 51 | message, err := wire.UnmarshalRouteRetired(frame.Payload) | |
M internal/usecase/daemon/same_peer_switch.go +0 -1
| @@ -96,7 +96,6 @@ func (d *Daemon) switchSamePeerForAttachment(effect *attachmentEffect, request p | |||
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | 98 | if fresh, admitted := effect.ac.beginAttachmentEffect(transition.published); admitted { | |
| 99 | - | d.closePicker(effect.ac) | |
| 100 | 99 | fresh.End() | |
| 101 | 100 | } | |
| 102 | 101 | d.touchMRU(target) | |
M internal/usecase/daemon/session.go +0 -3
| @@ -1195,7 +1195,6 @@ func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool, | |||
| 1195 | 1195 | d.log.Info("tab closed", "session", name) | |
| 1196 | 1196 | markSnapshotDirty(sess) | |
| 1197 | 1197 | ||
| 1198 | - | d.clearDestroyedTabPreview(tb) | |
| 1199 | 1198 | tb.mu.Lock() | |
| 1200 | 1199 | panes := tb.panesSnapshot() | |
| 1201 | 1200 | tb.mu.Unlock() | |
| @@ -1814,7 +1813,6 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas | |||
| 1814 | 1813 | rc.waitForTimerWorkers() | |
| 1815 | 1814 | } | |
| 1816 | 1815 | for _, attachment := range attachments { | |
| 1817 | - | d.unregisterPreview(attachment.ac) | |
| 1818 | 1816 | attachment.ac.clearCaptureFrames() | |
| 1819 | 1817 | } | |
| 1820 | 1818 | ||
| @@ -1828,7 +1826,6 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas | |||
| 1828 | 1826 | sess.clipFiles = nil | |
| 1829 | 1827 | sess.mu.Unlock() | |
| 1830 | 1828 | for _, tb := range tabs { | |
| 1831 | - | d.clearDestroyedTabPreview(tb) | |
| 1832 | 1829 | for _, attachment := range attachments { | |
| 1833 | 1830 | d.teardownFloating(tb, attachment.ac) | |
| 1834 | 1831 | } | |
M internal/usecase/daemon/session_back.go +2 -1
| @@ -23,7 +23,8 @@ func (d *Daemon) backSessionForAttachment(effect *attachmentEffect) error { | |||
| 23 | 23 | if snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 { | |
| 24 | 24 | return nil | |
| 25 | 25 | } | |
| 26 | - | if target, ok := effect.ac.routeAttentionTarget(snapshot.Previous); ok { | |
| 26 | + | if subscribed, ok := effect.ac.routeAttentionTarget(snapshot.Previous); ok && subscribed.SourceKey == "" { | |
| 27 | + | target := subscribed.Target | |
| 27 | 28 | targetSession, _, live := d.samePeerTarget(protocol.SamePeerSwitchRequest{Target: target}) | |
| 28 | 29 | if live && targetSession != effect.sess { | |
| 29 | 30 | return offerSamePeerAttachTarget(effect, target) | |
M internal/usecase/daemon/session_capabilities_test.go +9 -6
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | ||
| 7 | 9 | "github.com/bnema/vev/internal/domain" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| @@ -15,23 +17,24 @@ func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) { | |||
| 15 | 17 | } | |
| 16 | 18 | } | |
| 17 | 19 | ||
| 18 | - | func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) { | |
| 20 | + | func TestOpenPickerForIntentRejectsNonYieldingSource(t *testing.T) { | |
| 19 | 21 | d, sess, ac, _ := newManualSessionWithPTYs(t) | |
| 20 | 22 | sess.caps = sessionCapabilities{cannotYieldMoves: true} | |
| 23 | + | effect := admitPickerEffectForTest(t, sess, ac) | |
| 21 | 24 | ||
| 22 | 25 | tests := []struct { | |
| 23 | 26 | name string | |
| 24 | - | intent pickerIntent | |
| 27 | + | intent protocol.PickerIntent | |
| 25 | 28 | wantErr error | |
| 26 | 29 | }{ | |
| 27 | - | {name: "pickerMoveTab", intent: pickerMoveTab, wantErr: errSessionCannotYieldMoves}, | |
| 28 | - | {name: "pickerNavigate", intent: pickerNavigate, wantErr: nil}, | |
| 30 | + | {name: "move tab", intent: protocol.PickerIntentMoveTab, wantErr: errSessionCannotYieldMoves}, | |
| 31 | + | {name: "navigation", intent: protocol.PickerIntentNavigation, wantErr: nil}, | |
| 29 | 32 | } | |
| 30 | 33 | for _, tt := range tests { | |
| 31 | 34 | t.Run(tt.name, func(t *testing.T) { | |
| 32 | - | err := d.enterPickerForIntent(sess, ac, tt.intent, moveSourceLocator{}) | |
| 35 | + | err := d.openPickerForAttachment(ac, effect, tt.intent, moveSourceLocator{}, 0) | |
| 33 | 36 | if !errors.Is(err, tt.wantErr) { | |
| 34 | - | t.Fatalf("enterPickerForIntent error = %v, want %v", err, tt.wantErr) | |
| 37 | + | t.Fatalf("openPickerForAttachment error = %v, want %v", err, tt.wantErr) | |
| 35 | 38 | } | |
| 36 | 39 | }) | |
| 37 | 40 | } | |
M internal/usecase/daemon/session_view.go +5 -7
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "time" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 8 | 7 | ) | |
| 9 | 8 | ||
| 10 | 9 | // viewOptions controls optional (costly) fields captured by snapshotView. | |
| @@ -92,16 +91,15 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 92 | 91 | return view | |
| 93 | 92 | } | |
| 94 | 93 | ||
| 95 | - | // pickerView renders this snapshot as the picker's value type. Field-for-field | |
| 96 | - | // equivalent to the inline construction previously in pickerViews. | |
| 97 | - | func (view sessionView) pickerView() picker.SessionView { | |
| 98 | - | out := picker.SessionView{ | |
| 94 | + | // pickerView renders this snapshot as the picker's daemon-private value type. | |
| 95 | + | func (view sessionView) pickerView() pickerSessionView { | |
| 96 | + | out := pickerSessionView{ | |
| 99 | 97 | ID: view.id, | |
| 100 | 98 | Incarnation: view.incarnation, | |
| 101 | 99 | Name: view.name, | |
| 102 | 100 | TargetName: view.name, | |
| 103 | 101 | Active: view.defaultTab, | |
| 104 | - | Tabs: make([]picker.TabEntry, 0, len(view.tabs)), | |
| 102 | + | Tabs: make([]pickerTabEntry, 0, len(view.tabs)), | |
| 105 | 103 | } | |
| 106 | 104 | if !view.ephemeral { | |
| 107 | 105 | createdAt := view.createdAt | |
| @@ -109,7 +107,7 @@ func (view sessionView) pickerView() picker.SessionView { | |||
| 109 | 107 | } | |
| 110 | 108 | attention := false | |
| 111 | 109 | for _, tb := range view.tabs { | |
| 112 | - | out.Tabs = append(out.Tabs, picker.TabEntry{ | |
| 110 | + | out.Tabs = append(out.Tabs, pickerTabEntry{ | |
| 113 | 111 | TabID: tb.id, | |
| 114 | 112 | Name: tb.name, | |
| 115 | 113 | Detail: tabTitleDetail(tb.name, tb.focusedTitle), | |
M internal/usecase/daemon/status.go +3 -17
| @@ -283,34 +283,20 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||
| 283 | 283 | if d != nil { | |
| 284 | 284 | state.attentionFrame = d.attentionFrame() | |
| 285 | 285 | } | |
| 286 | - | homePicker := ac != nil && ac.startupOverlay == protocol.StartupOverlaySessionPicker && ac.navigationCapabilities&protocol.NavigationCapabilityBack != 0 | |
| 287 | - | if homePicker { | |
| 288 | - | // The local session only hosts the picker. Its name, tabs and bar | |
| 289 | - | // scripts do not describe the remote route the user is still on. | |
| 290 | - | // Leave that chrome empty until the client publishes its snapshot. | |
| 291 | - | if active, ok := activeRouteEntryForLifecycle(routeSnapshot, routeSnapshot.ActiveEntry.Target.LifecycleID); ok { | |
| 292 | - | kind := recentRouteLocal | |
| 293 | - | if active.Kind == protocol.RouteKindRemote { | |
| 294 | - | kind = recentRouteRemote | |
| 295 | - | } | |
| 296 | - | state.status.session = formatRecentRouteName(recentRoutePresentation{ | |
| 297 | - | name: active.Name, hostLabel: domain.RemoteDisplayOrigin(active.HostLabel), kind: kind, ephemeral: active.Ephemeral, | |
| 298 | - | }) | |
| 299 | - | } | |
| 300 | - | } else if cur != nil { | |
| 286 | + | if cur != nil { | |
| 301 | 287 | includeTerminalTitle := true | |
| 302 | 288 | if d != nil { | |
| 303 | 289 | includeTerminalTitle = d.currentTabsConfig().TerminalTitle | |
| 304 | 290 | } | |
| 305 | 291 | state.status = cur.statusSegmentsFor(ac, includeTerminalTitle) | |
| 306 | 292 | } | |
| 307 | - | if !homePicker && cur != nil && d != nil { | |
| 293 | + | if cur != nil && d != nil { | |
| 308 | 294 | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | |
| 309 | 295 | } | |
| 310 | 296 | if ranked != nil { | |
| 311 | 297 | state.rankedRecent = ranked | |
| 312 | 298 | } else if routeSnapshot.Generation != 0 { | |
| 313 | - | state.mru = d.formatRecentRouteSnapshotForAttachment(ac, routeSnapshot) | |
| 299 | + | state.mru = formatRecentRouteSnapshot(routeSnapshot) | |
| 314 | 300 | } | |
| 315 | 301 | return state | |
| 316 | 302 | } | |
M internal/usecase/daemon/status_test.go +0 -2
| @@ -18,7 +18,6 @@ import ( | |||
| 18 | 18 | "github.com/bnema/vev/internal/usecase/command" | |
| 19 | 19 | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | 20 | "github.com/bnema/vev/internal/usecase/palette" | |
| 21 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 22 | 21 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 23 | 22 | "github.com/bnema/vev/internal/usecase/ui" | |
| 24 | 23 | ) | |
| @@ -1026,7 +1025,6 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse( | |||
| 1026 | 1025 | state, ok := captureRenderState(sess, ac, renderCaptureRequest{ | |
| 1027 | 1026 | bars: bars, | |
| 1028 | 1027 | overlays: capturedOverlayRenderState{}, | |
| 1029 | - | preview: picker.Preview{}, | |
| 1030 | 1028 | floatingCfg: domain.FloatingConfig{}, | |
| 1031 | 1029 | reset: false, | |
| 1032 | 1030 | lease: nil, | |
A internal/usecase/daemon/tab_test_helpers_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // newTestTabWithContext builds one tab wired to a caller-owned context, so a | |
| 11 | + | // test can cancel the tab and its panes together. | |
| 12 | + | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | |
| 13 | + | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | |
| 14 | + | tb.ctx, tb.cancel = ctx, cancel | |
| 15 | + | for _, pane := range tb.panes { | |
| 16 | + | pane.ctx, pane.cancel = ctx, cancel | |
| 17 | + | } | |
| 18 | + | return tb | |
| 19 | + | } | |
M internal/usecase/daemon/typed_connection_test.go +38 -88
| @@ -156,14 +156,14 @@ func testDecodeClientFrame(frame wire.Frame) (protocol.ClientMessage, error) { | |||
| 156 | 156 | return wire.UnmarshalRouteAttentionSubscription(frame.Payload) | |
| 157 | 157 | case wire.MsgSamePeerSwitchRequest: | |
| 158 | 158 | return wire.UnmarshalSamePeerSwitchRequest(frame.Payload) | |
| 159 | - | case wire.MsgParkedRouteRequest: | |
| 160 | - | return wire.UnmarshalParkedRouteRequest(frame.Payload) | |
| 161 | 159 | case wire.MsgNavigationInventoryRequest: | |
| 162 | 160 | return wire.UnmarshalNavigationInventoryRequest(frame.Payload) | |
| 163 | 161 | case wire.MsgNavigationInventoryPublication: | |
| 164 | 162 | return wire.UnmarshalNavigationInventoryPublication(frame.Payload) | |
| 165 | 163 | case wire.MsgNavigationInventoryFailure: | |
| 166 | 164 | return wire.UnmarshalNavigationInventoryFailure(frame.Payload) | |
| 165 | + | case wire.MsgPickerPreviewRequest: | |
| 166 | + | return wire.UnmarshalPickerPreviewRequest(frame.Payload) | |
| 167 | 167 | case wire.MsgRecentRouteSnapshot: | |
| 168 | 168 | return wire.UnmarshalRecentRouteSnapshot(frame.Payload) | |
| 169 | 169 | case wire.MsgRouteNavigationFailure: | |
| @@ -296,8 +296,6 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 296 | 296 | return wire.Frame{Type: wire.MsgSessions, Payload: wire.MarshalSessions(m)}, nil | |
| 297 | 297 | case protocol.CommandResult: | |
| 298 | 298 | return wire.Frame{Type: wire.MsgCommandResult, Payload: wire.MarshalCommandResult(m)}, nil | |
| 299 | - | case protocol.NavigationDirective: | |
| 300 | - | return wire.Frame{Type: wire.MsgNavigationAction, Payload: wire.MarshalNavigationDirective(m)}, nil | |
| 301 | 299 | case protocol.AttachTarget: | |
| 302 | 300 | return wire.Frame{Type: wire.MsgAttachTarget, Payload: wire.MarshalAttachTarget(m)}, nil | |
| 303 | 301 | case protocol.RemotePreview: | |
| @@ -323,8 +321,6 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 323 | 321 | case protocol.SamePeerSwitchFailure: | |
| 324 | 322 | payload, err := wire.MarshalSamePeerSwitchFailure(m) | |
| 325 | 323 | return wire.Frame{Type: wire.MsgSamePeerSwitchFailure, Payload: payload}, err | |
| 326 | - | case protocol.ParkedRouteResponse: | |
| 327 | - | return wire.Frame{Type: wire.MsgParkedRouteResponse, Payload: wire.MarshalParkedRouteResponse(m)}, nil | |
| 328 | 324 | case protocol.NavigationInventoryResponse: | |
| 329 | 325 | payload := wire.MarshalNavigationInventoryResponse(m) | |
| 330 | 326 | if payload == nil { | |
| @@ -343,6 +339,42 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 343 | 339 | return wire.Frame{}, errors.New("test server connection: invalid inventory selection") | |
| 344 | 340 | } | |
| 345 | 341 | return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: payload}, nil | |
| 342 | + | case protocol.PickerSnapshot: | |
| 343 | + | payload := wire.MarshalPickerSnapshot(m) | |
| 344 | + | if payload == nil { | |
| 345 | + | return wire.Frame{}, errors.New("test server connection: invalid picker snapshot") | |
| 346 | + | } | |
| 347 | + | return wire.Frame{Type: wire.MsgPickerSnapshot, Payload: payload}, nil | |
| 348 | + | case protocol.PickerOffer: | |
| 349 | + | payload := wire.MarshalPickerOffer(m) | |
| 350 | + | if payload == nil { | |
| 351 | + | return wire.Frame{}, errors.New("test server connection: invalid picker offer") | |
| 352 | + | } | |
| 353 | + | return wire.Frame{Type: wire.MsgPickerOffer, Payload: payload}, nil | |
| 354 | + | case protocol.PickerClosed: | |
| 355 | + | payload := wire.MarshalPickerClosed(m) | |
| 356 | + | if payload == nil { | |
| 357 | + | return wire.Frame{}, errors.New("test server connection: invalid picker close") | |
| 358 | + | } | |
| 359 | + | return wire.Frame{Type: wire.MsgPickerClosedServer, Payload: payload}, nil | |
| 360 | + | case protocol.PickerResult: | |
| 361 | + | payload := wire.MarshalPickerResult(m) | |
| 362 | + | if payload == nil { | |
| 363 | + | return wire.Frame{}, errors.New("test server connection: invalid picker result") | |
| 364 | + | } | |
| 365 | + | return wire.Frame{Type: wire.MsgPickerResult, Payload: payload}, nil | |
| 366 | + | case protocol.PickerFailure: | |
| 367 | + | payload := wire.MarshalPickerFailure(m) | |
| 368 | + | if payload == nil { | |
| 369 | + | return wire.Frame{}, errors.New("test server connection: invalid picker failure") | |
| 370 | + | } | |
| 371 | + | return wire.Frame{Type: wire.MsgPickerFailure, Payload: payload}, nil | |
| 372 | + | case protocol.PickerPreview: | |
| 373 | + | payload := wire.MarshalPickerPreview(m) | |
| 374 | + | if payload == nil { | |
| 375 | + | return wire.Frame{}, errors.New("test server connection: invalid picker preview") | |
| 376 | + | } | |
| 377 | + | return wire.Frame{Type: wire.MsgPickerPreview, Payload: payload}, nil | |
| 346 | 378 | default: | |
| 347 | 379 | return wire.Frame{}, errors.New("test server connection: unsupported server message") | |
| 348 | 380 | } | |
| @@ -789,44 +821,6 @@ func (t *countingOutputTransport) Capabilities() protocol.ConnectionCapabilities | |||
| 789 | 821 | func (t *countingOutputTransport) LinkState() ports.LinkState { return testLinkState(t) } | |
| 790 | 822 | func (t *countingOutputTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) } | |
| 791 | 823 | ||
| 792 | - | func (t *parkedRouteExpiryTransport) ReceiveClient() (protocol.ClientMessage, error) { | |
| 793 | - | return testReceiveClient(t) | |
| 794 | - | } | |
| 795 | - | func (t *parkedRouteExpiryTransport) SendServer(m protocol.ServerMessage) error { | |
| 796 | - | return testSendServer(t, m) | |
| 797 | - | } | |
| 798 | - | func (t *parkedRouteExpiryTransport) SendServerAsync(m protocol.ServerMessage) error { | |
| 799 | - | return testSendServerAsync(t, m) | |
| 800 | - | } | |
| 801 | - | func (t *parkedRouteExpiryTransport) SendServerSynchronous(m protocol.ServerMessage) error { | |
| 802 | - | return testSendServerSynchronous(t, m) | |
| 803 | - | } | |
| 804 | - | func (t *parkedRouteExpiryTransport) Capabilities() protocol.ConnectionCapabilities { | |
| 805 | - | return testServerCapabilities(t) | |
| 806 | - | } | |
| 807 | - | func (t *parkedRouteExpiryTransport) LinkState() ports.LinkState { return testLinkState(t) } | |
| 808 | - | func (t *parkedRouteExpiryTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) } | |
| 809 | - | ||
| 810 | - | func (t *remotePickerSendErrorTransport) ReceiveClient() (protocol.ClientMessage, error) { | |
| 811 | - | return testReceiveClient(t) | |
| 812 | - | } | |
| 813 | - | func (t *remotePickerSendErrorTransport) SendServer(m protocol.ServerMessage) error { | |
| 814 | - | return testSendServer(t, m) | |
| 815 | - | } | |
| 816 | - | func (t *remotePickerSendErrorTransport) SendServerAsync(m protocol.ServerMessage) error { | |
| 817 | - | return testSendServerAsync(t, m) | |
| 818 | - | } | |
| 819 | - | func (t *remotePickerSendErrorTransport) SendServerSynchronous(m protocol.ServerMessage) error { | |
| 820 | - | return testSendServerSynchronous(t, m) | |
| 821 | - | } | |
| 822 | - | func (t *remotePickerSendErrorTransport) Capabilities() protocol.ConnectionCapabilities { | |
| 823 | - | return testServerCapabilities(t) | |
| 824 | - | } | |
| 825 | - | func (t *remotePickerSendErrorTransport) LinkState() ports.LinkState { return testLinkState(t) } | |
| 826 | - | func (t *remotePickerSendErrorTransport) LinkEvents() <-chan ports.LinkEvent { | |
| 827 | - | return testLinkEvents(t) | |
| 828 | - | } | |
| 829 | - | ||
| 830 | 824 | func (t failingOutputTransport) ReceiveClient() (protocol.ClientMessage, error) { | |
| 831 | 825 | return testReceiveClient(t) | |
| 832 | 826 | } | |
| @@ -861,24 +855,6 @@ func (t cacheFailTransport) Capabilities() protocol.ConnectionCapabilities { | |||
| 861 | 855 | func (t cacheFailTransport) LinkState() ports.LinkState { return testLinkState(t) } | |
| 862 | 856 | func (t cacheFailTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) } | |
| 863 | 857 | ||
| 864 | - | func (t parkedRouteFailTransport) ReceiveClient() (protocol.ClientMessage, error) { | |
| 865 | - | return testReceiveClient(t) | |
| 866 | - | } | |
| 867 | - | func (t parkedRouteFailTransport) SendServer(m protocol.ServerMessage) error { | |
| 868 | - | return testSendServer(t, m) | |
| 869 | - | } | |
| 870 | - | func (t parkedRouteFailTransport) SendServerAsync(m protocol.ServerMessage) error { | |
| 871 | - | return testSendServerAsync(t, m) | |
| 872 | - | } | |
| 873 | - | func (t parkedRouteFailTransport) SendServerSynchronous(m protocol.ServerMessage) error { | |
| 874 | - | return testSendServerSynchronous(t, m) | |
| 875 | - | } | |
| 876 | - | func (t parkedRouteFailTransport) Capabilities() protocol.ConnectionCapabilities { | |
| 877 | - | return testServerCapabilities(t) | |
| 878 | - | } | |
| 879 | - | func (t parkedRouteFailTransport) LinkState() ports.LinkState { return testLinkState(t) } | |
| 880 | - | func (t parkedRouteFailTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) } | |
| 881 | - | ||
| 882 | 858 | func (t *blockedRenderReplacementTransport) SendOutput(o protocol.Output) error { | |
| 883 | 859 | return testSendOutput(t, o) | |
| 884 | 860 | } | |
| @@ -1071,24 +1047,6 @@ func (t *countingOutputTransport) SendOutputSynchronous(o protocol.Output) error | |||
| 1071 | 1047 | return testSendOutputSynchronous(t, o) | |
| 1072 | 1048 | } | |
| 1073 | 1049 | ||
| 1074 | - | func (t *parkedRouteExpiryTransport) SendOutput(o protocol.Output) error { return testSendOutput(t, o) } | |
| 1075 | - | func (t *parkedRouteExpiryTransport) SendOutputAsync(o protocol.Output) error { | |
| 1076 | - | return testSendOutputAsync(t, o) | |
| 1077 | - | } | |
| 1078 | - | func (t *parkedRouteExpiryTransport) SendOutputSynchronous(o protocol.Output) error { | |
| 1079 | - | return testSendOutputSynchronous(t, o) | |
| 1080 | - | } | |
| 1081 | - | ||
| 1082 | - | func (t *remotePickerSendErrorTransport) SendOutput(o protocol.Output) error { | |
| 1083 | - | return testSendOutput(t, o) | |
| 1084 | - | } | |
| 1085 | - | func (t *remotePickerSendErrorTransport) SendOutputAsync(o protocol.Output) error { | |
| 1086 | - | return testSendOutputAsync(t, o) | |
| 1087 | - | } | |
| 1088 | - | func (t *remotePickerSendErrorTransport) SendOutputSynchronous(o protocol.Output) error { | |
| 1089 | - | return testSendOutputSynchronous(t, o) | |
| 1090 | - | } | |
| 1091 | - | ||
| 1092 | 1050 | func (t failingOutputTransport) SendOutput(o protocol.Output) error { return testSendOutput(t, o) } | |
| 1093 | 1051 | func (t failingOutputTransport) SendOutputAsync(o protocol.Output) error { | |
| 1094 | 1052 | return testSendOutputAsync(t, o) | |
| @@ -1104,11 +1062,3 @@ func (t cacheFailTransport) SendOutputAsync(o protocol.Output) error { | |||
| 1104 | 1062 | func (t cacheFailTransport) SendOutputSynchronous(o protocol.Output) error { | |
| 1105 | 1063 | return testSendOutputSynchronous(t, o) | |
| 1106 | 1064 | } | |
| 1107 | - | ||
| 1108 | - | func (t parkedRouteFailTransport) SendOutput(o protocol.Output) error { return testSendOutput(t, o) } | |
| 1109 | - | func (t parkedRouteFailTransport) SendOutputAsync(o protocol.Output) error { | |
| 1110 | - | return testSendOutputAsync(t, o) | |
| 1111 | - | } | |
| 1112 | - | func (t parkedRouteFailTransport) SendOutputSynchronous(o protocol.Output) error { | |
| 1113 | - | return testSendOutputSynchronous(t, o) | |
| 1114 | - | } | |
M internal/usecase/daemon/ui_action_navigation_test.go +0 -45
| @@ -12,7 +12,6 @@ func TestUIActionNavigationUsesAdmittedCause(t *testing.T) { | |||
| 12 | 12 | for _, actionID := range []uint64{0, 17} { | |
| 13 | 13 | for _, message := range []protocol.ServerMessage{ | |
| 14 | 14 | protocol.AttachTarget{CauseActionID: 999, Session: "destination", Intent: protocol.IntentAttach}, | |
| 15 | - | protocol.NavigationDirective{CauseActionID: 999, Action: protocol.NavigationBack}, | |
| 16 | 15 | protocol.RouteNavigationAction{CauseActionID: 999, SnapshotGeneration: 1, Key: 2, Generation: 3}, | |
| 17 | 16 | protocol.RouteCreateSessionAction{CauseActionID: 999, RequestID: 1, SnapshotGeneration: 2, Key: 3, Generation: 4, SessionName: "new"}, | |
| 18 | 17 | } { | |
| @@ -41,8 +40,6 @@ func testNavigationName(message protocol.ServerMessage, actionID uint64) string | |||
| 41 | 40 | switch message.(type) { | |
| 42 | 41 | case protocol.AttachTarget: | |
| 43 | 42 | return name + "attach" | |
| 44 | - | case protocol.NavigationDirective: | |
| 45 | - | return name + "directive" | |
| 46 | 43 | case protocol.RouteNavigationAction: | |
| 47 | 44 | return name + "recent" | |
| 48 | 45 | default: | |
| @@ -57,10 +54,6 @@ func testNavigationCause(t *testing.T, frame wire.Frame) uint64 { | |||
| 57 | 54 | message, err := wire.UnmarshalAttachTarget(frame.Payload) | |
| 58 | 55 | require.NoError(t, err) | |
| 59 | 56 | return message.CauseActionID | |
| 60 | - | case wire.MsgNavigationAction: | |
| 61 | - | message, err := wire.UnmarshalNavigationDirective(frame.Payload) | |
| 62 | - | require.NoError(t, err) | |
| 63 | - | return message.CauseActionID | |
| 64 | 57 | case wire.MsgNavigateRecentRoute: | |
| 65 | 58 | message, err := wire.UnmarshalRouteNavigationAction(frame.Payload) | |
| 66 | 59 | require.NoError(t, err) | |
| @@ -75,44 +68,6 @@ func testNavigationCause(t *testing.T, frame wire.Frame) uint64 { | |||
| 75 | 68 | } | |
| 76 | 69 | } | |
| 77 | 70 | ||
| 78 | - | func TestUIActionDelayedKeyRetainsOriginalCause(t *testing.T) { | |
| 79 | - | for _, revoked := range []bool{false, true} { | |
| 80 | - | t.Run(map[bool]string{false: "later action", true: "revoked lease"}[revoked], func(t *testing.T) { | |
| 81 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 82 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 83 | - | t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |
| 84 | - | capability := captureAttachmentCapability(sess, ac, ac.transport()) | |
| 85 | - | effect, ok := ac.beginAttachmentEffect(capability) | |
| 86 | - | require.True(t, ok) | |
| 87 | - | effect.uiActionID = 7 | |
| 88 | - | handler := daemonKeyHandler{d: d, ac: ac, effect: effect} | |
| 89 | - | effect.End() | |
| 90 | - | later, ok := ac.beginAttachmentEffect(capability) | |
| 91 | - | require.True(t, ok) | |
| 92 | - | later.uiActionID = 99 | |
| 93 | - | later.End() | |
| 94 | - | if revoked { | |
| 95 | - | rc.mu.Lock() | |
| 96 | - | rc.rebindAttachmentWithReadinessLocked(ac, true) | |
| 97 | - | rc.mu.Unlock() | |
| 98 | - | } | |
| 99 | - | current, delayed, owned := handler.acquireAttachmentEffect() | |
| 100 | - | if revoked { | |
| 101 | - | require.Nil(t, current) | |
| 102 | - | require.Nil(t, delayed) | |
| 103 | - | require.Empty(t, sends) | |
| 104 | - | return | |
| 105 | - | } | |
| 106 | - | require.Same(t, sess, current) | |
| 107 | - | require.True(t, owned) | |
| 108 | - | t.Cleanup(delayed.End) | |
| 109 | - | require.Equal(t, uint64(7), delayed.uiActionID) | |
| 110 | - | require.NoError(t, d.sendNavigationActionForAttachment(delayed, protocol.NavigationBack)) | |
| 111 | - | require.Equal(t, uint64(7), testNavigationCause(t, awaitTestValue(t, sends, "delayed navigation was not sent"))) | |
| 112 | - | }) | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | ||
| 116 | 71 | func TestUIActionInputPaletteSubmissionCarriesItsOwnCause(t *testing.T) { | |
| 117 | 72 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 118 | 73 | rc := d.attachCoordinator(sess, nil, ac, true) | |
A internal/usecase/picker/modal.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package picker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // This file owns the picker's presentation geometry. The serving daemon and the | |
| 9 | + | // presenting client both need it: the daemon sizes the preview it captures from | |
| 10 | + | // the same box the client draws, so the viewport always fits its pane. | |
| 11 | + | ||
| 12 | + | // Modal is the picker's floating presentation: a centred box over the session, | |
| 13 | + | // never a full-screen takeover. Both sides resolve it against the terminal size | |
| 14 | + | // they are working with. | |
| 15 | + | var Modal = ui.Modal{ | |
| 16 | + | WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, | |
| 17 | + | Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // PreviewRect returns the preview pane inside the modal for one terminal size. | |
| 21 | + | // An empty rectangle means this terminal has no room for a preview. | |
| 22 | + | func PreviewRect(terminal domain.Size) domain.Rect { | |
| 23 | + | presentation := Modal.Resolve(terminal) | |
| 24 | + | return ChooseGeometry(Size(presentation.Inner)).Preview | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Size converts a rectangle to the dimensions a frame is built from. | |
| 28 | + | func Size(rect domain.Rect) domain.Size { | |
| 29 | + | return domain.Size{Cols: rect.Width, Rows: rect.Height} | |
| 30 | + | } | |
M internal/usecase/picker/model.go +314 -504 collapsed to keep the page light
M internal/usecase/picker/model_test.go +262 -968 collapsed to keep the page light
D internal/usecase/picker/remote_parity_test.go +0 -82 collapsed to keep the page light
M internal/usecase/picker/search.go +48 -71 collapsed to keep the page light
A internal/usecase/picker/target.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package picker | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |
| 4 | + | ||
| 5 | + | // Target is the resolved navigation destination behind one opaque picker key. | |
| 6 | + | // It stays source-private: only the owning daemon resolves keys to targets and | |
| 7 | + | // commits them; a presenting client never sees one. | |
| 8 | + | type Target struct { | |
| 9 | + | Session domain.SessionID | |
| 10 | + | Incarnation domain.IncarnationID | |
| 11 | + | Name string | |
| 12 | + | RemoteKey *domain.RemoteSessionKey | |
| 13 | + | RemoteTarget *domain.RemoteSessionTarget | |
| 14 | + | RemoteHost string | |
| 15 | + | UnavailableReason string | |
| 16 | + | TabID domain.TabStableID | |
| 17 | + | TabIndex int | |
| 18 | + | Stopped bool | |
| 19 | + | // ExpectedCreatedAt optionally pins this target to a particular named | |
| 20 | + | // session lifecycle. Callers that obtain a snapshot outside the daemon use | |
| 21 | + | // it to reject a same-name replacement at commit time. | |
| 22 | + | ExpectedCreatedAt *int64 | |
| 23 | + | } | |
M internal/usecase/remotes/monitor.go +5 -1
| @@ -341,9 +341,13 @@ func (m *Monitor) applyResult(now time.Time, state *serviceState, result ports.R | |||
| 341 | 341 | return | |
| 342 | 342 | } | |
| 343 | 343 | state.registryDone = true | |
| 344 | + | wasInitialized := state.registryInit | |
| 344 | 345 | state.registryInit = true | |
| 345 | 346 | state.registryError = nil | |
| 346 | - | if m.reconcileRegistrations(now, state, result.Registrations) { | |
| 347 | + | if m.reconcileRegistrations(now, state, result.Registrations) || !wasInitialized { | |
| 348 | + | // The first successful registry read is itself a publication: an | |
| 349 | + | // empty registry is a known state, and a monitor that never | |
| 350 | + | // publishes it leaves every reader reporting "checking remotes". | |
| 347 | 351 | state.dirty = true | |
| 348 | 352 | } | |
| 349 | 353 | if expedited { | |
M internal/usecase/remotes/monitor_test.go +20 -0
| @@ -283,6 +283,26 @@ func TestMonitorBootstrapsRegistryAndCacheIndependently(t *testing.T) { | |||
| 283 | 283 | } | |
| 284 | 284 | } | |
| 285 | 285 | ||
| 286 | + | func TestMonitorEmptyRegistryStillPublishesInitialized(t *testing.T) { | |
| 287 | + | clock := newFakeClock(time.Unix(1_000, 0)) | |
| 288 | + | runtime := newFakeRuntime() | |
| 289 | + | monitor, _ := startMonitor(t, clock, runtime) | |
| 290 | + | sub := monitor.Subscribe() | |
| 291 | + | defer sub.Close() | |
| 292 | + | ||
| 293 | + | _ = receiveJob(t, runtime.incoming, "registry read") | |
| 294 | + | _ = receiveJob(t, runtime.incoming, "cache load") | |
| 295 | + | ||
| 296 | + | // A registry read with no registration is an authoritative empty state, | |
| 297 | + | // not an unfinished check: readers must stop reporting "checking". | |
| 298 | + | runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead} | |
| 299 | + | waitWake(t, sub.Changed(), "empty registry publication") | |
| 300 | + | snapshot := monitor.Snapshot() | |
| 301 | + | if !snapshot.Initialized || len(snapshot.Hosts) != 0 || snapshot.Revision == 0 { | |
| 302 | + | t.Fatalf("empty registry snapshot = %+v, want initialized with no hosts", snapshot) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 286 | 306 | func TestMonitorCacheSeedingRules(t *testing.T) { | |
| 287 | 307 | clock := newFakeClock(time.Unix(1_000, 0)) | |
| 288 | 308 | runtime := newFakeRuntime() | |